1use std::path::Path;
4use std::process::Stdio;
5
6use futures_util::future::BoxFuture;
7use serde_json::{Map, Value, json};
8use tokio_util::sync::CancellationToken;
9use yapi_agent::{Tool, UpdateSink};
10use yapi_types::event::ToolResult;
11use yapi_types::message::ToolDeclaration;
12
13use super::external::{ExternalTool, ensure_tool};
14use super::path::{relative, resolve_to_cwd};
15use super::truncate::DEFAULT_MAX_BYTES;
16use super::{ToolEnv, capped_output, check_abort, js_number, plain_declaration, text_result};
17
18const DEFAULT_LIMIT: f64 = 1000.0;
19
20pub struct Find {
22 env: ToolEnv,
23 declaration: ToolDeclaration,
24}
25
26impl Find {
27 pub fn new(env: ToolEnv) -> Find {
29 Find {
30 env,
31 declaration: plain_declaration(
32 "find",
33 format!(
34 "Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to {} results or {}KB (whichever is hit first).",
35 DEFAULT_LIMIT,
36 DEFAULT_MAX_BYTES / 1024
37 ),
38 json!({"type":"object","required":["pattern"],"properties":{
39 "pattern":{"type":"string","description":"Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"},
40 "path":{"type":"string","description":"Directory to search in (default: current directory)"},
41 "limit":{"type":"number","description":"Maximum number of results (default: 1000)"}}}),
42 ),
43 }
44 }
45}
46
47impl Tool for Find {
48 fn declaration(&self) -> &ToolDeclaration {
49 &self.declaration
50 }
51
52 fn execute(
53 &self,
54 _call_id: String,
55 args: Value,
56 cancel: CancellationToken,
57 _updates: UpdateSink,
58 ) -> BoxFuture<'_, Result<ToolResult, String>> {
59 Box::pin(async move {
60 check_abort(&cancel)?;
61 tokio::select! {
62 () = cancel.cancelled() => Err("Operation aborted".to_owned()),
63 result = self.find(args) => result,
64 }
65 })
66 }
67}
68
69fn relativize(result: &str, search: &Path) -> String {
72 let trailing = result.ends_with('/');
73 let path = Path::new(result);
74 let relative = if path.is_absolute() {
75 relative(search, path)
76 } else {
77 result.to_owned()
78 };
79 if trailing && !relative.ends_with('/') {
80 format!("{relative}/")
81 } else {
82 relative
83 }
84}
85
86impl Find {
87 async fn find(&self, args: Value) -> Result<ToolResult, String> {
88 let pattern = args["pattern"].as_str().unwrap_or_default();
89 let dir = args["path"]
90 .as_str()
91 .filter(|p| !p.is_empty())
92 .unwrap_or(".");
93 let search = resolve_to_cwd(dir, &self.env.cwd);
94 let limit = args["limit"].as_f64().unwrap_or(DEFAULT_LIMIT);
95 let Some(fd) = ensure_tool(ExternalTool::Fd, &self.env.bin_dir).await else {
96 return Err("fd is not available and could not be downloaded".into());
97 };
98 let mut command = tokio::process::Command::new(&fd);
99 command.args(["--glob", "--color=never", "--hidden"]);
100 if !search.ancestors().any(|dir| dir.join(".git").exists()) {
103 command.arg("--no-require-git");
104 }
105 command.args(["--max-results", &js_number(limit)]);
106 let mut effective = pattern.to_owned();
108 if pattern.contains('/') {
109 command.arg("--full-path");
110 if !pattern.starts_with('/') && !pattern.starts_with("**/") && pattern != "**" {
111 effective = format!("**/{pattern}");
112 }
113 }
114 let output = command
115 .arg("--")
116 .arg(&effective)
117 .arg(&search)
118 .stdin(Stdio::null())
119 .kill_on_drop(true)
120 .output()
121 .await
122 .map_err(|err| format!("Failed to run fd: {err}"))?;
123 let stdout = String::from_utf8_lossy(&output.stdout);
124 let lines: Vec<&str> = stdout.lines().collect();
125 let joined = lines.join("\n");
126 if !output.status.success() && joined.is_empty() {
127 let stderr = String::from_utf8_lossy(&output.stderr);
128 let message = stderr.trim();
129 return Err(if message.is_empty() {
130 format!(
131 "fd exited with code {}",
132 output
133 .status
134 .code()
135 .map_or("null".to_owned(), |c| c.to_string())
136 )
137 } else {
138 message.to_owned()
139 });
140 }
141 if joined.is_empty() {
142 return Ok(text_result("No files found matching pattern", None));
143 }
144 let results: Vec<String> = lines
145 .iter()
146 .map(|line| line.trim_end_matches('\r').trim())
147 .filter(|line| !line.is_empty())
148 .map(|line| relativize(line, &search))
149 .collect();
150 let mut details = Map::new();
151 let mut notices = Vec::new();
152 if results.len() as f64 >= limit {
153 notices.push(format!(
154 "{} results limit reached. Use limit={} for more, or refine pattern",
155 js_number(limit),
156 js_number(limit * 2.0)
157 ));
158 details.insert("resultLimitReached".into(), json!(limit));
159 }
160 Ok(capped_output(
161 &results.join("\n"),
162 notices,
163 Vec::new(),
164 details,
165 ))
166 }
167}