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()
299 && context_dir.is_dir()
300 && let Ok(entries) = std::fs::read_dir(&context_dir)
301 {
302 for entry in entries.flatten() {
303 let path = entry.path();
304 if path.extension().and_then(|s| s.to_str()) == Some("md")
305 && let Some(file_name) = path.file_name().and_then(|n| n.to_str())
306 && let Ok(content) = std::fs::read_to_string(&path)
307 && let Some(sections) = search_documentation_content(
308 &content,
309 query,
310 &format!(".context/{}", file_name),
311 )
312 {
313 found_sections.push(sections);
314 searched_files.push(format!(".context/{}", file_name));
315 }
316 }
317 }
318
319 let output = if found_sections.is_empty() {
321 format!(
322 "No relevant documentation found for query '{}' in files: {}\n\nTry:\n- Using different keywords\n- Searching the codebase directly with explore_codebase",
323 query,
324 searched_files.join(", ")
325 )
326 } else {
327 format!(
328 "Found documentation for '{}' in {} file(s):\n\n{}",
329 query,
330 found_sections.len(),
331 found_sections.join("\n\n---\n\n")
332 )
333 };
334
335 log::debug!(
336 "Documentation search found {} sections",
337 found_sections.len()
338 );
339
340 Ok(ToolResult {
341 description: format!("Searched documentation for: {}", query),
342 output,
343 success: !found_sections.is_empty(),
344 })
345}
346
347fn search_documentation_content(content: &str, query: &str, file_name: &str) -> Option<String> {
349 let stop_words = [
351 "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
352 "from", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do",
353 "does", "did", "will", "would", "should", "could", "may", "might", "can", "what", "how",
354 "where", "when", "why", "which", "who",
355 ];
356 let keywords: Vec<String> = query
357 .to_lowercase()
358 .split_whitespace()
359 .filter(|word| !stop_words.contains(word) && word.len() > 2)
360 .map(|s| s.to_string())
361 .collect();
362
363 if keywords.is_empty() {
364 return None;
365 }
366
367 let lines: Vec<&str> = content.lines().collect();
368 let mut relevant_sections = Vec::new();
369 let mut current_section = String::new();
370 let mut current_section_title = String::new();
371 let mut in_relevant_section = false;
372 let mut relevance_score = 0;
373
374 for line in lines.iter() {
375 let line_lower = line.to_lowercase();
376
377 if line.starts_with('#') {
379 if in_relevant_section && relevance_score >= 2 {
381 relevant_sections.push(format!(
383 "## {} ({})\n\n{}",
384 current_section_title,
385 file_name,
386 current_section.trim()
387 ));
388 }
389
390 current_section.clear();
392 current_section_title = line.trim_start_matches('#').trim().to_string();
393 relevance_score = 0;
394 in_relevant_section = false;
395
396 let heading_lower = line_lower.clone();
398 for keyword in &keywords {
399 if heading_lower.contains(keyword) {
400 in_relevant_section = true;
401 relevance_score += 10;
402 }
403 }
404 }
405
406 let mut line_matches = 0;
408 for keyword in &keywords {
409 if line_lower.contains(keyword) {
410 in_relevant_section = true;
411 line_matches += 1;
412 }
413 }
414 relevance_score += line_matches;
415
416 if in_relevant_section || relevance_score > 0 {
418 current_section.push_str(line);
419 current_section.push('\n');
420
421 if current_section.lines().count() > 150 {
423 break;
424 }
425 }
426 }
427
428 if in_relevant_section && relevance_score >= 2 {
430 relevant_sections.push(format!(
432 "## {} ({})\n\n{}",
433 current_section_title,
434 file_name,
435 current_section.trim()
436 ));
437 }
438
439 if relevant_sections.is_empty() {
440 None
441 } else {
442 Some(
444 relevant_sections
445 .iter()
446 .take(3)
447 .cloned()
448 .collect::<Vec<_>>()
449 .join("\n\n"),
450 )
451 }
452}
453
454fn format_exploration_results(response: &crate::models::QueryResponse, pattern: &str) -> String {
456 if response.results.is_empty() {
457 return format!("No results found for pattern: {}", pattern);
458 }
459
460 let mut output = Vec::new();
461 output.push(format!(
462 "Found {} total matches across {} files for pattern '{}':\n",
463 response.pagination.total,
464 response.results.len(),
465 pattern
466 ));
467
468 for (idx, file_group) in response.results.iter().take(5).enumerate() {
470 output.push(format!("\n{}. {}", idx + 1, file_group.path));
471
472 for match_result in file_group.matches.iter().take(3) {
474 for (idx, line) in match_result.context_before.iter().enumerate() {
476 let line_num = match_result
477 .span
478 .start_line
479 .saturating_sub(match_result.context_before.len() - idx);
480 output.push(format!(" Line {}: {}", line_num, line.trim()));
481 }
482
483 output.push(format!(
485 " Line {}: {}",
486 match_result.span.start_line,
487 match_result.preview.lines().next().unwrap_or("").trim()
488 ));
489
490 for (idx, line) in match_result.context_after.iter().enumerate() {
492 let line_num = match_result.span.start_line + idx + 1;
493 output.push(format!(" Line {}: {}", line_num, line.trim()));
494 }
495 }
496
497 if file_group.matches.len() > 3 {
498 output.push(format!(
499 " ... and {} more matches",
500 file_group.matches.len() - 3
501 ));
502 }
503 }
504
505 if response.results.len() > 5 {
506 output.push(format!(
507 "\n... and {} more files",
508 response.results.len() - 5
509 ));
510 }
511
512 output.join("\n")
513}
514
515fn format_hotspots(hotspots: &[(String, usize)]) -> String {
517 if hotspots.is_empty() {
518 return "No dependency hotspots found.".to_string();
519 }
520
521 let mut output = Vec::new();
522 output.push(format!(
523 "Top {} most-imported files:\n",
524 hotspots.len().min(10)
525 ));
526
527 for (idx, (path, count)) in hotspots.iter().take(10).enumerate() {
528 output.push(format!("{}. {} ({} importers)", idx + 1, path, count));
529 }
530
531 if hotspots.len() > 10 {
532 output.push(format!("\n... and {} more hotspots", hotspots.len() - 10));
533 }
534
535 output.join("\n")
536}
537
538fn format_unused_files(unused: &[String]) -> String {
540 if unused.is_empty() {
541 return "No unused files found (all files are imported by others).".to_string();
542 }
543
544 let mut output = Vec::new();
545 output.push(format!(
546 "Found {} unused files (no importers):\n",
547 unused.len()
548 ));
549
550 for (idx, path) in unused.iter().take(15).enumerate() {
551 output.push(format!("{}. {}", idx + 1, path));
552 }
553
554 if unused.len() > 15 {
555 output.push(format!("\n... and {} more unused files", unused.len() - 15));
556 }
557
558 output.join("\n")
559}
560
561fn format_circular_deps(circular: &[Vec<String>]) -> String {
563 if circular.is_empty() {
564 return "No circular dependencies found.".to_string();
565 }
566
567 let mut output = Vec::new();
568 output.push(format!(
569 "Found {} circular dependency chains:\n",
570 circular.len()
571 ));
572
573 for (idx, cycle) in circular.iter().take(5).enumerate() {
574 output.push(format!("\n{}. Cycle ({} files):", idx + 1, cycle.len()));
575 output.push(format!(" {}", cycle.join(" → ")));
576 }
577
578 if circular.len() > 5 {
579 output.push(format!(
580 "\n... and {} more circular dependencies",
581 circular.len() - 5
582 ));
583 }
584
585 output.join("\n")
586}
587
588fn execute_get_statistics(cache: &CacheManager) -> Result<ToolResult> {
590 log::info!("Executing get_statistics tool");
591
592 let stats = cache.stats().context("Failed to get cache statistics")?;
594
595 let output = format_statistics(&stats);
597
598 log::debug!("Statistics retrieved successfully");
599
600 Ok(ToolResult {
601 description: "Retrieved index statistics".to_string(),
602 output,
603 success: true,
604 })
605}
606
607fn execute_get_dependencies(
609 file_path: &str,
610 reverse: bool,
611 cache: &CacheManager,
612) -> Result<ToolResult> {
613 log::info!(
614 "Executing get_dependencies tool: file={}, reverse={}",
615 file_path,
616 reverse
617 );
618
619 let deps_index = DependencyIndex::new(CacheManager::new(cache.workspace_root()));
621
622 let file_id = deps_index
624 .get_file_id_by_path(file_path)
625 .context(format!("Failed to find file: {}", file_path))?
626 .ok_or_else(|| anyhow::anyhow!("File not found: {}", file_path))?;
627
628 let output = if reverse {
629 let dependent_ids = deps_index
631 .get_dependents(file_id)
632 .context("Failed to get reverse dependencies")?;
633
634 let paths = deps_index.get_file_paths(&dependent_ids)?;
636 let dependents: Vec<String> = dependent_ids
637 .iter()
638 .filter_map(|id| paths.get(id).cloned())
639 .collect();
640
641 format_reverse_dependencies(file_path, &dependents)
642 } else {
643 let deps = deps_index
645 .get_dependencies_info(file_id)
646 .context("Failed to get dependencies")?;
647
648 format_dependencies(file_path, &deps)
649 };
650
651 let description = if reverse {
652 format!("Found reverse dependencies for: {}", file_path)
653 } else {
654 format!("Found dependencies for: {}", file_path)
655 };
656
657 log::debug!("Dependencies retrieved successfully");
658
659 Ok(ToolResult {
660 description,
661 output,
662 success: true,
663 })
664}
665
666fn execute_get_analysis_summary(min_dependents: usize, cache: &CacheManager) -> Result<ToolResult> {
668 log::info!(
669 "Executing get_analysis_summary tool: min_dependents={}",
670 min_dependents
671 );
672
673 let deps_index = DependencyIndex::new(CacheManager::new(cache.workspace_root()));
675
676 let hotspot_ids = deps_index.find_hotspots(Some(10), min_dependents)?;
678 let hotspot_count = hotspot_ids.len();
679
680 let unused_ids = deps_index.find_unused_files()?;
682 let unused_count = unused_ids.len();
683
684 let circular_ids = deps_index.detect_circular_dependencies()?;
686 let circular_count = circular_ids.len();
687
688 let output =
690 format_analysis_summary(hotspot_count, unused_count, circular_count, min_dependents);
691
692 log::debug!("Analysis summary retrieved successfully");
693
694 Ok(ToolResult {
695 description: "Retrieved dependency analysis summary".to_string(),
696 output,
697 success: true,
698 })
699}
700
701fn execute_find_islands(
703 min_size: usize,
704 max_size: usize,
705 cache: &CacheManager,
706) -> Result<ToolResult> {
707 log::info!(
708 "Executing find_islands tool: min_size={}, max_size={}",
709 min_size,
710 max_size
711 );
712
713 let deps_index = DependencyIndex::new(CacheManager::new(cache.workspace_root()));
715
716 let all_islands = deps_index.find_islands()?;
718
719 let filtered_islands: Vec<Vec<i64>> = all_islands
721 .into_iter()
722 .filter(|island| island.len() >= min_size && island.len() <= max_size)
723 .collect();
724
725 let all_ids: Vec<i64> = filtered_islands
727 .iter()
728 .flat_map(|island| island.iter())
729 .copied()
730 .collect::<std::collections::HashSet<_>>()
731 .into_iter()
732 .collect();
733
734 let paths = deps_index.get_file_paths(&all_ids)?;
735
736 let islands_with_paths: Vec<Vec<String>> = filtered_islands
737 .iter()
738 .map(|island| {
739 island
740 .iter()
741 .filter_map(|id| paths.get(id).cloned())
742 .collect()
743 })
744 .collect();
745
746 let output = format_islands(&islands_with_paths, min_size, max_size);
748
749 log::debug!(
750 "Islands retrieved successfully: {} islands found",
751 islands_with_paths.len()
752 );
753
754 Ok(ToolResult {
755 description: format!("Found {} disconnected components", islands_with_paths.len()),
756 output,
757 success: true,
758 })
759}
760
761fn format_statistics(stats: &crate::models::IndexStats) -> String {
763 let mut output = Vec::new();
764
765 output.push("# Index Statistics\n".to_string());
766 output.push(format!("Total files: {}", stats.total_files));
767 output.push(format!(
768 "Index size: {:.2} MB\n",
769 stats.index_size_bytes as f64 / 1_048_576.0
770 ));
771
772 if !stats.files_by_language.is_empty() {
774 output.push("## Files by Language\n".to_string());
775 let mut lang_counts: Vec<_> = stats.files_by_language.iter().collect();
776 lang_counts.sort_by(|a, b| b.1.cmp(a.1)); for (lang, count) in lang_counts.iter().take(10) {
779 let percentage = (**count as f64 / stats.total_files as f64) * 100.0;
780 output.push(format!("- {}: {} files ({:.1}%)", lang, count, percentage));
781 }
782
783 if lang_counts.len() > 10 {
784 output.push(format!("... and {} more languages", lang_counts.len() - 10));
785 }
786 }
787
788 if !stats.lines_by_language.is_empty() {
790 output.push("\n## Lines of Code by Language\n".to_string());
791 let mut line_counts: Vec<_> = stats.lines_by_language.iter().collect();
792 line_counts.sort_by(|a, b| b.1.cmp(a.1)); let total_lines: usize = stats.lines_by_language.values().sum();
795
796 for (lang, count) in line_counts.iter().take(10) {
797 let percentage = (**count as f64 / total_lines as f64) * 100.0;
798 let formatted_count = count
799 .to_string()
800 .as_str()
801 .chars()
802 .rev()
803 .enumerate()
804 .map(|(i, c)| {
805 if i != 0 && i % 3 == 0 {
806 format!(",{}", c)
807 } else {
808 c.to_string()
809 }
810 })
811 .collect::<Vec<_>>()
812 .into_iter()
813 .rev()
814 .collect::<String>();
815 output.push(format!(
816 "- {}: {} lines ({:.1}%)",
817 lang, formatted_count, percentage
818 ));
819 }
820
821 if line_counts.len() > 10 {
822 output.push(format!("... and {} more languages", line_counts.len() - 10));
823 }
824 }
825
826 output.push(format!("\nLast updated: {}", stats.last_updated));
827
828 output.join("\n")
829}
830
831fn format_dependencies(file_path: &str, deps: &[crate::models::DependencyInfo]) -> String {
833 if deps.is_empty() {
834 return format!("File '{}' has no dependencies.", file_path);
835 }
836
837 let mut output = Vec::new();
838 output.push(format!("# Dependencies of '{}'\n", file_path));
839 output.push(format!("Found {} dependencies:\n", deps.len()));
840
841 for (idx, dep) in deps.iter().take(20).enumerate() {
842 let line_info = dep
843 .line
844 .map(|l| format!(" (line {})", l))
845 .unwrap_or_default();
846 output.push(format!("{}. {}{}", idx + 1, dep.path, line_info));
847
848 if let Some(symbols) = &dep.symbols
850 && !symbols.is_empty()
851 {
852 output.push(format!(" Symbols: {}", symbols.join(", ")));
853 }
854 }
855
856 if deps.len() > 20 {
857 output.push(format!("\n... and {} more dependencies", deps.len() - 20));
858 }
859
860 output.join("\n")
861}
862
863fn format_reverse_dependencies(file_path: &str, dependents: &[String]) -> String {
865 if dependents.is_empty() {
866 return format!("No files depend on '{}'.", file_path);
867 }
868
869 let mut output = Vec::new();
870 output.push(format!("# Files that import '{}'\n", file_path));
871 output.push(format!("Found {} files:\n", dependents.len()));
872
873 for (idx, path) in dependents.iter().take(20).enumerate() {
874 output.push(format!("{}. {}", idx + 1, path));
875 }
876
877 if dependents.len() > 20 {
878 output.push(format!("\n... and {} more files", dependents.len() - 20));
879 }
880
881 output.join("\n")
882}
883
884fn format_analysis_summary(
886 hotspot_count: usize,
887 unused_count: usize,
888 circular_count: usize,
889 min_dependents: usize,
890) -> String {
891 let mut output = Vec::new();
892
893 output.push("# Dependency Analysis Summary\n".to_string());
894 output.push(format!(
895 "Hotspots (files with {}+ importers): {}",
896 min_dependents, hotspot_count
897 ));
898 output.push(format!("Unused files (no importers): {}", unused_count));
899 output.push(format!("Circular dependency chains: {}", circular_count));
900
901 if hotspot_count > 0 {
902 output.push(
903 "\n**Hotspots** indicate central/important files that many other files depend on."
904 .to_string(),
905 );
906 }
907
908 if unused_count > 0 {
909 output.push(
910 "\n**Unused files** may be dead code or entry points (like main.rs, index.ts)."
911 .to_string(),
912 );
913 }
914
915 if circular_count > 0 {
916 output.push("\n**Circular dependencies** can cause compilation issues and indicate architectural problems.".to_string());
917 }
918
919 output.join("\n")
920}
921
922fn format_islands(islands: &[Vec<String>], min_size: usize, max_size: usize) -> String {
924 if islands.is_empty() {
925 return format!(
926 "No disconnected components found (size {}-{}).",
927 min_size, max_size
928 );
929 }
930
931 let mut output = Vec::new();
932 output.push("# Disconnected Components (Islands)\n".to_string());
933 output.push(format!(
934 "Found {} islands (size {}-{}):\n",
935 islands.len(),
936 min_size,
937 max_size
938 ));
939
940 for (idx, island) in islands.iter().take(5).enumerate() {
941 output.push(format!(
942 "\n{}. Island with {} files:",
943 idx + 1,
944 island.len()
945 ));
946
947 for (file_idx, file) in island.iter().take(10).enumerate() {
948 output.push(format!(" {}. {}", file_idx + 1, file));
949 }
950
951 if island.len() > 10 {
952 output.push(format!(" ... and {} more files", island.len() - 10));
953 }
954 }
955
956 if islands.len() > 5 {
957 output.push(format!("\n... and {} more islands", islands.len() - 5));
958 }
959
960 output.push("\n**Islands** are groups of files that depend on each other but have no dependencies outside the group.".to_string());
961 output.push("This can indicate isolated subsystems or potential dead code.".to_string());
962
963 output.join("\n")
964}
965
966pub fn format_tool_results(results: &[ToolResult]) -> String {
968 if results.is_empty() {
969 return String::new();
970 }
971
972 let mut output = Vec::new();
973 output.push("## Tool Execution Results\n".to_string());
974
975 for (idx, result) in results.iter().enumerate() {
976 output.push(format!("\n### Tool {} - {}", idx + 1, result.description));
977 output.push(String::new());
978 output.push(result.output.clone());
979 output.push(String::new());
980 }
981
982 output.join("\n")
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988
989 #[test]
990 fn test_format_tool_results_empty() {
991 let results = vec![];
992 let output = format_tool_results(&results);
993 assert!(output.is_empty());
994 }
995
996 #[test]
997 fn test_format_tool_results_single() {
998 let results = vec![ToolResult {
999 description: "Test tool".to_string(),
1000 output: "Test output".to_string(),
1001 success: true,
1002 }];
1003
1004 let output = format_tool_results(&results);
1005 assert!(output.contains("Tool Execution Results"));
1006 assert!(output.contains("Test tool"));
1007 assert!(output.contains("Test output"));
1008 }
1009
1010 #[test]
1011 fn test_format_hotspots() {
1012 let hotspots = vec![
1013 ("src/main.rs".to_string(), 10),
1014 ("src/lib.rs".to_string(), 5),
1015 ];
1016
1017 let output = format_hotspots(&hotspots);
1018 assert!(output.contains("most-imported files"));
1019 assert!(output.contains("src/main.rs"));
1020 assert!(output.contains("10 importers"));
1021 }
1022
1023 #[test]
1024 fn test_format_unused_files() {
1025 let unused = vec!["src/old.rs".to_string(), "tests/legacy.rs".to_string()];
1026
1027 let output = format_unused_files(&unused);
1028 assert!(output.contains("unused files"));
1029 assert!(output.contains("src/old.rs"));
1030 }
1031
1032 #[test]
1033 fn test_format_circular_deps() {
1034 let circular = vec![vec![
1035 "a.rs".to_string(),
1036 "b.rs".to_string(),
1037 "a.rs".to_string(),
1038 ]];
1039
1040 let output = format_circular_deps(&circular);
1041 assert!(output.contains("circular dependency"));
1042 assert!(output.contains("a.rs → b.rs → a.rs"));
1043 }
1044}