use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use adk_core::{Result, Tool, ToolContext};
use async_trait::async_trait;
use serde_json::{Value, json};
use walkdir::WalkDir;
use crate::error::DevToolError;
use crate::tools::read::require_str;
use crate::workspace::Workspace;
const MAX_RESULTS: usize = 1000;
pub struct GlobTool {
workspace: Workspace,
}
impl GlobTool {
pub fn new(workspace: Workspace) -> Self {
Self { workspace }
}
}
#[async_trait]
impl Tool for GlobTool {
fn name(&self) -> &str {
"glob"
}
fn description(&self) -> &str {
"List files matching a glob pattern (e.g. 'src/**/*.rs'). Returns paths \
relative to the workspace root. The pattern is relative to the workspace \
and may not contain '..' or an absolute path."
}
fn is_read_only(&self) -> bool {
true
}
fn is_concurrency_safe(&self) -> bool {
true
}
fn parameters_schema(&self) -> Option<Value> {
Some(json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Glob pattern, e.g. '**/*.toml'." },
"path": { "type": "string", "description": "Optional sub-directory to search within." }
},
"required": ["pattern"]
}))
}
async fn execute(&self, _ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
let pattern = require_str(&args, "pattern")?;
let escapes = Path::new(&pattern).components().any(|component| match component {
Component::Prefix(_) | Component::RootDir | Component::ParentDir => true,
Component::CurDir | Component::Normal(_) => false,
});
if escapes {
return Err(DevToolError::PathEscape(pattern).into());
}
let base = match args.get("path").and_then(Value::as_str) {
Some(sub) => self.workspace.resolve(sub)?,
None => self.workspace.root().to_path_buf(),
};
let matcher = glob::Pattern::new(&pattern)
.map_err(|e| DevToolError::Other(format!("invalid glob pattern: {e}")))?;
let options = glob::MatchOptions { require_literal_separator: true, ..Default::default() };
let literal: PathBuf = Path::new(&pattern)
.components()
.take_while(|component| match component {
Component::Normal(part) => !part.to_string_lossy().contains(['*', '?', '[']),
Component::Prefix(_)
| Component::RootDir
| Component::CurDir
| Component::ParentDir => false,
})
.collect();
let start = base.join(literal);
let mut matches = Vec::new();
let mut truncated = false;
if start.exists() {
for entry in WalkDir::new(&start).sort_by_file_name().into_iter().flatten() {
let path = entry.path();
let Ok(relative) = path.strip_prefix(&base) else { continue };
if relative.as_os_str().is_empty() || !matcher.matches_path_with(relative, options)
{
continue;
}
let contained = std::fs::canonicalize(path)
.is_ok_and(|canonical| canonical.starts_with(self.workspace.root()));
if !contained {
continue;
}
if matches.len() >= MAX_RESULTS {
truncated = true;
break;
}
matches.push(self.workspace.display(path));
}
}
Ok(json!({
"pattern": pattern,
"matches": matches,
"count": matches.len(),
"truncated": truncated,
}))
}
}