Skip to main content

rpi_tools/tools/
find.rs

1//! Mirrors `packages/coding-agent/src/core/tools/find.ts` — the built-in `find`
2//! `AgentTool`. Searches for files by glob pattern and returns matching paths
3//! (relative to the search directory, posix separators). Read-only — goes
4//! through the `FileSystem` trait so it runs against both `OsExecutionEnv` and
5//! `InMemoryExecutionEnv`.
6//!
7//! Divergence from TS (documented): the TS tool **shells out to `fd`** (with
8//! gitignore-aware walking + `--full-path` rewrites + optional auto-download).
9//! The Rust port implements the walk **in-process** via the `FileSystem` trait
10//! and the `globset` crate, so it works against any `ExecutionEnv`. Trade-offs:
11//! no full `.gitignore` awareness in v1 (only `.git/` directories are skipped;
12//! revisit via the `ignore` crate for an `OsExecutionEnv`-only fast path), no
13//! external binary, and a deterministic sorted BFS walk order rather than fd's
14//! walk. The `**/`-prepend rule for path-containing patterns is ported; the
15//! Windows `[/\\]` separator rewrite is unnecessary (we normalize to posix
16//! internally). `relativizeFindResultPath` is ported directly.
17
18use std::collections::VecDeque;
19use std::path::Path;
20use std::sync::Arc;
21
22use async_trait::async_trait;
23use rpi_agent::agent_tool::AgentTool;
24use rpi_agent::error::AgentError;
25use rpi_agent::types::{AgentToolResult, TextContentOrImage, ToolResultPartial};
26use rpi_ai::types::Tool;
27use schemars::JsonSchema;
28use serde::Deserialize;
29use tokio_util::sync::CancellationToken;
30
31use crate::env::ExecutionEnv;
32use crate::env::FileKind;
33use crate::path_utils::resolve_read_tool_path;
34use crate::tools::tool_context::ExecutionToolContext;
35use crate::truncate::{
36    format_size, truncate_head, TruncationOptions, TruncationResult, DEFAULT_MAX_BYTES,
37};
38
39/// Default max results. Mirrors TS `find.ts::DEFAULT_LIMIT`.
40const DEFAULT_LIMIT: u32 = 1000;
41
42/// Input for the find tool. Mirrors TS `FindToolInput`.
43#[derive(Debug, Clone, JsonSchema, Deserialize)]
44pub struct FindInput {
45    /// Glob pattern to match files, e.g. `*.ts`, `**/*.json`, `src/**/*.spec.ts`.
46    pub pattern: String,
47    /// Directory to search in (default: current directory).
48    #[serde(default)]
49    pub path: Option<String>,
50    /// Maximum number of results (default 1000).
51    #[serde(default)]
52    pub limit: Option<u32>,
53}
54
55/// Structured details for the find tool. Mirrors TS `FindToolDetails`.
56#[derive(Debug, Clone, serde::Serialize)]
57pub struct FindToolDetails {
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub truncation: Option<TruncationResult>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub result_limit_reached: Option<u32>,
62}
63
64/// Options for `create_find_tool`. Forward-compat seam (the TS tool carries a
65/// pluggable `operations` bag for remote backends; the Rust port always uses
66/// the `FileSystem` trait).
67#[derive(Debug, Clone, Default)]
68pub struct FindToolOptions {}
69
70/// The built-in `find` tool. Holds an `Arc<dyn ExecutionEnv>` (read-only view).
71pub struct FindTool {
72    schema: Tool,
73    env: Arc<dyn ExecutionEnv>,
74}
75
76impl FindTool {
77    fn schema() -> Tool {
78        let params = schemars::schema_for!(FindInput);
79        Tool {
80            name: "find".to_string(),
81            description: format!(
82                "Search for files by glob pattern. Returns matching file paths relative to the \
83                 search directory. Skips `.git/` directories. Output is truncated to {DEFAULT_LIMIT} \
84                 results or {kb}KB (whichever is hit first).",
85                kb = DEFAULT_MAX_BYTES / 1024
86            ),
87            parameters: rpi_ai::types::Schema::new(serde_json::to_value(params).unwrap_or_default()),
88            constrained_sampling: None,
89        }
90    }
91}
92
93/// Build the `find` tool from a context. Mirrors TS `createFindTool`.
94pub fn create_find_tool(
95    context: &ExecutionToolContext,
96    options: Option<FindToolOptions>,
97) -> Arc<dyn AgentTool> {
98    let _ = options; // forward-compat seam, no surface in v1
99    Arc::new(FindTool {
100        schema: FindTool::schema(),
101        env: context.env().clone(),
102    })
103}
104
105#[async_trait]
106impl AgentTool for FindTool {
107    fn schema(&self) -> &Tool {
108        &self.schema
109    }
110    fn label(&self) -> &str {
111        "find"
112    }
113
114    async fn execute(
115        &self,
116        _tool_call_id: &str,
117        params: serde_json::Value,
118        signal: CancellationToken,
119        _on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
120    ) -> Result<AgentToolResult, AgentError> {
121        let input: FindInput = serde_json::from_value(params)
122            .map_err(|e| AgentError::Validation(format!("find input invalid: {e}")))?;
123
124        let cancel = Some(&signal);
125        let effective_limit = input.limit.unwrap_or(DEFAULT_LIMIT).max(1) as usize;
126
127        // Resolve the search path (read-only + fuzzy variants).
128        let search_path =
129            resolve_read_tool_path(&*self.env, input.path.as_deref().unwrap_or("."), cancel)
130                .await
131                .map_err(file_err_to_agent)?;
132
133        // Path-not-found (the TS custom-glob branch checks exists; the fd branch
134        // lets fd error — we check up-front for a uniform in-process path).
135        if !self
136            .env
137            .exists(&search_path, cancel)
138            .await
139            .map_err(file_err_to_agent)?
140        {
141            return Err(AgentError::Tool(format!("Path not found: {search_path}")));
142        }
143
144        // Compile the glob, applying the TS `**/`-prepend rewrite for
145        // path-containing patterns. `**` alone matches everything.
146        let effective_pattern = rewrite_pattern(&input.pattern);
147        let matcher = globset::Glob::new(&effective_pattern)
148            .map_err(|e| AgentError::Validation(format!("find pattern invalid: {e}")))?
149            .compile_matcher();
150
151        // Collect matching paths up to the limit.
152        let mut collected: Vec<String> = Vec::new();
153        let mut result_limit_reached = false;
154
155        // BFS walk from the search root. Match against the relative posix path.
156        let mut queue: VecDeque<(String, String)> = VecDeque::new();
157        queue.push_back((search_path.clone(), String::new()));
158        while let Some((abs_dir, rel_dir)) = queue.pop_front() {
159            if collected.len() >= effective_limit {
160                result_limit_reached = true;
161                break;
162            }
163            let mut entries = match self.env.list_dir(&abs_dir, cancel).await {
164                Ok(e) => e,
165                Err(_) => continue, // unreadable dir — skip
166            };
167            entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
168            for entry in entries {
169                if collected.len() >= effective_limit {
170                    result_limit_reached = true;
171                    break;
172                }
173                let child_abs = entry.path.to_string_lossy().into_owned();
174                let rel = if rel_dir.is_empty() {
175                    entry.name.clone()
176                } else {
177                    format!("{rel_dir}/{}", entry.name)
178                };
179                match entry.kind {
180                    FileKind::File => {
181                        if matcher.is_match(&rel) {
182                            collected.push(relativize(&rel));
183                        }
184                    }
185                    FileKind::Directory => {
186                        // Skip `.git/` (v1 has no full .gitignore support).
187                        if entry.name == ".git" {
188                            continue;
189                        }
190                        // Directories that match the glob are reported with a
191                        // trailing slash (mirrors `relativizeFindResultPath`).
192                        if matcher.is_match(&rel) {
193                            let mut r = relativize(&rel);
194                            if !r.ends_with('/') {
195                                r.push('/');
196                            }
197                            collected.push(r);
198                        }
199                        queue.push_back((child_abs, rel));
200                    }
201                    FileKind::Symlink => {
202                        // Skip symlinks during the walk (avoid loops).
203                    }
204                }
205            }
206        }
207
208        if collected.is_empty() {
209            return Ok(AgentToolResult {
210                content: vec![TextContentOrImage::text("No files found matching pattern")],
211                details: serde_json::Value::Null,
212                ..Default::default()
213            });
214        }
215
216        let raw_output = collected.join("\n");
217        // Byte cap only — line cap disabled (result count already capped).
218        let truncation = truncate_head(
219            &raw_output,
220            TruncationOptions {
221                max_lines: Some(usize::MAX),
222                max_bytes: None,
223            },
224        );
225        let was_truncated = truncation.truncated;
226        let mut output = truncation.content.clone();
227        let mut details = FindToolDetails {
228            truncation: None,
229            result_limit_reached: None,
230        };
231        let mut notices: Vec<String> = Vec::new();
232        if result_limit_reached {
233            notices.push(format!(
234                "{effective_limit} results limit reached. Use limit={} for more, or refine pattern",
235                effective_limit * 2
236            ));
237            details.result_limit_reached = Some(effective_limit as u32);
238        }
239        if was_truncated {
240            notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
241            details.truncation = Some(truncation);
242        }
243        if !notices.is_empty() {
244            output.push_str(&format!("\n\n[{}]", notices.join(". ")));
245        }
246
247        let details_value = serde_json::to_value(&details).unwrap_or(serde_json::Value::Null);
248        Ok(AgentToolResult {
249            content: vec![TextContentOrImage::text(output)],
250            details: details_value,
251            ..Default::default()
252        })
253    }
254}
255
256/// Rewrite the glob pattern the way TS `find.ts` does for the `fd --full-path`
257/// branch: a path-containing pattern (has a `/`) that isn't already rooted or
258/// `**`-prefixed gets a `**/` prepend so it matches at any depth. Patterns with
259/// no `/` are used as-is (basename match). `**` alone stays `**`.
260///
261/// The TS Windows `[/\\]` separator rewrite is intentionally NOT ported — we
262/// normalize all relative paths to posix internally, so the matcher sees `/`.
263fn rewrite_pattern(pattern: &str) -> String {
264    if !pattern.contains('/') {
265        return pattern.to_string();
266    }
267    if pattern == "**" || pattern.starts_with('/') || pattern.starts_with("**/") {
268        return strip_native_sep(pattern);
269    }
270    strip_native_sep(&format!("**/{pattern}"))
271}
272
273/// Normalize any backslash separators to posix `/` (defensive — user patterns
274/// on Windows may contain `\`).
275fn strip_native_sep(p: &str) -> String {
276    p.replace('\\', "/")
277}
278
279/// Relativize a walk result path. `rel` is already relative to the search root
280/// and posix-normalized; this keeps a trailing `/` for directory matches
281/// (mirrors `relativizeFindResultPath`'s trailing-separator preservation).
282fn relativize(rel: &str) -> String {
283    rel.to_string()
284}
285
286/// Filename component of a path string (for the no-`/` debugging path; unused
287/// in the main walk but kept for parity/debugging clarity).
288#[allow(dead_code)]
289fn basename(p: &str) -> String {
290    Path::new(p)
291        .file_name()
292        .map(|n| n.to_string_lossy().into_owned())
293        .unwrap_or_else(|| p.to_string())
294}
295
296/// Map a `FileError` into an `AgentError::Tool` (same shape as `read.rs`).
297fn file_err_to_agent(e: crate::error::FileError) -> AgentError {
298    AgentError::Tool(e.to_string())
299}