Skip to main content

kiss_coding/tools/
find.rs

1//! Find tool: gitignore-aware glob file search.
2
3use kiss_agent::tool::{AgentTool, ToolResult, ToolUpdateSink};
4use kiss_agent::tools::path::resolve;
5use kiss_agent::tools::truncate::{DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncate_head};
6use serde_json::{Value, json};
7use std::path::PathBuf;
8use tokio_util::sync::CancellationToken;
9
10const DEFAULT_LIMIT: usize = 1000;
11
12pub struct FindTool {
13    pub cwd: PathBuf,
14}
15
16#[async_trait::async_trait]
17impl AgentTool for FindTool {
18    fn name(&self) -> &str {
19        "find"
20    }
21
22    fn description(&self) -> String {
23        "Find files by glob pattern. Respects .gitignore. Returns paths relative to the search directory.".to_string()
24    }
25
26    fn parameters(&self) -> Value {
27        json!({
28            "type": "object",
29            "properties": {
30                "pattern": {"type": "string", "description": "Glob pattern to match files, e.g. '*.rs', '**/*.json', or 'src/**/*.spec.ts'"},
31                "path": {"type": "string", "description": "Directory to search in (default: current directory)"},
32                "limit": {"type": "number", "description": "Maximum number of results (default: 1000)"},
33            },
34            "required": ["pattern"],
35        })
36    }
37
38    async fn execute(
39        &self,
40        _id: &str,
41        args: Value,
42        cancel: CancellationToken,
43        _on_update: Option<ToolUpdateSink>,
44    ) -> anyhow::Result<ToolResult> {
45        let pattern = args["pattern"].as_str().unwrap_or_default().to_string();
46        let search_path = resolve(&self.cwd, args["path"].as_str().unwrap_or("."));
47        let limit = args["limit"]
48            .as_f64()
49            .map(|v| v as usize)
50            .unwrap_or(DEFAULT_LIMIT);
51
52        let result = tokio::task::spawn_blocking(move || {
53            if !search_path.exists() {
54                anyhow::bail!("Path not found: {}", search_path.display());
55            }
56            let normalized = if pattern.contains('/') { pattern.clone() } else { format!("**/{pattern}") };
57            let glob = globset::GlobBuilder::new(&normalized)
58                .literal_separator(true)
59                .build()
60                .map_err(|e| anyhow::anyhow!("Invalid glob: {e}"))?
61                .compile_matcher();
62
63            let mut results: Vec<String> = Vec::new();
64            let mut walker = ignore::WalkBuilder::new(&search_path);
65            walker
66                .hidden(true)
67                .git_ignore(true)
68                .git_global(true)
69                .require_git(false);
70            let walker = walker.build();
71            for entry in walker {
72                if cancel.is_cancelled() || results.len() >= limit {
73                    break;
74                }
75                let Ok(entry) = entry else { continue };
76                if !entry.file_type().is_some_and(|t| t.is_file()) {
77                    continue;
78                }
79                let rel = entry.path().strip_prefix(&search_path).unwrap_or(entry.path());
80                if glob.is_match(rel) {
81                    results.push(rel.display().to_string().replace('\\', "/"));
82                }
83            }
84            results.sort();
85
86            if results.is_empty() {
87                return Ok(ToolResult::text("No files found"));
88            }
89            let limit_reached = results.len() >= limit;
90            let joined = results.join("\n");
91            let truncation = truncate_head(&joined, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
92            let mut output = truncation.content.clone();
93            if limit_reached {
94                output.push_str(&format!("\n\n[Result limit of {limit} reached.]"));
95            }
96            Ok(ToolResult {
97                content: vec![kiss_ai::ContentBlock::text(output)],
98                details: json!({"resultLimitReached": if limit_reached { Some(limit) } else { None }}),
99                ..Default::default()
100            })
101        })
102        .await??;
103        Ok(result)
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use futures::{StreamExt as _, stream};
111    use std::time::Instant;
112
113    #[tokio::test]
114    async fn glob_find_respects_gitignore() {
115        let dir = tempfile::tempdir().unwrap();
116        std::fs::create_dir_all(dir.path().join("src")).unwrap();
117        std::fs::create_dir_all(dir.path().join("target")).unwrap();
118        std::fs::write(dir.path().join("src/main.rs"), "").unwrap();
119        std::fs::write(dir.path().join("target/out.rs"), "").unwrap();
120        std::fs::write(dir.path().join(".gitignore"), "target/\n").unwrap();
121        let tool = FindTool {
122            cwd: dir.path().to_path_buf(),
123        };
124        let r = tool
125            .execute(
126                "1",
127                json!({"pattern": "*.rs"}),
128                CancellationToken::new(),
129                None,
130            )
131            .await
132            .unwrap();
133        let text = r.output_text();
134        assert!(text.contains("src/main.rs"));
135        assert!(!text.contains("target/out.rs"));
136    }
137
138    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
139    #[ignore = "release-mode performance benchmark"]
140    async fn benchmark_performance_200_find_calls() {
141        const CALLS: usize = 200;
142        let dir = tempfile::tempdir().unwrap();
143        for directory in 0..10 {
144            let path = dir.path().join(format!("src/module_{directory:02}"));
145            std::fs::create_dir_all(&path).unwrap();
146            for file in 0..20 {
147                std::fs::write(path.join(format!("file_{file:03}.rs")), "fn item() {}\n").unwrap();
148            }
149        }
150        let tool = FindTool {
151            cwd: dir.path().to_path_buf(),
152        };
153
154        for (name, concurrency) in [("unlimited", usize::MAX), ("bounded", 32)] {
155            let started = Instant::now();
156            let results = stream::iter(0..CALLS)
157                .map(|index| {
158                    tool.execute(
159                        "bench",
160                        json!({"pattern": format!("file_{:03}.rs", index % 20)}),
161                        CancellationToken::new(),
162                        None,
163                    )
164                })
165                .buffer_unordered(concurrency)
166                .collect::<Vec<_>>()
167                .await;
168            assert!(results.into_iter().all(|result| result.is_ok()));
169
170            let mut sample = [started.elapsed().as_nanos() / CALLS as u128];
171            kiss_bench::report(
172                &format!("find_batch_200_{name}"),
173                &mut sample,
174                CALLS,
175                &format!("200_file_tree_max_active={concurrency}"),
176            );
177        }
178    }
179}