Skip to main content

yapi_core/tools/
find.rs

1//! `find`: files by glob pattern through `fd`, respecting `.gitignore`.
2
3use 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
20/// The `find` tool.
21pub struct Find {
22    env: ToolEnv,
23    declaration: ToolDeclaration,
24}
25
26impl Find {
27    /// A `find` tool for `env`.
28    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
69/// A result path relative to the search root with `/` separators, keeping a
70/// trailing separator.
71fn 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        // Outside a git repository fd would ignore .gitignore files. Inside one, its
101        // own rules stop parent rules at nested repositories.
102        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        // With a path in the pattern, fd matches the absolute path.
107        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}