Skip to main content

code_analyze_mcp/
formatter.rs

1//! Output formatting for analysis results across different modes.
2//!
3//! Formats semantic analysis, call graphs, and directory structures into human-readable text.
4//! Handles multiline wrapping, pagination, and summary generation.
5
6use crate::graph::CallChain;
7use crate::graph::CallGraph;
8use crate::pagination::PaginationMode;
9use crate::test_detection::is_test_file;
10use crate::traversal::WalkEntry;
11use crate::types::{ClassInfo, FileInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
12use std::collections::{HashMap, HashSet};
13use std::fmt::Write;
14use std::path::Path;
15use thiserror::Error;
16use tracing::instrument;
17
18const MULTILINE_THRESHOLD: usize = 10;
19
20/// Format a list of function signatures wrapped at 100 characters with bullet annotation.
21fn format_function_list_wrapped<'a>(
22    functions: impl Iterator<Item = &'a crate::types::FunctionInfo>,
23    call_frequency: &std::collections::HashMap<String, usize>,
24) -> String {
25    let mut output = String::new();
26    let mut line = String::from("  ");
27    for (i, func) in functions.enumerate() {
28        let mut call_marker = func.compact_signature();
29
30        if let Some(&count) = call_frequency.get(&func.name)
31            && count > 3
32        {
33            call_marker.push_str(&format!("\u{2022}{}", count));
34        }
35
36        if i == 0 {
37            line.push_str(&call_marker);
38        } else if line.len() + call_marker.len() + 2 > 100 {
39            output.push_str(&line);
40            output.push('\n');
41            let mut new_line = String::with_capacity(2 + call_marker.len());
42            new_line.push_str("  ");
43            new_line.push_str(&call_marker);
44            line = new_line;
45        } else {
46            line.push_str(", ");
47            line.push_str(&call_marker);
48        }
49    }
50    if !line.trim().is_empty() {
51        output.push_str(&line);
52        output.push('\n');
53    }
54    output
55}
56
57/// Build a bracket string for file info (line count, function count, class count).
58/// Returns None if all counts are zero, otherwise returns "[42L, 7F, 2C]" format.
59fn format_file_info_parts(line_count: usize, fn_count: usize, cls_count: usize) -> Option<String> {
60    let mut parts = Vec::new();
61    if line_count > 0 {
62        parts.push(format!("{}L", line_count));
63    }
64    if fn_count > 0 {
65        parts.push(format!("{}F", fn_count));
66    }
67    if cls_count > 0 {
68        parts.push(format!("{}C", cls_count));
69    }
70    if parts.is_empty() {
71        None
72    } else {
73        Some(format!("[{}]", parts.join(", ")))
74    }
75}
76
77/// Strip a base path from a Path, returning a relative path or the original on failure.
78fn strip_base_path(path: &Path, base_path: Option<&Path>) -> String {
79    match base_path {
80        Some(base) => {
81            if let Ok(rel_path) = path.strip_prefix(base) {
82                rel_path.display().to_string()
83            } else {
84                path.display().to_string()
85            }
86        }
87        None => path.display().to_string(),
88    }
89}
90
91#[derive(Debug, Error)]
92pub enum FormatterError {
93    #[error("Graph error: {0}")]
94    GraphError(#[from] crate::graph::GraphError),
95}
96
97/// Format directory structure analysis results.
98#[instrument(skip_all)]
99pub fn format_structure(
100    entries: &[WalkEntry],
101    analysis_results: &[FileInfo],
102    max_depth: Option<u32>,
103    _base_path: Option<&Path>,
104) -> String {
105    let mut output = String::new();
106
107    // Build a map of path -> analysis for quick lookup
108    let analysis_map: HashMap<String, &FileInfo> = analysis_results
109        .iter()
110        .map(|a| (a.path.clone(), a))
111        .collect();
112
113    // Partition files into production and test
114    let (prod_files, test_files): (Vec<_>, Vec<_>) =
115        analysis_results.iter().partition(|a| !a.is_test);
116
117    // Calculate totals
118    let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
119    let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
120    let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
121
122    // Count files by language and calculate percentages
123    let mut lang_counts: HashMap<String, usize> = HashMap::new();
124    for analysis in analysis_results {
125        *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
126    }
127    let total_files = analysis_results.len();
128
129    // Leading summary line with totals
130    let primary_lang = lang_counts
131        .iter()
132        .max_by_key(|&(_, count)| count)
133        .map(|(name, count)| {
134            let percentage = if total_files > 0 {
135                (*count * 100) / total_files
136            } else {
137                0
138            };
139            format!("{} {}%", name, percentage)
140        })
141        .unwrap_or_else(|| "unknown 0%".to_string());
142
143    output.push_str(&format!(
144        "{} files, {}L, {}F, {}C ({})\n",
145        total_files, total_loc, total_functions, total_classes, primary_lang
146    ));
147
148    // SUMMARY block
149    output.push_str("SUMMARY:\n");
150    let depth_label = match max_depth {
151        Some(n) if n > 0 => format!(" (max_depth={})", n),
152        _ => String::new(),
153    };
154    output.push_str(&format!(
155        "Shown: {} files ({} prod, {} test), {}L, {}F, {}C{}\n",
156        total_files,
157        prod_files.len(),
158        test_files.len(),
159        total_loc,
160        total_functions,
161        total_classes,
162        depth_label
163    ));
164
165    if !lang_counts.is_empty() {
166        output.push_str("Languages: ");
167        let mut langs: Vec<_> = lang_counts.iter().collect();
168        langs.sort_by_key(|&(name, _)| name);
169        let lang_strs: Vec<String> = langs
170            .iter()
171            .map(|(name, count)| {
172                let percentage = if total_files > 0 {
173                    (**count * 100) / total_files
174                } else {
175                    0
176                };
177                format!("{} ({}%)", name, percentage)
178            })
179            .collect();
180        output.push_str(&lang_strs.join(", "));
181        output.push('\n');
182    }
183
184    output.push('\n');
185
186    // PATH block - tree structure (production files only)
187    output.push_str("PATH [LOC, FUNCTIONS, CLASSES]\n");
188
189    for entry in entries {
190        // Skip the root directory itself
191        if entry.depth == 0 {
192            continue;
193        }
194
195        // Calculate indentation
196        let indent = "  ".repeat(entry.depth - 1);
197
198        // Get just the filename/dirname
199        let name = entry
200            .path
201            .file_name()
202            .and_then(|n| n.to_str())
203            .unwrap_or("?");
204
205        // For files, append analysis info
206        if !entry.is_dir {
207            if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
208                // Skip test files in production section
209                if analysis.is_test {
210                    continue;
211                }
212
213                if let Some(info_str) = format_file_info_parts(
214                    analysis.line_count,
215                    analysis.function_count,
216                    analysis.class_count,
217                ) {
218                    output.push_str(&format!("{}{} {}\n", indent, name, info_str));
219                } else {
220                    output.push_str(&format!("{}{}\n", indent, name));
221                }
222            }
223            // Skip files not in analysis_map (binary/unreadable files)
224        } else {
225            output.push_str(&format!("{}{}/\n", indent, name));
226        }
227    }
228
229    // TEST FILES section (if any test files exist)
230    if !test_files.is_empty() {
231        output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
232
233        for entry in entries {
234            // Skip the root directory itself
235            if entry.depth == 0 {
236                continue;
237            }
238
239            // Calculate indentation
240            let indent = "  ".repeat(entry.depth - 1);
241
242            // Get just the filename/dirname
243            let name = entry
244                .path
245                .file_name()
246                .and_then(|n| n.to_str())
247                .unwrap_or("?");
248
249            // For files, append analysis info
250            if !entry.is_dir
251                && let Some(analysis) = analysis_map.get(&entry.path.display().to_string())
252            {
253                // Only show test files in test section
254                if !analysis.is_test {
255                    continue;
256                }
257
258                if let Some(info_str) = format_file_info_parts(
259                    analysis.line_count,
260                    analysis.function_count,
261                    analysis.class_count,
262                ) {
263                    output.push_str(&format!("{}{} {}\n", indent, name, info_str));
264                } else {
265                    output.push_str(&format!("{}{}\n", indent, name));
266                }
267            }
268        }
269    }
270
271    output
272}
273
274/// Format file-level semantic analysis results.
275#[instrument(skip_all)]
276pub fn format_file_details(
277    path: &str,
278    analysis: &SemanticAnalysis,
279    line_count: usize,
280    is_test: bool,
281    base_path: Option<&Path>,
282) -> String {
283    let mut output = String::new();
284
285    // FILE: header with counts, prepend [TEST] if applicable
286    let display_path = strip_base_path(Path::new(path), base_path);
287    if is_test {
288        output.push_str(&format!(
289            "FILE [TEST] {}({}L, {}F, {}C, {}I)\n",
290            display_path,
291            line_count,
292            analysis.functions.len(),
293            analysis.classes.len(),
294            analysis.imports.len()
295        ));
296    } else {
297        output.push_str(&format!(
298            "FILE: {}({}L, {}F, {}C, {}I)\n",
299            display_path,
300            line_count,
301            analysis.functions.len(),
302            analysis.classes.len(),
303            analysis.imports.len()
304        ));
305    }
306
307    // C: section with classes
308    output.push_str(&format_classes_section(&analysis.classes));
309
310    // F: section with functions, parameters, return types and call frequency
311    if !analysis.functions.is_empty() {
312        output.push_str("F:\n");
313        output.push_str(&format_function_list_wrapped(
314            analysis.functions.iter(),
315            &analysis.call_frequency,
316        ));
317    }
318
319    // I: section with imports grouped by module
320    output.push_str(&format_imports_section(&analysis.imports));
321
322    output
323}
324
325/// Format chains as a tree-indented output, grouped by depth-1 symbol.
326/// Groups chains by their first symbol (depth-1), deduplicates and sorts depth-2 children,
327/// then renders with 2-space indentation using the provided arrow.
328/// focus_symbol is the name of the depth-0 symbol (focus point) to prepend on depth-1 lines.
329///
330/// Indentation rules:
331/// - Depth-1: `  {focus} {arrow} {parent}` (2-space indent)
332/// - Depth-2: `    {arrow} {child}` (4-space indent)
333/// - Empty:   `  (none)` (2-space indent)
334fn format_chains_as_tree(chains: &[(&str, &str)], arrow: &str, focus_symbol: &str) -> String {
335    use std::collections::BTreeMap;
336
337    if chains.is_empty() {
338        return "  (none)\n".to_string();
339    }
340
341    let mut output = String::new();
342
343    // Group chains by depth-1 symbol, counting duplicate children
344    let mut groups: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
345    for (parent, child) in chains {
346        // Only count non-empty children
347        if !child.is_empty() {
348            *groups
349                .entry(parent.to_string())
350                .or_default()
351                .entry(child.to_string())
352                .or_insert(0) += 1;
353        } else {
354            // Ensure parent is in groups even if no children
355            groups.entry(parent.to_string()).or_default();
356        }
357    }
358
359    // Render grouped tree
360    for (parent, children) in groups {
361        let _ = writeln!(output, "  {} {} {}", focus_symbol, arrow, parent);
362        // Sort children by count descending, then alphabetically
363        let mut sorted: Vec<_> = children.into_iter().collect();
364        sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
365        for (child, count) in sorted {
366            if count > 1 {
367                let _ = writeln!(output, "    {} {} (x{})", arrow, child, count);
368            } else {
369                let _ = writeln!(output, "    {} {}", arrow, child);
370            }
371        }
372    }
373
374    output
375}
376
377/// Format focused symbol analysis with call graph.
378#[instrument(skip_all)]
379pub fn format_focused(
380    graph: &CallGraph,
381    symbol: &str,
382    follow_depth: u32,
383    base_path: Option<&Path>,
384) -> Result<String, FormatterError> {
385    let mut output = String::new();
386
387    // Compute all counts BEFORE output begins
388    let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
389    let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
390    let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
391
392    // Partition incoming_chains into production and test callers
393    let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
394        incoming_chains.clone().into_iter().partition(|chain| {
395            chain
396                .chain
397                .first()
398                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
399        });
400
401    // Count unique callers
402    let callers_count = prod_chains
403        .iter()
404        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
405        .collect::<std::collections::HashSet<_>>()
406        .len();
407
408    // Count unique callees
409    let callees_count = outgoing_chains
410        .iter()
411        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
412        .collect::<std::collections::HashSet<_>>()
413        .len();
414
415    // FOCUS section - with inline counts
416    output.push_str(&format!(
417        "FOCUS: {} ({} defs, {} callers, {} callees)\n",
418        symbol, def_count, callers_count, callees_count
419    ));
420
421    // DEPTH section
422    output.push_str(&format!("DEPTH: {}\n", follow_depth));
423
424    // DEFINED section - show where the symbol is defined
425    if let Some(definitions) = graph.definitions.get(symbol) {
426        output.push_str("DEFINED:\n");
427        for (path, line) in definitions {
428            output.push_str(&format!(
429                "  {}:{}\n",
430                strip_base_path(path, base_path),
431                line
432            ));
433        }
434    } else {
435        output.push_str("DEFINED: (not found)\n");
436    }
437
438    // CALLERS section - who calls this symbol
439    output.push_str("CALLERS:\n");
440
441    // Render production callers
442    let prod_refs: Vec<_> = prod_chains
443        .iter()
444        .filter_map(|chain| {
445            if chain.chain.len() >= 2 {
446                Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
447            } else if chain.chain.len() == 1 {
448                Some((chain.chain[0].0.as_str(), ""))
449            } else {
450                None
451            }
452        })
453        .collect();
454
455    if prod_refs.is_empty() {
456        output.push_str("  (none)\n");
457    } else {
458        output.push_str(&format_chains_as_tree(&prod_refs, "<-", symbol));
459    }
460
461    // Render test callers summary if any
462    if !test_chains.is_empty() {
463        let mut test_files: Vec<_> = test_chains
464            .iter()
465            .filter_map(|chain| {
466                chain
467                    .chain
468                    .first()
469                    .map(|(_, path, _)| path.to_string_lossy().into_owned())
470            })
471            .collect();
472        test_files.sort();
473        test_files.dedup();
474
475        // Strip base path for display
476        let display_files: Vec<_> = test_files
477            .iter()
478            .map(|f| strip_base_path(Path::new(f), base_path))
479            .collect();
480
481        let file_list = display_files.join(", ");
482        output.push_str(&format!(
483            "CALLERS (test): {} test functions (in {})\n",
484            test_chains.len(),
485            file_list
486        ));
487    }
488
489    // CALLEES section - what this symbol calls
490    output.push_str("CALLEES:\n");
491    let outgoing_refs: Vec<_> = outgoing_chains
492        .iter()
493        .filter_map(|chain| {
494            if chain.chain.len() >= 2 {
495                Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
496            } else if chain.chain.len() == 1 {
497                Some((chain.chain[0].0.as_str(), ""))
498            } else {
499                None
500            }
501        })
502        .collect();
503
504    if outgoing_refs.is_empty() {
505        output.push_str("  (none)\n");
506    } else {
507        output.push_str(&format_chains_as_tree(&outgoing_refs, "->", symbol));
508    }
509
510    // STATISTICS section
511    output.push_str("STATISTICS:\n");
512    let incoming_count = prod_refs
513        .iter()
514        .map(|(p, _)| p)
515        .collect::<std::collections::HashSet<_>>()
516        .len();
517    let outgoing_count = outgoing_refs
518        .iter()
519        .map(|(p, _)| p)
520        .collect::<std::collections::HashSet<_>>()
521        .len();
522    output.push_str(&format!("  Incoming calls: {}\n", incoming_count));
523    output.push_str(&format!("  Outgoing calls: {}\n", outgoing_count));
524
525    // FILES section - collect unique files from production chains
526    let mut files = HashSet::new();
527    for chain in &prod_chains {
528        for (_, path, _) in &chain.chain {
529            files.insert(path.clone());
530        }
531    }
532    for chain in &outgoing_chains {
533        for (_, path, _) in &chain.chain {
534            files.insert(path.clone());
535        }
536    }
537    if let Some(definitions) = graph.definitions.get(symbol) {
538        for (path, _) in definitions {
539            files.insert(path.clone());
540        }
541    }
542
543    // Partition files into production and test
544    let (prod_files, test_files): (Vec<_>, Vec<_>) =
545        files.into_iter().partition(|path| !is_test_file(path));
546
547    output.push_str("FILES:\n");
548    if prod_files.is_empty() && test_files.is_empty() {
549        output.push_str("  (none)\n");
550    } else {
551        // Show production files first
552        if !prod_files.is_empty() {
553            let mut sorted_files = prod_files;
554            sorted_files.sort();
555            for file in sorted_files {
556                output.push_str(&format!("  {}\n", strip_base_path(&file, base_path)));
557            }
558        }
559
560        // Show test files in separate subsection
561        if !test_files.is_empty() {
562            output.push_str("  TEST FILES:\n");
563            let mut sorted_files = test_files;
564            sorted_files.sort();
565            for file in sorted_files {
566                output.push_str(&format!("    {}\n", strip_base_path(&file, base_path)));
567            }
568        }
569    }
570
571    Ok(output)
572}
573
574/// Format a compact summary of focused symbol analysis.
575/// Used when output would exceed the size threshold or when explicitly requested.
576#[instrument(skip_all)]
577pub fn format_focused_summary(
578    graph: &CallGraph,
579    symbol: &str,
580    follow_depth: u32,
581    base_path: Option<&Path>,
582) -> Result<String, FormatterError> {
583    let mut output = String::new();
584
585    // Compute all counts BEFORE output begins
586    let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
587    let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
588    let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
589
590    // Partition incoming_chains into production and test callers
591    let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
592        incoming_chains.into_iter().partition(|chain| {
593            chain
594                .chain
595                .first()
596                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
597        });
598
599    // Count unique production callers
600    let callers_count = prod_chains
601        .iter()
602        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
603        .collect::<std::collections::HashSet<_>>()
604        .len();
605
606    // Count unique callees
607    let callees_count = outgoing_chains
608        .iter()
609        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
610        .collect::<std::collections::HashSet<_>>()
611        .len();
612
613    // FOCUS header
614    output.push_str(&format!(
615        "FOCUS: {} ({} defs, {} callers, {} callees)\n",
616        symbol, def_count, callers_count, callees_count
617    ));
618
619    // DEPTH line
620    output.push_str(&format!("DEPTH: {}\n", follow_depth));
621
622    // DEFINED section
623    if let Some(definitions) = graph.definitions.get(symbol) {
624        output.push_str("DEFINED:\n");
625        for (path, line) in definitions {
626            output.push_str(&format!(
627                "  {}:{}\n",
628                strip_base_path(path, base_path),
629                line
630            ));
631        }
632    } else {
633        output.push_str("DEFINED: (not found)\n");
634    }
635
636    // CALLERS (production, top 10 by frequency)
637    output.push_str("CALLERS (top 10):\n");
638    if prod_chains.is_empty() {
639        output.push_str("  (none)\n");
640    } else {
641        // Collect caller names with their file paths (from chain.chain.first())
642        let mut caller_freq: std::collections::HashMap<String, (usize, String)> =
643            std::collections::HashMap::new();
644        for chain in &prod_chains {
645            if let Some((name, path, _)) = chain.chain.first() {
646                let file_path = strip_base_path(path, base_path);
647                caller_freq
648                    .entry(name.clone())
649                    .and_modify(|(count, _)| *count += 1)
650                    .or_insert((1, file_path));
651            }
652        }
653
654        // Sort by frequency descending, take top 10
655        let mut sorted_callers: Vec<_> = caller_freq.into_iter().collect();
656        sorted_callers.sort_by(|a, b| b.1.0.cmp(&a.1.0));
657
658        for (name, (_, file_path)) in sorted_callers.into_iter().take(10) {
659            output.push_str(&format!("  {} {}\n", name, file_path));
660        }
661    }
662
663    // CALLERS (test) - summary only
664    if !test_chains.is_empty() {
665        let mut test_files: Vec<_> = test_chains
666            .iter()
667            .filter_map(|chain| {
668                chain
669                    .chain
670                    .first()
671                    .map(|(_, path, _)| path.to_string_lossy().into_owned())
672            })
673            .collect();
674        test_files.sort();
675        test_files.dedup();
676
677        output.push_str(&format!(
678            "CALLERS (test): {} test functions (in {} files)\n",
679            test_chains.len(),
680            test_files.len()
681        ));
682    }
683
684    // CALLEES (top 10 by frequency)
685    output.push_str("CALLEES (top 10):\n");
686    if outgoing_chains.is_empty() {
687        output.push_str("  (none)\n");
688    } else {
689        // Collect callee names and count frequency
690        let mut callee_freq: std::collections::HashMap<String, usize> =
691            std::collections::HashMap::new();
692        for chain in &outgoing_chains {
693            if let Some((name, _, _)) = chain.chain.first() {
694                *callee_freq.entry(name.clone()).or_insert(0) += 1;
695            }
696        }
697
698        // Sort by frequency descending, take top 10
699        let mut sorted_callees: Vec<_> = callee_freq.into_iter().collect();
700        sorted_callees.sort_by(|a, b| b.1.cmp(&a.1));
701
702        for (name, _) in sorted_callees.into_iter().take(10) {
703            output.push_str(&format!("  {}\n", name));
704        }
705    }
706
707    // SUGGESTION section
708    output.push_str("SUGGESTION:\n");
709    output.push_str("Use summary=false with force=true for full output\n");
710
711    Ok(output)
712}
713
714/// Format a compact summary for large directory analysis results.
715/// Used when output would exceed the size threshold or when explicitly requested.
716#[instrument(skip_all)]
717pub fn format_summary(
718    entries: &[WalkEntry],
719    analysis_results: &[FileInfo],
720    max_depth: Option<u32>,
721    _base_path: Option<&Path>,
722) -> String {
723    let mut output = String::new();
724
725    // Partition files into production and test
726    let (prod_files, test_files): (Vec<_>, Vec<_>) =
727        analysis_results.iter().partition(|a| !a.is_test);
728
729    // Calculate totals
730    let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
731    let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
732    let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
733
734    // Count files by language
735    let mut lang_counts: HashMap<String, usize> = HashMap::new();
736    for analysis in analysis_results {
737        *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
738    }
739    let total_files = analysis_results.len();
740
741    // Leading summary line with totals
742    let primary_lang = lang_counts
743        .iter()
744        .max_by_key(|&(_, count)| count)
745        .map(|(name, count)| {
746            let percentage = if total_files > 0 {
747                (*count * 100) / total_files
748            } else {
749                0
750            };
751            format!("{} {}%", name, percentage)
752        })
753        .unwrap_or_else(|| "unknown 0%".to_string());
754
755    output.push_str(&format!(
756        "{} files, {}L, {}F, {}C ({})\n",
757        total_files, total_loc, total_functions, total_classes, primary_lang
758    ));
759
760    // SUMMARY block
761    output.push_str("SUMMARY:\n");
762    let depth_label = match max_depth {
763        Some(n) if n > 0 => format!(" (max_depth={})", n),
764        _ => String::new(),
765    };
766    output.push_str(&format!(
767        "{} files ({} prod, {} test), {}L, {}F, {}C{}\n",
768        total_files,
769        prod_files.len(),
770        test_files.len(),
771        total_loc,
772        total_functions,
773        total_classes,
774        depth_label
775    ));
776
777    if !lang_counts.is_empty() {
778        output.push_str("Languages: ");
779        let mut langs: Vec<_> = lang_counts.iter().collect();
780        langs.sort_by_key(|&(name, _)| name);
781        let lang_strs: Vec<String> = langs
782            .iter()
783            .map(|(name, count)| {
784                let percentage = if total_files > 0 {
785                    (**count * 100) / total_files
786                } else {
787                    0
788                };
789                format!("{} ({}%)", name, percentage)
790            })
791            .collect();
792        output.push_str(&lang_strs.join(", "));
793        output.push('\n');
794    }
795
796    output.push('\n');
797
798    // STRUCTURE (depth 1) block
799    output.push_str("STRUCTURE (depth 1):\n");
800
801    // Build a map of path -> analysis for quick lookup
802    let analysis_map: HashMap<String, &FileInfo> = analysis_results
803        .iter()
804        .map(|a| (a.path.clone(), a))
805        .collect();
806
807    // Collect depth-1 entries (directories and files at depth 1)
808    let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
809    depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
810
811    for entry in depth1_entries {
812        let name = entry
813            .path
814            .file_name()
815            .and_then(|n| n.to_str())
816            .unwrap_or("?");
817
818        if entry.is_dir {
819            // For directories, aggregate stats from all files under this directory
820            let dir_path_str = entry.path.display().to_string();
821            let files_in_dir: Vec<&FileInfo> = analysis_results
822                .iter()
823                .filter(|f| f.path.starts_with(&dir_path_str))
824                .collect();
825
826            if !files_in_dir.is_empty() {
827                let dir_file_count = files_in_dir.len();
828                let dir_loc: usize = files_in_dir.iter().map(|f| f.line_count).sum();
829                let dir_functions: usize = files_in_dir.iter().map(|f| f.function_count).sum();
830                let dir_classes: usize = files_in_dir.iter().map(|f| f.class_count).sum();
831
832                output.push_str(&format!(
833                    "  {}/ [{} files, {}L, {}F, {}C]\n",
834                    name, dir_file_count, dir_loc, dir_functions, dir_classes
835                ));
836            } else {
837                output.push_str(&format!("  {}/\n", name));
838            }
839        } else {
840            // For files, show individual stats
841            if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
842                if let Some(info_str) = format_file_info_parts(
843                    analysis.line_count,
844                    analysis.function_count,
845                    analysis.class_count,
846                ) {
847                    output.push_str(&format!("  {} {}\n", name, info_str));
848                } else {
849                    output.push_str(&format!("  {}\n", name));
850                }
851            }
852        }
853    }
854
855    output.push('\n');
856
857    // SUGGESTION block
858    output.push_str("SUGGESTION:\n");
859    output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
860
861    output
862}
863
864/// Format a compact summary of file details for large FileDetails output.
865///
866/// Returns FILE header with path/LOC/counts, top 10 functions by line span descending,
867/// classes inline if <=10, import count, and suggestion block.
868#[instrument(skip_all)]
869pub fn format_file_details_summary(
870    semantic: &SemanticAnalysis,
871    path: &str,
872    line_count: usize,
873) -> String {
874    let mut output = String::new();
875
876    // FILE header
877    output.push_str("FILE:\n");
878    output.push_str(&format!("  path: {}\n", path));
879    output.push_str(&format!(
880        "  {}L, {}F, {}C\n",
881        line_count,
882        semantic.functions.len(),
883        semantic.classes.len()
884    ));
885    output.push('\n');
886
887    // Top 10 functions by line span (end_line - start_line) descending
888    if !semantic.functions.is_empty() {
889        output.push_str("TOP FUNCTIONS BY SIZE:\n");
890        let mut funcs: Vec<&crate::types::FunctionInfo> = semantic.functions.iter().collect();
891        let k = funcs.len().min(10);
892        if k > 0 {
893            funcs.select_nth_unstable_by(k.saturating_sub(1), |a, b| {
894                let a_span = a.end_line.saturating_sub(a.line);
895                let b_span = b.end_line.saturating_sub(b.line);
896                b_span.cmp(&a_span)
897            });
898            funcs[..k].sort_by(|a, b| {
899                let a_span = a.end_line.saturating_sub(a.line);
900                let b_span = b.end_line.saturating_sub(b.line);
901                b_span.cmp(&a_span)
902            });
903        }
904
905        for func in &funcs[..k] {
906            let span = func.end_line.saturating_sub(func.line);
907            let params = if func.parameters.is_empty() {
908                String::new()
909            } else {
910                format!("({})", func.parameters.join(", "))
911            };
912            output.push_str(&format!(
913                "  {}:{}: {} {} [{}L]\n",
914                func.line, func.end_line, func.name, params, span
915            ));
916        }
917        output.push('\n');
918    }
919
920    // Classes inline if <=10, else multiline with method count
921    if !semantic.classes.is_empty() {
922        output.push_str("CLASSES:\n");
923        if semantic.classes.len() <= 10 {
924            // Inline format: one class per line with method count
925            for class in &semantic.classes {
926                let methods_count = class.methods.len();
927                output.push_str(&format!("  {}: {}M\n", class.name, methods_count));
928            }
929        } else {
930            // Multiline format with summary
931            output.push_str(&format!("  {} classes total\n", semantic.classes.len()));
932            for class in semantic.classes.iter().take(5) {
933                output.push_str(&format!("    {}\n", class.name));
934            }
935            if semantic.classes.len() > 5 {
936                output.push_str(&format!(
937                    "    ... and {} more\n",
938                    semantic.classes.len() - 5
939                ));
940            }
941        }
942        output.push('\n');
943    }
944
945    // Import count only
946    output.push_str(&format!("Imports: {}\n", semantic.imports.len()));
947    output.push('\n');
948
949    // SUGGESTION block
950    output.push_str("SUGGESTION:\n");
951    output.push_str("Use force=true for full output, or narrow your scope\n");
952
953    output
954}
955
956/// Format a paginated subset of files for Overview mode.
957#[instrument(skip_all)]
958pub fn format_structure_paginated(
959    paginated_files: &[FileInfo],
960    total_files: usize,
961    max_depth: Option<u32>,
962    base_path: Option<&Path>,
963) -> String {
964    let mut output = String::new();
965
966    let depth_label = match max_depth {
967        Some(n) if n > 0 => format!(" (max_depth={})", n),
968        _ => String::new(),
969    };
970    output.push_str(&format!(
971        "PAGINATED: showing {} of {} files{}\n\n",
972        paginated_files.len(),
973        total_files,
974        depth_label
975    ));
976
977    let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
978    let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
979
980    if !prod_files.is_empty() {
981        output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
982        for file in &prod_files {
983            output.push_str(&format_file_entry(file, base_path));
984        }
985    }
986
987    if !test_files.is_empty() {
988        output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
989        for file in &test_files {
990            output.push_str(&format_file_entry(file, base_path));
991        }
992    }
993
994    output
995}
996
997/// Format a paginated subset of functions for FileDetails mode.
998/// Shows classes and imports only on the first page (offset == 0).
999/// Header shows position context: `FILE: path (NL, start-end/totalF, CC, II)`.
1000#[instrument(skip_all)]
1001pub fn format_file_details_paginated(
1002    functions_page: &[FunctionInfo],
1003    total_functions: usize,
1004    semantic: &SemanticAnalysis,
1005    path: &str,
1006    line_count: usize,
1007    offset: usize,
1008) -> String {
1009    let mut output = String::new();
1010
1011    let start = offset + 1; // 1-indexed for display
1012    let end = offset + functions_page.len();
1013
1014    output.push_str(&format!(
1015        "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)\n",
1016        path,
1017        line_count,
1018        start,
1019        end,
1020        total_functions,
1021        semantic.classes.len(),
1022        semantic.imports.len(),
1023    ));
1024
1025    // Classes and imports sections only on first page
1026    if offset == 0 {
1027        output.push_str(&format_classes_section(&semantic.classes));
1028        output.push_str(&format_imports_section(&semantic.imports));
1029    }
1030
1031    // F: section with paginated function slice
1032    if !functions_page.is_empty() {
1033        output.push_str("F:\n");
1034        output.push_str(&format_function_list_wrapped(
1035            functions_page.iter(),
1036            &semantic.call_frequency,
1037        ));
1038    }
1039
1040    output
1041}
1042
1043/// Parameters for `format_focused_paginated`.
1044pub struct FocusedPaginatedParams<'a> {
1045    pub paginated_chains: &'a [CallChain],
1046    pub total: usize,
1047    pub mode: PaginationMode,
1048    pub symbol: &'a str,
1049    pub prod_chains: &'a [CallChain],
1050    pub test_chains: &'a [CallChain],
1051    pub outgoing_chains: &'a [CallChain],
1052    pub def_count: usize,
1053    pub offset: usize,
1054    pub base_path: Option<&'a Path>,
1055}
1056
1057/// Format a paginated subset of callers or callees for SymbolFocus mode.
1058/// Mode is determined by the `mode` parameter:
1059/// - `PaginationMode::Callers`: paginate production callers; show test callers summary and callees summary.
1060/// - `PaginationMode::Callees`: paginate callees; show callers summary and test callers summary.
1061#[instrument(skip_all)]
1062#[allow(clippy::too_many_arguments)]
1063pub fn format_focused_paginated(
1064    paginated_chains: &[CallChain],
1065    total: usize,
1066    mode: PaginationMode,
1067    symbol: &str,
1068    prod_chains: &[CallChain],
1069    test_chains: &[CallChain],
1070    outgoing_chains: &[CallChain],
1071    def_count: usize,
1072    offset: usize,
1073    base_path: Option<&Path>,
1074) -> String {
1075    let start = offset + 1; // 1-indexed
1076    let end = offset + paginated_chains.len();
1077
1078    let callers_count = prod_chains
1079        .iter()
1080        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
1081        .collect::<std::collections::HashSet<_>>()
1082        .len();
1083
1084    let callees_count = outgoing_chains
1085        .iter()
1086        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
1087        .collect::<std::collections::HashSet<_>>()
1088        .len();
1089
1090    let mut output = String::new();
1091
1092    output.push_str(&format!(
1093        "FOCUS: {} ({} defs, {} callers, {} callees)\n",
1094        symbol, def_count, callers_count, callees_count
1095    ));
1096
1097    match mode {
1098        PaginationMode::Callers => {
1099            // Paginate production callers
1100            output.push_str(&format!("CALLERS ({}-{} of {}):\n", start, end, total));
1101
1102            let page_refs: Vec<_> = paginated_chains
1103                .iter()
1104                .filter_map(|chain| {
1105                    if chain.chain.len() >= 2 {
1106                        Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1107                    } else if chain.chain.len() == 1 {
1108                        Some((chain.chain[0].0.as_str(), ""))
1109                    } else {
1110                        None
1111                    }
1112                })
1113                .collect();
1114
1115            if page_refs.is_empty() {
1116                output.push_str("  (none)\n");
1117            } else {
1118                output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1119            }
1120
1121            // Test callers summary
1122            if !test_chains.is_empty() {
1123                let mut test_files: Vec<_> = test_chains
1124                    .iter()
1125                    .filter_map(|chain| {
1126                        chain
1127                            .chain
1128                            .first()
1129                            .map(|(_, path, _)| path.to_string_lossy().into_owned())
1130                    })
1131                    .collect();
1132                test_files.sort();
1133                test_files.dedup();
1134
1135                let display_files: Vec<_> = test_files
1136                    .iter()
1137                    .map(|f| strip_base_path(std::path::Path::new(f), base_path))
1138                    .collect();
1139
1140                output.push_str(&format!(
1141                    "CALLERS (test): {} test functions (in {})\n",
1142                    test_chains.len(),
1143                    display_files.join(", ")
1144                ));
1145            }
1146
1147            // Callees summary
1148            let callee_names: Vec<_> = outgoing_chains
1149                .iter()
1150                .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1151                .collect::<std::collections::HashSet<_>>()
1152                .into_iter()
1153                .collect();
1154            if callee_names.is_empty() {
1155                output.push_str("CALLEES: (none)\n");
1156            } else {
1157                output.push_str(&format!(
1158                    "CALLEES: {} (use cursor for callee pagination)\n",
1159                    callees_count
1160                ));
1161            }
1162        }
1163        PaginationMode::Callees => {
1164            // Callers summary
1165            output.push_str(&format!("CALLERS: {} production callers\n", callers_count));
1166
1167            // Test callers summary
1168            if !test_chains.is_empty() {
1169                output.push_str(&format!(
1170                    "CALLERS (test): {} test functions\n",
1171                    test_chains.len()
1172                ));
1173            }
1174
1175            // Paginate callees
1176            output.push_str(&format!("CALLEES ({}-{} of {}):\n", start, end, total));
1177
1178            let page_refs: Vec<_> = paginated_chains
1179                .iter()
1180                .filter_map(|chain| {
1181                    if chain.chain.len() >= 2 {
1182                        Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1183                    } else if chain.chain.len() == 1 {
1184                        Some((chain.chain[0].0.as_str(), ""))
1185                    } else {
1186                        None
1187                    }
1188                })
1189                .collect();
1190
1191            if page_refs.is_empty() {
1192                output.push_str("  (none)\n");
1193            } else {
1194                output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1195            }
1196        }
1197        PaginationMode::Default => {
1198            unreachable!("format_focused_paginated called with PaginationMode::Default")
1199        }
1200    }
1201
1202    output
1203}
1204
1205fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1206    let mut parts = Vec::new();
1207    if file.line_count > 0 {
1208        parts.push(format!("{}L", file.line_count));
1209    }
1210    if file.function_count > 0 {
1211        parts.push(format!("{}F", file.function_count));
1212    }
1213    if file.class_count > 0 {
1214        parts.push(format!("{}C", file.class_count));
1215    }
1216    let display_path = strip_base_path(Path::new(&file.path), base_path);
1217    if parts.is_empty() {
1218        format!("{}\n", display_path)
1219    } else {
1220        format!("{} [{}]\n", display_path, parts.join(", "))
1221    }
1222}
1223
1224#[cfg(test)]
1225mod tests {
1226    use super::*;
1227
1228    #[test]
1229    fn test_strip_base_path_relative() {
1230        let path = Path::new("/home/user/project/src/main.rs");
1231        let base = Path::new("/home/user/project");
1232        let result = strip_base_path(path, Some(base));
1233        assert_eq!(result, "src/main.rs");
1234    }
1235
1236    #[test]
1237    fn test_strip_base_path_fallback_absolute() {
1238        let path = Path::new("/other/project/src/main.rs");
1239        let base = Path::new("/home/user/project");
1240        let result = strip_base_path(path, Some(base));
1241        assert_eq!(result, "/other/project/src/main.rs");
1242    }
1243
1244    #[test]
1245    fn test_strip_base_path_none() {
1246        let path = Path::new("/home/user/project/src/main.rs");
1247        let result = strip_base_path(path, None);
1248        assert_eq!(result, "/home/user/project/src/main.rs");
1249    }
1250
1251    #[test]
1252    fn test_format_file_details_summary_empty() {
1253        use crate::types::SemanticAnalysis;
1254        use std::collections::HashMap;
1255
1256        let semantic = SemanticAnalysis {
1257            functions: vec![],
1258            classes: vec![],
1259            imports: vec![],
1260            references: vec![],
1261            call_frequency: HashMap::new(),
1262            calls: vec![],
1263            assignments: vec![],
1264            field_accesses: vec![],
1265        };
1266
1267        let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1268
1269        // Should contain FILE header, Imports count, and SUGGESTION
1270        assert!(result.contains("FILE:"));
1271        assert!(result.contains("100L, 0F, 0C"));
1272        assert!(result.contains("src/main.rs"));
1273        assert!(result.contains("Imports: 0"));
1274        assert!(result.contains("SUGGESTION:"));
1275    }
1276
1277    #[test]
1278    fn test_format_file_details_summary_with_functions() {
1279        use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1280        use std::collections::HashMap;
1281
1282        let semantic = SemanticAnalysis {
1283            functions: vec![
1284                FunctionInfo {
1285                    name: "short".to_string(),
1286                    line: 10,
1287                    end_line: 12,
1288                    parameters: vec![],
1289                    return_type: None,
1290                },
1291                FunctionInfo {
1292                    name: "long_function".to_string(),
1293                    line: 20,
1294                    end_line: 50,
1295                    parameters: vec!["x".to_string(), "y".to_string()],
1296                    return_type: Some("i32".to_string()),
1297                },
1298            ],
1299            classes: vec![ClassInfo {
1300                name: "MyClass".to_string(),
1301                line: 60,
1302                end_line: 80,
1303                methods: vec![],
1304                fields: vec![],
1305                inherits: vec![],
1306            }],
1307            imports: vec![],
1308            references: vec![],
1309            call_frequency: HashMap::new(),
1310            calls: vec![],
1311            assignments: vec![],
1312            field_accesses: vec![],
1313        };
1314
1315        let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1316
1317        // Should contain FILE header with counts
1318        assert!(result.contains("FILE:"));
1319        assert!(result.contains("src/lib.rs"));
1320        assert!(result.contains("250L, 2F, 1C"));
1321
1322        // Should contain TOP FUNCTIONS BY SIZE with longest first
1323        assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1324        let long_idx = result.find("long_function").unwrap_or(0);
1325        let short_idx = result.find("short").unwrap_or(0);
1326        assert!(
1327            long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1328            "long_function should appear before short"
1329        );
1330
1331        // Should contain classes inline
1332        assert!(result.contains("CLASSES:"));
1333        assert!(result.contains("MyClass:"));
1334
1335        // Should contain import count
1336        assert!(result.contains("Imports: 0"));
1337    }
1338    #[test]
1339    fn test_format_file_info_parts_all_zero() {
1340        assert_eq!(format_file_info_parts(0, 0, 0), None);
1341    }
1342
1343    #[test]
1344    fn test_format_file_info_parts_partial() {
1345        assert_eq!(
1346            format_file_info_parts(42, 0, 3),
1347            Some("[42L, 3C]".to_string())
1348        );
1349    }
1350
1351    #[test]
1352    fn test_format_file_info_parts_all_nonzero() {
1353        assert_eq!(
1354            format_file_info_parts(100, 5, 2),
1355            Some("[100L, 5F, 2C]".to_string())
1356        );
1357    }
1358
1359    #[test]
1360    fn test_format_function_list_wrapped_empty() {
1361        let freq = std::collections::HashMap::new();
1362        let result = format_function_list_wrapped(std::iter::empty(), &freq);
1363        assert_eq!(result, "");
1364    }
1365
1366    #[test]
1367    fn test_format_function_list_wrapped_bullet_annotation() {
1368        use crate::types::FunctionInfo;
1369        use std::collections::HashMap;
1370
1371        let mut freq = HashMap::new();
1372        freq.insert("frequent".to_string(), 5); // count > 3 should get bullet
1373
1374        let funcs = vec![FunctionInfo {
1375            name: "frequent".to_string(),
1376            line: 1,
1377            end_line: 10,
1378            parameters: vec![],
1379            return_type: Some("void".to_string()),
1380        }];
1381
1382        let result = format_function_list_wrapped(funcs.iter(), &freq);
1383        // Should contain bullet (U+2022) followed by count
1384        assert!(result.contains("\u{2022}5"));
1385    }
1386}
1387
1388fn format_classes_section(classes: &[ClassInfo]) -> String {
1389    let mut output = String::new();
1390    if classes.is_empty() {
1391        return output;
1392    }
1393    output.push_str("C:\n");
1394    if classes.len() <= MULTILINE_THRESHOLD {
1395        let class_strs: Vec<String> = classes
1396            .iter()
1397            .map(|class| {
1398                if class.inherits.is_empty() {
1399                    format!("{}:{}", class.name, class.line)
1400                } else {
1401                    format!(
1402                        "{}:{} ({})",
1403                        class.name,
1404                        class.line,
1405                        class.inherits.join(", ")
1406                    )
1407                }
1408            })
1409            .collect();
1410        output.push_str("  ");
1411        output.push_str(&class_strs.join("; "));
1412        output.push('\n');
1413    } else {
1414        for class in classes {
1415            if class.inherits.is_empty() {
1416                output.push_str(&format!("  {}:{}\n", class.name, class.line));
1417            } else {
1418                output.push_str(&format!(
1419                    "  {}:{} ({})\n",
1420                    class.name,
1421                    class.line,
1422                    class.inherits.join(", ")
1423                ));
1424            }
1425        }
1426    }
1427    output
1428}
1429
1430fn format_imports_section(imports: &[ImportInfo]) -> String {
1431    let mut output = String::new();
1432    if imports.is_empty() {
1433        return output;
1434    }
1435    output.push_str("I:\n");
1436    let mut module_map: HashMap<String, usize> = HashMap::new();
1437    for import in imports {
1438        module_map
1439            .entry(import.module.clone())
1440            .and_modify(|count| *count += 1)
1441            .or_insert(1);
1442    }
1443    let mut modules: Vec<_> = module_map.keys().cloned().collect();
1444    modules.sort();
1445    let formatted_modules: Vec<String> = modules
1446        .iter()
1447        .map(|module| format!("{}({})", module, module_map[module]))
1448        .collect();
1449    if formatted_modules.len() <= MULTILINE_THRESHOLD {
1450        output.push_str("  ");
1451        output.push_str(&formatted_modules.join("; "));
1452        output.push('\n');
1453    } else {
1454        for module_str in formatted_modules {
1455            output.push_str("  ");
1456            output.push_str(&module_str);
1457            output.push('\n');
1458        }
1459    }
1460    output
1461}