Skip to main content

nika_engine/tools/
glob.rs

1//! Glob Tool - Find files by pattern
2//!
3//! Fast pattern matching using the `ignore` crate:
4//! - Supports `**/*.rs`, `src/**/*.ts` patterns
5//! - Respects .gitignore automatically
6//! - Sorted by modification time (deterministic)
7
8use 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// ═══════════════════════════════════════════════════════════════════════════
21// PARAMETERS & RESULT
22// ═══════════════════════════════════════════════════════════════════════════
23
24/// Parameters for the Glob tool
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct GlobParams {
27    /// Glob pattern (e.g., "**/*.rs", "src/**/*.ts")
28    pub pattern: String,
29
30    /// Base path to search in (default: working directory)
31    #[serde(default)]
32    pub path: Option<String>,
33}
34
35/// Result from glob search
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct GlobResult {
38    /// Matching file paths (absolute)
39    pub matches: Vec<String>,
40
41    /// Number of matches
42    pub count: usize,
43
44    /// Base path searched
45    pub base_path: String,
46}
47
48// ═══════════════════════════════════════════════════════════════════════════
49// GLOB TOOL
50// ═══════════════════════════════════════════════════════════════════════════
51
52/// Glob tool for finding files by pattern
53///
54/// # Features
55///
56/// - Fast pattern matching via `ignore` crate
57/// - Respects .gitignore automatically
58/// - Results sorted by modification time
59/// - Supports recursive patterns (`**`)
60pub struct GlobTool {
61    ctx: Arc<ToolContext>,
62}
63
64impl GlobTool {
65    /// Maximum files to return (prevent memory issues)
66    pub const MAX_RESULTS: usize = 10000;
67
68    /// Create a new Glob tool
69    pub fn new(ctx: Arc<ToolContext>) -> Self {
70        Self { ctx }
71    }
72
73    /// Execute the glob search
74    pub async fn execute(&self, params: GlobParams) -> Result<GlobResult, NikaError> {
75        // Determine base path
76        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        // Build glob matcher
82        let glob = globset::GlobBuilder::new(&params.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        // Walk directory and collect matches
92        let mut matches: Vec<(String, SystemTime)> = Vec::new();
93
94        let walker = WalkBuilder::new(&base_path)
95            .hidden(false) // Include hidden files
96            .git_ignore(true) // Respect .gitignore
97            .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            // Skip directories
105            if path.is_dir() {
106                continue;
107            }
108
109            // Check if path matches the pattern
110            // We need to match against the relative path from base
111            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                // Limit results
123                if matches.len() >= Self::MAX_RESULTS {
124                    break;
125                }
126            }
127        }
128
129        // Sort by modification time (newest first for determinism)
130        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        // Emit event
136        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        // Format output as newline-separated paths
192        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// ═══════════════════════════════════════════════════════════════════════════
210// TESTS
211// ═══════════════════════════════════════════════════════════════════════════
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::tools::context::testing::{create_test_tree, setup_test};
217
218    /// Create standard test files for glob tests
219    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}