use std::io;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use super::{MAX_RESULTS, ToolDefinition, ToolOutput, ToolUseRequest};
use crate::tools::TIMEOUT_SECS;
use crate::tools::registry::{ToolContext, ToolError, ToolExecution};
use crate::tools::subproc::CommandResult;
const NAME: &str = "find_files";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FindFilesInput {
pattern: String,
glob: Option<String>,
extensions: Vec<String>,
max_depth: Option<u32>,
include_hidden: bool,
follow_symlinks: bool,
}
#[derive(Clone, Debug)]
pub struct FindFiles<'a> {
pub pattern: &'a str,
pub root: &'a Path,
pub glob: Option<&'a str>,
pub extensions: &'a [String],
pub max_depth: Option<u32>,
pub max_results: usize,
pub include_hidden: bool,
pub follow_symlinks: bool,
}
impl FindFiles<'_> {
pub fn run(&self) -> ToolOutput {
if !super::path::is_within_root(self.root, self.root) {
return ToolOutput::failed("find_files", "invalid workspace root".to_string());
}
let timeout = Duration::from_secs(TIMEOUT_SECS);
let result = if super::subproc::command_exists("fd") {
FdFind {
pattern: self.pattern,
root: self.root,
glob: self.glob,
extensions: self.extensions,
max_depth: self.max_depth,
include_hidden: self.include_hidden,
follow_symlinks: self.follow_symlinks,
timeout,
}
.run()
} else {
self.fallback(timeout)
};
match result {
Ok(output) => {
let paths: Vec<String> = output
.stdout
.lines()
.filter(|l| !l.is_empty())
.map(|s| s.to_string())
.collect();
let paths = super::subproc::truncate_results(paths, self.max_results);
ToolOutput::ok("find_files", paths)
}
Err(e) => ToolOutput::failed("find_files", format!("find_files failed: {e}")),
}
}
fn fallback(&self, timeout: Duration) -> io::Result<CommandResult> {
let mut cmd = Command::new("find");
cmd.arg(self.root).arg("-type").arg("f");
if !self.include_hidden {
cmd.arg("-not").arg("-path").arg("*/.*");
}
if let Some(depth) = self.max_depth {
cmd.arg("-maxdepth").arg(depth.to_string());
}
cmd.arg("-name").arg(self.pattern);
for ext in self.extensions {
cmd.arg("-o").arg("-name").arg(format!("*.{ext}"));
}
super::subproc::run_with_timeout(cmd, timeout)
}
}
#[derive(Clone, Debug)]
pub struct FdFind<'a> {
pub pattern: &'a str,
pub root: &'a Path,
pub glob: Option<&'a str>,
pub extensions: &'a [String],
pub max_depth: Option<u32>,
pub include_hidden: bool,
pub follow_symlinks: bool,
pub timeout: Duration,
}
impl FdFind<'_> {
pub fn run(&self) -> io::Result<CommandResult> {
let mut cmd = Command::new("fd");
cmd.arg("--type").arg("f");
if self.include_hidden {
cmd.arg("--hidden");
}
if self.follow_symlinks {
cmd.arg("--follow");
}
if let Some(depth) = self.max_depth {
cmd.arg("--max-depth").arg(depth.to_string());
}
if let Some(g) = self.glob {
cmd.arg("--glob").arg(g);
}
for ext in self.extensions {
cmd.arg("--extension").arg(ext);
}
cmd.arg(self.pattern).arg(self.root);
super::subproc::run_with_timeout(cmd, self.timeout)
}
}
pub fn definition() -> ToolDefinition {
ToolDefinition::new(
NAME,
r#"find_files
Locate files by name or glob under the workspace root.
Use this when you know (or can guess) a file name and need its path. Prefer this over
listing all files. Paths are contained to the root; hidden files and symlinks are off
unless requested. Capped at 100 results; long lines truncate at 512 chars."#,
serde_json::json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "File name or glob pattern to search for." },
"glob": { "type": "string", "description": "Optional additional glob filter." },
"extensions": { "type": "array", "items": { "type": "string" } },
"max_depth": { "type": "integer" },
"include_hidden": { "type": "boolean" },
"follow_symlinks": { "type": "boolean" }
},
"required": ["pattern"]
}),
)
}
pub fn parse_arguments(arguments: &str) -> Result<FindFilesInput, ToolError> {
let args = serde_json::from_str::<serde_json::Value>(arguments).unwrap_or(serde_json::Value::Null);
Ok(FindFilesInput {
pattern: args
.get("pattern")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
glob: args.get("glob").and_then(|value| value.as_str()).map(str::to_string),
extensions: args
.get("extensions")
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
max_depth: args
.get("max_depth")
.and_then(|value| value.as_u64())
.map(|depth| depth as u32),
include_hidden: args
.get("include_hidden")
.and_then(|value| value.as_bool())
.unwrap_or(false),
follow_symlinks: args
.get("follow_symlinks")
.and_then(|value| value.as_bool())
.unwrap_or(false),
})
}
pub fn execute_request(request: &ToolUseRequest, ctx: &ToolContext<'_>) -> ToolExecution {
match parse_arguments(&request.arguments) {
Ok(input) => ToolExecution::output(exec_input(&input, ctx.root)),
Err(error) => ToolExecution::output(ToolOutput::failed(NAME, error.to_string())),
}
}
fn exec_input(input: &FindFilesInput, root: &Path) -> ToolOutput {
FindFiles {
pattern: &input.pattern,
root,
glob: input.glob.as_deref(),
extensions: &input.extensions,
max_depth: input.max_depth,
max_results: MAX_RESULTS,
include_hidden: input.include_hidden,
follow_symlinks: input.follow_symlinks,
}
.run()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
app::ToolStatus,
tools::{self, MAX_RESULTS},
};
#[test]
fn find_files_finds_cli_rs() {
let output = FindFiles {
pattern: "mod.rs",
root: Path::new("src/cli"),
glob: None,
extensions: &[],
max_depth: None,
max_results: MAX_RESULTS,
include_hidden: false,
follow_symlinks: false,
}
.run();
assert_eq!(output.status, ToolStatus::Ok);
assert!(output.display.lines.iter().any(|p| p.contains("cli/mod.rs")));
}
#[test]
fn find_files_no_matches_returns_empty() {
let output = FindFiles {
pattern: "zzz_nonexistent_zzz",
root: Path::new("src"),
glob: None,
extensions: &[],
max_depth: None,
max_results: MAX_RESULTS,
include_hidden: false,
follow_symlinks: false,
}
.run();
assert_eq!(output.status, ToolStatus::Ok);
assert!(output.display.lines.is_empty());
}
#[test]
fn parse_arguments_reads_all_fields() {
let input = parse_arguments(
r#"{"pattern":"cli","glob":"*.rs","extensions":["rs"],"max_depth":2,"include_hidden":true,"follow_symlinks":true}"#,
)
.expect("parse");
assert_eq!(input.pattern, "cli");
assert_eq!(input.glob.as_deref(), Some("*.rs"));
assert_eq!(input.extensions, vec!["rs"]);
assert_eq!(input.max_depth, Some(2));
assert!(input.include_hidden);
assert!(input.follow_symlinks);
}
#[test]
fn parse_arguments_malformed_json_uses_safe_defaults() {
let input = parse_arguments("not valid json").expect("parse");
assert_eq!(input.pattern, "");
assert_eq!(input.glob, None);
assert!(input.extensions.is_empty());
assert_eq!(input.max_depth, None);
assert!(!input.include_hidden);
assert!(!input.follow_symlinks);
}
#[test]
fn registry_execute_finds_file() {
let request = ToolUseRequest::new(
NAME.to_string(),
serde_json::json!({"pattern":"Cargo.toml"}).to_string(),
"call_1".to_string(),
);
let output = tools::registry::execute(&request, &tools::registry::ToolContext::new(Path::new("."))).output;
assert_eq!(output.status, ToolStatus::Ok);
assert!(output.display.lines.iter().any(|path| path.contains("Cargo.toml")));
}
}