rename crate again to signal-gateway-code-tool
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "signal-gateway-code-tool"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
signal-gateway-assistant = { workspace = true }
|
||||
|
||||
async-trait = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["rustls-tls"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tar = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Configuration types for repository code browsing.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::{path::PathBuf, str::FromStr};
|
||||
|
||||
/// A GitHub repository identifier (owner/repo).
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(try_from = "String")]
|
||||
pub struct GitHubRepo {
|
||||
/// The repository owner (user or organization).
|
||||
pub owner: String,
|
||||
/// The repository name.
|
||||
pub repo: String,
|
||||
}
|
||||
|
||||
impl FromStr for GitHubRepo {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let parts: Vec<&str> = s.split('/').collect();
|
||||
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
|
||||
return Err(format!(
|
||||
"invalid GitHub repo '{}': expected 'owner/repo' format",
|
||||
s
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
owner: parts[0].to_string(),
|
||||
repo: parts[1].to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for GitHubRepo {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(s: String) -> Result<Self, Self::Error> {
|
||||
s.parse()
|
||||
}
|
||||
}
|
||||
|
||||
/// Source of repository code - either a GitHub repo or a local tarball file.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// GitHub repository source. Downloads tarballs from GitHub API.
|
||||
#[serde(rename = "github")]
|
||||
GitHub {
|
||||
/// GitHub repository in "owner/repo" format.
|
||||
repo: GitHubRepo,
|
||||
/// Path to file containing the GitHub personal access token.
|
||||
/// Optional for public repositories (unauthenticated access has lower rate limits).
|
||||
token_file: Option<PathBuf>,
|
||||
},
|
||||
/// Local tarball file source. Reads a .tar.gz file from disk.
|
||||
#[serde(rename = "file")]
|
||||
File {
|
||||
/// Path to the tarball file (.tar.gz).
|
||||
path: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
/// Configuration for an application's source code access.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
pub struct CodeToolConfig {
|
||||
/// Name of the application (used to identify it in tool calls).
|
||||
pub name: String,
|
||||
/// Source of the repository code.
|
||||
#[serde(flatten)]
|
||||
pub source: Source,
|
||||
/// Glob patterns to filter which files are included from the tarball.
|
||||
/// If non-empty, only files matching at least one pattern are kept.
|
||||
/// Uses gitignore-style glob syntax (e.g., "*.rs", "src/**/*.rs").
|
||||
#[serde(default)]
|
||||
pub glob: Vec<String>,
|
||||
/// Include files that aren't valid UTF-8 (using lossy conversion).
|
||||
/// By default (false), non-UTF-8 files are skipped entirely.
|
||||
#[serde(default)]
|
||||
pub include_non_utf8: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_source() {
|
||||
let json = r#"{
|
||||
"name": "my-app",
|
||||
"github": {
|
||||
"repo": "owner/repo-name",
|
||||
"token_file": "/path/to/token"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let config: CodeToolConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.name, "my-app");
|
||||
assert_eq!(
|
||||
config.source,
|
||||
Source::GitHub {
|
||||
repo: GitHubRepo {
|
||||
owner: "owner".to_string(),
|
||||
repo: "repo-name".to_string(),
|
||||
},
|
||||
token_file: Some(PathBuf::from("/path/to/token")),
|
||||
}
|
||||
);
|
||||
assert!(config.glob.is_empty());
|
||||
assert!(!config.include_non_utf8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_source_no_token() {
|
||||
let json = r#"{
|
||||
"name": "public-app",
|
||||
"github": {
|
||||
"repo": "org/public-repo"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let config: CodeToolConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.name, "public-app");
|
||||
assert_eq!(
|
||||
config.source,
|
||||
Source::GitHub {
|
||||
repo: GitHubRepo {
|
||||
owner: "org".to_string(),
|
||||
repo: "public-repo".to_string(),
|
||||
},
|
||||
token_file: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_file_source() {
|
||||
let json = r#"{
|
||||
"name": "local-app",
|
||||
"file": {
|
||||
"path": "/tmp/source.tar.gz"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let config: CodeToolConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.name, "local-app");
|
||||
assert_eq!(
|
||||
config.source,
|
||||
Source::File {
|
||||
path: PathBuf::from("/tmp/source.tar.gz"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_glob_and_options() {
|
||||
let json = r#"{
|
||||
"name": "filtered-app",
|
||||
"github": {
|
||||
"repo": "owner/repo"
|
||||
},
|
||||
"glob": ["**/*.rs", "Cargo.toml"],
|
||||
"include_non_utf8": true
|
||||
}"#;
|
||||
|
||||
let config: CodeToolConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.name, "filtered-app");
|
||||
assert_eq!(config.glob, vec!["**/*.rs", "Cargo.toml"]);
|
||||
assert!(config.include_non_utf8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_string() {
|
||||
let repo: GitHubRepo = "owner/repo".parse().unwrap();
|
||||
assert_eq!(repo.owner, "owner");
|
||||
assert_eq!(repo.repo, "repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_invalid() {
|
||||
assert!("invalid".parse::<GitHubRepo>().is_err());
|
||||
assert!("".parse::<GitHubRepo>().is_err());
|
||||
assert!("/repo".parse::<GitHubRepo>().is_err());
|
||||
assert!("owner/".parse::<GitHubRepo>().is_err());
|
||||
assert!("a/b/c".parse::<GitHubRepo>().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
//! Application source code browsing via GitHub tarball downloads.
|
||||
//!
|
||||
//! This crate provides tools for browsing application source code by downloading
|
||||
//! tarballs from GitHub and caching them in memory.
|
||||
|
||||
mod config;
|
||||
|
||||
pub use config::{CodeToolConfig, GitHubRepo, Source};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use flate2::read::GzDecoder;
|
||||
use globset::{Glob, GlobSet, GlobSetBuilder};
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use signal_gateway_assistant::{Tool, ToolExecutor, ToolResult};
|
||||
use std::{
|
||||
collections::HashMap, error::Error, fmt::Write, future::Future, io::Read, pin::Pin, sync::Arc,
|
||||
};
|
||||
use tar::Archive;
|
||||
use tokio::sync::{Mutex, MutexGuard};
|
||||
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.
|
||||
///
|
||||
/// This is an async callback that returns a future resolving to the SHA.
|
||||
pub type ShaCallback = Arc<
|
||||
dyn Fn() -> Pin<Box<dyn Future<Output = Result<String, Box<dyn Error + Send + Sync>>> + Send>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
/// Internal representation of the resolved source.
|
||||
enum ResolvedSource {
|
||||
GitHub {
|
||||
owner: String,
|
||||
repo: String,
|
||||
token: Option<String>,
|
||||
},
|
||||
File {
|
||||
path: std::path::PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
/// Application source code browser.
|
||||
///
|
||||
/// Downloads and caches GitHub tarballs for browsing application source code.
|
||||
pub struct CodeTool {
|
||||
name: String,
|
||||
source: ResolvedSource,
|
||||
glob_filter: Option<GlobSet>,
|
||||
include_non_utf8: bool,
|
||||
get_sha: ShaCallback,
|
||||
client: reqwest::Client,
|
||||
cache: Mutex<Option<CachedTarball>>,
|
||||
}
|
||||
|
||||
impl CodeTool {
|
||||
/// Create a new CodeTool instance from configuration.
|
||||
///
|
||||
/// The `get_sha` callback is called to determine which git SHA to download/load.
|
||||
/// 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).
|
||||
pub fn new(config: CodeToolConfig, get_sha: ShaCallback) -> Result<Self, std::io::Error> {
|
||||
let source = match config.source {
|
||||
Source::GitHub { repo, token_file } => {
|
||||
let token = token_file
|
||||
.as_ref()
|
||||
.map(|path| std::fs::read_to_string(path).map(|s| s.trim().to_string()))
|
||||
.transpose()?;
|
||||
ResolvedSource::GitHub {
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
token,
|
||||
}
|
||||
}
|
||||
Source::File { path } => ResolvedSource::File { path },
|
||||
};
|
||||
|
||||
// Compile glob patterns if any are specified
|
||||
let glob_filter =
|
||||
if config.glob.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
for pattern in &config.glob {
|
||||
let glob = Glob::new(pattern).map_err(|e| {
|
||||
std::io::Error::other(format!("invalid glob pattern '{}': {}", pattern, e))
|
||||
})?;
|
||||
builder.add(glob);
|
||||
}
|
||||
Some(builder.build().map_err(|e| {
|
||||
std::io::Error::other(format!("failed to build glob set: {}", e))
|
||||
})?)
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
name: config.name,
|
||||
source,
|
||||
glob_filter,
|
||||
include_non_utf8: config.include_non_utf8,
|
||||
get_sha,
|
||||
client: reqwest::Client::new(),
|
||||
cache: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the application name.
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// Get the current tarball, downloading or reading from file as needed.
|
||||
///
|
||||
/// Returns a mutex guard containing the cached tarball. This method never fails;
|
||||
/// instead it logs warnings and returns whatever is currently cached:
|
||||
///
|
||||
/// - If the SHA callback fails, returns the existing cache (possibly stale or None)
|
||||
/// - If the tarball download/read fails, returns the existing cache
|
||||
/// - If tarball extraction fails, returns the existing cache
|
||||
///
|
||||
/// This design assumes that stale code is better than no code, since most of the
|
||||
/// codebase is likely unchanged between versions.
|
||||
async fn get_current_tarball(&self) -> MutexGuard<'_, Option<CachedTarball>> {
|
||||
let current_sha = match (self.get_sha)().await {
|
||||
Ok(sha) => sha,
|
||||
Err(e) => {
|
||||
warn!("Failed to get current SHA for {}: {e}", self.name);
|
||||
return self.cache.lock().await;
|
||||
}
|
||||
};
|
||||
|
||||
let mut cache = self.cache.lock().await;
|
||||
|
||||
// Check if we need to load/reload the tarball.
|
||||
// For file sources, we only load once (no refresh after initial load).
|
||||
// For GitHub sources, we reload when the SHA changes.
|
||||
let needs_refresh = match (&*cache, &self.source) {
|
||||
(None, _) => true,
|
||||
(Some(_), ResolvedSource::File { .. }) => false,
|
||||
(Some(cached), ResolvedSource::GitHub { .. }) => cached.sha != current_sha,
|
||||
};
|
||||
|
||||
if needs_refresh {
|
||||
info!("Loading tarball for {} at {}", self.name, current_sha);
|
||||
|
||||
let tarball = match self.load_tarball(¤t_sha).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!("Failed to load tarball for {}: {e}", self.name);
|
||||
return cache;
|
||||
}
|
||||
};
|
||||
|
||||
let files = match self.extract_tarball(&tarball) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
error!("Failed to extract tarball for {}: {e}", self.name);
|
||||
return cache;
|
||||
}
|
||||
};
|
||||
|
||||
*cache = Some(CachedTarball {
|
||||
sha: current_sha,
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
cache
|
||||
}
|
||||
|
||||
/// Load a tarball either from GitHub or from a local file.
|
||||
async fn load_tarball(&self, sha: &str) -> Result<Vec<u8>, String> {
|
||||
match &self.source {
|
||||
ResolvedSource::GitHub { owner, repo, token } => {
|
||||
self.download_tarball_from_github(owner, repo, token.as_deref(), sha)
|
||||
.await
|
||||
}
|
||||
ResolvedSource::File { path } => std::fs::read(path)
|
||||
.map_err(|e| format!("Failed to read tarball from {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Download a tarball from GitHub for the given SHA.
|
||||
async fn download_tarball_from_github(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
token: Option<&str>,
|
||||
sha: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let url = format!(
|
||||
"https://api.github.com/repos/{}/{}/tarball/{}",
|
||||
owner, repo, sha
|
||||
);
|
||||
|
||||
let mut request = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "signal-gateway")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28");
|
||||
|
||||
if let Some(token) = token {
|
||||
request = request.header("Authorization", format!("Bearer {}", token));
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"GitHub API error: {} {}",
|
||||
response.status(),
|
||||
response.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.map(|b| b.to_vec())
|
||||
.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`).
|
||||
///
|
||||
/// If `path` is None or empty, lists the root directory.
|
||||
pub async fn ls(&self, path: Option<&str>) -> Result<String, String> {
|
||||
let cache = self.get_current_tarball().await;
|
||||
let cached = cache.as_ref().ok_or("source code not available")?;
|
||||
|
||||
let prefix = path.unwrap_or("").trim_start_matches('/');
|
||||
let prefix = if prefix.is_empty() {
|
||||
String::new()
|
||||
} else if prefix.ends_with('/') {
|
||||
prefix.to_string()
|
||||
} else {
|
||||
format!("{}/", prefix)
|
||||
};
|
||||
|
||||
let mut entries = std::collections::BTreeSet::new();
|
||||
|
||||
for file_path in cached.files.keys() {
|
||||
if prefix.is_empty() || file_path.starts_with(&prefix) {
|
||||
// Get the part after the prefix
|
||||
let remainder = if prefix.is_empty() {
|
||||
file_path.as_str()
|
||||
} else {
|
||||
&file_path[prefix.len()..]
|
||||
};
|
||||
|
||||
// Get just the first component (file or directory name)
|
||||
if let Some(first) = remainder.split('/').next()
|
||||
&& !first.is_empty()
|
||||
{
|
||||
// Check if it's a directory (has more components)
|
||||
let is_dir = remainder.contains('/');
|
||||
let entry = if is_dir {
|
||||
format!("{}/", first)
|
||||
} else {
|
||||
first.to_string()
|
||||
};
|
||||
entries.insert(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
Ok(format!(
|
||||
"No files found in '{}'",
|
||||
prefix.trim_end_matches('/')
|
||||
))
|
||||
} else {
|
||||
Ok(entries.into_iter().collect::<Vec<_>>().join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Find files matching a glob pattern (like `find`).
|
||||
///
|
||||
/// Supports simple glob patterns with `*` wildcards.
|
||||
pub async fn find(&self, pattern: Option<&str>) -> Result<String, String> {
|
||||
let cache = self.get_current_tarball().await;
|
||||
let cached = cache.as_ref().ok_or("source code not available")?;
|
||||
|
||||
let pattern = pattern.unwrap_or("*");
|
||||
|
||||
// Convert glob pattern to regex
|
||||
let regex_pattern = glob_to_regex(pattern);
|
||||
let regex = Regex::new(®ex_pattern).map_err(|e| format!("Invalid pattern: {e}"))?;
|
||||
|
||||
let mut matches: Vec<&str> = cached
|
||||
.files
|
||||
.keys()
|
||||
.filter(|path| regex.is_match(path))
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
|
||||
matches.sort();
|
||||
|
||||
if matches.is_empty() {
|
||||
Ok(format!("No files matching '{}'", pattern))
|
||||
} else {
|
||||
Ok(matches.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a file's contents.
|
||||
///
|
||||
/// If `line_range` is provided, only returns those lines (1-indexed, inclusive).
|
||||
pub async fn read(
|
||||
&self,
|
||||
path: &str,
|
||||
line_start: Option<usize>,
|
||||
line_end: Option<usize>,
|
||||
) -> Result<String, String> {
|
||||
let cache = self.get_current_tarball().await;
|
||||
let cached = cache.as_ref().ok_or("source code not available")?;
|
||||
|
||||
let path = path.trim_start_matches('/');
|
||||
let file = cached
|
||||
.files
|
||||
.get(path)
|
||||
.ok_or_else(|| format!("File not found: {}", path))?;
|
||||
|
||||
let lines: Vec<&str> = file.content.lines().collect();
|
||||
|
||||
// Handle line range (1-indexed)
|
||||
let start = line_start.unwrap_or(1).saturating_sub(1);
|
||||
let end = line_end.unwrap_or(lines.len()).min(lines.len());
|
||||
|
||||
if start >= lines.len() {
|
||||
return Ok(format!(
|
||||
"Line {} is past end of file ({} lines)",
|
||||
start + 1,
|
||||
lines.len()
|
||||
));
|
||||
}
|
||||
|
||||
let mut output = String::new();
|
||||
for (i, line) in lines[start..end].iter().enumerate() {
|
||||
writeln!(&mut output, "{:>6}\t{}", start + i + 1, line)
|
||||
.map_err(|e| format!("Format error: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Search for a regex pattern in all files.
|
||||
///
|
||||
/// - `pattern`: The regex pattern to search for.
|
||||
/// - `context`: Number of context lines to show (like `grep -C`).
|
||||
/// - `path_prefix`: Optional path prefix to limit search scope.
|
||||
pub async fn search(
|
||||
&self,
|
||||
pattern: &str,
|
||||
context: u32,
|
||||
path_prefix: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {e}"))?;
|
||||
|
||||
let cache = self.get_current_tarball().await;
|
||||
let cached = cache.as_ref().ok_or("source code not available")?;
|
||||
|
||||
let prefix = path_prefix.map(|p| p.trim_start_matches('/'));
|
||||
|
||||
let mut output = String::new();
|
||||
let mut match_count = 0;
|
||||
let mut file_count = 0;
|
||||
const MAX_MATCHES: usize = 100;
|
||||
|
||||
let mut sorted_files: Vec<_> = cached.files.iter().collect();
|
||||
sorted_files.sort_by_key(|(path, _)| *path);
|
||||
|
||||
'outer: for (path, file) in sorted_files {
|
||||
// Skip if path doesn't match prefix
|
||||
if let Some(prefix) = prefix
|
||||
&& !path.starts_with(prefix)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip binary-looking files
|
||||
if looks_binary(&file.content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let lines: Vec<&str> = file.content.lines().collect();
|
||||
let mut file_matches = Vec::new();
|
||||
|
||||
for (line_num, line) in lines.iter().enumerate() {
|
||||
if regex.is_match(line) {
|
||||
file_matches.push(line_num);
|
||||
match_count += 1;
|
||||
if match_count >= MAX_MATCHES {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !file_matches.is_empty() {
|
||||
file_count += 1;
|
||||
|
||||
if context == 0 {
|
||||
// No context, just print matches
|
||||
for &line_num in &file_matches {
|
||||
writeln!(
|
||||
&mut output,
|
||||
"{}:{}: {}",
|
||||
path,
|
||||
line_num + 1,
|
||||
lines[line_num]
|
||||
)
|
||||
.map_err(|e| format!("Format error: {e}"))?;
|
||||
}
|
||||
} else {
|
||||
// Print with context
|
||||
writeln!(&mut output, "=== {} ===", path)
|
||||
.map_err(|e| format!("Format error: {e}"))?;
|
||||
|
||||
let context = context as usize;
|
||||
let mut printed = std::collections::BTreeSet::new();
|
||||
|
||||
for &match_line in &file_matches {
|
||||
let start = match_line.saturating_sub(context);
|
||||
let end = (match_line + context + 1).min(lines.len());
|
||||
|
||||
// Add separator if there's a gap
|
||||
if let Some(&last) = printed.iter().next_back()
|
||||
&& start > last + 1
|
||||
{
|
||||
writeln!(&mut output, "---")
|
||||
.map_err(|e| format!("Format error: {e}"))?;
|
||||
}
|
||||
|
||||
for (i, line) in lines[start..end].iter().enumerate() {
|
||||
let line_idx = start + i;
|
||||
if printed.insert(line_idx) {
|
||||
let marker = if line_idx == match_line { ">" } else { " " };
|
||||
writeln!(&mut output, "{}{:>5}\t{}", marker, line_idx + 1, line)
|
||||
.map_err(|e| format!("Format error: {e}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
writeln!(&mut output).map_err(|e| format!("Format error: {e}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if match_count == 0 {
|
||||
Ok(format!("No matches for '{}'", pattern))
|
||||
} else {
|
||||
let truncated = if match_count >= MAX_MATCHES {
|
||||
format!(" (truncated at {} matches)", MAX_MATCHES)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Ok(format!(
|
||||
"{}\n[{} matches in {} files{}]",
|
||||
output.trim_end(),
|
||||
match_count,
|
||||
file_count,
|
||||
truncated
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a simple glob pattern to a regex.
|
||||
fn glob_to_regex(pattern: &str) -> String {
|
||||
let mut regex = String::from("^");
|
||||
for c in pattern.chars() {
|
||||
match c {
|
||||
'*' => regex.push_str(".*"),
|
||||
'?' => regex.push('.'),
|
||||
'.' | '+' | '(' | ')' | '[' | ']' | '{' | '}' | '^' | '$' | '|' | '\\' => {
|
||||
regex.push('\\');
|
||||
regex.push(c);
|
||||
}
|
||||
_ => regex.push(c),
|
||||
}
|
||||
}
|
||||
regex.push('$');
|
||||
regex
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub struct CodeToolTools {
|
||||
apps: Vec<CodeTool>,
|
||||
}
|
||||
|
||||
impl CodeToolTools {
|
||||
/// Create a new CodeToolTools instance.
|
||||
pub fn new(apps: Vec<CodeTool>) -> Self {
|
||||
Self { apps }
|
||||
}
|
||||
|
||||
/// Find an app by name.
|
||||
fn find_app(&self, name: &str) -> Option<&CodeTool> {
|
||||
self.apps.iter().find(|app| app.name() == name)
|
||||
}
|
||||
|
||||
/// Get list of app names for error messages.
|
||||
fn app_names(&self) -> String {
|
||||
self.apps
|
||||
.iter()
|
||||
.map(|a| a.name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LsInput {
|
||||
app: String,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FindInput {
|
||||
app: String,
|
||||
pattern: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReadInput {
|
||||
app: String,
|
||||
path: String,
|
||||
line_start: Option<usize>,
|
||||
line_end: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SearchInput {
|
||||
app: String,
|
||||
pattern: String,
|
||||
#[serde(default)]
|
||||
context: u32,
|
||||
path_prefix: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolExecutor for CodeToolTools {
|
||||
fn tools(&self) -> Vec<Tool> {
|
||||
vec![
|
||||
Tool {
|
||||
name: "code_ls",
|
||||
description: "List files in a directory of an application's source code.",
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"app": {
|
||||
"type": "string",
|
||||
"description": "Name of the application"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory path to list (optional, defaults to root)"
|
||||
}
|
||||
},
|
||||
"required": ["app"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "code_find",
|
||||
description: "Find files matching a glob pattern in an application's source code.",
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"app": {
|
||||
"type": "string",
|
||||
"description": "Name of the application"
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern to match (e.g., '*.rs', 'src/*.py')"
|
||||
}
|
||||
},
|
||||
"required": ["app"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "code_read",
|
||||
description: "Read a file from an application's source code.",
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"app": {
|
||||
"type": "string",
|
||||
"description": "Name of the application"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to read"
|
||||
},
|
||||
"line_start": {
|
||||
"type": "integer",
|
||||
"description": "Starting line number (1-indexed, optional)"
|
||||
},
|
||||
"line_end": {
|
||||
"type": "integer",
|
||||
"description": "Ending line number (inclusive, optional)"
|
||||
}
|
||||
},
|
||||
"required": ["app", "path"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "code_search",
|
||||
description: "Search for a regex pattern in an application's source code (like grep).",
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"app": {
|
||||
"type": "string",
|
||||
"description": "Name of the application"
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Regex pattern to search for"
|
||||
},
|
||||
"context": {
|
||||
"type": "integer",
|
||||
"description": "Number of context lines to show (like grep -C, default 0)"
|
||||
},
|
||||
"path_prefix": {
|
||||
"type": "string",
|
||||
"description": "Optional path prefix to limit search scope"
|
||||
}
|
||||
},
|
||||
"required": ["app", "pattern"]
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn has_tool(&self, name: &str) -> bool {
|
||||
matches!(name, "code_ls" | "code_find" | "code_read" | "code_search")
|
||||
}
|
||||
|
||||
async fn execute(&self, name: &str, input: &serde_json::Value) -> Result<ToolResult, String> {
|
||||
match name {
|
||||
"code_ls" => {
|
||||
let input: LsInput = serde_json::from_value(input.clone())
|
||||
.map_err(|e| format!("Invalid input: {e}"))?;
|
||||
let app = self.find_app(&input.app).ok_or_else(|| {
|
||||
format!(
|
||||
"Unknown app '{}'. Available: {}",
|
||||
input.app,
|
||||
self.app_names()
|
||||
)
|
||||
})?;
|
||||
let result = app.ls(input.path.as_deref()).await?;
|
||||
Ok(ToolResult::new(result))
|
||||
}
|
||||
"code_find" => {
|
||||
let input: FindInput = serde_json::from_value(input.clone())
|
||||
.map_err(|e| format!("Invalid input: {e}"))?;
|
||||
let app = self.find_app(&input.app).ok_or_else(|| {
|
||||
format!(
|
||||
"Unknown app '{}'. Available: {}",
|
||||
input.app,
|
||||
self.app_names()
|
||||
)
|
||||
})?;
|
||||
let result = app.find(input.pattern.as_deref()).await?;
|
||||
Ok(ToolResult::new(result))
|
||||
}
|
||||
"code_read" => {
|
||||
let input: ReadInput = serde_json::from_value(input.clone())
|
||||
.map_err(|e| format!("Invalid input: {e}"))?;
|
||||
let app = self.find_app(&input.app).ok_or_else(|| {
|
||||
format!(
|
||||
"Unknown app '{}'. Available: {}",
|
||||
input.app,
|
||||
self.app_names()
|
||||
)
|
||||
})?;
|
||||
let result = app
|
||||
.read(&input.path, input.line_start, input.line_end)
|
||||
.await?;
|
||||
Ok(ToolResult::new(result))
|
||||
}
|
||||
"code_search" => {
|
||||
let input: SearchInput = serde_json::from_value(input.clone())
|
||||
.map_err(|e| format!("Invalid input: {e}"))?;
|
||||
let app = self.find_app(&input.app).ok_or_else(|| {
|
||||
format!(
|
||||
"Unknown app '{}'. Available: {}",
|
||||
input.app,
|
||||
self.app_names()
|
||||
)
|
||||
})?;
|
||||
let result = app
|
||||
.search(&input.pattern, input.context, input.path_prefix.as_deref())
|
||||
.await?;
|
||||
Ok(ToolResult::new(result))
|
||||
}
|
||||
_ => Err(format!("Unknown tool: {name}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Integration tests for signal-gateway-code-tool.
|
||||
//!
|
||||
//! These tests exercise the GitHub tarball download and file browsing functionality
|
||||
//! against a real public repository at a pinned commit.
|
||||
|
||||
use signal_gateway_code_tool::{CodeTool, CodeToolConfig, GitHubRepo, ShaCallback, Source};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Test against cbeck88/ver-stub-rs at a known commit.
|
||||
/// This is a public repo, so no token is needed.
|
||||
const TEST_OWNER: &str = "cbeck88";
|
||||
const TEST_REPO: &str = "ver-stub-rs";
|
||||
const TEST_SHA: &str = "79b98e25f27ae4f5dd73a5a3d8f37dad655a57e8";
|
||||
|
||||
fn create_test_repo_code() -> CodeTool {
|
||||
create_test_repo_code_with_glob(vec![])
|
||||
}
|
||||
|
||||
fn create_test_repo_code_with_glob(glob: Vec<String>) -> CodeTool {
|
||||
let config = CodeToolConfig {
|
||||
name: "test-app".to_string(),
|
||||
source: Source::GitHub {
|
||||
repo: GitHubRepo {
|
||||
owner: TEST_OWNER.to_string(),
|
||||
repo: TEST_REPO.to_string(),
|
||||
},
|
||||
token_file: None, // Public repo, no auth needed
|
||||
},
|
||||
glob,
|
||||
include_non_utf8: false,
|
||||
};
|
||||
|
||||
let sha = TEST_SHA.to_string();
|
||||
let sha_callback: ShaCallback = Arc::new(move || {
|
||||
let sha = sha.clone();
|
||||
Box::pin(async move { Ok(sha) })
|
||||
});
|
||||
|
||||
CodeTool::new(config, sha_callback).expect("Failed to create CodeTool")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ls_root() {
|
||||
let app = create_test_repo_code();
|
||||
|
||||
let result = app.ls(None).await.expect("ls failed");
|
||||
|
||||
// Verify expected top-level entries exist
|
||||
assert!(result.contains("Cargo.toml"), "Should contain Cargo.toml");
|
||||
assert!(result.contains("README.md"), "Should contain README.md");
|
||||
assert!(
|
||||
result.contains("ver-stub/"),
|
||||
"Should contain ver-stub/ directory"
|
||||
);
|
||||
assert!(
|
||||
result.contains("ver-stub-build/"),
|
||||
"Should contain ver-stub-build/ directory"
|
||||
);
|
||||
assert!(result.contains("tests.sh"), "Should contain tests.sh");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ls_subdirectory() {
|
||||
let app = create_test_repo_code();
|
||||
|
||||
let result = app.ls(Some("ver-stub")).await.expect("ls failed");
|
||||
|
||||
// Should have src/ directory and Cargo.toml
|
||||
assert!(result.contains("src/"), "Should contain src/ directory");
|
||||
assert!(result.contains("Cargo.toml"), "Should contain Cargo.toml");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_find_rust_files() {
|
||||
let app = create_test_repo_code();
|
||||
|
||||
let result = app.find(Some("*.rs")).await.expect("find failed");
|
||||
|
||||
// Should find Rust source files
|
||||
assert!(
|
||||
result.contains("ver-stub/src/lib.rs"),
|
||||
"Should find ver-stub/src/lib.rs"
|
||||
);
|
||||
assert!(
|
||||
result.contains("ver-stub-build/src/lib.rs"),
|
||||
"Should find ver-stub-build/src/lib.rs"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_find_with_path_pattern() {
|
||||
let app = create_test_repo_code();
|
||||
|
||||
let result = app
|
||||
.find(Some("ver-stub-build/src/*.rs"))
|
||||
.await
|
||||
.expect("find failed");
|
||||
|
||||
// Should find files in ver-stub-build/src/
|
||||
assert!(
|
||||
result.contains("ver-stub-build/src/lib.rs"),
|
||||
"Should find lib.rs"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_cargo_toml() {
|
||||
let app = create_test_repo_code();
|
||||
|
||||
let result = app
|
||||
.read("Cargo.toml", None, None)
|
||||
.await
|
||||
.expect("read failed");
|
||||
|
||||
// Verify content matches what we know is in the file
|
||||
assert!(result.contains("[workspace]"), "Should contain [workspace]");
|
||||
assert!(result.contains("ver-stub"), "Should contain ver-stub");
|
||||
assert!(
|
||||
result.contains("ver-stub-build"),
|
||||
"Should contain ver-stub-build"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_with_line_range() {
|
||||
let app = create_test_repo_code();
|
||||
|
||||
// Read just the first 5 lines
|
||||
let result = app
|
||||
.read("Cargo.toml", Some(1), Some(5))
|
||||
.await
|
||||
.expect("read failed");
|
||||
|
||||
// Should only have 5 lines
|
||||
let line_count = result.lines().count();
|
||||
assert_eq!(line_count, 5, "Should have exactly 5 lines");
|
||||
|
||||
// First line should be [workspace]
|
||||
assert!(
|
||||
result.contains("[workspace]"),
|
||||
"First lines should contain [workspace]"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_nonexistent_file() {
|
||||
let app = create_test_repo_code();
|
||||
|
||||
let result = app.read("nonexistent-file.txt", None, None).await;
|
||||
|
||||
assert!(result.is_err(), "Should fail for nonexistent file");
|
||||
assert!(
|
||||
result.unwrap_err().contains("not found"),
|
||||
"Error should mention file not found"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_simple() {
|
||||
let app = create_test_repo_code();
|
||||
|
||||
let result = app
|
||||
.search("workspace", 0, None)
|
||||
.await
|
||||
.expect("search failed");
|
||||
|
||||
// Should find "workspace" in Cargo.toml
|
||||
assert!(
|
||||
result.contains("Cargo.toml"),
|
||||
"Should find match in Cargo.toml"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_with_context() {
|
||||
let app = create_test_repo_code();
|
||||
|
||||
let result = app
|
||||
.search("resolver", 2, None)
|
||||
.await
|
||||
.expect("search failed");
|
||||
|
||||
// Should have context lines around the match
|
||||
assert!(
|
||||
result.contains("Cargo.toml"),
|
||||
"Should find match in Cargo.toml"
|
||||
);
|
||||
// With context, should see surrounding lines
|
||||
assert!(
|
||||
result.contains("[workspace]"),
|
||||
"Should show context including [workspace]"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_with_path_prefix() {
|
||||
let app = create_test_repo_code();
|
||||
|
||||
// Search only in ver-stub-build directory
|
||||
let result = app
|
||||
.search("pub", 0, Some("ver-stub-build/src"))
|
||||
.await
|
||||
.expect("search failed");
|
||||
|
||||
// Should only find matches in ver-stub-build/src
|
||||
for line in result.lines() {
|
||||
if line.contains(":") && !line.starts_with('[') {
|
||||
// This is a match line (path:line: content)
|
||||
assert!(
|
||||
line.starts_with("ver-stub-build/src"),
|
||||
"All matches should be in ver-stub-build/src, got: {}",
|
||||
line
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_no_matches() {
|
||||
let app = create_test_repo_code();
|
||||
|
||||
let result = app
|
||||
.search("xyzzy_unlikely_string_12345", 0, None)
|
||||
.await
|
||||
.expect("search failed");
|
||||
|
||||
assert!(result.contains("No matches"), "Should report no matches");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_glob_filter_rust_files_only() {
|
||||
// Only include .rs files
|
||||
let app = create_test_repo_code_with_glob(vec!["**/*.rs".to_string()]);
|
||||
|
||||
let result = app.find(Some("*")).await.expect("find failed");
|
||||
|
||||
// Should find Rust files
|
||||
assert!(result.contains(".rs"), "Should contain .rs files");
|
||||
|
||||
// Should NOT find non-Rust files
|
||||
assert!(
|
||||
!result.contains("Cargo.toml"),
|
||||
"Should not contain Cargo.toml"
|
||||
);
|
||||
assert!(
|
||||
!result.contains("README.md"),
|
||||
"Should not contain README.md"
|
||||
);
|
||||
assert!(!result.contains("tests.sh"), "Should not contain tests.sh");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_glob_filter_specific_directory() {
|
||||
// Only include files in ver-stub/src
|
||||
let app = create_test_repo_code_with_glob(vec!["ver-stub/src/**".to_string()]);
|
||||
|
||||
let result = app.find(Some("*")).await.expect("find failed");
|
||||
|
||||
// Should find files in ver-stub/src
|
||||
assert!(
|
||||
result.contains("ver-stub/src/lib.rs"),
|
||||
"Should contain ver-stub/src/lib.rs"
|
||||
);
|
||||
|
||||
// Should NOT find files outside ver-stub/src
|
||||
assert!(
|
||||
!result.contains("ver-stub-build/"),
|
||||
"Should not contain ver-stub-build files"
|
||||
);
|
||||
assert!(
|
||||
!result.contains("Cargo.toml"),
|
||||
"Should not contain root Cargo.toml"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_glob_filter_multiple_patterns() {
|
||||
// Include both Cargo.toml files and shell scripts
|
||||
let app =
|
||||
create_test_repo_code_with_glob(vec!["**/Cargo.toml".to_string(), "*.sh".to_string()]);
|
||||
|
||||
let result = app.find(Some("*")).await.expect("find failed");
|
||||
|
||||
// Should find Cargo.toml files
|
||||
assert!(result.contains("Cargo.toml"), "Should contain Cargo.toml");
|
||||
|
||||
// Should find shell scripts
|
||||
assert!(result.contains("tests.sh"), "Should contain tests.sh");
|
||||
|
||||
// Should NOT find other files
|
||||
assert!(
|
||||
!result.contains("README.md"),
|
||||
"Should not contain README.md"
|
||||
);
|
||||
assert!(!result.contains(".rs"), "Should not contain .rs files");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_glob_filter_ls_shows_filtered_dirs() {
|
||||
// Only include files in ver-stub directory
|
||||
let app = create_test_repo_code_with_glob(vec!["ver-stub/**".to_string()]);
|
||||
|
||||
let result = app.ls(None).await.expect("ls failed");
|
||||
|
||||
// Root ls should only show ver-stub/ since other dirs are empty after filtering
|
||||
assert!(result.contains("ver-stub/"), "Should show ver-stub/");
|
||||
|
||||
// Other directories should not appear (they have no matching files)
|
||||
assert!(
|
||||
!result.contains("ver-stub-build/"),
|
||||
"Should not show ver-stub-build/"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user