kiss_coding/tools/
find.rs1use 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}