move CachedFile, CachedTarball tot heir own submodule, clean up things
This commit is contained in:
@@ -0,0 +1,134 @@
|
|||||||
|
//! Cached tarball contents and extraction logic.
|
||||||
|
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
|
use globset::GlobSet;
|
||||||
|
use std::{collections::HashMap, io::Read};
|
||||||
|
use tar::Archive;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// A file stored in memory from the tarball.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CachedFile {
|
||||||
|
/// The file contents as a string (lossy UTF-8 conversion).
|
||||||
|
pub content: String,
|
||||||
|
/// Whether this file looks like binary data.
|
||||||
|
pub is_binary: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CachedFile {
|
||||||
|
/// Create a new CachedFile from content, detecting if it looks binary.
|
||||||
|
fn new(content: String) -> Self {
|
||||||
|
let is_binary = looks_binary(&content);
|
||||||
|
Self { content, is_binary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached tarball contents.
|
||||||
|
pub struct CachedTarball {
|
||||||
|
/// The git SHA this tarball corresponds to.
|
||||||
|
pub sha: String,
|
||||||
|
/// Map from file path to file contents.
|
||||||
|
pub files: HashMap<String, CachedFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CachedTarball {
|
||||||
|
/// Extract a tarball into a CachedTarball.
|
||||||
|
///
|
||||||
|
/// - `glob_filter`: Optional pre-compiled glob patterns to filter files.
|
||||||
|
/// - `include_non_utf8`: Whether to include non-UTF-8 files using lossy conversion.
|
||||||
|
pub fn extract(
|
||||||
|
current_sha: String,
|
||||||
|
tarball: &[u8],
|
||||||
|
glob_filter: Option<&GlobSet>,
|
||||||
|
include_non_utf8: bool,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let files = extract_files(tarball, glob_filter, include_non_utf8)?;
|
||||||
|
|
||||||
|
info!("Extracted {} files from tarball", files.len());
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
sha: current_sha,
|
||||||
|
files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract files from a tarball into a map.
|
||||||
|
fn extract_files(
|
||||||
|
tarball: &[u8],
|
||||||
|
glob_filter: Option<&GlobSet>,
|
||||||
|
include_non_utf8: bool,
|
||||||
|
) -> Result<HashMap<String, CachedFile>, String> {
|
||||||
|
let decoder = GzDecoder::new(tarball);
|
||||||
|
let mut archive = Archive::new(decoder);
|
||||||
|
|
||||||
|
let mut files = HashMap::new();
|
||||||
|
|
||||||
|
for entry in archive
|
||||||
|
.entries()
|
||||||
|
.map_err(|e| format!("Failed to read tarball: {e}"))?
|
||||||
|
{
|
||||||
|
let mut entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
||||||
|
|
||||||
|
// Skip directories
|
||||||
|
if entry.header().entry_type().is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = entry
|
||||||
|
.path()
|
||||||
|
.map_err(|e| format!("Failed to get path: {e}"))?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// GitHub tarballs have a prefix like "owner-repo-sha/"
|
||||||
|
// Strip the first component
|
||||||
|
let path = path.split('/').skip(1).collect::<Vec<_>>().join("/");
|
||||||
|
|
||||||
|
if path.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply glob filter if configured
|
||||||
|
if let Some(glob_filter) = glob_filter {
|
||||||
|
if !glob_filter.is_match(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file contents
|
||||||
|
let mut contents = Vec::new();
|
||||||
|
if entry.read_to_end(&mut contents).is_err() {
|
||||||
|
continue; // Skip files we can't read
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to string, handling non-UTF-8 based on config
|
||||||
|
let content = match String::from_utf8(contents) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
if include_non_utf8 {
|
||||||
|
// Use lossy conversion if configured to include non-UTF-8
|
||||||
|
String::from_utf8_lossy(e.as_bytes()).into_owned()
|
||||||
|
} else {
|
||||||
|
// Skip non-UTF-8 files by default
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
files.insert(path, CachedFile::new(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if content looks like binary data.
|
||||||
|
fn looks_binary(content: &str) -> bool {
|
||||||
|
// Check first 1000 chars for null bytes or high ratio of non-printable chars
|
||||||
|
let sample: String = content.chars().take(1000).collect();
|
||||||
|
let non_printable = sample
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_ascii_graphic() && !c.is_ascii_whitespace())
|
||||||
|
.count();
|
||||||
|
non_printable > sample.len() / 10
|
||||||
|
}
|
||||||
@@ -3,38 +3,22 @@
|
|||||||
//! This crate provides tools for browsing application source code by downloading
|
//! This crate provides tools for browsing application source code by downloading
|
||||||
//! tarballs from GitHub and caching them in memory.
|
//! tarballs from GitHub and caching them in memory.
|
||||||
|
|
||||||
|
mod cached;
|
||||||
mod config;
|
mod config;
|
||||||
|
|
||||||
pub use config::{CodeToolConfig, GitHubRepo, Source};
|
pub use config::{CodeToolConfig, GitHubRepo, Source};
|
||||||
|
|
||||||
|
use cached::CachedTarball;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use flate2::read::GzDecoder;
|
|
||||||
use globset::{Glob, GlobSet, GlobSetBuilder};
|
use globset::{Glob, GlobSet, GlobSetBuilder};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use signal_gateway_assistant::{Tool, ToolExecutor, ToolResult};
|
use signal_gateway_assistant::{Tool, ToolExecutor, ToolResult};
|
||||||
use std::{
|
use std::{error::Error, fmt::Write, future::Future, pin::Pin, sync::Arc};
|
||||||
collections::HashMap, error::Error, fmt::Write, future::Future, io::Read, pin::Pin, sync::Arc,
|
|
||||||
};
|
|
||||||
use tar::Archive;
|
|
||||||
use tokio::sync::{Mutex, MutexGuard};
|
use tokio::sync::{Mutex, MutexGuard};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
/// A file stored in memory from the tarball.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct CachedFile {
|
|
||||||
/// The file contents as a string (lossy UTF-8 conversion).
|
|
||||||
content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cached tarball contents.
|
|
||||||
struct CachedTarball {
|
|
||||||
/// The git SHA this tarball corresponds to.
|
|
||||||
sha: String,
|
|
||||||
/// Map from file path to file contents.
|
|
||||||
files: HashMap<String, CachedFile>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Callback type for getting the current deployed git SHA.
|
/// Callback type for getting the current deployed git SHA.
|
||||||
///
|
///
|
||||||
/// This is an async callback that returns a future resolving to the SHA.
|
/// This is an async callback that returns a future resolving to the SHA.
|
||||||
@@ -60,10 +44,9 @@ enum ResolvedSource {
|
|||||||
///
|
///
|
||||||
/// Downloads and caches GitHub tarballs for browsing application source code.
|
/// Downloads and caches GitHub tarballs for browsing application source code.
|
||||||
pub struct CodeTool {
|
pub struct CodeTool {
|
||||||
name: String,
|
config: CodeToolConfig,
|
||||||
source: ResolvedSource,
|
source: ResolvedSource,
|
||||||
glob_filter: Option<GlobSet>,
|
glob_filter: Option<GlobSet>,
|
||||||
include_non_utf8: bool,
|
|
||||||
get_sha: ShaCallback,
|
get_sha: ShaCallback,
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
cache: Mutex<Option<CachedTarball>>,
|
cache: Mutex<Option<CachedTarball>>,
|
||||||
@@ -76,19 +59,19 @@ impl CodeTool {
|
|||||||
/// For GitHub sources, this is the commit SHA. For file sources, this can be
|
/// For GitHub sources, this is the commit SHA. For file sources, this can be
|
||||||
/// used to track file modification (e.g., mtime or a version string).
|
/// used to track file modification (e.g., mtime or a version string).
|
||||||
pub fn new(config: CodeToolConfig, get_sha: ShaCallback) -> Result<Self, std::io::Error> {
|
pub fn new(config: CodeToolConfig, get_sha: ShaCallback) -> Result<Self, std::io::Error> {
|
||||||
let source = match config.source {
|
let source = match &config.source {
|
||||||
Source::GitHub { repo, token_file } => {
|
Source::GitHub { repo, token_file } => {
|
||||||
let token = token_file
|
let token = token_file
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|path| std::fs::read_to_string(path).map(|s| s.trim().to_string()))
|
.map(|path| std::fs::read_to_string(path).map(|s| s.trim().to_string()))
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
ResolvedSource::GitHub {
|
ResolvedSource::GitHub {
|
||||||
owner: repo.owner,
|
owner: repo.owner.clone(),
|
||||||
repo: repo.repo,
|
repo: repo.repo.clone(),
|
||||||
token,
|
token,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Source::File { path } => ResolvedSource::File { path },
|
Source::File { path } => ResolvedSource::File { path: path.clone() },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compile glob patterns if any are specified
|
// Compile glob patterns if any are specified
|
||||||
@@ -109,10 +92,9 @@ impl CodeTool {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
name: config.name,
|
config,
|
||||||
source,
|
source,
|
||||||
glob_filter,
|
glob_filter,
|
||||||
include_non_utf8: config.include_non_utf8,
|
|
||||||
get_sha,
|
get_sha,
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
cache: Mutex::new(None),
|
cache: Mutex::new(None),
|
||||||
@@ -121,7 +103,7 @@ impl CodeTool {
|
|||||||
|
|
||||||
/// Get the application name.
|
/// Get the application name.
|
||||||
pub fn name(&self) -> &str {
|
pub fn name(&self) -> &str {
|
||||||
&self.name
|
&self.config.name
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current tarball, downloading or reading from file as needed.
|
/// Get the current tarball, downloading or reading from file as needed.
|
||||||
@@ -139,7 +121,7 @@ impl CodeTool {
|
|||||||
let current_sha = match (self.get_sha)().await {
|
let current_sha = match (self.get_sha)().await {
|
||||||
Ok(sha) => sha,
|
Ok(sha) => sha,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to get current SHA for {}: {e}", self.name);
|
warn!("Failed to get current SHA for {}: {e}", self.config.name);
|
||||||
return self.cache.lock().await;
|
return self.cache.lock().await;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -156,28 +138,32 @@ impl CodeTool {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if needs_refresh {
|
if needs_refresh {
|
||||||
info!("Loading tarball for {} at {}", self.name, current_sha);
|
info!(
|
||||||
|
"Loading tarball for {} at {}",
|
||||||
|
self.config.name, current_sha
|
||||||
|
);
|
||||||
|
|
||||||
let tarball = match self.load_tarball(¤t_sha).await {
|
let tarball = match self.load_tarball(¤t_sha).await {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to load tarball for {}: {e}", self.name);
|
error!("Failed to load tarball for {}: {e}", self.config.name);
|
||||||
return cache;
|
return cache;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let files = match self.extract_tarball(&tarball) {
|
match CachedTarball::extract(
|
||||||
Ok(f) => f,
|
current_sha,
|
||||||
|
&tarball,
|
||||||
|
self.glob_filter.as_ref(),
|
||||||
|
self.config.include_non_utf8,
|
||||||
|
) {
|
||||||
|
Ok(cached_tarball) => {
|
||||||
|
*cache = Some(cached_tarball);
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to extract tarball for {}: {e}", self.name);
|
error!("Failed to extract tarball for {}: {e}", self.config.name);
|
||||||
return cache;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
*cache = Some(CachedTarball {
|
|
||||||
sha: current_sha,
|
|
||||||
files,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cache
|
cache
|
||||||
@@ -239,72 +225,6 @@ impl CodeTool {
|
|||||||
.map_err(|e| format!("Failed to read response body: {e}"))
|
.map_err(|e| format!("Failed to read response body: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a tarball into a map of file paths to contents.
|
|
||||||
fn extract_tarball(&self, tarball: &[u8]) -> Result<HashMap<String, CachedFile>, String> {
|
|
||||||
let decoder = GzDecoder::new(tarball);
|
|
||||||
let mut archive = Archive::new(decoder);
|
|
||||||
|
|
||||||
let mut files = HashMap::new();
|
|
||||||
|
|
||||||
for entry in archive
|
|
||||||
.entries()
|
|
||||||
.map_err(|e| format!("Failed to read tarball: {e}"))?
|
|
||||||
{
|
|
||||||
let mut entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
|
||||||
|
|
||||||
// Skip directories
|
|
||||||
if entry.header().entry_type().is_dir() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = entry
|
|
||||||
.path()
|
|
||||||
.map_err(|e| format!("Failed to get path: {e}"))?
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// GitHub tarballs have a prefix like "owner-repo-sha/"
|
|
||||||
// Strip the first component
|
|
||||||
let path = path.split('/').skip(1).collect::<Vec<_>>().join("/");
|
|
||||||
|
|
||||||
if path.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply glob filter if configured
|
|
||||||
if let Some(ref glob_filter) = self.glob_filter {
|
|
||||||
if !glob_filter.is_match(&path) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read file contents
|
|
||||||
let mut contents = Vec::new();
|
|
||||||
if entry.read_to_end(&mut contents).is_err() {
|
|
||||||
continue; // Skip files we can't read
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to string, handling non-UTF-8 based on config
|
|
||||||
let content = match String::from_utf8(contents) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
if self.include_non_utf8 {
|
|
||||||
// Use lossy conversion if configured to include non-UTF-8
|
|
||||||
String::from_utf8_lossy(e.as_bytes()).into_owned()
|
|
||||||
} else {
|
|
||||||
// Skip non-UTF-8 files by default
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
files.insert(path, CachedFile { content });
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Extracted {} files from tarball", files.len());
|
|
||||||
Ok(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List files in a directory (like `ls`).
|
/// List files in a directory (like `ls`).
|
||||||
///
|
///
|
||||||
/// If `path` is None or empty, lists the root directory.
|
/// If `path` is None or empty, lists the root directory.
|
||||||
@@ -463,7 +383,7 @@ impl CodeTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip binary-looking files
|
// Skip binary-looking files
|
||||||
if looks_binary(&file.content) {
|
if file.is_binary {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,17 +468,6 @@ impl CodeTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if content looks like binary data.
|
|
||||||
fn looks_binary(content: &str) -> bool {
|
|
||||||
// Check first 1000 chars for null bytes or high ratio of non-printable chars
|
|
||||||
let sample: String = content.chars().take(1000).collect();
|
|
||||||
let non_printable = sample
|
|
||||||
.chars()
|
|
||||||
.filter(|c| !c.is_ascii_graphic() && !c.is_ascii_whitespace())
|
|
||||||
.count();
|
|
||||||
non_printable > sample.len() / 10
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tool executor for multiple application source code browsers.
|
/// Tool executor for multiple application source code browsers.
|
||||||
pub struct CodeToolTools {
|
pub struct CodeToolTools {
|
||||||
apps: Vec<CodeTool>,
|
apps: Vec<CodeTool>,
|
||||||
|
|||||||
Reference in New Issue
Block a user