1use 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
29fn 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
75const 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
93const DEFAULT_MAX_RESULTS: usize = 50;
95
96pub struct CodeAnalysisSkill {
99 descriptor: SkillDescriptor,
100}
101
102impl CodeAnalysisSkill {
103 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![], }],
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
214fn 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
231fn 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
239fn 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
297fn 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
325fn 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
342fn 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
375fn 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
387fn 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
397fn format_system_time(time: SystemTime) -> String {
399 match time.duration_since(SystemTime::UNIX_EPOCH) {
400 Ok(dur) => {
401 let secs = dur.as_secs();
402 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
414fn 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 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
488async 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, };
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
562async 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 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 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 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
664async 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 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; for line in content.lines() {
703 if line.trim().is_empty() {
704 entry.1 += 1; } else if is_comment(line, lang) {
706 entry.2 += 1; } else {
708 entry.0 += 1; }
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 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
751async 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
775async 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 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#[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
1005async 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#[cfg(test)]
1083#[allow(clippy::unwrap_used, clippy::expect_used)]
1084mod tests {
1085 use super::*;
1086 use std::fs;
1087
1088 fn setup_temp_project() -> tempfile::TempDir {
1090 let dir = tempfile::tempdir().unwrap();
1091
1092 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 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 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 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 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 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}