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()
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    // Format output
320    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
347/// Search documentation content for query and extract relevant sections
348fn search_documentation_content(content: &str, query: &str, file_name: &str) -> Option<String> {
349    // Tokenize query into keywords (filter out common stop words)
350    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        // Check if this is a heading
378        if line.starts_with('#') {
379            // Save previous section if it was relevant
380            if in_relevant_section && relevance_score >= 2 {
381                // Need at least 2 keyword matches
382                relevant_sections.push(format!(
383                    "## {} ({})\n\n{}",
384                    current_section_title,
385                    file_name,
386                    current_section.trim()
387                ));
388            }
389
390            // Start new section
391            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            // Check if heading contains any query keywords
397            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        // Check if content contains any query keywords
407        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        // Add line to current section (with some context)
417        if in_relevant_section || relevance_score > 0 {
418            current_section.push_str(line);
419            current_section.push('\n');
420
421            // Limit section size to prevent massive outputs
422            if current_section.lines().count() > 150 {
423                break;
424            }
425        }
426    }
427
428    // Save last section if relevant
429    if in_relevant_section && relevance_score >= 2 {
430        // Need at least 2 keyword matches
431        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        // Sort sections by relevance (most matches first) and limit to top 3
443        Some(
444            relevant_sections
445                .iter()
446                .take(3)
447                .cloned()
448                .collect::<Vec<_>>()
449                .join("\n\n"),
450        )
451    }
452}
453
454/// Format exploration query results for LLM
455fn 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    // Show first 5 file groups
469    for (idx, file_group) in response.results.iter().take(5).enumerate() {
470        output.push(format!("\n{}. {}", idx + 1, file_group.path));
471
472        // Show first 3 matches per file
473        for match_result in file_group.matches.iter().take(3) {
474            // Show context before the match
475            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            // Show the match line itself
484            output.push(format!(
485                "   Line {}: {}",
486                match_result.span.start_line,
487                match_result.preview.lines().next().unwrap_or("").trim()
488            ));
489
490            // Show context after the match
491            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
515/// Format hotspot analysis results
516fn 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
538/// Format unused files analysis results
539fn 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
561/// Format circular dependency analysis results
562fn 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
588/// Execute get statistics tool
589fn execute_get_statistics(cache: &CacheManager) -> Result<ToolResult> {
590    log::info!("Executing get_statistics tool");
591
592    // Get index statistics
593    let stats = cache.stats().context("Failed to get cache statistics")?;
594
595    // Format output
596    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
607/// Execute get dependencies tool
608fn 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    // Create dependency index
620    let deps_index = DependencyIndex::new(CacheManager::new(cache.workspace_root()));
621
622    // Get file ID by path (supports fuzzy matching)
623    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        // Get files that depend on this file (reverse dependencies)
630        let dependent_ids = deps_index
631            .get_dependents(file_id)
632            .context("Failed to get reverse dependencies")?;
633
634        // Convert file IDs to paths
635        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        // Get dependencies of this file
644        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
666/// Execute get analysis summary tool
667fn 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    // Create dependency index
674    let deps_index = DependencyIndex::new(CacheManager::new(cache.workspace_root()));
675
676    // Get hotspots
677    let hotspot_ids = deps_index.find_hotspots(Some(10), min_dependents)?;
678    let hotspot_count = hotspot_ids.len();
679
680    // Get unused files count
681    let unused_ids = deps_index.find_unused_files()?;
682    let unused_count = unused_ids.len();
683
684    // Get circular dependencies count
685    let circular_ids = deps_index.detect_circular_dependencies()?;
686    let circular_count = circular_ids.len();
687
688    // Format summary
689    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
701/// Execute find islands tool
702fn 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    // Create dependency index
714    let deps_index = DependencyIndex::new(CacheManager::new(cache.workspace_root()));
715
716    // Get all islands
717    let all_islands = deps_index.find_islands()?;
718
719    // Filter by size
720    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    // Convert file IDs to paths
726    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    // Format output
747    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
761/// Format statistics output
762fn 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    // Files by language
773    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)); // Sort by count descending
777
778        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    // Lines by language
789    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)); // Sort by count descending
793
794        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
831/// Format dependencies output
832fn 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        // Show imported symbols if available
849        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
863/// Format reverse dependencies output
864fn 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
884/// Format analysis summary output
885fn 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
922/// Format islands output
923fn 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
966/// Format all tool results into a single context string for the next LLM call
967pub 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}