Skip to main content

reflex/semantic/
tools.rs

1//! Tool execution system for agentic context gathering
2//!
3//! This module handles execution of tool calls from the LLM including:
4//! - Running `rfx context` commands
5//! - Executing exploratory queries
6//! - Running codebase analysis (hotspots, unused files, etc.)
7
8use 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/// Result of executing a tool call
17#[derive(Debug, Clone)]
18pub struct ToolResult {
19    /// Description of what this tool did
20    pub description: String,
21
22    /// The output/result from the tool
23    pub output: String,
24
25    /// Whether the tool execution was successful
26    pub success: bool,
27}
28
29/// Execute a single tool call
30pub 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
56/// Execute context gathering tool
57fn execute_gather_context(
58    params: &ContextGatheringParams,
59    cache: &CacheManager,
60) -> Result<ToolResult> {
61    log::info!("Executing gather_context tool");
62
63    // Build context options from params
64    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, // Always use text format for LLM consumption
75    };
76
77    // If no specific flags, enable all context types by default
78    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    // Generate context
89    let output = crate::context::generate_context(cache, &opts)
90        .context("Failed to generate codebase context")?;
91
92    // Build description of what was gathered
93    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
131/// Execute exploratory codebase query
132async 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    // Parse the command
140    let parsed = parse_command(command)
141        .with_context(|| format!("Failed to parse exploration command: {}", command))?;
142
143    // Convert to QueryFilter
144    let filter = parsed.to_query_filter()?;
145
146    // Create query engine
147    let engine = QueryEngine::new(CacheManager::new(cache.workspace_root()));
148
149    // Execute query
150    let response = engine
151        .search_with_metadata(&parsed.pattern, filter)
152        .with_context(|| format!("Failed to execute exploration query: {}", command))?;
153
154    // Format results for LLM consumption
155    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
169/// Execute structure analysis (hotspots, unused files, etc.)
170fn execute_analyze_structure(
171    analysis_type: AnalysisType,
172    cache: &CacheManager,
173) -> Result<ToolResult> {
174    log::info!("Executing analyze_structure tool: {:?}", analysis_type);
175
176    // Create dependency index
177    let deps_index = DependencyIndex::new(CacheManager::new(cache.workspace_root()));
178
179    let output = match analysis_type {
180        AnalysisType::Hotspots => {
181            // Get hotspots (returns file IDs and counts)
182            let hotspot_ids = deps_index.find_hotspots(Some(10), 2)?; // top 10, min 2 dependents
183
184            // Convert file IDs to paths
185            let file_ids: Vec<i64> = hotspot_ids.iter().map(|(id, _)| *id).collect();
186            let paths = deps_index.get_file_paths(&file_ids)?;
187
188            // Convert to (String, usize) format
189            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            // Get unused files (returns file IDs)
198            let unused_ids = deps_index.find_unused_files()?;
199
200            // Convert file IDs to paths
201            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            // Get circular dependencies (returns vectors of file IDs)
211            let circular_ids = deps_index.detect_circular_dependencies()?;
212
213            // Collect all unique file IDs
214            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            // Convert all IDs to paths
223            let paths = deps_index.get_file_paths(&all_ids)?;
224
225            // Convert cycles to path cycles
226            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
255/// Execute documentation search tool
256fn 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    // Default documentation files to search
266    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    // Search specified documentation files
273    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                // Search for query keywords in the content
286                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    // Also search .context/ directory for markdown files
297    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    // Format output
321    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
348/// Search documentation content for query and extract relevant sections
349fn search_documentation_content(content: &str, query: &str, file_name: &str) -> Option<String> {
350    // Tokenize query into keywords (filter out common stop words)
351    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        // Check if this is a heading
379        if line.starts_with('#') {
380            // Save previous section if it was relevant
381            if in_relevant_section && relevance_score >= 2 {
382                // Need at least 2 keyword matches
383                relevant_sections.push(format!(
384                    "## {} ({})\n\n{}",
385                    current_section_title,
386                    file_name,
387                    current_section.trim()
388                ));
389            }
390
391            // Start new section
392            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            // Check if heading contains any query keywords
398            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        // Check if content contains any query keywords
408        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        // Add line to current section (with some context)
418        if in_relevant_section || relevance_score > 0 {
419            current_section.push_str(line);
420            current_section.push('\n');
421
422            // Limit section size to prevent massive outputs
423            if current_section.lines().count() > 150 {
424                break;
425            }
426        }
427    }
428
429    // Save last section if relevant
430    if in_relevant_section && relevance_score >= 2 {
431        // Need at least 2 keyword matches
432        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        // Sort sections by relevance (most matches first) and limit to top 3
444        Some(
445            relevant_sections
446                .iter()
447                .take(3)
448                .cloned()
449                .collect::<Vec<_>>()
450                .join("\n\n"),
451        )
452    }
453}
454
455/// Format exploration query results for LLM
456fn 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    // Show first 5 file groups
470    for (idx, file_group) in response.results.iter().take(5).enumerate() {
471        output.push(format!("\n{}. {}", idx + 1, file_group.path));
472
473        // Show first 3 matches per file
474        for match_result in file_group.matches.iter().take(3) {
475            // Show context before the match
476            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            // Show the match line itself
485            output.push(format!(
486                "   Line {}: {}",
487                match_result.span.start_line,
488                match_result.preview.lines().next().unwrap_or("").trim()
489            ));
490
491            // Show context after the match
492            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
516/// Format hotspot analysis results
517fn 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
539/// Format unused files analysis results
540fn 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
562/// Format circular dependency analysis results
563fn 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
589/// Execute get statistics tool
590fn execute_get_statistics(cache: &CacheManager) -> Result<ToolResult> {
591    log::info!("Executing get_statistics tool");
592
593    // Get index statistics
594    let stats = cache.stats().context("Failed to get cache statistics")?;
595
596    // Format output
597    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
608/// Execute get dependencies tool
609fn 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    // Create dependency index
621    let deps_index = DependencyIndex::new(CacheManager::new(cache.workspace_root()));
622
623    // Get file ID by path (supports fuzzy matching)
624    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        // Get files that depend on this file (reverse dependencies)
631        let dependent_ids = deps_index
632            .get_dependents(file_id)
633            .context("Failed to get reverse dependencies")?;
634
635        // Convert file IDs to paths
636        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        // Get dependencies of this file
645        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
667/// Execute get analysis summary tool
668fn 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    // Create dependency index
675    let deps_index = DependencyIndex::new(CacheManager::new(cache.workspace_root()));
676
677    // Get hotspots
678    let hotspot_ids = deps_index.find_hotspots(Some(10), min_dependents)?;
679    let hotspot_count = hotspot_ids.len();
680
681    // Get unused files count
682    let unused_ids = deps_index.find_unused_files()?;
683    let unused_count = unused_ids.len();
684
685    // Get circular dependencies count
686    let circular_ids = deps_index.detect_circular_dependencies()?;
687    let circular_count = circular_ids.len();
688
689    // Format summary
690    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
702/// Execute find islands tool
703fn 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    // Create dependency index
715    let deps_index = DependencyIndex::new(CacheManager::new(cache.workspace_root()));
716
717    // Get all islands
718    let all_islands = deps_index.find_islands()?;
719
720    // Filter by size
721    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    // Convert file IDs to paths
727    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    // Format output
748    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
762/// Format statistics output
763fn 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    // Files by language
774    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)); // Sort by count descending
778
779        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    // Lines by language
790    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)); // Sort by count descending
794
795        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
832/// Format dependencies output
833fn 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        // Show imported symbols if available
850        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
864/// Format reverse dependencies output
865fn 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
885/// Format analysis summary output
886fn 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
923/// Format islands output
924fn 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
967/// Format all tool results into a single context string for the next LLM call
968pub 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}