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::CallGraph;
7use crate::graph::InternalCallChain;
8use crate::pagination::PaginationMode;
9use crate::test_detection::is_test_file;
10use crate::traversal::WalkEntry;
11use crate::types::{ClassInfo, FileInfo, FunctionInfo, ImportInfo, ModuleInfo, SemanticAnalysis};
12use std::collections::{HashMap, HashSet};
13use std::fmt::Write;
14use std::path::{Path, PathBuf};
15use thiserror::Error;
16use tracing::instrument;
17
18const MULTILINE_THRESHOLD: usize = 10;
19
20/// Check if a function falls within a class's line range (method detection).
21fn is_method_of_class(func: &FunctionInfo, class: &ClassInfo) -> bool {
22    func.line >= class.line && func.end_line <= class.end_line
23}
24
25/// Collect methods for each class, preferring ClassInfo.methods when populated (Rust case),
26/// falling back to line-range intersection for languages that do not populate ClassInfo.methods.
27fn collect_class_methods<'a>(
28    classes: &'a [ClassInfo],
29    functions: &'a [FunctionInfo],
30) -> HashMap<String, Vec<&'a FunctionInfo>> {
31    let mut methods_by_class: HashMap<String, Vec<&'a FunctionInfo>> = HashMap::new();
32    for class in classes {
33        if !class.methods.is_empty() {
34            // Rust: parser already populated methods via extract_impl_methods
35            methods_by_class.insert(class.name.clone(), class.methods.iter().collect());
36        } else {
37            // Python/Java/TS/Go: infer methods by line-range containment
38            let methods: Vec<&FunctionInfo> = functions
39                .iter()
40                .filter(|f| is_method_of_class(f, class))
41                .collect();
42            methods_by_class.insert(class.name.clone(), methods);
43        }
44    }
45    methods_by_class
46}
47
48/// Format a list of function signatures wrapped at 100 characters with bullet annotation.
49fn format_function_list_wrapped<'a>(
50    functions: impl Iterator<Item = &'a crate::types::FunctionInfo>,
51    call_frequency: &std::collections::HashMap<String, usize>,
52) -> String {
53    let mut output = String::new();
54    let mut line = String::from("  ");
55    for (i, func) in functions.enumerate() {
56        let mut call_marker = func.compact_signature();
57
58        if let Some(&count) = call_frequency.get(&func.name)
59            && count > 3
60        {
61            call_marker.push_str(&format!("\u{2022}{}", count));
62        }
63
64        if i == 0 {
65            line.push_str(&call_marker);
66        } else if line.len() + call_marker.len() + 2 > 100 {
67            output.push_str(&line);
68            output.push('\n');
69            let mut new_line = String::with_capacity(2 + call_marker.len());
70            new_line.push_str("  ");
71            new_line.push_str(&call_marker);
72            line = new_line;
73        } else {
74            line.push_str(", ");
75            line.push_str(&call_marker);
76        }
77    }
78    if !line.trim().is_empty() {
79        output.push_str(&line);
80        output.push('\n');
81    }
82    output
83}
84
85/// Build a bracket string for file info (line count, function count, class count).
86/// Returns None if all counts are zero, otherwise returns "[42L, 7F, 2C]" format.
87fn format_file_info_parts(line_count: usize, fn_count: usize, cls_count: usize) -> Option<String> {
88    let mut parts = Vec::new();
89    if line_count > 0 {
90        parts.push(format!("{}L", line_count));
91    }
92    if fn_count > 0 {
93        parts.push(format!("{}F", fn_count));
94    }
95    if cls_count > 0 {
96        parts.push(format!("{}C", cls_count));
97    }
98    if parts.is_empty() {
99        None
100    } else {
101        Some(format!("[{}]", parts.join(", ")))
102    }
103}
104
105/// Strip a base path from a Path, returning a relative path or the original on failure.
106fn strip_base_path(path: &Path, base_path: Option<&Path>) -> String {
107    match base_path {
108        Some(base) => {
109            if let Ok(rel_path) = path.strip_prefix(base) {
110                rel_path.display().to_string()
111            } else {
112                path.display().to_string()
113            }
114        }
115        None => path.display().to_string(),
116    }
117}
118
119#[derive(Debug, Error)]
120pub enum FormatterError {
121    #[error("Graph error: {0}")]
122    GraphError(#[from] crate::graph::GraphError),
123}
124
125/// Format directory structure analysis results.
126#[instrument(skip_all)]
127pub fn format_structure(
128    entries: &[WalkEntry],
129    analysis_results: &[FileInfo],
130    max_depth: Option<u32>,
131    _base_path: Option<&Path>,
132) -> String {
133    let mut output = String::new();
134
135    // Build a map of path -> analysis for quick lookup
136    let analysis_map: HashMap<String, &FileInfo> = analysis_results
137        .iter()
138        .map(|a| (a.path.clone(), a))
139        .collect();
140
141    // Partition files into production and test
142    let (prod_files, test_files): (Vec<_>, Vec<_>) =
143        analysis_results.iter().partition(|a| !a.is_test);
144
145    // Calculate totals
146    let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
147    let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
148    let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
149
150    // Count files by language and calculate percentages
151    let mut lang_counts: HashMap<String, usize> = HashMap::new();
152    for analysis in analysis_results {
153        *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
154    }
155    let total_files = analysis_results.len();
156
157    // Leading summary line with totals
158    let primary_lang = lang_counts
159        .iter()
160        .max_by_key(|&(_, count)| count)
161        .map(|(name, count)| {
162            let percentage = if total_files > 0 {
163                (*count * 100) / total_files
164            } else {
165                0
166            };
167            format!("{} {}%", name, percentage)
168        })
169        .unwrap_or_else(|| "unknown 0%".to_string());
170
171    output.push_str(&format!(
172        "{} files, {}L, {}F, {}C ({})\n",
173        total_files, total_loc, total_functions, total_classes, primary_lang
174    ));
175
176    // SUMMARY block
177    output.push_str("SUMMARY:\n");
178    let depth_label = match max_depth {
179        Some(n) if n > 0 => format!(" (max_depth={})", n),
180        _ => String::new(),
181    };
182    output.push_str(&format!(
183        "Shown: {} files ({} prod, {} test), {}L, {}F, {}C{}\n",
184        total_files,
185        prod_files.len(),
186        test_files.len(),
187        total_loc,
188        total_functions,
189        total_classes,
190        depth_label
191    ));
192
193    if !lang_counts.is_empty() {
194        output.push_str("Languages: ");
195        let mut langs: Vec<_> = lang_counts.iter().collect();
196        langs.sort_by_key(|&(name, _)| name);
197        let lang_strs: Vec<String> = langs
198            .iter()
199            .map(|(name, count)| {
200                let percentage = if total_files > 0 {
201                    (**count * 100) / total_files
202                } else {
203                    0
204                };
205                format!("{} ({}%)", name, percentage)
206            })
207            .collect();
208        output.push_str(&lang_strs.join(", "));
209        output.push('\n');
210    }
211
212    output.push('\n');
213
214    // PATH block - tree structure (production files only)
215    output.push_str("PATH [LOC, FUNCTIONS, CLASSES]\n");
216
217    for entry in entries {
218        // Skip the root directory itself
219        if entry.depth == 0 {
220            continue;
221        }
222
223        // Calculate indentation
224        let indent = "  ".repeat(entry.depth - 1);
225
226        // Get just the filename/dirname
227        let name = entry
228            .path
229            .file_name()
230            .and_then(|n| n.to_str())
231            .unwrap_or("?");
232
233        // For files, append analysis info
234        if !entry.is_dir {
235            if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
236                // Skip test files in production section
237                if analysis.is_test {
238                    continue;
239                }
240
241                if let Some(info_str) = format_file_info_parts(
242                    analysis.line_count,
243                    analysis.function_count,
244                    analysis.class_count,
245                ) {
246                    output.push_str(&format!("{}{} {}\n", indent, name, info_str));
247                } else {
248                    output.push_str(&format!("{}{}\n", indent, name));
249                }
250            }
251            // Skip files not in analysis_map (binary/unreadable files)
252        } else {
253            output.push_str(&format!("{}{}/\n", indent, name));
254        }
255    }
256
257    // TEST FILES section (if any test files exist)
258    if !test_files.is_empty() {
259        output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
260
261        for entry in entries {
262            // Skip the root directory itself
263            if entry.depth == 0 {
264                continue;
265            }
266
267            // Calculate indentation
268            let indent = "  ".repeat(entry.depth - 1);
269
270            // Get just the filename/dirname
271            let name = entry
272                .path
273                .file_name()
274                .and_then(|n| n.to_str())
275                .unwrap_or("?");
276
277            // For files, append analysis info
278            if !entry.is_dir
279                && let Some(analysis) = analysis_map.get(&entry.path.display().to_string())
280            {
281                // Only show test files in test section
282                if !analysis.is_test {
283                    continue;
284                }
285
286                if let Some(info_str) = format_file_info_parts(
287                    analysis.line_count,
288                    analysis.function_count,
289                    analysis.class_count,
290                ) {
291                    output.push_str(&format!("{}{} {}\n", indent, name, info_str));
292                } else {
293                    output.push_str(&format!("{}{}\n", indent, name));
294                }
295            }
296        }
297    }
298
299    output
300}
301
302/// Format file-level semantic analysis results.
303#[instrument(skip_all)]
304pub fn format_file_details(
305    path: &str,
306    analysis: &SemanticAnalysis,
307    line_count: usize,
308    is_test: bool,
309    base_path: Option<&Path>,
310) -> String {
311    let mut output = String::new();
312
313    // FILE: header with counts, prepend [TEST] if applicable
314    let display_path = strip_base_path(Path::new(path), base_path);
315    if is_test {
316        output.push_str(&format!(
317            "FILE [TEST] {}({}L, {}F, {}C, {}I)\n",
318            display_path,
319            line_count,
320            analysis.functions.len(),
321            analysis.classes.len(),
322            analysis.imports.len()
323        ));
324    } else {
325        output.push_str(&format!(
326            "FILE: {}({}L, {}F, {}C, {}I)\n",
327            display_path,
328            line_count,
329            analysis.functions.len(),
330            analysis.classes.len(),
331            analysis.imports.len()
332        ));
333    }
334
335    // C: section with classes and methods
336    output.push_str(&format_classes_section(
337        &analysis.classes,
338        &analysis.functions,
339    ));
340
341    // F: section with top-level functions only (exclude methods)
342    let top_level_functions: Vec<&FunctionInfo> = analysis
343        .functions
344        .iter()
345        .filter(|func| {
346            !analysis
347                .classes
348                .iter()
349                .any(|class| is_method_of_class(func, class))
350        })
351        .collect();
352
353    if !top_level_functions.is_empty() {
354        output.push_str("F:\n");
355        output.push_str(&format_function_list_wrapped(
356            top_level_functions.iter().copied(),
357            &analysis.call_frequency,
358        ));
359    }
360
361    // I: section with imports grouped by module
362    output.push_str(&format_imports_section(&analysis.imports));
363
364    output
365}
366
367/// Format chains as a tree-indented output, grouped by depth-1 symbol.
368/// Groups chains by their first symbol (depth-1), deduplicates and sorts depth-2 children,
369/// then renders with 2-space indentation using the provided arrow.
370/// focus_symbol is the name of the depth-0 symbol (focus point) to prepend on depth-1 lines.
371///
372/// Indentation rules:
373/// - Depth-1: `  {focus} {arrow} {parent}` (2-space indent)
374/// - Depth-2: `    {arrow} {child}` (4-space indent)
375/// - Empty:   `  (none)` (2-space indent)
376fn format_chains_as_tree(chains: &[(&str, &str)], arrow: &str, focus_symbol: &str) -> String {
377    use std::collections::BTreeMap;
378
379    if chains.is_empty() {
380        return "  (none)\n".to_string();
381    }
382
383    let mut output = String::new();
384
385    // Group chains by depth-1 symbol, counting duplicate children
386    let mut groups: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
387    for (parent, child) in chains {
388        // Only count non-empty children
389        if !child.is_empty() {
390            *groups
391                .entry(parent.to_string())
392                .or_default()
393                .entry(child.to_string())
394                .or_insert(0) += 1;
395        } else {
396            // Ensure parent is in groups even if no children
397            groups.entry(parent.to_string()).or_default();
398        }
399    }
400
401    // Render grouped tree
402    for (parent, children) in groups {
403        let _ = writeln!(output, "  {} {} {}", focus_symbol, arrow, parent);
404        // Sort children by count descending, then alphabetically
405        let mut sorted: Vec<_> = children.into_iter().collect();
406        sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
407        for (child, count) in sorted {
408            if count > 1 {
409                let _ = writeln!(output, "    {} {} (x{})", arrow, child, count);
410            } else {
411                let _ = writeln!(output, "    {} {}", arrow, child);
412            }
413        }
414    }
415
416    output
417}
418
419/// Format focused symbol analysis with call graph.
420#[instrument(skip_all)]
421pub fn format_focused(
422    graph: &CallGraph,
423    symbol: &str,
424    follow_depth: u32,
425    base_path: Option<&Path>,
426) -> Result<String, FormatterError> {
427    let mut output = String::new();
428
429    // Compute all counts BEFORE output begins
430    let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
431    let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
432    let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
433
434    // Partition incoming_chains into production and test callers
435    let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
436        incoming_chains.clone().into_iter().partition(|chain| {
437            chain
438                .chain
439                .first()
440                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
441        });
442
443    // Count unique callers
444    let callers_count = prod_chains
445        .iter()
446        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
447        .collect::<std::collections::HashSet<_>>()
448        .len();
449
450    // Count unique callees
451    let callees_count = outgoing_chains
452        .iter()
453        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
454        .collect::<std::collections::HashSet<_>>()
455        .len();
456
457    // FOCUS section - with inline counts
458    output.push_str(&format!(
459        "FOCUS: {} ({} defs, {} callers, {} callees)\n",
460        symbol, def_count, callers_count, callees_count
461    ));
462
463    // DEPTH section
464    output.push_str(&format!("DEPTH: {}\n", follow_depth));
465
466    // DEFINED section - show where the symbol is defined
467    if let Some(definitions) = graph.definitions.get(symbol) {
468        output.push_str("DEFINED:\n");
469        for (path, line) in definitions {
470            output.push_str(&format!(
471                "  {}:{}\n",
472                strip_base_path(path, base_path),
473                line
474            ));
475        }
476    } else {
477        output.push_str("DEFINED: (not found)\n");
478    }
479
480    // CALLERS section - who calls this symbol
481    output.push_str("CALLERS:\n");
482
483    // Render production callers
484    let prod_refs: Vec<_> = prod_chains
485        .iter()
486        .filter_map(|chain| {
487            if chain.chain.len() >= 2 {
488                Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
489            } else if chain.chain.len() == 1 {
490                Some((chain.chain[0].0.as_str(), ""))
491            } else {
492                None
493            }
494        })
495        .collect();
496
497    if prod_refs.is_empty() {
498        output.push_str("  (none)\n");
499    } else {
500        output.push_str(&format_chains_as_tree(&prod_refs, "<-", symbol));
501    }
502
503    // Render test callers summary if any
504    if !test_chains.is_empty() {
505        let mut test_files: Vec<_> = test_chains
506            .iter()
507            .filter_map(|chain| {
508                chain
509                    .chain
510                    .first()
511                    .map(|(_, path, _)| path.to_string_lossy().into_owned())
512            })
513            .collect();
514        test_files.sort();
515        test_files.dedup();
516
517        // Strip base path for display
518        let display_files: Vec<_> = test_files
519            .iter()
520            .map(|f| strip_base_path(Path::new(f), base_path))
521            .collect();
522
523        let file_list = display_files.join(", ");
524        output.push_str(&format!(
525            "CALLERS (test): {} test functions (in {})\n",
526            test_chains.len(),
527            file_list
528        ));
529    }
530
531    // CALLEES section - what this symbol calls
532    output.push_str("CALLEES:\n");
533    let outgoing_refs: Vec<_> = outgoing_chains
534        .iter()
535        .filter_map(|chain| {
536            if chain.chain.len() >= 2 {
537                Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
538            } else if chain.chain.len() == 1 {
539                Some((chain.chain[0].0.as_str(), ""))
540            } else {
541                None
542            }
543        })
544        .collect();
545
546    if outgoing_refs.is_empty() {
547        output.push_str("  (none)\n");
548    } else {
549        output.push_str(&format_chains_as_tree(&outgoing_refs, "->", symbol));
550    }
551
552    // STATISTICS section
553    output.push_str("STATISTICS:\n");
554    let incoming_count = prod_refs
555        .iter()
556        .map(|(p, _)| p)
557        .collect::<std::collections::HashSet<_>>()
558        .len();
559    let outgoing_count = outgoing_refs
560        .iter()
561        .map(|(p, _)| p)
562        .collect::<std::collections::HashSet<_>>()
563        .len();
564    output.push_str(&format!("  Incoming calls: {}\n", incoming_count));
565    output.push_str(&format!("  Outgoing calls: {}\n", outgoing_count));
566
567    // FILES section - collect unique files from production chains
568    let mut files = HashSet::new();
569    for chain in &prod_chains {
570        for (_, path, _) in &chain.chain {
571            files.insert(path.clone());
572        }
573    }
574    for chain in &outgoing_chains {
575        for (_, path, _) in &chain.chain {
576            files.insert(path.clone());
577        }
578    }
579    if let Some(definitions) = graph.definitions.get(symbol) {
580        for (path, _) in definitions {
581            files.insert(path.clone());
582        }
583    }
584
585    // Partition files into production and test
586    let (prod_files, test_files): (Vec<_>, Vec<_>) =
587        files.into_iter().partition(|path| !is_test_file(path));
588
589    output.push_str("FILES:\n");
590    if prod_files.is_empty() && test_files.is_empty() {
591        output.push_str("  (none)\n");
592    } else {
593        // Show production files first
594        if !prod_files.is_empty() {
595            let mut sorted_files = prod_files;
596            sorted_files.sort();
597            for file in sorted_files {
598                output.push_str(&format!("  {}\n", strip_base_path(&file, base_path)));
599            }
600        }
601
602        // Show test files in separate subsection
603        if !test_files.is_empty() {
604            output.push_str("  TEST FILES:\n");
605            let mut sorted_files = test_files;
606            sorted_files.sort();
607            for file in sorted_files {
608                output.push_str(&format!("    {}\n", strip_base_path(&file, base_path)));
609            }
610        }
611    }
612
613    Ok(output)
614}
615
616/// Format a compact summary of focused symbol analysis.
617/// Used when output would exceed the size threshold or when explicitly requested.
618#[instrument(skip_all)]
619pub fn format_focused_summary(
620    graph: &CallGraph,
621    symbol: &str,
622    follow_depth: u32,
623    base_path: Option<&Path>,
624) -> Result<String, FormatterError> {
625    let mut output = String::new();
626
627    // Compute all counts BEFORE output begins
628    let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
629    let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
630    let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
631
632    // Partition incoming_chains into production and test callers
633    let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
634        incoming_chains.into_iter().partition(|chain| {
635            chain
636                .chain
637                .first()
638                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
639        });
640
641    // Count unique production callers
642    let callers_count = prod_chains
643        .iter()
644        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
645        .collect::<std::collections::HashSet<_>>()
646        .len();
647
648    // Count unique callees
649    let callees_count = outgoing_chains
650        .iter()
651        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
652        .collect::<std::collections::HashSet<_>>()
653        .len();
654
655    // FOCUS header
656    output.push_str(&format!(
657        "FOCUS: {} ({} defs, {} callers, {} callees)\n",
658        symbol, def_count, callers_count, callees_count
659    ));
660
661    // DEPTH line
662    output.push_str(&format!("DEPTH: {}\n", follow_depth));
663
664    // DEFINED section
665    if let Some(definitions) = graph.definitions.get(symbol) {
666        output.push_str("DEFINED:\n");
667        for (path, line) in definitions {
668            output.push_str(&format!(
669                "  {}:{}\n",
670                strip_base_path(path, base_path),
671                line
672            ));
673        }
674    } else {
675        output.push_str("DEFINED: (not found)\n");
676    }
677
678    // CALLERS (production, top 10 by frequency)
679    output.push_str("CALLERS (top 10):\n");
680    if prod_chains.is_empty() {
681        output.push_str("  (none)\n");
682    } else {
683        // Collect caller names with their file paths (from chain.chain.first())
684        let mut caller_freq: std::collections::HashMap<String, (usize, String)> =
685            std::collections::HashMap::new();
686        for chain in &prod_chains {
687            if let Some((name, path, _)) = chain.chain.first() {
688                let file_path = strip_base_path(path, base_path);
689                caller_freq
690                    .entry(name.clone())
691                    .and_modify(|(count, _)| *count += 1)
692                    .or_insert((1, file_path));
693            }
694        }
695
696        // Sort by frequency descending, take top 10
697        let mut sorted_callers: Vec<_> = caller_freq.into_iter().collect();
698        sorted_callers.sort_by(|a, b| b.1.0.cmp(&a.1.0));
699
700        for (name, (_, file_path)) in sorted_callers.into_iter().take(10) {
701            output.push_str(&format!("  {} {}\n", name, file_path));
702        }
703    }
704
705    // CALLERS (test) - summary only
706    if !test_chains.is_empty() {
707        let mut test_files: Vec<_> = test_chains
708            .iter()
709            .filter_map(|chain| {
710                chain
711                    .chain
712                    .first()
713                    .map(|(_, path, _)| path.to_string_lossy().into_owned())
714            })
715            .collect();
716        test_files.sort();
717        test_files.dedup();
718
719        output.push_str(&format!(
720            "CALLERS (test): {} test functions (in {} files)\n",
721            test_chains.len(),
722            test_files.len()
723        ));
724    }
725
726    // CALLEES (top 10 by frequency)
727    output.push_str("CALLEES (top 10):\n");
728    if outgoing_chains.is_empty() {
729        output.push_str("  (none)\n");
730    } else {
731        // Collect callee names and count frequency
732        let mut callee_freq: std::collections::HashMap<String, usize> =
733            std::collections::HashMap::new();
734        for chain in &outgoing_chains {
735            if let Some((name, _, _)) = chain.chain.first() {
736                *callee_freq.entry(name.clone()).or_insert(0) += 1;
737            }
738        }
739
740        // Sort by frequency descending, take top 10
741        let mut sorted_callees: Vec<_> = callee_freq.into_iter().collect();
742        sorted_callees.sort_by(|a, b| b.1.cmp(&a.1));
743
744        for (name, _) in sorted_callees.into_iter().take(10) {
745            output.push_str(&format!("  {}\n", name));
746        }
747    }
748
749    // SUGGESTION section
750    output.push_str("SUGGESTION:\n");
751    output.push_str("Use summary=false with force=true for full output\n");
752
753    Ok(output)
754}
755
756/// Format a compact summary for large directory analysis results.
757/// Used when output would exceed the size threshold or when explicitly requested.
758#[instrument(skip_all)]
759pub fn format_summary(
760    entries: &[WalkEntry],
761    analysis_results: &[FileInfo],
762    max_depth: Option<u32>,
763    _base_path: Option<&Path>,
764    subtree_counts: Option<&[(PathBuf, usize)]>,
765) -> String {
766    let mut output = String::new();
767
768    // Partition files into production and test
769    let (prod_files, test_files): (Vec<_>, Vec<_>) =
770        analysis_results.iter().partition(|a| !a.is_test);
771
772    // Calculate totals
773    let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
774    let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
775    let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
776
777    // Count files by language
778    let mut lang_counts: HashMap<String, usize> = HashMap::new();
779    for analysis in analysis_results {
780        *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
781    }
782    let total_files = analysis_results.len();
783
784    // SUMMARY block
785    output.push_str("SUMMARY:\n");
786    let depth_label = match max_depth {
787        Some(n) if n > 0 => format!(" (max_depth={})", n),
788        _ => String::new(),
789    };
790    output.push_str(&format!(
791        "{} files ({} prod, {} test), {}L, {}F, {}C{}\n",
792        total_files,
793        prod_files.len(),
794        test_files.len(),
795        total_loc,
796        total_functions,
797        total_classes,
798        depth_label
799    ));
800
801    if !lang_counts.is_empty() {
802        output.push_str("Languages: ");
803        let mut langs: Vec<_> = lang_counts.iter().collect();
804        langs.sort_by_key(|&(name, _)| name);
805        let lang_strs: Vec<String> = langs
806            .iter()
807            .map(|(name, count)| {
808                let percentage = if total_files > 0 {
809                    (**count * 100) / total_files
810                } else {
811                    0
812                };
813                format!("{} ({}%)", name, percentage)
814            })
815            .collect();
816        output.push_str(&lang_strs.join(", "));
817        output.push('\n');
818    }
819
820    output.push('\n');
821
822    // STRUCTURE (depth 1) block
823    output.push_str("STRUCTURE (depth 1):\n");
824
825    // Build a map of path -> analysis for quick lookup
826    let analysis_map: HashMap<String, &FileInfo> = analysis_results
827        .iter()
828        .map(|a| (a.path.clone(), a))
829        .collect();
830
831    // Collect depth-1 entries (directories and files at depth 1)
832    let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
833    depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
834
835    // Track largest non-excluded directory for SUGGESTION
836    let mut largest_dir_name: Option<String> = None;
837    let mut largest_dir_path: Option<String> = None;
838    let mut largest_dir_count: usize = 0;
839
840    for entry in depth1_entries {
841        let name = entry
842            .path
843            .file_name()
844            .and_then(|n| n.to_str())
845            .unwrap_or("?");
846
847        if entry.is_dir {
848            // For directories, aggregate stats from all files under this directory
849            let dir_path_str = entry.path.display().to_string();
850            let files_in_dir: Vec<&FileInfo> = analysis_results
851                .iter()
852                .filter(|f| Path::new(&f.path).starts_with(&entry.path))
853                .collect();
854
855            if !files_in_dir.is_empty() {
856                let dir_file_count = files_in_dir.len();
857                let dir_loc: usize = files_in_dir.iter().map(|f| f.line_count).sum();
858                let dir_functions: usize = files_in_dir.iter().map(|f| f.function_count).sum();
859                let dir_classes: usize = files_in_dir.iter().map(|f| f.class_count).sum();
860
861                // Track largest non-excluded directory for SUGGESTION
862                let entry_name_str = name.to_string();
863                let effective_count = if let Some(counts) = subtree_counts {
864                    counts
865                        .binary_search_by_key(&&entry.path, |(p, _)| p)
866                        .ok()
867                        .map(|i| counts[i].1)
868                        .unwrap_or(dir_file_count)
869                } else {
870                    dir_file_count
871                };
872                if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
873                    && effective_count > largest_dir_count
874                {
875                    largest_dir_count = effective_count;
876                    largest_dir_name = Some(entry_name_str);
877                    largest_dir_path = Some(
878                        entry
879                            .path
880                            .canonicalize()
881                            .unwrap_or_else(|_| entry.path.clone())
882                            .display()
883                            .to_string(),
884                    );
885                }
886
887                // Build hint: top-N files sorted by class_count desc, fallback to function_count
888                let hint = if files_in_dir.len() > 1 && (dir_classes > 0 || dir_functions > 0) {
889                    let mut top_files = files_in_dir.clone();
890                    top_files.sort_unstable_by(|a, b| {
891                        b.class_count
892                            .cmp(&a.class_count)
893                            .then(b.function_count.cmp(&a.function_count))
894                            .then(a.path.cmp(&b.path))
895                    });
896
897                    let has_classes = top_files.iter().any(|f| f.class_count > 0);
898
899                    // Re-sort for function fallback if no classes
900                    if !has_classes {
901                        top_files.sort_unstable_by(|a, b| {
902                            b.function_count
903                                .cmp(&a.function_count)
904                                .then(a.path.cmp(&b.path))
905                        });
906                    }
907
908                    let dir_path = Path::new(&dir_path_str);
909                    let top_n: Vec<String> = top_files
910                        .iter()
911                        .take(3)
912                        .filter(|f| {
913                            if has_classes {
914                                f.class_count > 0
915                            } else {
916                                f.function_count > 0
917                            }
918                        })
919                        .map(|f| {
920                            let rel = Path::new(&f.path)
921                                .strip_prefix(dir_path)
922                                .map(|p| p.to_string_lossy().into_owned())
923                                .unwrap_or_else(|_| {
924                                    Path::new(&f.path)
925                                        .file_name()
926                                        .and_then(|n| n.to_str())
927                                        .map(|s| s.to_owned())
928                                        .unwrap_or_else(|| "?".to_owned())
929                                });
930                            let count = if has_classes {
931                                f.class_count
932                            } else {
933                                f.function_count
934                            };
935                            let suffix = if has_classes { 'C' } else { 'F' };
936                            format!("{}({}{})", rel, count, suffix)
937                        })
938                        .collect();
939                    if top_n.is_empty() {
940                        String::new()
941                    } else {
942                        format!(" top: {}", top_n.join(", "))
943                    }
944                } else {
945                    String::new()
946                };
947
948                // Collect depth-2 sub-package directories (immediate children of this directory)
949                let mut subdirs: Vec<String> = entries
950                    .iter()
951                    .filter(|e| e.depth == 2 && e.is_dir && e.path.starts_with(&entry.path))
952                    .filter_map(|e| {
953                        e.path
954                            .file_name()
955                            .and_then(|n| n.to_str())
956                            .map(|s| s.to_owned())
957                    })
958                    .collect();
959                subdirs.sort();
960                subdirs.dedup();
961                let subdir_suffix = if subdirs.is_empty() {
962                    String::new()
963                } else {
964                    let subdirs_capped: Vec<String> =
965                        subdirs.iter().take(5).map(|s| format!("{}/", s)).collect();
966                    format!("  sub: {}", subdirs_capped.join(", "))
967                };
968
969                let files_label = if let Some(counts) = subtree_counts {
970                    let true_count = counts
971                        .binary_search_by_key(&&entry.path, |(p, _)| p)
972                        .ok()
973                        .map(|i| counts[i].1)
974                        .unwrap_or(dir_file_count);
975                    if true_count != dir_file_count {
976                        let depth_val = max_depth.unwrap_or(0);
977                        format!(
978                            "{} files total; showing {} at depth={}, {}L, {}F, {}C",
979                            true_count,
980                            dir_file_count,
981                            depth_val,
982                            dir_loc,
983                            dir_functions,
984                            dir_classes
985                        )
986                    } else {
987                        format!(
988                            "{} files, {}L, {}F, {}C",
989                            dir_file_count, dir_loc, dir_functions, dir_classes
990                        )
991                    }
992                } else {
993                    format!(
994                        "{} files, {}L, {}F, {}C",
995                        dir_file_count, dir_loc, dir_functions, dir_classes
996                    )
997                };
998                output.push_str(&format!(
999                    "  {}/ [{}]{}{}\n",
1000                    name, files_label, hint, subdir_suffix
1001                ));
1002            } else {
1003                // No analyzed files at this depth, but subtree_counts may have a true count
1004                let entry_name_str = name.to_string();
1005                if let Some(counts) = subtree_counts {
1006                    let true_count = counts
1007                        .binary_search_by_key(&&entry.path, |(p, _)| p)
1008                        .ok()
1009                        .map(|i| counts[i].1)
1010                        .unwrap_or(0);
1011                    if true_count > 0 {
1012                        // Track for SUGGESTION
1013                        if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
1014                            && true_count > largest_dir_count
1015                        {
1016                            largest_dir_count = true_count;
1017                            largest_dir_name = Some(entry_name_str);
1018                            largest_dir_path = Some(
1019                                entry
1020                                    .path
1021                                    .canonicalize()
1022                                    .unwrap_or_else(|_| entry.path.clone())
1023                                    .display()
1024                                    .to_string(),
1025                            );
1026                        }
1027                        let depth_val = max_depth.unwrap_or(0);
1028                        output.push_str(&format!(
1029                            "  {}/ [{} files total; showing 0 at depth={}, 0L, 0F, 0C]\n",
1030                            name, true_count, depth_val
1031                        ));
1032                    } else {
1033                        output.push_str(&format!("  {}/\n", name));
1034                    }
1035                } else {
1036                    output.push_str(&format!("  {}/\n", name));
1037                }
1038            }
1039        } else {
1040            // For files, show individual stats
1041            if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
1042                if let Some(info_str) = format_file_info_parts(
1043                    analysis.line_count,
1044                    analysis.function_count,
1045                    analysis.class_count,
1046                ) {
1047                    output.push_str(&format!("  {} {}\n", name, info_str));
1048                } else {
1049                    output.push_str(&format!("  {}\n", name));
1050                }
1051            }
1052        }
1053    }
1054
1055    output.push('\n');
1056
1057    // SUGGESTION block
1058    if let (Some(name), Some(path)) = (largest_dir_name, largest_dir_path) {
1059        output.push_str(&format!(
1060            "SUGGESTION: Largest source directory: {}/ ({} files total). For module details, re-run with path={} and max_depth=2.\n",
1061            name, largest_dir_count, path
1062        ));
1063    } else {
1064        output.push_str("SUGGESTION:\n");
1065        output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
1066    }
1067
1068    output
1069}
1070
1071/// Format a compact summary of file details for large FileDetails output.
1072///
1073/// Returns FILE header with path/LOC/counts, top 10 functions by line span descending,
1074/// classes inline if <=10, import count, and suggestion block.
1075#[instrument(skip_all)]
1076pub fn format_file_details_summary(
1077    semantic: &SemanticAnalysis,
1078    path: &str,
1079    line_count: usize,
1080) -> String {
1081    let mut output = String::new();
1082
1083    // FILE header
1084    output.push_str("FILE:\n");
1085    output.push_str(&format!("  path: {}\n", path));
1086    output.push_str(&format!(
1087        "  {}L, {}F, {}C\n",
1088        line_count,
1089        semantic.functions.len(),
1090        semantic.classes.len()
1091    ));
1092    output.push('\n');
1093
1094    // Top 10 functions by line span (end_line - start_line) descending
1095    if !semantic.functions.is_empty() {
1096        output.push_str("TOP FUNCTIONS BY SIZE:\n");
1097        let mut funcs: Vec<&crate::types::FunctionInfo> = semantic.functions.iter().collect();
1098        let k = funcs.len().min(10);
1099        if k > 0 {
1100            funcs.select_nth_unstable_by(k.saturating_sub(1), |a, b| {
1101                let a_span = a.end_line.saturating_sub(a.line);
1102                let b_span = b.end_line.saturating_sub(b.line);
1103                b_span.cmp(&a_span)
1104            });
1105            funcs[..k].sort_by(|a, b| {
1106                let a_span = a.end_line.saturating_sub(a.line);
1107                let b_span = b.end_line.saturating_sub(b.line);
1108                b_span.cmp(&a_span)
1109            });
1110        }
1111
1112        for func in &funcs[..k] {
1113            let span = func.end_line.saturating_sub(func.line);
1114            let params = if func.parameters.is_empty() {
1115                String::new()
1116            } else {
1117                format!("({})", func.parameters.join(", "))
1118            };
1119            output.push_str(&format!(
1120                "  {}:{}: {} {} [{}L]\n",
1121                func.line, func.end_line, func.name, params, span
1122            ));
1123        }
1124        output.push('\n');
1125    }
1126
1127    // Classes inline if <=10, else multiline with method count
1128    if !semantic.classes.is_empty() {
1129        output.push_str("CLASSES:\n");
1130        if semantic.classes.len() <= 10 {
1131            // Inline format: one class per line with method count
1132            for class in &semantic.classes {
1133                let methods_count = class.methods.len();
1134                output.push_str(&format!("  {}: {}M\n", class.name, methods_count));
1135            }
1136        } else {
1137            // Multiline format with summary
1138            output.push_str(&format!("  {} classes total\n", semantic.classes.len()));
1139            for class in semantic.classes.iter().take(5) {
1140                output.push_str(&format!("    {}\n", class.name));
1141            }
1142            if semantic.classes.len() > 5 {
1143                output.push_str(&format!(
1144                    "    ... and {} more\n",
1145                    semantic.classes.len() - 5
1146                ));
1147            }
1148        }
1149        output.push('\n');
1150    }
1151
1152    // Import count only
1153    output.push_str(&format!("Imports: {}\n", semantic.imports.len()));
1154    output.push('\n');
1155
1156    // SUGGESTION block
1157    output.push_str("SUGGESTION:\n");
1158    output.push_str("Use force=true for full output, or narrow your scope\n");
1159
1160    output
1161}
1162
1163/// Format a paginated subset of files for Overview mode.
1164#[instrument(skip_all)]
1165pub fn format_structure_paginated(
1166    paginated_files: &[FileInfo],
1167    total_files: usize,
1168    max_depth: Option<u32>,
1169    base_path: Option<&Path>,
1170    verbose: bool,
1171) -> String {
1172    let mut output = String::new();
1173
1174    let depth_label = match max_depth {
1175        Some(n) if n > 0 => format!(" (max_depth={})", n),
1176        _ => String::new(),
1177    };
1178    output.push_str(&format!(
1179        "PAGINATED: showing {} of {} files{}\n\n",
1180        paginated_files.len(),
1181        total_files,
1182        depth_label
1183    ));
1184
1185    let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
1186    let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
1187
1188    if !prod_files.is_empty() {
1189        if verbose {
1190            output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
1191        }
1192        for file in &prod_files {
1193            output.push_str(&format_file_entry(file, base_path));
1194        }
1195    }
1196
1197    if !test_files.is_empty() {
1198        if verbose {
1199            output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
1200        } else if !prod_files.is_empty() {
1201            output.push('\n');
1202        }
1203        for file in &test_files {
1204            output.push_str(&format_file_entry(file, base_path));
1205        }
1206    }
1207
1208    output
1209}
1210
1211/// Format a paginated subset of functions for FileDetails mode.
1212/// When `verbose=false` (default/compact): shows `C:` (if non-empty) and `F:` with wrapped rendering; omits `I:`.
1213/// When `verbose=true`: shows `C:`, `I:`, and `F:` with wrapped rendering on the first page (offset == 0).
1214/// Header shows position context: `FILE: path (NL, start-end/totalF, CC, II)`.
1215#[instrument(skip_all)]
1216#[allow(clippy::too_many_arguments)]
1217pub fn format_file_details_paginated(
1218    functions_page: &[FunctionInfo],
1219    total_functions: usize,
1220    semantic: &SemanticAnalysis,
1221    path: &str,
1222    line_count: usize,
1223    offset: usize,
1224    verbose: bool,
1225) -> String {
1226    let mut output = String::new();
1227
1228    let start = offset + 1; // 1-indexed for display
1229    let end = offset + functions_page.len();
1230
1231    output.push_str(&format!(
1232        "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)\n",
1233        path,
1234        line_count,
1235        start,
1236        end,
1237        total_functions,
1238        semantic.classes.len(),
1239        semantic.imports.len(),
1240    ));
1241
1242    // Classes section on first page for both verbose and compact modes
1243    if offset == 0 && !semantic.classes.is_empty() {
1244        output.push_str(&format_classes_section(
1245            &semantic.classes,
1246            &semantic.functions,
1247        ));
1248    }
1249
1250    // Imports section only on first page in verbose mode
1251    if offset == 0 && verbose {
1252        output.push_str(&format_imports_section(&semantic.imports));
1253    }
1254
1255    // F: section with paginated function slice (exclude methods)
1256    let top_level_functions: Vec<&FunctionInfo> = functions_page
1257        .iter()
1258        .filter(|func| {
1259            !semantic
1260                .classes
1261                .iter()
1262                .any(|class| is_method_of_class(func, class))
1263        })
1264        .collect();
1265
1266    if !top_level_functions.is_empty() {
1267        output.push_str("F:\n");
1268        output.push_str(&format_function_list_wrapped(
1269            top_level_functions.iter().copied(),
1270            &semantic.call_frequency,
1271        ));
1272    }
1273
1274    output
1275}
1276
1277/// Format a paginated subset of callers or callees for SymbolFocus mode.
1278/// Mode is determined by the `mode` parameter:
1279/// - `PaginationMode::Callers`: paginate production callers; show test callers summary and callees summary.
1280/// - `PaginationMode::Callees`: paginate callees; show callers summary and test callers summary.
1281#[instrument(skip_all)]
1282#[allow(clippy::too_many_arguments)]
1283pub(crate) fn format_focused_paginated(
1284    paginated_chains: &[InternalCallChain],
1285    total: usize,
1286    mode: PaginationMode,
1287    symbol: &str,
1288    prod_chains: &[InternalCallChain],
1289    test_chains: &[InternalCallChain],
1290    outgoing_chains: &[InternalCallChain],
1291    def_count: usize,
1292    offset: usize,
1293    base_path: Option<&Path>,
1294    _verbose: bool,
1295) -> String {
1296    let start = offset + 1; // 1-indexed
1297    let end = offset + paginated_chains.len();
1298
1299    let callers_count = prod_chains.len();
1300
1301    let callees_count = outgoing_chains.len();
1302
1303    let mut output = String::new();
1304
1305    output.push_str(&format!(
1306        "FOCUS: {} ({} defs, {} callers, {} callees)\n",
1307        symbol, def_count, callers_count, callees_count
1308    ));
1309
1310    match mode {
1311        PaginationMode::Callers => {
1312            // Paginate production callers
1313            output.push_str(&format!("CALLERS ({}-{} of {}):\n", start, end, total));
1314
1315            let page_refs: Vec<_> = paginated_chains
1316                .iter()
1317                .filter_map(|chain| {
1318                    if chain.chain.len() >= 2 {
1319                        Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1320                    } else if chain.chain.len() == 1 {
1321                        Some((chain.chain[0].0.as_str(), ""))
1322                    } else {
1323                        None
1324                    }
1325                })
1326                .collect();
1327
1328            if page_refs.is_empty() {
1329                output.push_str("  (none)\n");
1330            } else {
1331                output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1332            }
1333
1334            // Test callers summary
1335            if !test_chains.is_empty() {
1336                let mut test_files: Vec<_> = test_chains
1337                    .iter()
1338                    .filter_map(|chain| {
1339                        chain
1340                            .chain
1341                            .first()
1342                            .map(|(_, path, _)| path.to_string_lossy().into_owned())
1343                    })
1344                    .collect();
1345                test_files.sort();
1346                test_files.dedup();
1347
1348                let display_files: Vec<_> = test_files
1349                    .iter()
1350                    .map(|f| strip_base_path(std::path::Path::new(f), base_path))
1351                    .collect();
1352
1353                output.push_str(&format!(
1354                    "CALLERS (test): {} test functions (in {})\n",
1355                    test_chains.len(),
1356                    display_files.join(", ")
1357                ));
1358            }
1359
1360            // Callees summary
1361            let callee_names: Vec<_> = outgoing_chains
1362                .iter()
1363                .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1364                .collect::<std::collections::HashSet<_>>()
1365                .into_iter()
1366                .collect();
1367            if callee_names.is_empty() {
1368                output.push_str("CALLEES: (none)\n");
1369            } else {
1370                output.push_str(&format!(
1371                    "CALLEES: {} (use cursor for callee pagination)\n",
1372                    callees_count
1373                ));
1374            }
1375        }
1376        PaginationMode::Callees => {
1377            // Callers summary
1378            output.push_str(&format!("CALLERS: {} production callers\n", callers_count));
1379
1380            // Test callers summary
1381            if !test_chains.is_empty() {
1382                output.push_str(&format!(
1383                    "CALLERS (test): {} test functions\n",
1384                    test_chains.len()
1385                ));
1386            }
1387
1388            // Paginate callees
1389            output.push_str(&format!("CALLEES ({}-{} of {}):\n", start, end, total));
1390
1391            let page_refs: Vec<_> = paginated_chains
1392                .iter()
1393                .filter_map(|chain| {
1394                    if chain.chain.len() >= 2 {
1395                        Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1396                    } else if chain.chain.len() == 1 {
1397                        Some((chain.chain[0].0.as_str(), ""))
1398                    } else {
1399                        None
1400                    }
1401                })
1402                .collect();
1403
1404            if page_refs.is_empty() {
1405                output.push_str("  (none)\n");
1406            } else {
1407                output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1408            }
1409        }
1410        PaginationMode::Default => {
1411            unreachable!("format_focused_paginated called with PaginationMode::Default")
1412        }
1413    }
1414
1415    output
1416}
1417
1418fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1419    let mut parts = Vec::new();
1420    if file.line_count > 0 {
1421        parts.push(format!("{}L", file.line_count));
1422    }
1423    if file.function_count > 0 {
1424        parts.push(format!("{}F", file.function_count));
1425    }
1426    if file.class_count > 0 {
1427        parts.push(format!("{}C", file.class_count));
1428    }
1429    let display_path = strip_base_path(Path::new(&file.path), base_path);
1430    if parts.is_empty() {
1431        format!("{}\n", display_path)
1432    } else {
1433        format!("{} [{}]\n", display_path, parts.join(", "))
1434    }
1435}
1436
1437/// Format a [`ModuleInfo`] into a compact single-block string.
1438///
1439/// Output format:
1440/// ```text
1441/// FILE: <name> (<line_count>L, <fn_count>F, <import_count>I)
1442/// F:
1443///   func1:10, func2:42
1444/// I:
1445///   module1:item1, item2; module2:item1; module3
1446/// ```
1447///
1448/// The `F:` section is omitted when there are no functions; likewise `I:` when
1449/// there are no imports.
1450#[instrument(skip_all)]
1451pub fn format_module_info(info: &ModuleInfo) -> String {
1452    use std::fmt::Write as _;
1453    let fn_count = info.functions.len();
1454    let import_count = info.imports.len();
1455    let mut out = String::with_capacity(64 + fn_count * 24 + import_count * 32);
1456    let _ = writeln!(
1457        out,
1458        "FILE: {} ({}L, {}F, {}I)",
1459        info.name, info.line_count, fn_count, import_count
1460    );
1461    if !info.functions.is_empty() {
1462        out.push_str("F:\n  ");
1463        let parts: Vec<String> = info
1464            .functions
1465            .iter()
1466            .map(|f| format!("{}:{}", f.name, f.line))
1467            .collect();
1468        out.push_str(&parts.join(", "));
1469        out.push('\n');
1470    }
1471    if !info.imports.is_empty() {
1472        out.push_str("I:\n  ");
1473        let parts: Vec<String> = info
1474            .imports
1475            .iter()
1476            .map(|i| {
1477                if i.items.is_empty() {
1478                    i.module.clone()
1479                } else {
1480                    format!("{}:{}", i.module, i.items.join(", "))
1481                }
1482            })
1483            .collect();
1484        out.push_str(&parts.join("; "));
1485        out.push('\n');
1486    }
1487    out
1488}
1489
1490#[cfg(test)]
1491mod tests {
1492    use super::*;
1493
1494    #[test]
1495    fn test_strip_base_path_relative() {
1496        let path = Path::new("/home/user/project/src/main.rs");
1497        let base = Path::new("/home/user/project");
1498        let result = strip_base_path(path, Some(base));
1499        assert_eq!(result, "src/main.rs");
1500    }
1501
1502    #[test]
1503    fn test_strip_base_path_fallback_absolute() {
1504        let path = Path::new("/other/project/src/main.rs");
1505        let base = Path::new("/home/user/project");
1506        let result = strip_base_path(path, Some(base));
1507        assert_eq!(result, "/other/project/src/main.rs");
1508    }
1509
1510    #[test]
1511    fn test_strip_base_path_none() {
1512        let path = Path::new("/home/user/project/src/main.rs");
1513        let result = strip_base_path(path, None);
1514        assert_eq!(result, "/home/user/project/src/main.rs");
1515    }
1516
1517    #[test]
1518    fn test_format_file_details_summary_empty() {
1519        use crate::types::SemanticAnalysis;
1520        use std::collections::HashMap;
1521
1522        let semantic = SemanticAnalysis {
1523            functions: vec![],
1524            classes: vec![],
1525            imports: vec![],
1526            references: vec![],
1527            call_frequency: HashMap::new(),
1528            calls: vec![],
1529            assignments: vec![],
1530            field_accesses: vec![],
1531        };
1532
1533        let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1534
1535        // Should contain FILE header, Imports count, and SUGGESTION
1536        assert!(result.contains("FILE:"));
1537        assert!(result.contains("100L, 0F, 0C"));
1538        assert!(result.contains("src/main.rs"));
1539        assert!(result.contains("Imports: 0"));
1540        assert!(result.contains("SUGGESTION:"));
1541    }
1542
1543    #[test]
1544    fn test_format_file_details_summary_with_functions() {
1545        use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1546        use std::collections::HashMap;
1547
1548        let semantic = SemanticAnalysis {
1549            functions: vec![
1550                FunctionInfo {
1551                    name: "short".to_string(),
1552                    line: 10,
1553                    end_line: 12,
1554                    parameters: vec![],
1555                    return_type: None,
1556                },
1557                FunctionInfo {
1558                    name: "long_function".to_string(),
1559                    line: 20,
1560                    end_line: 50,
1561                    parameters: vec!["x".to_string(), "y".to_string()],
1562                    return_type: Some("i32".to_string()),
1563                },
1564            ],
1565            classes: vec![ClassInfo {
1566                name: "MyClass".to_string(),
1567                line: 60,
1568                end_line: 80,
1569                methods: vec![],
1570                fields: vec![],
1571                inherits: vec![],
1572            }],
1573            imports: vec![],
1574            references: vec![],
1575            call_frequency: HashMap::new(),
1576            calls: vec![],
1577            assignments: vec![],
1578            field_accesses: vec![],
1579        };
1580
1581        let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1582
1583        // Should contain FILE header with counts
1584        assert!(result.contains("FILE:"));
1585        assert!(result.contains("src/lib.rs"));
1586        assert!(result.contains("250L, 2F, 1C"));
1587
1588        // Should contain TOP FUNCTIONS BY SIZE with longest first
1589        assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1590        let long_idx = result.find("long_function").unwrap_or(0);
1591        let short_idx = result.find("short").unwrap_or(0);
1592        assert!(
1593            long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1594            "long_function should appear before short"
1595        );
1596
1597        // Should contain classes inline
1598        assert!(result.contains("CLASSES:"));
1599        assert!(result.contains("MyClass:"));
1600
1601        // Should contain import count
1602        assert!(result.contains("Imports: 0"));
1603    }
1604    #[test]
1605    fn test_format_file_info_parts_all_zero() {
1606        assert_eq!(format_file_info_parts(0, 0, 0), None);
1607    }
1608
1609    #[test]
1610    fn test_format_file_info_parts_partial() {
1611        assert_eq!(
1612            format_file_info_parts(42, 0, 3),
1613            Some("[42L, 3C]".to_string())
1614        );
1615    }
1616
1617    #[test]
1618    fn test_format_file_info_parts_all_nonzero() {
1619        assert_eq!(
1620            format_file_info_parts(100, 5, 2),
1621            Some("[100L, 5F, 2C]".to_string())
1622        );
1623    }
1624
1625    #[test]
1626    fn test_format_function_list_wrapped_empty() {
1627        let freq = std::collections::HashMap::new();
1628        let result = format_function_list_wrapped(std::iter::empty(), &freq);
1629        assert_eq!(result, "");
1630    }
1631
1632    #[test]
1633    fn test_format_function_list_wrapped_bullet_annotation() {
1634        use crate::types::FunctionInfo;
1635        use std::collections::HashMap;
1636
1637        let mut freq = HashMap::new();
1638        freq.insert("frequent".to_string(), 5); // count > 3 should get bullet
1639
1640        let funcs = vec![FunctionInfo {
1641            name: "frequent".to_string(),
1642            line: 1,
1643            end_line: 10,
1644            parameters: vec![],
1645            return_type: Some("void".to_string()),
1646        }];
1647
1648        let result = format_function_list_wrapped(funcs.iter(), &freq);
1649        // Should contain bullet (U+2022) followed by count
1650        assert!(result.contains("\u{2022}5"));
1651    }
1652
1653    #[test]
1654    fn test_compact_format_omits_sections() {
1655        use crate::types::{ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
1656        use std::collections::HashMap;
1657
1658        let funcs: Vec<FunctionInfo> = (0..10)
1659            .map(|i| FunctionInfo {
1660                name: format!("fn_{}", i),
1661                line: i * 5 + 1,
1662                end_line: i * 5 + 4,
1663                parameters: vec![format!("x: u32")],
1664                return_type: Some("bool".to_string()),
1665            })
1666            .collect();
1667        let imports: Vec<ImportInfo> = vec![ImportInfo {
1668            module: "std::collections".to_string(),
1669            items: vec!["HashMap".to_string()],
1670            line: 1,
1671        }];
1672        let classes: Vec<ClassInfo> = vec![ClassInfo {
1673            name: "MyStruct".to_string(),
1674            line: 100,
1675            end_line: 150,
1676            methods: vec![],
1677            fields: vec![],
1678            inherits: vec![],
1679        }];
1680        let semantic = SemanticAnalysis {
1681            functions: funcs,
1682            classes,
1683            imports,
1684            references: vec![],
1685            call_frequency: HashMap::new(),
1686            calls: vec![],
1687            assignments: vec![],
1688            field_accesses: vec![],
1689        };
1690
1691        let verbose_out = format_file_details_paginated(
1692            &semantic.functions,
1693            semantic.functions.len(),
1694            &semantic,
1695            "src/lib.rs",
1696            100,
1697            0,
1698            true,
1699        );
1700        let compact_out = format_file_details_paginated(
1701            &semantic.functions,
1702            semantic.functions.len(),
1703            &semantic,
1704            "src/lib.rs",
1705            100,
1706            0,
1707            false,
1708        );
1709
1710        // Verbose includes C:, I:, F: section headers
1711        assert!(verbose_out.contains("C:\n"), "verbose must have C: section");
1712        assert!(verbose_out.contains("I:\n"), "verbose must have I: section");
1713        assert!(verbose_out.contains("F:\n"), "verbose must have F: section");
1714
1715        // Compact includes C: and F: but omits I: (imports)
1716        assert!(
1717            compact_out.contains("C:\n"),
1718            "compact must have C: section (restored)"
1719        );
1720        assert!(
1721            !compact_out.contains("I:\n"),
1722            "compact must not have I: section (imports omitted)"
1723        );
1724        assert!(
1725            compact_out.contains("F:\n"),
1726            "compact must have F: section with wrapped formatting"
1727        );
1728
1729        // Compact functions are wrapped: fn_0 and fn_1 must appear on the same line
1730        assert!(compact_out.contains("fn_0"), "compact must list functions");
1731        let has_two_on_same_line = compact_out
1732            .lines()
1733            .any(|l| l.contains("fn_0") && l.contains("fn_1"));
1734        assert!(
1735            has_two_on_same_line,
1736            "compact must render multiple functions per line (wrapped), not one-per-line"
1737        );
1738    }
1739
1740    /// Regression test: compact mode must be <= verbose for function-heavy files (no imports to mask regression).
1741    #[test]
1742    fn test_compact_mode_consistent_token_reduction() {
1743        use crate::types::{FunctionInfo, SemanticAnalysis};
1744        use std::collections::HashMap;
1745
1746        let funcs: Vec<FunctionInfo> = (0..50)
1747            .map(|i| FunctionInfo {
1748                name: format!("function_name_{}", i),
1749                line: i * 10 + 1,
1750                end_line: i * 10 + 8,
1751                parameters: vec![
1752                    "arg1: u32".to_string(),
1753                    "arg2: String".to_string(),
1754                    "arg3: Option<bool>".to_string(),
1755                ],
1756                return_type: Some("Result<Vec<String>, Error>".to_string()),
1757            })
1758            .collect();
1759
1760        let semantic = SemanticAnalysis {
1761            functions: funcs,
1762            classes: vec![],
1763            imports: vec![],
1764            references: vec![],
1765            call_frequency: HashMap::new(),
1766            calls: vec![],
1767            assignments: vec![],
1768            field_accesses: vec![],
1769        };
1770
1771        let verbose_out = format_file_details_paginated(
1772            &semantic.functions,
1773            semantic.functions.len(),
1774            &semantic,
1775            "src/large_file.rs",
1776            1000,
1777            0,
1778            true,
1779        );
1780        let compact_out = format_file_details_paginated(
1781            &semantic.functions,
1782            semantic.functions.len(),
1783            &semantic,
1784            "src/large_file.rs",
1785            1000,
1786            0,
1787            false,
1788        );
1789
1790        assert!(
1791            compact_out.len() <= verbose_out.len(),
1792            "compact ({} chars) must be <= verbose ({} chars)",
1793            compact_out.len(),
1794            verbose_out.len(),
1795        );
1796    }
1797
1798    /// Edge case test: Compact mode with empty classes should not emit C: header.
1799    #[test]
1800    fn test_format_module_info_happy_path() {
1801        use crate::types::{ModuleFunctionInfo, ModuleImportInfo, ModuleInfo};
1802        let info = ModuleInfo {
1803            name: "parser.rs".to_string(),
1804            line_count: 312,
1805            language: "rust".to_string(),
1806            functions: vec![
1807                ModuleFunctionInfo {
1808                    name: "parse_file".to_string(),
1809                    line: 24,
1810                },
1811                ModuleFunctionInfo {
1812                    name: "parse_block".to_string(),
1813                    line: 58,
1814                },
1815            ],
1816            imports: vec![
1817                ModuleImportInfo {
1818                    module: "crate::types".to_string(),
1819                    items: vec!["Token".to_string(), "Expr".to_string()],
1820                },
1821                ModuleImportInfo {
1822                    module: "std::io".to_string(),
1823                    items: vec!["BufReader".to_string()],
1824                },
1825            ],
1826        };
1827        let result = format_module_info(&info);
1828        assert!(result.starts_with("FILE: parser.rs (312L, 2F, 2I)"));
1829        assert!(result.contains("F:"));
1830        assert!(result.contains("parse_file:24"));
1831        assert!(result.contains("parse_block:58"));
1832        assert!(result.contains("I:"));
1833        assert!(result.contains("crate::types:Token, Expr"));
1834        assert!(result.contains("std::io:BufReader"));
1835        assert!(result.contains("; "));
1836        assert!(!result.contains('{'));
1837    }
1838
1839    #[test]
1840    fn test_format_module_info_empty() {
1841        use crate::types::ModuleInfo;
1842        let info = ModuleInfo {
1843            name: "empty.rs".to_string(),
1844            line_count: 0,
1845            language: "rust".to_string(),
1846            functions: vec![],
1847            imports: vec![],
1848        };
1849        let result = format_module_info(&info);
1850        assert!(result.starts_with("FILE: empty.rs (0L, 0F, 0I)"));
1851        assert!(!result.contains("F:"));
1852        assert!(!result.contains("I:"));
1853    }
1854
1855    #[test]
1856    fn test_compact_mode_empty_classes_no_header() {
1857        use crate::types::{FunctionInfo, SemanticAnalysis};
1858        use std::collections::HashMap;
1859
1860        let funcs: Vec<FunctionInfo> = (0..5)
1861            .map(|i| FunctionInfo {
1862                name: format!("fn_{}", i),
1863                line: i * 5 + 1,
1864                end_line: i * 5 + 4,
1865                parameters: vec![],
1866                return_type: None,
1867            })
1868            .collect();
1869
1870        let semantic = SemanticAnalysis {
1871            functions: funcs,
1872            classes: vec![], // Empty classes
1873            imports: vec![],
1874            references: vec![],
1875            call_frequency: HashMap::new(),
1876            calls: vec![],
1877            assignments: vec![],
1878            field_accesses: vec![],
1879        };
1880
1881        let compact_out = format_file_details_paginated(
1882            &semantic.functions,
1883            semantic.functions.len(),
1884            &semantic,
1885            "src/simple.rs",
1886            100,
1887            0,
1888            false,
1889        );
1890
1891        // Should not have stray C: header when classes are empty
1892        assert!(
1893            !compact_out.contains("C:\n"),
1894            "compact mode must not emit C: header when classes are empty"
1895        );
1896    }
1897
1898    #[test]
1899    fn test_format_classes_with_methods() {
1900        use crate::types::{ClassInfo, FunctionInfo};
1901
1902        let functions = vec![
1903            FunctionInfo {
1904                name: "method_a".to_string(),
1905                line: 5,
1906                end_line: 8,
1907                parameters: vec![],
1908                return_type: None,
1909            },
1910            FunctionInfo {
1911                name: "method_b".to_string(),
1912                line: 10,
1913                end_line: 12,
1914                parameters: vec![],
1915                return_type: None,
1916            },
1917            FunctionInfo {
1918                name: "top_level_func".to_string(),
1919                line: 50,
1920                end_line: 55,
1921                parameters: vec![],
1922                return_type: None,
1923            },
1924        ];
1925
1926        let classes = vec![ClassInfo {
1927            name: "MyClass".to_string(),
1928            line: 1,
1929            end_line: 30,
1930            methods: vec![],
1931            fields: vec![],
1932            inherits: vec![],
1933        }];
1934
1935        let output = format_classes_section(&classes, &functions);
1936
1937        assert!(
1938            output.contains("MyClass:1-30"),
1939            "class header should show start-end range"
1940        );
1941        assert!(output.contains("method_a:5"), "method_a should be listed");
1942        assert!(output.contains("method_b:10"), "method_b should be listed");
1943        assert!(
1944            !output.contains("top_level_func"),
1945            "top_level_func outside class range should not be listed"
1946        );
1947    }
1948
1949    #[test]
1950    fn test_format_classes_method_cap() {
1951        use crate::types::{ClassInfo, FunctionInfo};
1952
1953        let mut functions = Vec::new();
1954        for i in 0..15 {
1955            functions.push(FunctionInfo {
1956                name: format!("method_{}", i),
1957                line: 2 + i,
1958                end_line: 3 + i,
1959                parameters: vec![],
1960                return_type: None,
1961            });
1962        }
1963
1964        let classes = vec![ClassInfo {
1965            name: "LargeClass".to_string(),
1966            line: 1,
1967            end_line: 50,
1968            methods: vec![],
1969            fields: vec![],
1970            inherits: vec![],
1971        }];
1972
1973        let output = format_classes_section(&classes, &functions);
1974
1975        assert!(output.contains("method_0"), "first method should be listed");
1976        assert!(output.contains("method_9"), "10th method should be listed");
1977        assert!(
1978            !output.contains("method_10"),
1979            "11th method should not be listed (cap at 10)"
1980        );
1981        assert!(
1982            output.contains("... (5 more)"),
1983            "truncation message should show remaining count"
1984        );
1985    }
1986
1987    #[test]
1988    fn test_format_classes_no_methods() {
1989        use crate::types::{ClassInfo, FunctionInfo};
1990
1991        let functions = vec![FunctionInfo {
1992            name: "top_level".to_string(),
1993            line: 100,
1994            end_line: 105,
1995            parameters: vec![],
1996            return_type: None,
1997        }];
1998
1999        let classes = vec![ClassInfo {
2000            name: "EmptyClass".to_string(),
2001            line: 1,
2002            end_line: 50,
2003            methods: vec![],
2004            fields: vec![],
2005            inherits: vec![],
2006        }];
2007
2008        let output = format_classes_section(&classes, &functions);
2009
2010        assert!(
2011            output.contains("EmptyClass:1-50"),
2012            "empty class header should appear"
2013        );
2014        assert!(
2015            !output.contains("top_level"),
2016            "top-level functions outside class should not appear"
2017        );
2018    }
2019
2020    #[test]
2021    fn test_f_section_excludes_methods() {
2022        use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
2023        use std::collections::HashMap;
2024
2025        let functions = vec![
2026            FunctionInfo {
2027                name: "method_a".to_string(),
2028                line: 5,
2029                end_line: 10,
2030                parameters: vec![],
2031                return_type: None,
2032            },
2033            FunctionInfo {
2034                name: "top_level".to_string(),
2035                line: 50,
2036                end_line: 55,
2037                parameters: vec![],
2038                return_type: None,
2039            },
2040        ];
2041
2042        let semantic = SemanticAnalysis {
2043            functions,
2044            classes: vec![ClassInfo {
2045                name: "TestClass".to_string(),
2046                line: 1,
2047                end_line: 30,
2048                methods: vec![],
2049                fields: vec![],
2050                inherits: vec![],
2051            }],
2052            imports: vec![],
2053            references: vec![],
2054            call_frequency: HashMap::new(),
2055            calls: vec![],
2056            assignments: vec![],
2057            field_accesses: vec![],
2058        };
2059
2060        let output = format_file_details("test.rs", &semantic, 100, false, None);
2061
2062        assert!(output.contains("C:"), "classes section should exist");
2063        assert!(
2064            output.contains("method_a:5"),
2065            "method should be in C: section"
2066        );
2067        assert!(output.contains("F:"), "F: section should exist");
2068        assert!(
2069            output.contains("top_level"),
2070            "top-level function should be in F: section"
2071        );
2072
2073        // Verify method_a is not in F: section (check sequence: C: before method_a, F: after it)
2074        let f_pos = output.find("F:").unwrap();
2075        let method_pos = output.find("method_a").unwrap();
2076        assert!(
2077            method_pos < f_pos,
2078            "method_a should appear before F: section"
2079        );
2080    }
2081
2082    #[test]
2083    fn test_format_focused_paginated_unit() {
2084        use crate::graph::InternalCallChain;
2085        use crate::pagination::PaginationMode;
2086        use std::path::PathBuf;
2087
2088        // Arrange: create mock caller chains
2089        let make_chain = |name: &str| -> InternalCallChain {
2090            InternalCallChain {
2091                chain: vec![
2092                    (name.to_string(), PathBuf::from("src/lib.rs"), 10),
2093                    ("target".to_string(), PathBuf::from("src/lib.rs"), 5),
2094                ],
2095            }
2096        };
2097
2098        let prod_chains: Vec<InternalCallChain> = (0..8)
2099            .map(|i| make_chain(&format!("caller_{}", i)))
2100            .collect();
2101        let page = &prod_chains[0..3];
2102
2103        // Act
2104        let formatted = format_focused_paginated(
2105            page,
2106            8,
2107            PaginationMode::Callers,
2108            "target",
2109            &prod_chains,
2110            &[],
2111            &[],
2112            1,
2113            0,
2114            None,
2115            true,
2116        );
2117
2118        // Assert: header present
2119        assert!(
2120            formatted.contains("CALLERS (1-3 of 8):"),
2121            "header should show 1-3 of 8, got: {}",
2122            formatted
2123        );
2124        assert!(
2125            formatted.contains("FOCUS: target"),
2126            "should have FOCUS header"
2127        );
2128    }
2129}
2130
2131fn format_classes_section(classes: &[ClassInfo], functions: &[FunctionInfo]) -> String {
2132    let mut output = String::new();
2133    if classes.is_empty() {
2134        return output;
2135    }
2136    output.push_str("C:\n");
2137
2138    let methods_by_class = collect_class_methods(classes, functions);
2139    let has_methods = methods_by_class.values().any(|m| !m.is_empty());
2140
2141    if classes.len() <= MULTILINE_THRESHOLD && !has_methods {
2142        let class_strs: Vec<String> = classes
2143            .iter()
2144            .map(|class| {
2145                if class.inherits.is_empty() {
2146                    format!("{}:{}-{}", class.name, class.line, class.end_line)
2147                } else {
2148                    format!(
2149                        "{}:{}-{} ({})",
2150                        class.name,
2151                        class.line,
2152                        class.end_line,
2153                        class.inherits.join(", ")
2154                    )
2155                }
2156            })
2157            .collect();
2158        output.push_str("  ");
2159        output.push_str(&class_strs.join("; "));
2160        output.push('\n');
2161    } else {
2162        for class in classes {
2163            if class.inherits.is_empty() {
2164                output.push_str(&format!(
2165                    "  {}:{}-{}\n",
2166                    class.name, class.line, class.end_line
2167                ));
2168            } else {
2169                output.push_str(&format!(
2170                    "  {}:{}-{} ({})\n",
2171                    class.name,
2172                    class.line,
2173                    class.end_line,
2174                    class.inherits.join(", ")
2175                ));
2176            }
2177
2178            // Append methods for each class
2179            if let Some(methods) = methods_by_class.get(&class.name)
2180                && !methods.is_empty()
2181            {
2182                for (i, method) in methods.iter().take(10).enumerate() {
2183                    output.push_str(&format!("    {}:{}\n", method.name, method.line));
2184                    if i + 1 == 10 && methods.len() > 10 {
2185                        output.push_str(&format!("    ... ({} more)\n", methods.len() - 10));
2186                        break;
2187                    }
2188                }
2189            }
2190        }
2191    }
2192    output
2193}
2194
2195/// Format related files section (incoming/outgoing imports).
2196/// Returns empty string when import_graph is None.
2197fn format_imports_section(imports: &[ImportInfo]) -> String {
2198    let mut output = String::new();
2199    if imports.is_empty() {
2200        return output;
2201    }
2202    output.push_str("I:\n");
2203    let mut module_map: HashMap<String, usize> = HashMap::new();
2204    for import in imports {
2205        module_map
2206            .entry(import.module.clone())
2207            .and_modify(|count| *count += 1)
2208            .or_insert(1);
2209    }
2210    let mut modules: Vec<_> = module_map.keys().cloned().collect();
2211    modules.sort();
2212    let formatted_modules: Vec<String> = modules
2213        .iter()
2214        .map(|module| format!("{}({})", module, module_map[module]))
2215        .collect();
2216    if formatted_modules.len() <= MULTILINE_THRESHOLD {
2217        output.push_str("  ");
2218        output.push_str(&formatted_modules.join("; "));
2219        output.push('\n');
2220    } else {
2221        for module_str in formatted_modules {
2222            output.push_str("  ");
2223            output.push_str(&module_str);
2224            output.push('\n');
2225        }
2226    }
2227    output
2228}