1use std::sync::Arc;
9use std::time::SystemTime;
10
11use async_trait::async_trait;
12use ignore::WalkBuilder;
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15
16use super::context::{ToolContext, ToolEvent};
17use super::{FileTool, ToolErrorCode, ToolOutput};
18use crate::error::NikaError;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct GlobParams {
27 pub pattern: String,
29
30 #[serde(default)]
32 pub path: Option<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct GlobResult {
38 pub matches: Vec<String>,
40
41 pub count: usize,
43
44 pub base_path: String,
46}
47
48pub struct GlobTool {
61 ctx: Arc<ToolContext>,
62}
63
64impl GlobTool {
65 pub const MAX_RESULTS: usize = 10000;
67
68 pub fn new(ctx: Arc<ToolContext>) -> Self {
70 Self { ctx }
71 }
72
73 pub async fn execute(&self, params: GlobParams) -> Result<GlobResult, NikaError> {
75 let base_path = match params.path {
77 Some(ref p) => self.ctx.validate_path(p)?,
78 None => self.ctx.working_dir().to_path_buf(),
79 };
80
81 let glob = globset::GlobBuilder::new(¶ms.pattern)
83 .literal_separator(true)
84 .build()
85 .map_err(|e| NikaError::ToolError {
86 code: ToolErrorCode::InvalidGlobPattern.code(),
87 message: format!("Invalid glob pattern '{}': {}", params.pattern, e),
88 })?
89 .compile_matcher();
90
91 let mut matches: Vec<(String, SystemTime)> = Vec::new();
93
94 let walker = WalkBuilder::new(&base_path)
95 .hidden(false) .git_ignore(true) .git_global(true)
98 .git_exclude(true)
99 .build();
100
101 for entry in walker.filter_map(Result::ok) {
102 let path = entry.path();
103
104 if path.is_dir() {
106 continue;
107 }
108
109 let relative = path.strip_prefix(&base_path).unwrap_or(path);
112
113 if glob.is_match(relative) || glob.is_match(path) {
114 let modified = path
115 .metadata()
116 .ok()
117 .and_then(|m| m.modified().ok())
118 .unwrap_or(SystemTime::UNIX_EPOCH);
119
120 matches.push((path.to_string_lossy().to_string(), modified));
121
122 if matches.len() >= Self::MAX_RESULTS {
124 break;
125 }
126 }
127 }
128
129 matches.sort_by(|a, b| b.1.cmp(&a.1));
131
132 let count = matches.len();
133 let match_paths: Vec<String> = matches.into_iter().map(|(p, _)| p).collect();
134
135 self.ctx
137 .emit(ToolEvent::GlobSearch {
138 pattern: params.pattern,
139 matches: count,
140 base_path: base_path.to_string_lossy().to_string(),
141 })
142 .await;
143
144 Ok(GlobResult {
145 matches: match_paths,
146 count,
147 base_path: base_path.to_string_lossy().to_string(),
148 })
149 }
150}
151
152#[async_trait]
153impl FileTool for GlobTool {
154 fn name(&self) -> &'static str {
155 "glob"
156 }
157
158 fn description(&self) -> &'static str {
159 "Find files matching a glob pattern. Supports recursive patterns like '**/*.rs'. \
160 Respects .gitignore automatically. Results are sorted by modification time. \
161 Use this to discover files before reading them."
162 }
163
164 fn parameters_schema(&self) -> Value {
165 json!({
166 "type": "object",
167 "properties": {
168 "pattern": {
169 "type": "string",
170 "description": "Glob pattern (e.g., '**/*.rs', 'src/**/*.ts', '*.json')"
171 },
172 "path": {
173 "type": "string",
174 "description": "Base path to search in (default: working directory)"
175 }
176 },
177 "required": ["pattern", "path"],
178 "additionalProperties": false
179 })
180 }
181
182 async fn call(&self, params: Value) -> Result<ToolOutput, NikaError> {
183 let params: GlobParams =
184 serde_json::from_value(params).map_err(|e| NikaError::ToolError {
185 code: ToolErrorCode::InvalidGlobPattern.code(),
186 message: format!("Invalid parameters: {}", e),
187 })?;
188
189 let result = self.execute(params).await?;
190
191 let content = if result.matches.is_empty() {
193 "No matching files found".to_string()
194 } else {
195 format!(
196 "Found {} files:\n{}",
197 result.count,
198 result.matches.join("\n")
199 )
200 };
201
202 Ok(ToolOutput::success_with_data(
203 content,
204 serde_json::to_value(&result).unwrap_or_default(),
205 ))
206 }
207}
208
209#[cfg(test)]
214mod tests {
215 use super::*;
216 use crate::tools::context::testing::{create_test_tree, setup_test};
217
218 async fn create_test_files(temp_dir: &tempfile::TempDir) {
220 create_test_tree(
221 temp_dir,
222 &[
223 ("src/main.rs", "fn main() {}"),
224 ("src/lib.rs", "pub fn lib() {}"),
225 ("tests/test.rs", "#[test]"),
226 ("Cargo.toml", "[package]"),
227 ("README.md", "# Readme"),
228 ],
229 )
230 .await;
231 }
232
233 #[tokio::test]
234 async fn test_glob_all_rs_files() {
235 let (temp_dir, ctx) = setup_test().await;
236 create_test_files(&temp_dir).await;
237
238 let tool = GlobTool::new(ctx);
239 let result = tool
240 .execute(GlobParams {
241 pattern: "**/*.rs".to_string(),
242 path: None,
243 })
244 .await
245 .unwrap();
246
247 assert_eq!(result.count, 3);
248 assert!(result.matches.iter().any(|p| p.contains("main.rs")));
249 assert!(result.matches.iter().any(|p| p.contains("lib.rs")));
250 assert!(result.matches.iter().any(|p| p.contains("test.rs")));
251 }
252
253 #[tokio::test]
254 async fn test_glob_src_only() {
255 let (temp_dir, ctx) = setup_test().await;
256 create_test_files(&temp_dir).await;
257
258 let tool = GlobTool::new(ctx);
259 let result = tool
260 .execute(GlobParams {
261 pattern: "*.rs".to_string(),
262 path: Some(temp_dir.path().join("src").to_string_lossy().to_string()),
263 })
264 .await
265 .unwrap();
266
267 assert_eq!(result.count, 2);
268 assert!(result.matches.iter().all(|p| p.contains("src")));
269 }
270
271 #[tokio::test]
272 async fn test_glob_specific_extension() {
273 let (temp_dir, ctx) = setup_test().await;
274 create_test_files(&temp_dir).await;
275
276 let tool = GlobTool::new(ctx);
277 let result = tool
278 .execute(GlobParams {
279 pattern: "*.toml".to_string(),
280 path: None,
281 })
282 .await
283 .unwrap();
284
285 assert_eq!(result.count, 1);
286 assert!(result.matches[0].contains("Cargo.toml"));
287 }
288
289 #[tokio::test]
290 async fn test_glob_no_matches() {
291 let (temp_dir, ctx) = setup_test().await;
292 create_test_files(&temp_dir).await;
293
294 let tool = GlobTool::new(ctx);
295 let result = tool
296 .execute(GlobParams {
297 pattern: "**/*.xyz".to_string(),
298 path: None,
299 })
300 .await
301 .unwrap();
302
303 assert_eq!(result.count, 0);
304 assert!(result.matches.is_empty());
305 }
306
307 #[tokio::test]
308 async fn test_glob_invalid_pattern() {
309 let (_temp_dir, ctx) = setup_test().await;
310
311 let tool = GlobTool::new(ctx);
312 let result = tool
313 .execute(GlobParams {
314 pattern: "[invalid".to_string(),
315 path: None,
316 })
317 .await;
318
319 assert!(result.is_err());
320 assert!(result.unwrap_err().to_string().contains("Invalid glob"));
321 }
322
323 #[tokio::test]
324 async fn test_file_tool_trait() {
325 let (temp_dir, ctx) = setup_test().await;
326 create_test_files(&temp_dir).await;
327
328 let tool = GlobTool::new(ctx);
329
330 assert_eq!(tool.name(), "glob");
331 assert!(tool.description().contains("Find files"));
332
333 let result = tool.call(json!({ "pattern": "**/*.rs" })).await.unwrap();
334
335 assert!(!result.is_error);
336 assert!(result.content.contains("Found"));
337 }
338}