1use crate::cache::CacheManager;
9use crate::dependency::DependencyIndex;
10use crate::query::QueryEngine;
11use anyhow::{Context as AnyhowContext, Result};
12
13use super::executor::parse_command;
14use super::schema_agentic::{AnalysisType, ContextGatheringParams, ToolCall};
15
16#[derive(Debug, Clone)]
18pub struct ToolResult {
19 pub description: String,
21
22 pub output: String,
24
25 pub success: bool,
27}
28
29pub async fn execute_tool(tool: &ToolCall, cache: &CacheManager) -> Result<ToolResult> {
31 match tool {
32 ToolCall::GatherContext { params } => execute_gather_context(params, cache),
33 ToolCall::ExploreCodebase {
34 description,
35 command,
36 } => execute_explore_codebase(description, command, cache).await,
37 ToolCall::AnalyzeStructure { analysis_type } => {
38 execute_analyze_structure(*analysis_type, cache)
39 }
40 ToolCall::SearchDocumentation { query, files } => {
41 execute_search_documentation(query, files.as_deref(), cache)
42 }
43 ToolCall::GetStatistics => execute_get_statistics(cache),
44 ToolCall::GetDependencies { file_path, reverse } => {
45 execute_get_dependencies(file_path, *reverse, cache)
46 }
47 ToolCall::GetAnalysisSummary { min_dependents } => {
48 execute_get_analysis_summary(*min_dependents, cache)
49 }
50 ToolCall::FindIslands { min_size, max_size } => {
51 execute_find_islands(*min_size, *max_size, cache)
52 }
53 }
54}
55
56fn execute_gather_context(
58 params: &ContextGatheringParams,
59 cache: &CacheManager,
60) -> Result<ToolResult> {
61 log::info!("Executing gather_context tool");
62
63 let mut opts = crate::context::ContextOptions {
65 structure: params.structure,
66 path: params.path.clone(),
67 file_types: params.file_types,
68 project_type: params.project_type,
69 framework: params.framework,
70 entry_points: params.entry_points,
71 test_layout: params.test_layout,
72 config_files: params.config_files,
73 depth: params.depth,
74 json: false, };
76
77 if opts.is_empty() {
79 opts.structure = true;
80 opts.file_types = true;
81 opts.project_type = true;
82 opts.framework = true;
83 opts.entry_points = true;
84 opts.test_layout = true;
85 opts.config_files = true;
86 }
87
88 let output = crate::context::generate_context(cache, &opts)
90 .context("Failed to generate codebase context")?;
91
92 let mut parts = Vec::new();
94 if opts.structure {
95 parts.push("structure");
96 }
97 if opts.file_types {
98 parts.push("file types");
99 }
100 if opts.project_type {
101 parts.push("project type");
102 }
103 if opts.framework {
104 parts.push("frameworks");
105 }
106 if opts.entry_points {
107 parts.push("entry points");
108 }
109 if opts.test_layout {
110 parts.push("test layout");
111 }
112 if opts.config_files {
113 parts.push("config files");
114 }
115
116 let description = if parts.is_empty() {
117 "Gathered general codebase context".to_string()
118 } else {
119 format!("Gathered codebase context: {}", parts.join(", "))
120 };
121
122 log::debug!("Context gathering successful: {} chars", output.len());
123
124 Ok(ToolResult {
125 description,
126 output,
127 success: true,
128 })
129}
130
131async fn execute_explore_codebase(
133 description: &str,
134 command: &str,
135 cache: &CacheManager,
136) -> Result<ToolResult> {
137 log::info!("Executing explore_codebase tool: {}", description);
138
139 let parsed = parse_command(command)
141 .with_context(|| format!("Failed to parse exploration command: {}", command))?;
142
143 let filter = parsed.to_query_filter()?;
145
146 let engine = QueryEngine::new(CacheManager::new(cache.workspace_root()));
148
149 let response = engine
151 .search_with_metadata(&parsed.pattern, filter)
152 .with_context(|| format!("Failed to execute exploration query: {}", command))?;
153
154 let output = format_exploration_results(&response, &parsed.pattern);
156
157 log::debug!(
158 "Exploration query found {} file groups",
159 response.results.len()
160 );
161
162 Ok(ToolResult {
163 description: format!("Explored: {}", description),
164 output,
165 success: true,
166 })
167}
168
169fn execute_analyze_structure(
171 analysis_type: AnalysisType,
172 cache: &CacheManager,
173) -> Result<ToolResult> {
174 log::info!("Executing analyze_structure tool: {:?}", analysis_type);
175
176 let deps_index = DependencyIndex::new(CacheManager::new(cache.workspace_root()));
178
179 let output = match analysis_type {
180 AnalysisType::Hotspots => {
181 let hotspot_ids = deps_index.find_hotspots(Some(10), 2)?; let file_ids: Vec<i64> = hotspot_ids.iter().map(|(id, _)| *id).collect();
186 let paths = deps_index.get_file_paths(&file_ids)?;
187
188 let hotspots: Vec<(String, usize)> = hotspot_ids
190 .iter()
191 .filter_map(|(id, count)| paths.get(id).map(|path| (path.clone(), *count)))
192 .collect();
193
194 format_hotspots(&hotspots)
195 }
196 AnalysisType::Unused => {
197 let unused_ids = deps_index.find_unused_files()?;
199
200 let paths = deps_index.get_file_paths(&unused_ids)?;
202 let unused: Vec<String> = unused_ids
203 .iter()
204 .filter_map(|id| paths.get(id).cloned())
205 .collect();
206
207 format_unused_files(&unused)
208 }
209 AnalysisType::Circular => {
210 let circular_ids = deps_index.detect_circular_dependencies()?;
212
213 let all_ids: Vec<i64> = circular_ids
215 .iter()
216 .flat_map(|cycle| cycle.iter())
217 .copied()
218 .collect::<std::collections::HashSet<_>>()
219 .into_iter()
220 .collect();
221
222 let paths = deps_index.get_file_paths(&all_ids)?;
224
225 let circular: Vec<Vec<String>> = circular_ids
227 .iter()
228 .map(|cycle| {
229 cycle
230 .iter()
231 .filter_map(|id| paths.get(id).cloned())
232 .collect()
233 })
234 .collect();
235
236 format_circular_deps(&circular)
237 }
238 };
239
240 let description = match analysis_type {
241 AnalysisType::Hotspots => "Analyzed dependency hotspots (most-imported files)",
242 AnalysisType::Unused => "Analyzed unused files (no importers)",
243 AnalysisType::Circular => "Analyzed circular dependencies",
244 };
245
246 log::debug!("Analysis complete: {} chars", output.len());
247
248 Ok(ToolResult {
249 description: description.to_string(),
250 output,
251 success: true,
252 })
253}
254
255fn execute_search_documentation(
257 query: &str,
258 files: Option<&[String]>,
259 cache: &CacheManager,
260) -> Result<ToolResult> {
261 log::info!("Executing search_documentation tool: query='{}'", query);
262
263 let workspace_root = cache.workspace_root();
264
265 let default_files = vec!["CLAUDE.md".to_string(), "README.md".to_string()];
267 let search_files = files.unwrap_or(&default_files);
268
269 let mut found_sections = Vec::new();
270 let mut searched_files = Vec::new();
271
272 for file in search_files {
274 let file_path = workspace_root.join(file);
275
276 if !file_path.exists() {
277 log::debug!("Documentation file does not exist: {}", file);
278 continue;
279 }
280
281 searched_files.push(file.clone());
282
283 match std::fs::read_to_string(&file_path) {
284 Ok(content) => {
285 if let Some(sections) = search_documentation_content(&content, query, file) {
287 found_sections.push(sections);
288 }
289 }
290 Err(e) => {
291 log::warn!("Failed to read documentation file {}: {}", file, e);
292 }
293 }
294 }
295
296 let context_dir = workspace_root.join(".context");
298 if context_dir.exists() && context_dir.is_dir() {
299 if let Ok(entries) = std::fs::read_dir(&context_dir) {
300 for entry in entries.flatten() {
301 let path = entry.path();
302 if path.extension().and_then(|s| s.to_str()) == Some("md") {
303 if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
304 if let Ok(content) = std::fs::read_to_string(&path) {
305 if let Some(sections) = search_documentation_content(
306 &content,
307 query,
308 &format!(".context/{}", file_name),
309 ) {
310 found_sections.push(sections);
311 searched_files.push(format!(".context/{}", file_name));
312 }
313 }
314 }
315 }
316 }
317 }
318 }
319
320 let output = if found_sections.is_empty() {
322 format!(
323 "No relevant documentation found for query '{}' in files: {}\n\nTry:\n- Using different keywords\n- Searching the codebase directly with explore_codebase",
324 query,
325 searched_files.join(", ")
326 )
327 } else {
328 format!(
329 "Found documentation for '{}' in {} file(s):\n\n{}",
330 query,
331 found_sections.len(),
332 found_sections.join("\n\n---\n\n")
333 )
334 };
335
336 log::debug!(
337 "Documentation search found {} sections",
338 found_sections.len()
339 );
340
341 Ok(ToolResult {
342 description: format!("Searched documentation for: {}", query),
343 output,
344 success: !found_sections.is_empty(),
345 })
346}
347
348fn search_documentation_content(content: &str, query: &str, file_name: &str) -> Option<String> {
350 let stop_words = [
352 "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
353 "from", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do",
354 "does", "did", "will", "would", "should", "could", "may", "might", "can", "what", "how",
355 "where", "when", "why", "which", "who",
356 ];
357 let keywords: Vec<String> = query
358 .to_lowercase()
359 .split_whitespace()
360 .filter(|word| !stop_words.contains(word) && word.len() > 2)
361 .map(|s| s.to_string())
362 .collect();
363
364 if keywords.is_empty() {
365 return None;
366 }
367
368 let lines: Vec<&str> = content.lines().collect();
369 let mut relevant_sections = Vec::new();
370 let mut current_section = String::new();
371 let mut current_section_title = String::new();
372 let mut in_relevant_section = false;
373 let mut relevance_score = 0;
374
375 for line in lines.iter() {
376 let line_lower = line.to_lowercase();
377
378 if line.starts_with('#') {
380 if in_relevant_section && relevance_score >= 2 {
382 relevant_sections.push(format!(
384 "## {} ({})\n\n{}",
385 current_section_title,
386 file_name,
387 current_section.trim()
388 ));
389 }
390
391 current_section.clear();
393 current_section_title = line.trim_start_matches('#').trim().to_string();
394 relevance_score = 0;
395 in_relevant_section = false;
396
397 let heading_lower = line_lower.clone();
399 for keyword in &keywords {
400 if heading_lower.contains(keyword) {
401 in_relevant_section = true;
402 relevance_score += 10;
403 }
404 }
405 }
406
407 let mut line_matches = 0;
409 for keyword in &keywords {
410 if line_lower.contains(keyword) {
411 in_relevant_section = true;
412 line_matches += 1;
413 }
414 }
415 relevance_score += line_matches;
416
417 if in_relevant_section || relevance_score > 0 {
419 current_section.push_str(line);
420 current_section.push('\n');
421
422 if current_section.lines().count() > 150 {
424 break;
425 }
426 }
427 }
428
429 if in_relevant_section && relevance_score >= 2 {
431 relevant_sections.push(format!(
433 "## {} ({})\n\n{}",
434 current_section_title,
435 file_name,
436 current_section.trim()
437 ));
438 }
439
440 if relevant_sections.is_empty() {
441 None
442 } else {
443 Some(
445 relevant_sections
446 .iter()
447 .take(3)
448 .cloned()
449 .collect::<Vec<_>>()
450 .join("\n\n"),
451 )
452 }
453}
454
455fn format_exploration_results(response: &crate::models::QueryResponse, pattern: &str) -> String {
457 if response.results.is_empty() {
458 return format!("No results found for pattern: {}", pattern);
459 }
460
461 let mut output = Vec::new();
462 output.push(format!(
463 "Found {} total matches across {} files for pattern '{}':\n",
464 response.pagination.total,
465 response.results.len(),
466 pattern
467 ));
468
469 for (idx, file_group) in response.results.iter().take(5).enumerate() {
471 output.push(format!("\n{}. {}", idx + 1, file_group.path));
472
473 for match_result in file_group.matches.iter().take(3) {
475 for (idx, line) in match_result.context_before.iter().enumerate() {
477 let line_num = match_result
478 .span
479 .start_line
480 .saturating_sub(match_result.context_before.len() - idx);
481 output.push(format!(" Line {}: {}", line_num, line.trim()));
482 }
483
484 output.push(format!(
486 " Line {}: {}",
487 match_result.span.start_line,
488 match_result.preview.lines().next().unwrap_or("").trim()
489 ));
490
491 for (idx, line) in match_result.context_after.iter().enumerate() {
493 let line_num = match_result.span.start_line + idx + 1;
494 output.push(format!(" Line {}: {}", line_num, line.trim()));
495 }
496 }
497
498 if file_group.matches.len() > 3 {
499 output.push(format!(
500 " ... and {} more matches",
501 file_group.matches.len() - 3
502 ));
503 }
504 }
505
506 if response.results.len() > 5 {
507 output.push(format!(
508 "\n... and {} more files",
509 response.results.len() - 5
510 ));
511 }
512
513 output.join("\n")
514}
515
516fn format_hotspots(hotspots: &[(String, usize)]) -> String {
518 if hotspots.is_empty() {
519 return "No dependency hotspots found.".to_string();
520 }
521
522 let mut output = Vec::new();
523 output.push(format!(
524 "Top {} most-imported files:\n",
525 hotspots.len().min(10)
526 ));
527
528 for (idx, (path, count)) in hotspots.iter().take(10).enumerate() {
529 output.push(format!("{}. {} ({} importers)", idx + 1, path, count));
530 }
531
532 if hotspots.len() > 10 {
533 output.push(format!("\n... and {} more hotspots", hotspots.len() - 10));
534 }
535
536 output.join("\n")
537}
538
539fn format_unused_files(unused: &[String]) -> String {
541 if unused.is_empty() {
542 return "No unused files found (all files are imported by others).".to_string();
543 }
544
545 let mut output = Vec::new();
546 output.push(format!(
547 "Found {} unused files (no importers):\n",
548 unused.len()
549 ));
550
551 for (idx, path) in unused.iter().take(15).enumerate() {
552 output.push(format!("{}. {}", idx + 1, path));
553 }
554
555 if unused.len() > 15 {
556 output.push(format!("\n... and {} more unused files", unused.len() - 15));
557 }
558
559 output.join("\n")
560}
561
562fn format_circular_deps(circular: &[Vec<String>]) -> String {
564 if circular.is_empty() {
565 return "No circular dependencies found.".to_string();
566 }
567
568 let mut output = Vec::new();
569 output.push(format!(
570 "Found {} circular dependency chains:\n",
571 circular.len()
572 ));
573
574 for (idx, cycle) in circular.iter().take(5).enumerate() {
575 output.push(format!("\n{}. Cycle ({} files):", idx + 1, cycle.len()));
576 output.push(format!(" {}", cycle.join(" → ")));
577 }
578
579 if circular.len() > 5 {
580 output.push(format!(
581 "\n... and {} more circular dependencies",
582 circular.len() - 5
583 ));
584 }
585
586 output.join("\n")
587}
588
589fn execute_get_statistics(cache: &CacheManager) -> Result<ToolResult> {
591 log::info!("Executing get_statistics tool");
592
593 let stats = cache.stats().context("Failed to get cache statistics")?;
595
596 let output = format_statistics(&stats);
598
599 log::debug!("Statistics retrieved successfully");
600
601 Ok(ToolResult {
602 description: "Retrieved index statistics".to_string(),
603 output,
604 success: true,
605 })
606}
607
608fn execute_get_dependencies(
610 file_path: &str,
611 reverse: bool,
612 cache: &CacheManager,
613) -> Result<ToolResult> {
614 log::info!(
615 "Executing get_dependencies tool: file={}, reverse={}",
616 file_path,
617 reverse
618 );
619
620 let deps_index = DependencyIndex::new(CacheManager::new(cache.workspace_root()));
622
623 let file_id = deps_index
625 .get_file_id_by_path(file_path)
626 .context(format!("Failed to find file: {}", file_path))?
627 .ok_or_else(|| anyhow::anyhow!("File not found: {}", file_path))?;
628
629 let output = if reverse {
630 let dependent_ids = deps_index
632 .get_dependents(file_id)
633 .context("Failed to get reverse dependencies")?;
634
635 let paths = deps_index.get_file_paths(&dependent_ids)?;
637 let dependents: Vec<String> = dependent_ids
638 .iter()
639 .filter_map(|id| paths.get(id).cloned())
640 .collect();
641
642 format_reverse_dependencies(file_path, &dependents)
643 } else {
644 let deps = deps_index
646 .get_dependencies_info(file_id)
647 .context("Failed to get dependencies")?;
648
649 format_dependencies(file_path, &deps)
650 };
651
652 let description = if reverse {
653 format!("Found reverse dependencies for: {}", file_path)
654 } else {
655 format!("Found dependencies for: {}", file_path)
656 };
657
658 log::debug!("Dependencies retrieved successfully");
659
660 Ok(ToolResult {
661 description,
662 output,
663 success: true,
664 })
665}
666
667fn execute_get_analysis_summary(min_dependents: usize, cache: &CacheManager) -> Result<ToolResult> {
669 log::info!(
670 "Executing get_analysis_summary tool: min_dependents={}",
671 min_dependents
672 );
673
674 let deps_index = DependencyIndex::new(CacheManager::new(cache.workspace_root()));
676
677 let hotspot_ids = deps_index.find_hotspots(Some(10), min_dependents)?;
679 let hotspot_count = hotspot_ids.len();
680
681 let unused_ids = deps_index.find_unused_files()?;
683 let unused_count = unused_ids.len();
684
685 let circular_ids = deps_index.detect_circular_dependencies()?;
687 let circular_count = circular_ids.len();
688
689 let output =
691 format_analysis_summary(hotspot_count, unused_count, circular_count, min_dependents);
692
693 log::debug!("Analysis summary retrieved successfully");
694
695 Ok(ToolResult {
696 description: "Retrieved dependency analysis summary".to_string(),
697 output,
698 success: true,
699 })
700}
701
702fn execute_find_islands(
704 min_size: usize,
705 max_size: usize,
706 cache: &CacheManager,
707) -> Result<ToolResult> {
708 log::info!(
709 "Executing find_islands tool: min_size={}, max_size={}",
710 min_size,
711 max_size
712 );
713
714 let deps_index = DependencyIndex::new(CacheManager::new(cache.workspace_root()));
716
717 let all_islands = deps_index.find_islands()?;
719
720 let filtered_islands: Vec<Vec<i64>> = all_islands
722 .into_iter()
723 .filter(|island| island.len() >= min_size && island.len() <= max_size)
724 .collect();
725
726 let all_ids: Vec<i64> = filtered_islands
728 .iter()
729 .flat_map(|island| island.iter())
730 .copied()
731 .collect::<std::collections::HashSet<_>>()
732 .into_iter()
733 .collect();
734
735 let paths = deps_index.get_file_paths(&all_ids)?;
736
737 let islands_with_paths: Vec<Vec<String>> = filtered_islands
738 .iter()
739 .map(|island| {
740 island
741 .iter()
742 .filter_map(|id| paths.get(id).cloned())
743 .collect()
744 })
745 .collect();
746
747 let output = format_islands(&islands_with_paths, min_size, max_size);
749
750 log::debug!(
751 "Islands retrieved successfully: {} islands found",
752 islands_with_paths.len()
753 );
754
755 Ok(ToolResult {
756 description: format!("Found {} disconnected components", islands_with_paths.len()),
757 output,
758 success: true,
759 })
760}
761
762fn format_statistics(stats: &crate::models::IndexStats) -> String {
764 let mut output = Vec::new();
765
766 output.push(format!("# Index Statistics\n"));
767 output.push(format!("Total files: {}", stats.total_files));
768 output.push(format!(
769 "Index size: {:.2} MB\n",
770 stats.index_size_bytes as f64 / 1_048_576.0
771 ));
772
773 if !stats.files_by_language.is_empty() {
775 output.push("## Files by Language\n".to_string());
776 let mut lang_counts: Vec<_> = stats.files_by_language.iter().collect();
777 lang_counts.sort_by(|a, b| b.1.cmp(a.1)); for (lang, count) in lang_counts.iter().take(10) {
780 let percentage = (**count as f64 / stats.total_files as f64) * 100.0;
781 output.push(format!("- {}: {} files ({:.1}%)", lang, count, percentage));
782 }
783
784 if lang_counts.len() > 10 {
785 output.push(format!("... and {} more languages", lang_counts.len() - 10));
786 }
787 }
788
789 if !stats.lines_by_language.is_empty() {
791 output.push("\n## Lines of Code by Language\n".to_string());
792 let mut line_counts: Vec<_> = stats.lines_by_language.iter().collect();
793 line_counts.sort_by(|a, b| b.1.cmp(a.1)); let total_lines: usize = stats.lines_by_language.values().sum();
796
797 for (lang, count) in line_counts.iter().take(10) {
798 let percentage = (**count as f64 / total_lines as f64) * 100.0;
799 let formatted_count = count
800 .to_string()
801 .as_str()
802 .chars()
803 .rev()
804 .enumerate()
805 .map(|(i, c)| {
806 if i != 0 && i % 3 == 0 {
807 format!(",{}", c)
808 } else {
809 c.to_string()
810 }
811 })
812 .collect::<Vec<_>>()
813 .into_iter()
814 .rev()
815 .collect::<String>();
816 output.push(format!(
817 "- {}: {} lines ({:.1}%)",
818 lang, formatted_count, percentage
819 ));
820 }
821
822 if line_counts.len() > 10 {
823 output.push(format!("... and {} more languages", line_counts.len() - 10));
824 }
825 }
826
827 output.push(format!("\nLast updated: {}", stats.last_updated));
828
829 output.join("\n")
830}
831
832fn format_dependencies(file_path: &str, deps: &[crate::models::DependencyInfo]) -> String {
834 if deps.is_empty() {
835 return format!("File '{}' has no dependencies.", file_path);
836 }
837
838 let mut output = Vec::new();
839 output.push(format!("# Dependencies of '{}'\n", file_path));
840 output.push(format!("Found {} dependencies:\n", deps.len()));
841
842 for (idx, dep) in deps.iter().take(20).enumerate() {
843 let line_info = dep
844 .line
845 .map(|l| format!(" (line {})", l))
846 .unwrap_or_default();
847 output.push(format!("{}. {}{}", idx + 1, dep.path, line_info));
848
849 if let Some(symbols) = &dep.symbols {
851 if !symbols.is_empty() {
852 output.push(format!(" Symbols: {}", symbols.join(", ")));
853 }
854 }
855 }
856
857 if deps.len() > 20 {
858 output.push(format!("\n... and {} more dependencies", deps.len() - 20));
859 }
860
861 output.join("\n")
862}
863
864fn format_reverse_dependencies(file_path: &str, dependents: &[String]) -> String {
866 if dependents.is_empty() {
867 return format!("No files depend on '{}'.", file_path);
868 }
869
870 let mut output = Vec::new();
871 output.push(format!("# Files that import '{}'\n", file_path));
872 output.push(format!("Found {} files:\n", dependents.len()));
873
874 for (idx, path) in dependents.iter().take(20).enumerate() {
875 output.push(format!("{}. {}", idx + 1, path));
876 }
877
878 if dependents.len() > 20 {
879 output.push(format!("\n... and {} more files", dependents.len() - 20));
880 }
881
882 output.join("\n")
883}
884
885fn format_analysis_summary(
887 hotspot_count: usize,
888 unused_count: usize,
889 circular_count: usize,
890 min_dependents: usize,
891) -> String {
892 let mut output = Vec::new();
893
894 output.push("# Dependency Analysis Summary\n".to_string());
895 output.push(format!(
896 "Hotspots (files with {}+ importers): {}",
897 min_dependents, hotspot_count
898 ));
899 output.push(format!("Unused files (no importers): {}", unused_count));
900 output.push(format!("Circular dependency chains: {}", circular_count));
901
902 if hotspot_count > 0 {
903 output.push(
904 "\n**Hotspots** indicate central/important files that many other files depend on."
905 .to_string(),
906 );
907 }
908
909 if unused_count > 0 {
910 output.push(
911 "\n**Unused files** may be dead code or entry points (like main.rs, index.ts)."
912 .to_string(),
913 );
914 }
915
916 if circular_count > 0 {
917 output.push("\n**Circular dependencies** can cause compilation issues and indicate architectural problems.".to_string());
918 }
919
920 output.join("\n")
921}
922
923fn format_islands(islands: &[Vec<String>], min_size: usize, max_size: usize) -> String {
925 if islands.is_empty() {
926 return format!(
927 "No disconnected components found (size {}-{}).",
928 min_size, max_size
929 );
930 }
931
932 let mut output = Vec::new();
933 output.push(format!("# Disconnected Components (Islands)\n"));
934 output.push(format!(
935 "Found {} islands (size {}-{}):\n",
936 islands.len(),
937 min_size,
938 max_size
939 ));
940
941 for (idx, island) in islands.iter().take(5).enumerate() {
942 output.push(format!(
943 "\n{}. Island with {} files:",
944 idx + 1,
945 island.len()
946 ));
947
948 for (file_idx, file) in island.iter().take(10).enumerate() {
949 output.push(format!(" {}. {}", file_idx + 1, file));
950 }
951
952 if island.len() > 10 {
953 output.push(format!(" ... and {} more files", island.len() - 10));
954 }
955 }
956
957 if islands.len() > 5 {
958 output.push(format!("\n... and {} more islands", islands.len() - 5));
959 }
960
961 output.push("\n**Islands** are groups of files that depend on each other but have no dependencies outside the group.".to_string());
962 output.push("This can indicate isolated subsystems or potential dead code.".to_string());
963
964 output.join("\n")
965}
966
967pub fn format_tool_results(results: &[ToolResult]) -> String {
969 if results.is_empty() {
970 return String::new();
971 }
972
973 let mut output = Vec::new();
974 output.push("## Tool Execution Results\n".to_string());
975
976 for (idx, result) in results.iter().enumerate() {
977 output.push(format!("\n### Tool {} - {}", idx + 1, result.description));
978 output.push(String::new());
979 output.push(result.output.clone());
980 output.push(String::new());
981 }
982
983 output.join("\n")
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989
990 #[test]
991 fn test_format_tool_results_empty() {
992 let results = vec![];
993 let output = format_tool_results(&results);
994 assert!(output.is_empty());
995 }
996
997 #[test]
998 fn test_format_tool_results_single() {
999 let results = vec![ToolResult {
1000 description: "Test tool".to_string(),
1001 output: "Test output".to_string(),
1002 success: true,
1003 }];
1004
1005 let output = format_tool_results(&results);
1006 assert!(output.contains("Tool Execution Results"));
1007 assert!(output.contains("Test tool"));
1008 assert!(output.contains("Test output"));
1009 }
1010
1011 #[test]
1012 fn test_format_hotspots() {
1013 let hotspots = vec![
1014 ("src/main.rs".to_string(), 10),
1015 ("src/lib.rs".to_string(), 5),
1016 ];
1017
1018 let output = format_hotspots(&hotspots);
1019 assert!(output.contains("most-imported files"));
1020 assert!(output.contains("src/main.rs"));
1021 assert!(output.contains("10 importers"));
1022 }
1023
1024 #[test]
1025 fn test_format_unused_files() {
1026 let unused = vec!["src/old.rs".to_string(), "tests/legacy.rs".to_string()];
1027
1028 let output = format_unused_files(&unused);
1029 assert!(output.contains("unused files"));
1030 assert!(output.contains("src/old.rs"));
1031 }
1032
1033 #[test]
1034 fn test_format_circular_deps() {
1035 let circular = vec![vec![
1036 "a.rs".to_string(),
1037 "b.rs".to_string(),
1038 "a.rs".to_string(),
1039 ]];
1040
1041 let output = format_circular_deps(&circular);
1042 assert!(output.contains("circular dependency"));
1043 assert!(output.contains("a.rs → b.rs → a.rs"));
1044 }
1045}