Skip to main content

argentor_builtins/
code_analysis.rs

1//! Code analysis skill for the Argentor agent framework.
2//!
3//! Provides language-aware code analysis without depending on heavy external tools.
4//! Uses Rust's standard library for file traversal and the `regex` crate for
5//! pattern matching.
6//!
7//! # Supported operations
8//!
9//! - `search` — Search for a regex pattern across files.
10//! - `find_definitions` — Find function/struct/enum/trait/impl definitions.
11//! - `count_loc` — Count lines of code per language.
12//! - `file_tree` — Show directory tree structure.
13//! - `find_references` — Find all occurrences of a symbol name.
14//! - `analyze_imports` — List imports/dependencies used in a file.
15//! - `file_info` — Get detailed info about a file (size, lines, language, last modified).
16
17use argentor_core::{ArgentorResult, ToolCall, ToolResult};
18use argentor_security::Capability;
19use argentor_skills::skill::{Skill, SkillDescriptor};
20use async_trait::async_trait;
21use regex::Regex;
22use std::collections::HashMap;
23use std::fs;
24use std::path::{Path, PathBuf};
25use std::sync::OnceLock;
26use std::time::SystemTime;
27use tracing::info;
28
29// ---------------------------------------------------------------------------
30// Static regex patterns for analyze_imports (compiled once, reused across calls)
31// ---------------------------------------------------------------------------
32
33fn re_rust_use() -> &'static Regex {
34    static RE: OnceLock<Regex> = OnceLock::new();
35    RE.get_or_init(|| compile_static_regex(r"^\s*use\s+(.+);"))
36}
37
38fn re_python_import() -> &'static Regex {
39    static RE: OnceLock<Regex> = OnceLock::new();
40    RE.get_or_init(|| compile_static_regex(r"^\s*import\s+(.+)"))
41}
42
43fn re_python_from_import() -> &'static Regex {
44    static RE: OnceLock<Regex> = OnceLock::new();
45    RE.get_or_init(|| compile_static_regex(r"^\s*from\s+(\S+)\s+import\s+(.+)"))
46}
47
48fn re_js_import() -> &'static Regex {
49    static RE: OnceLock<Regex> = OnceLock::new();
50    RE.get_or_init(|| compile_static_regex(r"^\s*import\s+(.+)"))
51}
52
53fn re_js_require() -> &'static Regex {
54    static RE: OnceLock<Regex> = OnceLock::new();
55    RE.get_or_init(|| compile_static_regex(r#"require\s*\(\s*['"]([^'"]+)['"]\s*\)"#))
56}
57
58fn re_go_single_import() -> &'static Regex {
59    static RE: OnceLock<Regex> = OnceLock::new();
60    RE.get_or_init(|| compile_static_regex(r#"^\s*import\s+"([^"]+)""#))
61}
62
63fn re_go_block_import() -> &'static Regex {
64    static RE: OnceLock<Regex> = OnceLock::new();
65    RE.get_or_init(|| compile_static_regex(r#"^\s*"([^"]+)""#))
66}
67
68fn compile_static_regex(pattern: &str) -> Regex {
69    match Regex::new(pattern) {
70        Ok(regex) => regex,
71        Err(err) => panic!("invalid built-in code analysis regex `{pattern}`: {err}"),
72    }
73}
74
75/// Directories that are always excluded from traversal.
76const EXCLUDED_DIRS: &[&str] = &[
77    "node_modules",
78    "target",
79    ".git",
80    "__pycache__",
81    ".venv",
82    "venv",
83    ".tox",
84    "dist",
85    "build",
86    ".next",
87    ".nuxt",
88    "vendor",
89    ".idea",
90    ".vscode",
91];
92
93/// Maximum number of results returned by default.
94const DEFAULT_MAX_RESULTS: usize = 50;
95
96/// Code analysis skill. Provides language-aware code analysis using only the
97/// standard library for file traversal and the `regex` crate for pattern matching.
98pub struct CodeAnalysisSkill {
99    descriptor: SkillDescriptor,
100}
101
102impl CodeAnalysisSkill {
103    /// Create a new `CodeAnalysisSkill`.
104    pub fn new() -> Self {
105        Self {
106            descriptor: SkillDescriptor {
107                name: "code_analysis".to_string(),
108                description: "Analyze source code: search patterns, find definitions, \
109                              count lines of code, show file trees, find references, \
110                              analyze imports, and get file info."
111                    .to_string(),
112                parameters_schema: serde_json::json!({
113                    "type": "object",
114                    "properties": {
115                        "operation": {
116                            "type": "string",
117                            "enum": [
118                                "search",
119                                "find_definitions",
120                                "count_loc",
121                                "file_tree",
122                                "find_references",
123                                "analyze_imports",
124                                "file_info"
125                            ],
126                            "description": "The analysis operation to perform"
127                        },
128                        "path": {
129                            "type": "string",
130                            "description": "Root directory or file path for the operation"
131                        },
132                        "file_path": {
133                            "type": "string",
134                            "description": "Path to a specific file (for analyze_imports)"
135                        },
136                        "pattern": {
137                            "type": "string",
138                            "description": "Regex pattern to search for (for search operation)"
139                        },
140                        "name": {
141                            "type": "string",
142                            "description": "Symbol name filter (for find_definitions, find_references)"
143                        },
144                        "glob": {
145                            "type": "string",
146                            "description": "File filter glob pattern like '*.rs' (for search, file_tree)"
147                        },
148                        "language": {
149                            "type": "string",
150                            "description": "Language filter: rust, python, typescript, go (for find_definitions)"
151                        },
152                        "depth": {
153                            "type": "integer",
154                            "description": "Maximum directory depth for file_tree (default: 3)"
155                        },
156                        "max_results": {
157                            "type": "integer",
158                            "description": "Maximum number of results to return (default: 50)"
159                        },
160                        "exclude": {
161                            "type": "array",
162                            "items": { "type": "string" },
163                            "description": "Additional patterns to exclude (for count_loc)"
164                        }
165                    },
166                    "required": ["operation"]
167                }),
168                required_capabilities: vec![Capability::FileRead {
169                    allowed_paths: vec![], // Configured at runtime
170                }],
171                requires_approval: false,
172            },
173        }
174    }
175}
176
177impl Default for CodeAnalysisSkill {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183#[async_trait]
184impl Skill for CodeAnalysisSkill {
185    fn descriptor(&self) -> &SkillDescriptor {
186        &self.descriptor
187    }
188
189    async fn execute(&self, call: ToolCall) -> ArgentorResult<ToolResult> {
190        let operation = call.arguments["operation"].as_str().unwrap_or_default();
191
192        info!(operation, "code_analysis skill invoked");
193
194        match operation {
195            "search" => execute_search(&call).await,
196            "find_definitions" => execute_find_definitions(&call).await,
197            "count_loc" => execute_count_loc(&call).await,
198            "file_tree" => execute_file_tree(&call).await,
199            "find_references" => execute_find_references(&call).await,
200            "analyze_imports" => execute_analyze_imports(&call).await,
201            "file_info" => execute_file_info(&call).await,
202            _ => Ok(ToolResult::error(
203                &call.id,
204                format!(
205                    "Unknown operation '{operation}'. \
206                     Valid operations: search, find_definitions, count_loc, file_tree, \
207                     find_references, analyze_imports, file_info"
208                ),
209            )),
210        }
211    }
212}
213
214// ---------------------------------------------------------------------------
215// Helpers
216// ---------------------------------------------------------------------------
217
218/// Check whether a filename matches a simple glob pattern (e.g. "*.rs", "*.py").
219/// Supports only the `*` wildcard at the beginning of the pattern.
220fn matches_glob(filename: &str, glob_pattern: &str) -> bool {
221    if glob_pattern == "*" {
222        return true;
223    }
224    if let Some(suffix) = glob_pattern.strip_prefix('*') {
225        filename.ends_with(suffix)
226    } else {
227        filename == glob_pattern
228    }
229}
230
231/// Check whether a directory name should be excluded.
232fn is_excluded_dir(name: &str, extra_excludes: &[String]) -> bool {
233    if EXCLUDED_DIRS.contains(&name) {
234        return true;
235    }
236    extra_excludes.iter().any(|e| name == e.as_str())
237}
238
239/// Recursively walk a directory, collecting files.
240/// Respects depth limits and exclusion lists.
241fn walk_dir(
242    root: &Path,
243    glob_filter: Option<&str>,
244    extra_excludes: &[String],
245    max_depth: usize,
246) -> Vec<PathBuf> {
247    let mut files = Vec::new();
248    walk_dir_recursive(root, glob_filter, extra_excludes, 0, max_depth, &mut files);
249    files
250}
251
252fn walk_dir_recursive(
253    dir: &Path,
254    glob_filter: Option<&str>,
255    extra_excludes: &[String],
256    current_depth: usize,
257    max_depth: usize,
258    files: &mut Vec<PathBuf>,
259) {
260    if current_depth > max_depth {
261        return;
262    }
263
264    let entries = match fs::read_dir(dir) {
265        Ok(entries) => entries,
266        Err(_) => return,
267    };
268
269    for entry in entries.flatten() {
270        let path = entry.path();
271        let file_name = entry.file_name();
272        let name = file_name.to_string_lossy();
273
274        if path.is_dir() {
275            if !is_excluded_dir(&name, extra_excludes) {
276                walk_dir_recursive(
277                    &path,
278                    glob_filter,
279                    extra_excludes,
280                    current_depth + 1,
281                    max_depth,
282                    files,
283                );
284            }
285        } else if path.is_file() {
286            if let Some(glob) = glob_filter {
287                if matches_glob(&name, glob) {
288                    files.push(path);
289                }
290            } else {
291                files.push(path);
292            }
293        }
294    }
295}
296
297/// Detect language from file extension.
298fn detect_language(path: &Path) -> &'static str {
299    match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
300        "rs" => "rust",
301        "py" | "pyi" => "python",
302        "ts" | "tsx" => "typescript",
303        "js" | "jsx" | "mjs" | "cjs" => "javascript",
304        "go" => "go",
305        "java" => "java",
306        "c" | "h" => "c",
307        "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp",
308        "rb" => "ruby",
309        "sh" | "bash" | "zsh" => "shell",
310        "toml" => "toml",
311        "yaml" | "yml" => "yaml",
312        "json" => "json",
313        "md" | "markdown" => "markdown",
314        "html" | "htm" => "html",
315        "css" | "scss" | "sass" => "css",
316        "sql" => "sql",
317        "swift" => "swift",
318        "kt" | "kts" => "kotlin",
319        "lua" => "lua",
320        "zig" => "zig",
321        _ => "unknown",
322    }
323}
324
325/// Check if a line is a comment for the given language.
326fn is_comment(line: &str, lang: &str) -> bool {
327    let trimmed = line.trim();
328    match lang {
329        "rust" | "go" | "java" | "c" | "cpp" | "javascript" | "typescript" | "swift" | "kotlin"
330        | "zig" => {
331            trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
332        }
333        "python" | "ruby" | "shell" => trimmed.starts_with('#'),
334        "lua" => trimmed.starts_with("--"),
335        "html" => trimmed.starts_with("<!--"),
336        "css" => trimmed.starts_with("/*") || trimmed.starts_with('*'),
337        "sql" => trimmed.starts_with("--") || trimmed.starts_with("/*"),
338        _ => false,
339    }
340}
341
342/// Get definition patterns for a given language.
343fn definition_patterns(language: &str) -> Vec<&'static str> {
344    match language {
345        "rust" => vec![
346            r"(?:pub\s+)?(?:async\s+)?fn\s+\w+",
347            r"(?:pub\s+)?struct\s+\w+",
348            r"(?:pub\s+)?enum\s+\w+",
349            r"(?:pub\s+)?trait\s+\w+",
350            r"impl(?:<[^>]*>)?\s+\w+",
351            r"(?:pub\s+)?mod\s+\w+",
352            r"(?:pub\s+)?type\s+\w+",
353            r"(?:pub\s+)?const\s+\w+",
354            r"(?:pub\s+)?static\s+\w+",
355        ],
356        "python" => vec![r"(?:async\s+)?def\s+\w+", r"class\s+\w+"],
357        "typescript" | "javascript" => vec![
358            r"(?:async\s+)?function\s+\w+",
359            r"class\s+\w+",
360            r"interface\s+\w+",
361            r"(?:export\s+(?:default\s+)?)?(?:const|let|var)\s+\w+\s*=",
362            r"export\s+(?:default\s+)?(?:async\s+)?function\s+\w+",
363            r"export\s+(?:default\s+)?class\s+\w+",
364            r"export\s+(?:default\s+)?interface\s+\w+",
365        ],
366        "go" => vec![
367            r"func\s+(?:\([^)]*\)\s+)?\w+",
368            r"type\s+\w+\s+struct",
369            r"type\s+\w+\s+interface",
370        ],
371        _ => vec![],
372    }
373}
374
375/// Get file extensions for a given language.
376fn language_extensions(language: &str) -> Vec<&'static str> {
377    match language {
378        "rust" => vec!["rs"],
379        "python" => vec!["py", "pyi"],
380        "typescript" => vec!["ts", "tsx"],
381        "javascript" => vec!["js", "jsx", "mjs", "cjs"],
382        "go" => vec!["go"],
383        _ => vec![],
384    }
385}
386
387/// Build a glob filter from a language name.
388fn glob_for_language(language: &str) -> Option<Vec<String>> {
389    let exts = language_extensions(language);
390    if exts.is_empty() {
391        None
392    } else {
393        Some(exts.iter().map(|e| format!("*.{e}")).collect())
394    }
395}
396
397/// Format a `SystemTime` as an ISO 8601 string.
398fn format_system_time(time: SystemTime) -> String {
399    match time.duration_since(SystemTime::UNIX_EPOCH) {
400        Ok(dur) => {
401            let secs = dur.as_secs();
402            // Simple UTC formatting without pulling in chrono for this one call.
403            // chrono is already a dependency, so we use it.
404            let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
405            match dt {
406                Some(dt) => dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
407                None => "unknown".to_string(),
408            }
409        }
410        Err(_) => "unknown".to_string(),
411    }
412}
413
414/// Build the directory tree structure as a JSON-friendly nested representation.
415fn build_tree(
416    dir: &Path,
417    glob_filter: Option<&str>,
418    extra_excludes: &[String],
419    current_depth: usize,
420    max_depth: usize,
421) -> Vec<serde_json::Value> {
422    if current_depth > max_depth {
423        return vec![];
424    }
425
426    let entries = match fs::read_dir(dir) {
427        Ok(entries) => entries,
428        Err(_) => return vec![],
429    };
430
431    let mut items: Vec<(String, bool, PathBuf)> = Vec::new();
432
433    for entry in entries.flatten() {
434        let path = entry.path();
435        let file_name = entry.file_name();
436        let name = file_name.to_string_lossy().to_string();
437
438        if path.is_dir() {
439            if !is_excluded_dir(&name, extra_excludes) {
440                items.push((name, true, path));
441            }
442        } else if path.is_file() {
443            if let Some(glob) = glob_filter {
444                if matches_glob(&name, glob) {
445                    items.push((name, false, path));
446                }
447            } else {
448                items.push((name, false, path));
449            }
450        }
451    }
452
453    items.sort_by(|a, b| {
454        // Directories first, then alphabetical.
455        match (a.1, b.1) {
456            (true, false) => std::cmp::Ordering::Less,
457            (false, true) => std::cmp::Ordering::Greater,
458            _ => a.0.cmp(&b.0),
459        }
460    });
461
462    items
463        .into_iter()
464        .map(|(name, is_dir, path)| {
465            if is_dir {
466                let children = build_tree(
467                    &path,
468                    glob_filter,
469                    extra_excludes,
470                    current_depth + 1,
471                    max_depth,
472                );
473                serde_json::json!({
474                    "name": name,
475                    "type": "directory",
476                    "children": children,
477                })
478            } else {
479                serde_json::json!({
480                    "name": name,
481                    "type": "file",
482                })
483            }
484        })
485        .collect()
486}
487
488// ---------------------------------------------------------------------------
489// Operation implementations
490// ---------------------------------------------------------------------------
491
492/// `search` — Search for a regex pattern across files.
493async fn execute_search(call: &ToolCall) -> ArgentorResult<ToolResult> {
494    let pattern_str = call.arguments["pattern"].as_str().unwrap_or_default();
495    if pattern_str.is_empty() {
496        return Ok(ToolResult::error(
497            &call.id,
498            "Missing required parameter 'pattern'",
499        ));
500    }
501
502    let path_str = call.arguments["path"].as_str().unwrap_or(".");
503    let path = Path::new(path_str);
504    if !path.exists() {
505        return Ok(ToolResult::error(
506            &call.id,
507            format!("Path does not exist: '{path_str}'"),
508        ));
509    }
510
511    let re = match Regex::new(pattern_str) {
512        Ok(re) => re,
513        Err(e) => {
514            return Ok(ToolResult::error(
515                &call.id,
516                format!("Invalid regex pattern '{pattern_str}': {e}"),
517            ));
518        }
519    };
520
521    let glob_filter = call.arguments["glob"].as_str();
522    let max_results = call.arguments["max_results"]
523        .as_u64()
524        .unwrap_or(DEFAULT_MAX_RESULTS as u64) as usize;
525
526    let files = walk_dir(path, glob_filter, &[], 100);
527    let mut matches: Vec<serde_json::Value> = Vec::new();
528
529    for file_path in &files {
530        if matches.len() >= max_results {
531            break;
532        }
533
534        let content = match fs::read_to_string(file_path) {
535            Ok(c) => c,
536            Err(_) => continue, // Skip binary or unreadable files
537        };
538
539        for (line_num, line) in content.lines().enumerate() {
540            if matches.len() >= max_results {
541                break;
542            }
543            if re.is_match(line) {
544                matches.push(serde_json::json!({
545                    "file": file_path.display().to_string(),
546                    "line": line_num + 1,
547                    "content": line.trim(),
548                }));
549            }
550        }
551    }
552
553    let response = serde_json::json!({
554        "pattern": pattern_str,
555        "total_matches": matches.len(),
556        "matches": matches,
557    });
558
559    Ok(ToolResult::success(&call.id, response.to_string()))
560}
561
562/// `find_definitions` — Find function/struct/enum/trait/impl definitions.
563async fn execute_find_definitions(call: &ToolCall) -> ArgentorResult<ToolResult> {
564    let path_str = call.arguments["path"].as_str().unwrap_or(".");
565    let path = Path::new(path_str);
566    if !path.exists() {
567        return Ok(ToolResult::error(
568            &call.id,
569            format!("Path does not exist: '{path_str}'"),
570        ));
571    }
572
573    let name_filter = call.arguments["name"].as_str();
574    let language_filter = call.arguments["language"].as_str();
575
576    // Determine which languages and extensions to search.
577    let lang_globs: Vec<String> = if let Some(lang) = language_filter {
578        glob_for_language(lang).unwrap_or_default()
579    } else {
580        vec![]
581    };
582
583    let files = if lang_globs.is_empty() {
584        walk_dir(path, None, &[], 100)
585    } else {
586        let mut all_files = Vec::new();
587        for glob in &lang_globs {
588            all_files.extend(walk_dir(path, Some(glob), &[], 100));
589        }
590        all_files
591    };
592
593    let mut definitions: Vec<serde_json::Value> = Vec::new();
594
595    for file_path in &files {
596        let lang = if let Some(l) = language_filter {
597            l
598        } else {
599            detect_language(file_path)
600        };
601
602        let patterns = definition_patterns(lang);
603        if patterns.is_empty() {
604            continue;
605        }
606
607        let content = match fs::read_to_string(file_path) {
608            Ok(c) => c,
609            Err(_) => continue,
610        };
611
612        for pat_str in &patterns {
613            let re = match Regex::new(pat_str) {
614                Ok(re) => re,
615                Err(_) => continue,
616            };
617
618            for (line_num, line) in content.lines().enumerate() {
619                if let Some(m) = re.find(line) {
620                    let matched_text = m.as_str().trim();
621
622                    // If name filter is specified, check if the definition contains it.
623                    if let Some(name) = name_filter {
624                        if !matched_text.contains(name) {
625                            continue;
626                        }
627                    }
628
629                    definitions.push(serde_json::json!({
630                        "file": file_path.display().to_string(),
631                        "line": line_num + 1,
632                        "definition": matched_text,
633                        "language": lang,
634                    }));
635                }
636            }
637        }
638    }
639
640    // Deduplicate by (file, line)
641    definitions.sort_by(|a, b| {
642        let file_cmp = a["file"]
643            .as_str()
644            .unwrap_or("")
645            .cmp(b["file"].as_str().unwrap_or(""));
646        if file_cmp != std::cmp::Ordering::Equal {
647            return file_cmp;
648        }
649        a["line"]
650            .as_u64()
651            .unwrap_or(0)
652            .cmp(&b["line"].as_u64().unwrap_or(0))
653    });
654    definitions.dedup_by(|a, b| a["file"] == b["file"] && a["line"] == b["line"]);
655
656    let response = serde_json::json!({
657        "total": definitions.len(),
658        "definitions": definitions,
659    });
660
661    Ok(ToolResult::success(&call.id, response.to_string()))
662}
663
664/// `count_loc` — Count lines of code per language.
665async fn execute_count_loc(call: &ToolCall) -> ArgentorResult<ToolResult> {
666    let path_str = call.arguments["path"].as_str().unwrap_or(".");
667    let path = Path::new(path_str);
668    if !path.exists() {
669        return Ok(ToolResult::error(
670            &call.id,
671            format!("Path does not exist: '{path_str}'"),
672        ));
673    }
674
675    let extra_excludes: Vec<String> = call
676        .arguments
677        .get("exclude")
678        .and_then(|v| serde_json::from_value(v.clone()).ok())
679        .unwrap_or_default();
680
681    let files = walk_dir(path, None, &extra_excludes, 100);
682
683    // Per-language stats: (code_lines, blank_lines, comment_lines, file_count)
684    let mut stats: HashMap<String, (usize, usize, usize, usize)> = HashMap::new();
685    let mut total_files: usize = 0;
686
687    for file_path in &files {
688        let lang = detect_language(file_path);
689        if lang == "unknown" {
690            continue;
691        }
692
693        let content = match fs::read_to_string(file_path) {
694            Ok(c) => c,
695            Err(_) => continue,
696        };
697
698        total_files += 1;
699        let entry = stats.entry(lang.to_string()).or_insert((0, 0, 0, 0));
700        entry.3 += 1; // file_count
701
702        for line in content.lines() {
703            if line.trim().is_empty() {
704                entry.1 += 1; // blank
705            } else if is_comment(line, lang) {
706                entry.2 += 1; // comment
707            } else {
708                entry.0 += 1; // code
709            }
710        }
711    }
712
713    let mut languages: Vec<serde_json::Value> = stats
714        .iter()
715        .map(|(lang, (code, blank, comment, file_count))| {
716            serde_json::json!({
717                "language": lang,
718                "code_lines": code,
719                "blank_lines": blank,
720                "comment_lines": comment,
721                "total_lines": code + blank + comment,
722                "files": file_count,
723            })
724        })
725        .collect();
726
727    // Sort by code lines descending.
728    languages.sort_by(|a, b| {
729        b["code_lines"]
730            .as_u64()
731            .unwrap_or(0)
732            .cmp(&a["code_lines"].as_u64().unwrap_or(0))
733    });
734
735    let total_code: usize = stats.values().map(|(c, _, _, _)| c).sum();
736    let total_blank: usize = stats.values().map(|(_, b, _, _)| b).sum();
737    let total_comment: usize = stats.values().map(|(_, _, cm, _)| cm).sum();
738
739    let response = serde_json::json!({
740        "total_files": total_files,
741        "total_code_lines": total_code,
742        "total_blank_lines": total_blank,
743        "total_comment_lines": total_comment,
744        "total_lines": total_code + total_blank + total_comment,
745        "languages": languages,
746    });
747
748    Ok(ToolResult::success(&call.id, response.to_string()))
749}
750
751/// `file_tree` — Show directory tree structure.
752async fn execute_file_tree(call: &ToolCall) -> ArgentorResult<ToolResult> {
753    let path_str = call.arguments["path"].as_str().unwrap_or(".");
754    let path = Path::new(path_str);
755    if !path.exists() || !path.is_dir() {
756        return Ok(ToolResult::error(
757            &call.id,
758            format!("Path does not exist or is not a directory: '{path_str}'"),
759        ));
760    }
761
762    let depth = call.arguments["depth"].as_u64().unwrap_or(3) as usize;
763    let glob_filter = call.arguments["glob"].as_str();
764
765    let tree = build_tree(path, glob_filter, &[], 0, depth);
766
767    let response = serde_json::json!({
768        "root": path_str,
769        "tree": tree,
770    });
771
772    Ok(ToolResult::success(&call.id, response.to_string()))
773}
774
775/// `find_references` — Find all occurrences of a symbol name.
776async fn execute_find_references(call: &ToolCall) -> ArgentorResult<ToolResult> {
777    let name = call.arguments["name"].as_str().unwrap_or_default();
778    if name.is_empty() {
779        return Ok(ToolResult::error(
780            &call.id,
781            "Missing required parameter 'name'",
782        ));
783    }
784
785    let path_str = call.arguments["path"].as_str().unwrap_or(".");
786    let path = Path::new(path_str);
787    if !path.exists() {
788        return Ok(ToolResult::error(
789            &call.id,
790            format!("Path does not exist: '{path_str}'"),
791        ));
792    }
793
794    let max_results = call.arguments["max_results"]
795        .as_u64()
796        .unwrap_or(DEFAULT_MAX_RESULTS as u64) as usize;
797
798    let files = walk_dir(path, None, &[], 100);
799    let mut references: Vec<serde_json::Value> = Vec::new();
800
801    // Build a word-boundary regex for the symbol name.
802    let pattern = format!(r"\b{}\b", regex::escape(name));
803    let re = match Regex::new(&pattern) {
804        Ok(re) => re,
805        Err(e) => {
806            return Ok(ToolResult::error(
807                &call.id,
808                format!("Failed to build regex for name '{name}': {e}"),
809            ));
810        }
811    };
812
813    for file_path in &files {
814        if references.len() >= max_results {
815            break;
816        }
817
818        let content = match fs::read_to_string(file_path) {
819            Ok(c) => c,
820            Err(_) => continue,
821        };
822
823        for (line_num, line) in content.lines().enumerate() {
824            if references.len() >= max_results {
825                break;
826            }
827            if re.is_match(line) {
828                references.push(serde_json::json!({
829                    "file": file_path.display().to_string(),
830                    "line": line_num + 1,
831                    "content": line.trim(),
832                }));
833            }
834        }
835    }
836
837    let response = serde_json::json!({
838        "name": name,
839        "total_references": references.len(),
840        "references": references,
841    });
842
843    Ok(ToolResult::success(&call.id, response.to_string()))
844}
845
846/// `analyze_imports` — List imports/dependencies used in a file.
847#[allow(clippy::expect_used)]
848async fn execute_analyze_imports(call: &ToolCall) -> ArgentorResult<ToolResult> {
849    let file_path_str = call
850        .arguments
851        .get("file_path")
852        .and_then(|v| v.as_str())
853        .or_else(|| call.arguments.get("path").and_then(|v| v.as_str()))
854        .unwrap_or_default();
855
856    if file_path_str.is_empty() {
857        return Ok(ToolResult::error(
858            &call.id,
859            "Missing required parameter 'file_path' or 'path'",
860        ));
861    }
862
863    let path = Path::new(file_path_str);
864    if !path.exists() || !path.is_file() {
865        return Ok(ToolResult::error(
866            &call.id,
867            format!("File does not exist: '{file_path_str}'"),
868        ));
869    }
870
871    let content = match fs::read_to_string(path) {
872        Ok(c) => c,
873        Err(e) => {
874            return Ok(ToolResult::error(
875                &call.id,
876                format!("Failed to read '{file_path_str}': {e}"),
877            ));
878        }
879    };
880
881    let lang = detect_language(path);
882    let mut imports: Vec<serde_json::Value> = Vec::new();
883
884    match lang {
885        "rust" => {
886            let re = re_rust_use();
887            for (line_num, line) in content.lines().enumerate() {
888                if let Some(caps) = re.captures(line) {
889                    if let Some(m) = caps.get(1) {
890                        imports.push(serde_json::json!({
891                            "line": line_num + 1,
892                            "import": m.as_str().trim(),
893                            "statement": line.trim(),
894                        }));
895                    }
896                }
897            }
898        }
899        "python" => {
900            let import_re = re_python_import();
901            let from_re = re_python_from_import();
902            for (line_num, line) in content.lines().enumerate() {
903                if let Some(caps) = from_re.captures(line) {
904                    let module = caps.get(1).map(|m| m.as_str()).unwrap_or("");
905                    let names = caps.get(2).map(|m| m.as_str()).unwrap_or("");
906                    imports.push(serde_json::json!({
907                        "line": line_num + 1,
908                        "module": module,
909                        "names": names.trim(),
910                        "statement": line.trim(),
911                    }));
912                } else if let Some(caps) = import_re.captures(line) {
913                    if let Some(m) = caps.get(1) {
914                        imports.push(serde_json::json!({
915                            "line": line_num + 1,
916                            "import": m.as_str().trim(),
917                            "statement": line.trim(),
918                        }));
919                    }
920                }
921            }
922        }
923        "typescript" | "javascript" => {
924            let re = re_js_import();
925            let require_re = re_js_require();
926            for (line_num, line) in content.lines().enumerate() {
927                if let Some(caps) = re.captures(line) {
928                    if let Some(m) = caps.get(1) {
929                        imports.push(serde_json::json!({
930                            "line": line_num + 1,
931                            "import": m.as_str().trim(),
932                            "statement": line.trim(),
933                        }));
934                    }
935                } else if let Some(caps) = require_re.captures(line) {
936                    if let Some(m) = caps.get(1) {
937                        imports.push(serde_json::json!({
938                            "line": line_num + 1,
939                            "import": m.as_str().trim(),
940                            "statement": line.trim(),
941                        }));
942                    }
943                }
944            }
945        }
946        "go" => {
947            let single_re = re_go_single_import();
948            let block_import_re = re_go_block_import();
949            let mut in_import_block = false;
950            for (line_num, line) in content.lines().enumerate() {
951                let trimmed = line.trim();
952                if trimmed.starts_with("import (") {
953                    in_import_block = true;
954                    continue;
955                }
956                if in_import_block {
957                    if trimmed == ")" {
958                        in_import_block = false;
959                        continue;
960                    }
961                    if let Some(caps) = block_import_re.captures(line) {
962                        if let Some(m) = caps.get(1) {
963                            imports.push(serde_json::json!({
964                                "line": line_num + 1,
965                                "import": m.as_str().trim(),
966                                "statement": trimmed,
967                            }));
968                        }
969                    }
970                } else if let Some(caps) = single_re.captures(line) {
971                    if let Some(m) = caps.get(1) {
972                        imports.push(serde_json::json!({
973                            "line": line_num + 1,
974                            "import": m.as_str().trim(),
975                            "statement": trimmed,
976                        }));
977                    }
978                }
979            }
980        }
981        _ => {
982            return Ok(ToolResult::success(
983                &call.id,
984                serde_json::json!({
985                    "file": file_path_str,
986                    "language": lang,
987                    "imports": [],
988                    "note": format!("Import analysis not supported for language '{lang}'"),
989                })
990                .to_string(),
991            ));
992        }
993    }
994
995    let response = serde_json::json!({
996        "file": file_path_str,
997        "language": lang,
998        "total_imports": imports.len(),
999        "imports": imports,
1000    });
1001
1002    Ok(ToolResult::success(&call.id, response.to_string()))
1003}
1004
1005/// `file_info` — Get detailed info about a file.
1006async fn execute_file_info(call: &ToolCall) -> ArgentorResult<ToolResult> {
1007    let path_str = call.arguments["path"].as_str().unwrap_or_default();
1008    if path_str.is_empty() {
1009        return Ok(ToolResult::error(
1010            &call.id,
1011            "Missing required parameter 'path'",
1012        ));
1013    }
1014
1015    let path = Path::new(path_str);
1016    if !path.exists() {
1017        return Ok(ToolResult::error(
1018            &call.id,
1019            format!("Path does not exist: '{path_str}'"),
1020        ));
1021    }
1022
1023    let metadata = match fs::metadata(path) {
1024        Ok(m) => m,
1025        Err(e) => {
1026            return Ok(ToolResult::error(
1027                &call.id,
1028                format!("Failed to read metadata for '{path_str}': {e}"),
1029            ));
1030        }
1031    };
1032
1033    let size = metadata.len();
1034    let modified = metadata
1035        .modified()
1036        .map(format_system_time)
1037        .unwrap_or_else(|_| "unknown".to_string());
1038
1039    let is_dir = metadata.is_dir();
1040    let language = if is_dir {
1041        "directory"
1042    } else {
1043        detect_language(path)
1044    };
1045
1046    let line_count = if !is_dir {
1047        match fs::read_to_string(path) {
1048            Ok(content) => Some(content.lines().count()),
1049            Err(_) => None,
1050        }
1051    } else {
1052        None
1053    };
1054
1055    let mut response = serde_json::json!({
1056        "path": path_str,
1057        "size_bytes": size,
1058        "is_directory": is_dir,
1059        "language": language,
1060        "last_modified": modified,
1061    });
1062
1063    if let Some(lines) = line_count {
1064        response["lines"] = serde_json::json!(lines);
1065    }
1066
1067    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
1068        response["name"] = serde_json::json!(name);
1069    }
1070
1071    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1072        response["extension"] = serde_json::json!(ext);
1073    }
1074
1075    Ok(ToolResult::success(&call.id, response.to_string()))
1076}
1077
1078// ---------------------------------------------------------------------------
1079// Tests
1080// ---------------------------------------------------------------------------
1081
1082#[cfg(test)]
1083#[allow(clippy::unwrap_used, clippy::expect_used)]
1084mod tests {
1085    use super::*;
1086    use std::fs;
1087
1088    /// Helper: create a temporary directory with some Rust files.
1089    fn setup_temp_project() -> tempfile::TempDir {
1090        let dir = tempfile::tempdir().unwrap();
1091
1092        // Create a Rust source file
1093        let src_dir = dir.path().join("src");
1094        fs::create_dir_all(&src_dir).unwrap();
1095
1096        fs::write(
1097            src_dir.join("main.rs"),
1098            r#"use std::io;
1099use std::collections::HashMap;
1100
1101/// Entry point.
1102fn main() {
1103    println!("Hello, world!");
1104}
1105
1106pub struct Config {
1107    name: String,
1108}
1109
1110pub enum Status {
1111    Active,
1112    Inactive,
1113}
1114
1115pub trait Runnable {
1116    fn run(&self);
1117}
1118
1119impl Config {
1120    pub fn new(name: String) -> Self {
1121        Self { name }
1122    }
1123}
1124"#,
1125        )
1126        .unwrap();
1127
1128        fs::write(
1129            src_dir.join("lib.rs"),
1130            r#"//! Library root.
1131use serde::{Deserialize, Serialize};
1132
1133pub mod config;
1134
1135pub const VERSION: &str = "0.1.0";
1136
1137pub fn helper() -> bool {
1138    true
1139}
1140"#,
1141        )
1142        .unwrap();
1143
1144        // Create a Python file
1145        fs::write(
1146            dir.path().join("script.py"),
1147            r#"import os
1148from pathlib import Path
1149import sys
1150
1151def greet(name):
1152    print(f"Hello, {name}!")
1153
1154class Greeter:
1155    def __init__(self, name):
1156        self.name = name
1157"#,
1158        )
1159        .unwrap();
1160
1161        // Create a nested directory
1162        let sub = dir.path().join("sub");
1163        fs::create_dir_all(&sub).unwrap();
1164        fs::write(
1165            sub.join("helper.rs"),
1166            "pub fn add(a: i32, b: i32) -> i32 { a + b }\n",
1167        )
1168        .unwrap();
1169
1170        dir
1171    }
1172
1173    #[tokio::test]
1174    async fn test_count_loc() {
1175        let dir = setup_temp_project();
1176        let skill = CodeAnalysisSkill::new();
1177        let call = ToolCall {
1178            id: "t1".to_string(),
1179            name: "code_analysis".to_string(),
1180            arguments: serde_json::json!({
1181                "operation": "count_loc",
1182                "path": dir.path().display().to_string(),
1183            }),
1184        };
1185        let result = skill.execute(call).await.unwrap();
1186        assert!(!result.is_error, "Error: {}", result.content);
1187
1188        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1189        assert!(
1190            parsed["total_code_lines"].as_u64().unwrap() > 0,
1191            "Should have code lines"
1192        );
1193        assert!(
1194            parsed["total_files"].as_u64().unwrap() >= 3,
1195            "Should have at least 3 files"
1196        );
1197    }
1198
1199    #[tokio::test]
1200    async fn test_file_tree() {
1201        let dir = setup_temp_project();
1202        let skill = CodeAnalysisSkill::new();
1203        let call = ToolCall {
1204            id: "t2".to_string(),
1205            name: "code_analysis".to_string(),
1206            arguments: serde_json::json!({
1207                "operation": "file_tree",
1208                "path": dir.path().display().to_string(),
1209                "depth": 3,
1210            }),
1211        };
1212        let result = skill.execute(call).await.unwrap();
1213        assert!(!result.is_error, "Error: {}", result.content);
1214
1215        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1216        let tree = parsed["tree"].as_array().unwrap();
1217        assert!(!tree.is_empty(), "Tree should not be empty");
1218
1219        // Should contain the "src" directory
1220        let names: Vec<&str> = tree.iter().filter_map(|v| v["name"].as_str()).collect();
1221        assert!(names.contains(&"src"), "Tree should contain 'src' dir");
1222    }
1223
1224    #[tokio::test]
1225    async fn test_find_definitions_rust() {
1226        let dir = setup_temp_project();
1227        let skill = CodeAnalysisSkill::new();
1228        let call = ToolCall {
1229            id: "t3".to_string(),
1230            name: "code_analysis".to_string(),
1231            arguments: serde_json::json!({
1232                "operation": "find_definitions",
1233                "path": dir.path().display().to_string(),
1234                "language": "rust",
1235            }),
1236        };
1237        let result = skill.execute(call).await.unwrap();
1238        assert!(!result.is_error, "Error: {}", result.content);
1239
1240        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1241        let defs = parsed["definitions"].as_array().unwrap();
1242
1243        // Should find fn main
1244        let has_main = defs
1245            .iter()
1246            .any(|d| d["definition"].as_str().unwrap_or("").contains("fn main"));
1247        assert!(has_main, "Should find 'fn main' definition");
1248
1249        // Should find struct Config
1250        let has_config = defs.iter().any(|d| {
1251            d["definition"]
1252                .as_str()
1253                .unwrap_or("")
1254                .contains("struct Config")
1255        });
1256        assert!(has_config, "Should find 'struct Config' definition");
1257    }
1258
1259    #[tokio::test]
1260    async fn test_find_definitions_with_name_filter() {
1261        let dir = setup_temp_project();
1262        let skill = CodeAnalysisSkill::new();
1263        let call = ToolCall {
1264            id: "t3b".to_string(),
1265            name: "code_analysis".to_string(),
1266            arguments: serde_json::json!({
1267                "operation": "find_definitions",
1268                "path": dir.path().display().to_string(),
1269                "name": "main",
1270                "language": "rust",
1271            }),
1272        };
1273        let result = skill.execute(call).await.unwrap();
1274        assert!(!result.is_error, "Error: {}", result.content);
1275
1276        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1277        let defs = parsed["definitions"].as_array().unwrap();
1278        assert!(!defs.is_empty(), "Should find definitions matching 'main'");
1279        for d in defs {
1280            assert!(
1281                d["definition"].as_str().unwrap_or("").contains("main"),
1282                "Each definition should contain 'main'"
1283            );
1284        }
1285    }
1286
1287    #[tokio::test]
1288    async fn test_search_pattern() {
1289        let dir = setup_temp_project();
1290        let skill = CodeAnalysisSkill::new();
1291        let call = ToolCall {
1292            id: "t4".to_string(),
1293            name: "code_analysis".to_string(),
1294            arguments: serde_json::json!({
1295                "operation": "search",
1296                "pattern": "println!",
1297                "path": dir.path().display().to_string(),
1298            }),
1299        };
1300        let result = skill.execute(call).await.unwrap();
1301        assert!(!result.is_error, "Error: {}", result.content);
1302
1303        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1304        assert!(
1305            parsed["total_matches"].as_u64().unwrap() > 0,
1306            "Should find println! matches"
1307        );
1308    }
1309
1310    #[tokio::test]
1311    async fn test_search_with_glob() {
1312        let dir = setup_temp_project();
1313        let skill = CodeAnalysisSkill::new();
1314        let call = ToolCall {
1315            id: "t4b".to_string(),
1316            name: "code_analysis".to_string(),
1317            arguments: serde_json::json!({
1318                "operation": "search",
1319                "pattern": "def ",
1320                "path": dir.path().display().to_string(),
1321                "glob": "*.py",
1322            }),
1323        };
1324        let result = skill.execute(call).await.unwrap();
1325        assert!(!result.is_error, "Error: {}", result.content);
1326
1327        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1328        assert!(
1329            parsed["total_matches"].as_u64().unwrap() > 0,
1330            "Should find 'def' in Python files"
1331        );
1332    }
1333
1334    #[tokio::test]
1335    async fn test_analyze_imports_rust() {
1336        let dir = setup_temp_project();
1337        let skill = CodeAnalysisSkill::new();
1338        let call = ToolCall {
1339            id: "t5".to_string(),
1340            name: "code_analysis".to_string(),
1341            arguments: serde_json::json!({
1342                "operation": "analyze_imports",
1343                "file_path": dir.path().join("src/main.rs").display().to_string(),
1344            }),
1345        };
1346        let result = skill.execute(call).await.unwrap();
1347        assert!(!result.is_error, "Error: {}", result.content);
1348
1349        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1350        assert_eq!(parsed["language"].as_str().unwrap(), "rust");
1351        let imports = parsed["imports"].as_array().unwrap();
1352        assert!(imports.len() >= 2, "Should find at least 2 use statements");
1353
1354        let import_strs: Vec<&str> = imports
1355            .iter()
1356            .filter_map(|i| i["import"].as_str())
1357            .collect();
1358        assert!(
1359            import_strs.iter().any(|s| s.contains("std::io")),
1360            "Should find std::io import"
1361        );
1362    }
1363
1364    #[tokio::test]
1365    async fn test_analyze_imports_python() {
1366        let dir = setup_temp_project();
1367        let skill = CodeAnalysisSkill::new();
1368        let call = ToolCall {
1369            id: "t5b".to_string(),
1370            name: "code_analysis".to_string(),
1371            arguments: serde_json::json!({
1372                "operation": "analyze_imports",
1373                "file_path": dir.path().join("script.py").display().to_string(),
1374            }),
1375        };
1376        let result = skill.execute(call).await.unwrap();
1377        assert!(!result.is_error, "Error: {}", result.content);
1378
1379        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1380        assert_eq!(parsed["language"].as_str().unwrap(), "python");
1381        let imports = parsed["imports"].as_array().unwrap();
1382        assert!(
1383            imports.len() >= 2,
1384            "Should find at least 2 import statements"
1385        );
1386    }
1387
1388    #[tokio::test]
1389    async fn test_find_references() {
1390        let dir = setup_temp_project();
1391        let skill = CodeAnalysisSkill::new();
1392        let call = ToolCall {
1393            id: "t6".to_string(),
1394            name: "code_analysis".to_string(),
1395            arguments: serde_json::json!({
1396                "operation": "find_references",
1397                "name": "Config",
1398                "path": dir.path().display().to_string(),
1399            }),
1400        };
1401        let result = skill.execute(call).await.unwrap();
1402        assert!(!result.is_error, "Error: {}", result.content);
1403
1404        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1405        assert!(
1406            parsed["total_references"].as_u64().unwrap() >= 2,
1407            "Should find at least 2 references to 'Config' (struct + impl)"
1408        );
1409    }
1410
1411    #[tokio::test]
1412    async fn test_file_info() {
1413        let dir = setup_temp_project();
1414        let skill = CodeAnalysisSkill::new();
1415        let main_path = dir.path().join("src/main.rs");
1416        let call = ToolCall {
1417            id: "t7".to_string(),
1418            name: "code_analysis".to_string(),
1419            arguments: serde_json::json!({
1420                "operation": "file_info",
1421                "path": main_path.display().to_string(),
1422            }),
1423        };
1424        let result = skill.execute(call).await.unwrap();
1425        assert!(!result.is_error, "Error: {}", result.content);
1426
1427        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1428        assert_eq!(parsed["language"].as_str().unwrap(), "rust");
1429        assert_eq!(parsed["extension"].as_str().unwrap(), "rs");
1430        assert!(parsed["lines"].as_u64().unwrap() > 0);
1431        assert!(parsed["size_bytes"].as_u64().unwrap() > 0);
1432        assert!(!parsed["is_directory"].as_bool().unwrap());
1433    }
1434
1435    #[tokio::test]
1436    async fn test_unknown_operation() {
1437        let skill = CodeAnalysisSkill::new();
1438        let call = ToolCall {
1439            id: "t_err".to_string(),
1440            name: "code_analysis".to_string(),
1441            arguments: serde_json::json!({
1442                "operation": "nonexistent",
1443            }),
1444        };
1445        let result = skill.execute(call).await.unwrap();
1446        assert!(result.is_error);
1447        assert!(result.content.contains("Unknown operation"));
1448    }
1449
1450    #[tokio::test]
1451    async fn test_search_invalid_regex() {
1452        let dir = setup_temp_project();
1453        let skill = CodeAnalysisSkill::new();
1454        let call = ToolCall {
1455            id: "t_regex".to_string(),
1456            name: "code_analysis".to_string(),
1457            arguments: serde_json::json!({
1458                "operation": "search",
1459                "pattern": "[invalid",
1460                "path": dir.path().display().to_string(),
1461            }),
1462        };
1463        let result = skill.execute(call).await.unwrap();
1464        assert!(result.is_error);
1465        assert!(result.content.contains("Invalid regex"));
1466    }
1467
1468    #[tokio::test]
1469    async fn test_nonexistent_path() {
1470        let skill = CodeAnalysisSkill::new();
1471        let call = ToolCall {
1472            id: "t_nopath".to_string(),
1473            name: "code_analysis".to_string(),
1474            arguments: serde_json::json!({
1475                "operation": "count_loc",
1476                "path": "/tmp/argentor_nonexistent_dir_99999",
1477            }),
1478        };
1479        let result = skill.execute(call).await.unwrap();
1480        assert!(result.is_error);
1481        assert!(result.content.contains("does not exist"));
1482    }
1483
1484    #[test]
1485    fn test_matches_glob() {
1486        assert!(matches_glob("main.rs", "*.rs"));
1487        assert!(matches_glob("test.py", "*.py"));
1488        assert!(!matches_glob("main.rs", "*.py"));
1489        assert!(matches_glob("anything", "*"));
1490        assert!(matches_glob("exact.txt", "exact.txt"));
1491        assert!(!matches_glob("other.txt", "exact.txt"));
1492    }
1493
1494    #[test]
1495    fn test_detect_language() {
1496        assert_eq!(detect_language(Path::new("main.rs")), "rust");
1497        assert_eq!(detect_language(Path::new("script.py")), "python");
1498        assert_eq!(detect_language(Path::new("app.ts")), "typescript");
1499        assert_eq!(detect_language(Path::new("main.go")), "go");
1500        assert_eq!(detect_language(Path::new("style.css")), "css");
1501        assert_eq!(detect_language(Path::new("noext")), "unknown");
1502    }
1503
1504    #[test]
1505    fn test_is_comment() {
1506        assert!(is_comment("  // a comment", "rust"));
1507        assert!(is_comment("  # a comment", "python"));
1508        assert!(is_comment("  // a comment", "javascript"));
1509        assert!(is_comment("  -- a comment", "sql"));
1510        assert!(!is_comment("  let x = 1;", "rust"));
1511        assert!(!is_comment("  x = 1", "python"));
1512    }
1513
1514    #[test]
1515    fn test_descriptor() {
1516        let skill = CodeAnalysisSkill::new();
1517        assert_eq!(skill.descriptor().name, "code_analysis");
1518    }
1519}