Skip to main content

code_analyze_core/
formatter.rs

1// SPDX-FileCopyrightText: 2026 code-analyze-mcp contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Output formatting for analysis results across different modes.
4//!
5//! Formats semantic analysis, call graphs, and directory structures into human-readable text.
6//! Handles multiline wrapping, pagination, and summary generation.
7
8use crate::graph::CallGraph;
9use crate::graph::InternalCallChain;
10use crate::pagination::PaginationMode;
11use crate::test_detection::is_test_file;
12use crate::traversal::WalkEntry;
13use crate::types::{
14    AnalyzeFileField, ClassInfo, DefUseKind, DefUseSite, FileInfo, FunctionInfo, ImportInfo,
15    ModuleInfo, SemanticAnalysis,
16};
17use std::collections::{HashMap, HashSet};
18use std::fmt::Write;
19use std::path::{Path, PathBuf};
20use thiserror::Error;
21use tracing::instrument;
22
23const MULTILINE_THRESHOLD: usize = 10;
24
25/// Check if a function falls within a class's line range (method detection).
26fn is_method_of_class(func: &FunctionInfo, class: &ClassInfo) -> bool {
27    func.line >= class.line && func.end_line <= class.end_line
28}
29
30/// Collect methods for each class, preferring ClassInfo.methods when populated (Rust case),
31/// falling back to line-range intersection for languages that do not populate ClassInfo.methods.
32fn collect_class_methods<'a>(
33    classes: &'a [ClassInfo],
34    functions: &'a [FunctionInfo],
35) -> HashMap<String, Vec<&'a FunctionInfo>> {
36    let mut methods_by_class: HashMap<String, Vec<&'a FunctionInfo>> = HashMap::new();
37    for class in classes {
38        if class.methods.is_empty() {
39            // Python/Java/TS/Go: infer methods by line-range containment
40            let methods: Vec<&FunctionInfo> = functions
41                .iter()
42                .filter(|f| is_method_of_class(f, class))
43                .collect();
44            methods_by_class.insert(class.name.clone(), methods);
45        } else {
46            // Rust: parser already populated methods via extract_impl_methods
47            methods_by_class.insert(class.name.clone(), class.methods.iter().collect());
48        }
49    }
50    methods_by_class
51}
52
53/// Format a list of function signatures wrapped at 100 characters with bullet annotation.
54fn format_function_list_wrapped<'a>(
55    functions: impl Iterator<Item = &'a crate::types::FunctionInfo>,
56    call_frequency: &std::collections::HashMap<String, usize>,
57) -> String {
58    let mut output = String::new();
59    let mut line = String::from("  ");
60    for (i, func) in functions.enumerate() {
61        let mut call_marker = func.compact_signature();
62
63        if let Some(&count) = call_frequency.get(&func.name)
64            && count > 3
65        {
66            let _ = write!(call_marker, "\u{2022}{count}");
67        }
68
69        if i == 0 {
70            line.push_str(&call_marker);
71        } else if line.len() + call_marker.len() + 2 > 100 {
72            output.push_str(&line);
73            output.push('\n');
74            let mut new_line = String::with_capacity(2 + call_marker.len());
75            new_line.push_str("  ");
76            new_line.push_str(&call_marker);
77            line = new_line;
78        } else {
79            line.push_str(", ");
80            line.push_str(&call_marker);
81        }
82    }
83    if !line.trim().is_empty() {
84        output.push_str(&line);
85        output.push('\n');
86    }
87    output
88}
89
90/// Build a bracket string for file info (line count, function count, class count).
91/// Returns None if all counts are zero, otherwise returns "[42L, 7F, 2C]" format.
92fn format_file_info_parts(line_count: usize, fn_count: usize, cls_count: usize) -> Option<String> {
93    let mut parts = Vec::new();
94    if line_count > 0 {
95        parts.push(format!("{line_count}L"));
96    }
97    if fn_count > 0 {
98        parts.push(format!("{fn_count}F"));
99    }
100    if cls_count > 0 {
101        parts.push(format!("{cls_count}C"));
102    }
103    if parts.is_empty() {
104        None
105    } else {
106        Some(format!("[{}]", parts.join(", ")))
107    }
108}
109
110/// Strip a base path from a Path, returning a relative path or the original on failure.
111pub(crate) fn strip_base_path(path: &Path, base_path: Option<&Path>) -> String {
112    match base_path {
113        Some(base) => {
114            if let Ok(rel_path) = path.strip_prefix(base) {
115                rel_path.display().to_string()
116            } else {
117                path.display().to_string()
118            }
119        }
120        None => path.display().to_string(),
121    }
122}
123
124const SNIPPET_MAX_LEN: usize = 80;
125const SNIPPET_TRUNCATION_POINT: usize = 77;
126
127/// Extract the center line from a snippet window and truncate at a char boundary.
128pub(crate) fn snippet_one_line(snippet: &str) -> String {
129    let lines: Vec<&str> = snippet.split('\n').collect();
130    let center = if lines.len() >= 2 { lines[1] } else { lines[0] };
131    let trimmed = center.trim();
132    if trimmed.len() > SNIPPET_MAX_LEN {
133        let truncate_at = trimmed.floor_char_boundary(SNIPPET_TRUNCATION_POINT);
134        format!("{}...", &trimmed[..truncate_at])
135    } else {
136        trimmed.to_string()
137    }
138}
139
140/// Count (writes, reads) in a def-use site slice.
141fn def_use_write_read_counts(sites: &[DefUseSite]) -> (usize, usize) {
142    let w = sites
143        .iter()
144        .filter(|s| matches!(s.kind, DefUseKind::Write | DefUseKind::WriteRead))
145        .count();
146    (w, sites.len() - w)
147}
148
149/// Render a WRITES or READS group of def-use sites.
150fn render_def_use_group(
151    output: &mut String,
152    sites: &[DefUseSite],
153    heading: &str,
154    pred: impl Fn(&DefUseSite) -> bool,
155    base_path: Option<&Path>,
156) {
157    let filtered: Vec<_> = sites.iter().filter(|s| pred(s)).collect();
158    if filtered.is_empty() {
159        return;
160    }
161    let _ = writeln!(output, "  {heading}");
162    for site in filtered {
163        let file_display = strip_base_path(Path::new(&site.file), base_path);
164        let scope_str = site
165            .enclosing_scope
166            .as_ref()
167            .map(|s| format!("{}()", s))
168            .unwrap_or_default();
169        let snippet = snippet_one_line(&site.snippet);
170        let wr_label = if site.kind == DefUseKind::WriteRead {
171            " [write_read]"
172        } else {
173            ""
174        };
175        let _ = writeln!(
176            output,
177            "    {file_display}:{}  {scope_str}  {snippet}{wr_label}",
178            site.line
179        );
180    }
181}
182
183#[derive(Debug, Error)]
184pub enum FormatterError {
185    #[error("Graph error: {0}")]
186    GraphError(#[from] crate::graph::GraphError),
187}
188
189/// Format directory structure analysis results.
190#[instrument(skip_all)]
191#[allow(clippy::too_many_lines)] // exhaustive directory formatting logic; splitting harms readability
192pub fn format_structure(
193    entries: &[WalkEntry],
194    analysis_results: &[FileInfo],
195    max_depth: Option<u32>,
196) -> String {
197    let mut output = String::new();
198
199    // Build a map of path -> analysis for quick lookup
200    let analysis_map: HashMap<String, &FileInfo> = analysis_results
201        .iter()
202        .map(|a| (a.path.clone(), a))
203        .collect();
204
205    // Partition files into production and test
206    let (prod_files, test_files): (Vec<_>, Vec<_>) =
207        analysis_results.iter().partition(|a| !a.is_test);
208
209    // Calculate totals
210    let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
211    let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
212    let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
213
214    // Count files by language and calculate percentages
215    let mut lang_counts: HashMap<String, usize> = HashMap::new();
216    for analysis in analysis_results {
217        *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
218    }
219    let total_files = analysis_results.len();
220
221    // Leading summary line with totals
222    let primary_lang = lang_counts
223        .iter()
224        .max_by_key(|&(_, count)| count)
225        .map_or_else(
226            || "unknown 0%".to_string(),
227            |(name, count)| {
228                let percentage = (*count * 100).checked_div(total_files).unwrap_or_default();
229                format!("{name} {percentage}%")
230            },
231        );
232
233    let _ = writeln!(
234        output,
235        "{total_files} files, {total_loc}L, {total_functions}F, {total_classes}C ({primary_lang})"
236    );
237
238    // SUMMARY block
239    output.push_str("SUMMARY:\n");
240    let depth_label = match max_depth {
241        Some(n) if n > 0 => format!(" (max_depth={n})"),
242        _ => String::new(),
243    };
244    let _ = writeln!(
245        output,
246        "Shown: {} files ({} prod, {} test), {total_loc}L, {total_functions}F, {total_classes}C{depth_label}",
247        total_files,
248        prod_files.len(),
249        test_files.len()
250    );
251
252    if !lang_counts.is_empty() {
253        output.push_str("Languages: ");
254        let mut langs: Vec<_> = lang_counts.iter().collect();
255        langs.sort_by_key(|&(name, _)| name);
256        let lang_strs: Vec<String> = langs
257            .iter()
258            .map(|(name, count)| {
259                let percentage = (**count * 100).checked_div(total_files).unwrap_or_default();
260                format!("{name} ({percentage}%)")
261            })
262            .collect();
263        output.push_str(&lang_strs.join(", "));
264        output.push('\n');
265    }
266
267    output.push('\n');
268
269    // PATH block - tree structure (production and test files in single pass)
270    output.push_str("PATH [LOC, FUNCTIONS, CLASSES]\n");
271
272    let mut test_buf = String::new();
273
274    for entry in entries {
275        // Skip the root directory itself
276        if entry.depth == 0 {
277            continue;
278        }
279
280        // Calculate indentation
281        let indent = "  ".repeat(entry.depth - 1);
282
283        // Get just the filename/dirname
284        let name = entry
285            .path
286            .file_name()
287            .and_then(|n| n.to_str())
288            .unwrap_or("?");
289
290        // For files, append analysis info
291        if entry.is_dir {
292            let line = format!("{indent}{name}/\n");
293            output.push_str(&line);
294        } else if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
295            if let Some(info_str) = format_file_info_parts(
296                analysis.line_count,
297                analysis.function_count,
298                analysis.class_count,
299            ) {
300                let line = format!("{indent}{name} {info_str}\n");
301                if analysis.is_test {
302                    test_buf.push_str(&line);
303                } else {
304                    output.push_str(&line);
305                }
306            } else {
307                let line = format!("{indent}{name}\n");
308                if analysis.is_test {
309                    test_buf.push_str(&line);
310                } else {
311                    output.push_str(&line);
312                }
313            }
314            // Skip files not in analysis_map (binary/unreadable files)
315        }
316    }
317
318    // TEST FILES section (if any test files exist)
319    if !test_buf.is_empty() {
320        output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
321        output.push_str(&test_buf);
322    }
323
324    output
325}
326
327/// Format file-level semantic analysis results.
328#[instrument(skip_all)]
329pub fn format_file_details(
330    path: &str,
331    analysis: &SemanticAnalysis,
332    line_count: usize,
333    is_test: bool,
334    base_path: Option<&Path>,
335) -> String {
336    let mut output = String::new();
337
338    // FILE: header with counts, prepend [TEST] if applicable
339    let display_path = strip_base_path(Path::new(path), base_path);
340    let fn_count = analysis.functions.len();
341    let class_count = analysis.classes.len();
342    let import_count = analysis.imports.len();
343    if is_test {
344        let _ = writeln!(
345            output,
346            "FILE [TEST] {display_path}({line_count}L, {fn_count}F, {class_count}C, {import_count}I)"
347        );
348    } else {
349        let _ = writeln!(
350            output,
351            "FILE: {display_path}({line_count}L, {fn_count}F, {class_count}C, {import_count}I)"
352        );
353    }
354
355    // C: section with classes and methods
356    output.push_str(&format_classes_section(
357        &analysis.classes,
358        &analysis.functions,
359    ));
360
361    // F: section with top-level functions only (exclude methods)
362    let top_level_functions: Vec<&FunctionInfo> = analysis
363        .functions
364        .iter()
365        .filter(|func| {
366            !analysis
367                .classes
368                .iter()
369                .any(|class| is_method_of_class(func, class))
370        })
371        .collect();
372
373    if !top_level_functions.is_empty() {
374        output.push_str("F:\n");
375        output.push_str(&format_function_list_wrapped(
376            top_level_functions.iter().copied(),
377            &analysis.call_frequency,
378        ));
379    }
380
381    // I: section with imports grouped by module
382    output.push_str(&format_imports_section(&analysis.imports));
383
384    output
385}
386
387/// Format chains as a tree-indented output, grouped by depth-1 symbol.
388/// Groups chains by their first symbol (depth-1), deduplicates and sorts depth-2 children,
389/// then renders with 2-space indentation using the provided arrow.
390/// `focus_symbol` is the name of the depth-0 symbol (focus point) to prepend on depth-1 lines.
391///
392/// Indentation rules:
393/// - Depth-1: `  {focus} {arrow} {parent}` (2-space indent)
394/// - Depth-2: `    {arrow} {child}` (4-space indent)
395/// - Empty:   `  (none)` (2-space indent)
396fn format_chains_as_tree(chains: &[(&str, &str)], arrow: &str, focus_symbol: &str) -> String {
397    use std::collections::BTreeMap;
398
399    if chains.is_empty() {
400        return "  (none)\n".to_string();
401    }
402
403    let mut output = String::new();
404
405    // Group chains by depth-1 symbol, counting duplicate children
406    let mut groups: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
407    for (parent, child) in chains {
408        // Only count non-empty children
409        if child.is_empty() {
410            // Ensure parent is in groups even if no children
411            groups.entry(parent.to_string()).or_default();
412        } else {
413            *groups
414                .entry(parent.to_string())
415                .or_default()
416                .entry(child.to_string())
417                .or_insert(0) += 1;
418        }
419    }
420
421    // Render grouped tree
422    #[allow(clippy::similar_names)]
423    // domain-appropriate pairs (arrow/parent, child/count); renaming harms clarity
424    for (parent, children) in groups {
425        let _ = writeln!(output, "  {focus_symbol} {arrow} {parent}");
426        // Sort children by count descending, then alphabetically
427        let mut sorted: Vec<_> = children.into_iter().collect();
428        sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
429        for (child, count) in sorted {
430            if count > 1 {
431                let _ = writeln!(output, "    {arrow} {child} (x{count})");
432            } else {
433                let _ = writeln!(output, "    {arrow} {child}");
434            }
435        }
436    }
437
438    output
439}
440
441/// Format focused symbol analysis with call graph.
442#[instrument(skip_all)]
443#[allow(clippy::too_many_lines)] // exhaustive symbol focus formatting; splitting harms readability
444/// Internal helper that accepts pre-computed chains.
445#[allow(clippy::similar_names)] // callers_count/callees_count are domain-clear distinct pairs
446pub(crate) fn format_focused_internal(
447    graph: &CallGraph,
448    symbol: &str,
449    follow_depth: u32,
450    base_path: Option<&Path>,
451    incoming_chains: Option<&[InternalCallChain]>,
452    outgoing_chains: Option<&[InternalCallChain]>,
453    def_use_sites: &[DefUseSite],
454) -> Result<String, FormatterError> {
455    let mut output = String::new();
456
457    // Compute all counts BEFORE output begins
458    let def_count = graph.definitions.get(symbol).map_or(0, Vec::len);
459
460    // Use pre-computed chains if provided, otherwise compute them
461    let (incoming_chains_vec, outgoing_chains_vec);
462    let (incoming_chains_ref, outgoing_chains_ref) =
463        if let (Some(inc), Some(out)) = (incoming_chains, outgoing_chains) {
464            (inc, out)
465        } else {
466            incoming_chains_vec = graph.find_incoming_chains(symbol, follow_depth)?;
467            outgoing_chains_vec = graph.find_outgoing_chains(symbol, follow_depth)?;
468            (
469                incoming_chains_vec.as_slice(),
470                outgoing_chains_vec.as_slice(),
471            )
472        };
473
474    // Partition incoming_chains into production and test callers
475    let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
476        incoming_chains_ref.iter().cloned().partition(|chain| {
477            chain
478                .chain
479                .first()
480                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
481        });
482
483    // Count unique callers
484    let callers_count = prod_chains
485        .iter()
486        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
487        .collect::<std::collections::HashSet<_>>()
488        .len();
489
490    // Count unique callees
491    let callees_count = outgoing_chains_ref
492        .iter()
493        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
494        .collect::<std::collections::HashSet<_>>()
495        .len();
496
497    // FOCUS section - with inline counts
498    let _ = writeln!(
499        output,
500        "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
501    );
502
503    // DEPTH section
504    let _ = writeln!(output, "DEPTH: {follow_depth}");
505
506    // DEFINED section - show where the symbol is defined
507    if let Some(definitions) = graph.definitions.get(symbol) {
508        output.push_str("DEFINED:\n");
509        for (path, line) in definitions {
510            let display = strip_base_path(path, base_path);
511            let _ = writeln!(output, "  {display}:{line}");
512        }
513    } else {
514        output.push_str("DEFINED: (not found)\n");
515    }
516
517    // CALLERS section - who calls this symbol
518    output.push_str("CALLERS:\n");
519
520    // Render production callers
521    let prod_refs: Vec<_> = prod_chains
522        .iter()
523        .filter_map(|chain| {
524            if chain.chain.len() >= 2 {
525                Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
526            } else if chain.chain.len() == 1 {
527                Some((chain.chain[0].0.as_str(), ""))
528            } else {
529                None
530            }
531        })
532        .collect();
533
534    if prod_refs.is_empty() {
535        output.push_str("  (none)\n");
536    } else {
537        output.push_str(&format_chains_as_tree(&prod_refs, "<-", symbol));
538    }
539
540    // Render test callers summary if any
541    if !test_chains.is_empty() {
542        let mut test_files: Vec<_> = test_chains
543            .iter()
544            .filter_map(|chain| {
545                chain
546                    .chain
547                    .first()
548                    .map(|(_, path, _)| path.to_string_lossy().into_owned())
549            })
550            .collect();
551        test_files.sort();
552        test_files.dedup();
553
554        // Strip base path for display
555        let display_files: Vec<_> = test_files
556            .iter()
557            .map(|f| strip_base_path(Path::new(f), base_path))
558            .collect();
559
560        let file_list = display_files.join(", ");
561        let test_count = test_chains.len();
562        let _ = writeln!(
563            output,
564            "CALLERS (test): {test_count} test functions (in {file_list})"
565        );
566    }
567
568    // CALLEES section - what this symbol calls
569    output.push_str("CALLEES:\n");
570    let outgoing_refs: Vec<_> = outgoing_chains_ref
571        .iter()
572        .filter_map(|chain| {
573            if chain.chain.len() >= 2 {
574                Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
575            } else if chain.chain.len() == 1 {
576                Some((chain.chain[0].0.as_str(), ""))
577            } else {
578                None
579            }
580        })
581        .collect();
582
583    if outgoing_refs.is_empty() {
584        output.push_str("  (none)\n");
585    } else {
586        output.push_str(&format_chains_as_tree(&outgoing_refs, "->", symbol));
587    }
588
589    // FILES section - collect unique files from production chains
590    let mut files = HashSet::new();
591    for chain in &prod_chains {
592        for (_, path, _) in &chain.chain {
593            files.insert(path.clone());
594        }
595    }
596    for chain in outgoing_chains_ref {
597        for (_, path, _) in &chain.chain {
598            files.insert(path.clone());
599        }
600    }
601    if let Some(definitions) = graph.definitions.get(symbol) {
602        for (path, _) in definitions {
603            files.insert(path.clone());
604        }
605    }
606
607    // Partition files into production and test
608    let (prod_files, test_files): (Vec<_>, Vec<_>) =
609        files.into_iter().partition(|path| !is_test_file(path));
610
611    output.push_str("FILES:\n");
612    if prod_files.is_empty() && test_files.is_empty() {
613        output.push_str("  (none)\n");
614    } else {
615        // Show production files first
616        if !prod_files.is_empty() {
617            let mut sorted_files = prod_files;
618            sorted_files.sort();
619            for file in sorted_files {
620                let display = strip_base_path(&file, base_path);
621                let _ = writeln!(output, "  {display}");
622            }
623        }
624
625        // Show test files in separate subsection
626        if !test_files.is_empty() {
627            output.push_str("  TEST FILES:\n");
628            let mut sorted_files = test_files;
629            sorted_files.sort();
630            for file in sorted_files {
631                let display = strip_base_path(&file, base_path);
632                let _ = writeln!(output, "    {display}");
633            }
634        }
635    }
636
637    // DEF-USE SITES section - show writes and reads of the symbol
638    if !def_use_sites.is_empty() {
639        let (write_count, read_count) = def_use_write_read_counts(def_use_sites);
640        let total = def_use_sites.len();
641        let _ = writeln!(
642            output,
643            "DEF-USE SITES  {symbol}  ({total} total: {write_count} writes, {read_count} reads)"
644        );
645
646        render_def_use_group(
647            &mut output,
648            def_use_sites,
649            "WRITES",
650            |s| matches!(s.kind, DefUseKind::Write | DefUseKind::WriteRead),
651            base_path,
652        );
653        render_def_use_group(
654            &mut output,
655            def_use_sites,
656            "READS",
657            |s| s.kind == DefUseKind::Read,
658            base_path,
659        );
660    }
661
662    Ok(output)
663}
664
665/// Format a compact summary of focused symbol analysis.
666/// Used when output would exceed the size threshold or when explicitly requested.
667#[instrument(skip_all)]
668#[allow(clippy::too_many_lines)] // exhaustive symbol summary formatting; splitting harms readability
669#[allow(clippy::similar_names)] // domain pairs: callers_count/callees_count are intentionally similar
670/// Internal helper that accepts pre-computed chains.
671pub(crate) fn format_focused_summary_internal(
672    graph: &CallGraph,
673    symbol: &str,
674    follow_depth: u32,
675    base_path: Option<&Path>,
676    incoming_chains: Option<&[InternalCallChain]>,
677    outgoing_chains: Option<&[InternalCallChain]>,
678    def_use_sites: &[DefUseSite],
679) -> Result<String, FormatterError> {
680    let mut output = String::new();
681
682    // Compute all counts BEFORE output begins
683    let def_count = graph.definitions.get(symbol).map_or(0, Vec::len);
684
685    // Use pre-computed chains if provided, otherwise compute them
686    let (incoming_chains_vec, outgoing_chains_vec);
687    let (incoming_chains_ref, outgoing_chains_ref) =
688        if let (Some(inc), Some(out)) = (incoming_chains, outgoing_chains) {
689            (inc, out)
690        } else {
691            incoming_chains_vec = graph.find_incoming_chains(symbol, follow_depth)?;
692            outgoing_chains_vec = graph.find_outgoing_chains(symbol, follow_depth)?;
693            (
694                incoming_chains_vec.as_slice(),
695                outgoing_chains_vec.as_slice(),
696            )
697        };
698
699    // Partition incoming_chains into production and test callers
700    let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
701        incoming_chains_ref.iter().cloned().partition(|chain| {
702            chain
703                .chain
704                .first()
705                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
706        });
707
708    // Count unique production callers
709    let callers_count = prod_chains
710        .iter()
711        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
712        .collect::<std::collections::HashSet<_>>()
713        .len();
714
715    // Count unique callees
716    let callees_count = outgoing_chains_ref
717        .iter()
718        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
719        .collect::<std::collections::HashSet<_>>()
720        .len();
721
722    // FOCUS header
723    let _ = writeln!(
724        output,
725        "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
726    );
727
728    // DEPTH line
729    let _ = writeln!(output, "DEPTH: {follow_depth}");
730
731    // DEFINED section
732    if let Some(definitions) = graph.definitions.get(symbol) {
733        output.push_str("DEFINED:\n");
734        for (path, line) in definitions {
735            let display = strip_base_path(path, base_path);
736            let _ = writeln!(output, "  {display}:{line}");
737        }
738    } else {
739        output.push_str("DEFINED: (not found)\n");
740    }
741
742    // CALLERS (production, top 10 by frequency)
743    output.push_str("CALLERS (top 10):\n");
744    if prod_chains.is_empty() {
745        output.push_str("  (none)\n");
746    } else {
747        // Collect caller names with their file paths (from chain.chain.first())
748        let mut caller_freq: std::collections::HashMap<String, (usize, String)> =
749            std::collections::HashMap::new();
750        for chain in &prod_chains {
751            if let Some((name, path, _)) = chain.chain.first() {
752                let file_path = strip_base_path(path, base_path);
753                caller_freq
754                    .entry(name.clone())
755                    .and_modify(|(count, _)| *count += 1)
756                    .or_insert((1, file_path));
757            }
758        }
759
760        // Sort by frequency descending, take top 10
761        let mut sorted_callers: Vec<_> = caller_freq.into_iter().collect();
762        sorted_callers.sort_unstable_by_key(|b| std::cmp::Reverse(b.1.0));
763
764        for (name, (_, file_path)) in sorted_callers.into_iter().take(10) {
765            let _ = writeln!(output, "  {name} {file_path}");
766        }
767    }
768
769    // CALLERS (test) - summary only
770    if !test_chains.is_empty() {
771        let mut test_files: Vec<_> = test_chains
772            .iter()
773            .filter_map(|chain| {
774                chain
775                    .chain
776                    .first()
777                    .map(|(_, path, _)| path.to_string_lossy().into_owned())
778            })
779            .collect();
780        test_files.sort();
781        test_files.dedup();
782
783        let test_count = test_chains.len();
784        let test_file_count = test_files.len();
785        let _ = writeln!(
786            output,
787            "CALLERS (test): {test_count} test functions (in {test_file_count} files)"
788        );
789    }
790
791    // CALLEES (top 10 by frequency)
792    output.push_str("CALLEES (top 10):\n");
793    if outgoing_chains_ref.is_empty() {
794        output.push_str("  (none)\n");
795    } else {
796        // Collect callee names and count frequency
797        let mut callee_freq: std::collections::HashMap<String, usize> =
798            std::collections::HashMap::new();
799        for chain in outgoing_chains_ref {
800            if let Some((name, _, _)) = chain.chain.first() {
801                *callee_freq.entry(name.clone()).or_insert(0) += 1;
802            }
803        }
804
805        // Sort by frequency descending, take top 10
806        let mut sorted_callees: Vec<_> = callee_freq.into_iter().collect();
807        sorted_callees.sort_unstable_by_key(|b| std::cmp::Reverse(b.1));
808
809        for (name, _) in sorted_callees.into_iter().take(10) {
810            let _ = writeln!(output, "  {name}");
811        }
812    }
813
814    // SUGGESTION section
815    output.push_str("SUGGESTION:\n");
816    output.push_str("Use summary=false with force=true for full output\n");
817
818    // DEF-USE SITES brief summary
819    if !def_use_sites.is_empty() {
820        let (write_count, read_count) = def_use_write_read_counts(def_use_sites);
821        let total = def_use_sites.len();
822        let _ = writeln!(
823            output,
824            "DEF-USE SITES: {total} total ({write_count} writes, {read_count} reads)",
825        );
826    }
827
828    Ok(output)
829}
830
831/// Format a compact summary of focused symbol analysis.
832/// Public wrapper that computes chains if not provided.
833pub fn format_focused_summary(
834    graph: &CallGraph,
835    symbol: &str,
836    follow_depth: u32,
837    base_path: Option<&Path>,
838) -> Result<String, FormatterError> {
839    format_focused_summary_internal(graph, symbol, follow_depth, base_path, None, None, &[])
840}
841
842/// Format a compact summary for large directory analysis results.
843/// Used when output would exceed the size threshold or when explicitly requested.
844#[instrument(skip_all)]
845#[allow(clippy::too_many_lines)] // exhaustive directory summary formatting; splitting harms readability
846pub fn format_summary(
847    entries: &[WalkEntry],
848    analysis_results: &[FileInfo],
849    max_depth: Option<u32>,
850    subtree_counts: Option<&[(PathBuf, usize)]>,
851) -> String {
852    let mut output = String::new();
853
854    // Partition files into production and test
855    let (prod_files, test_files): (Vec<_>, Vec<_>) =
856        analysis_results.iter().partition(|a| !a.is_test);
857
858    // Calculate totals
859    let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
860    let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
861    let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
862
863    // Count files by language
864    let mut lang_counts: HashMap<String, usize> = HashMap::new();
865    for analysis in analysis_results {
866        *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
867    }
868    let total_files = analysis_results.len();
869
870    // SUMMARY block
871    output.push_str("SUMMARY:\n");
872    let depth_label = match max_depth {
873        Some(n) if n > 0 => format!(" (max_depth={n})"),
874        _ => String::new(),
875    };
876    let prod_count = prod_files.len();
877    let test_count = test_files.len();
878    let _ = writeln!(
879        output,
880        "{total_files} files ({prod_count} prod, {test_count} test), {total_loc}L, {total_functions}F, {total_classes}C{depth_label}"
881    );
882
883    if !lang_counts.is_empty() {
884        output.push_str("Languages: ");
885        let mut langs: Vec<_> = lang_counts.iter().collect();
886        langs.sort_unstable_by_key(|&(name, _)| name);
887        let lang_strs: Vec<String> = langs
888            .iter()
889            .map(|(name, count)| {
890                let percentage = (**count * 100).checked_div(total_files).unwrap_or_default();
891                format!("{name} ({percentage}%)")
892            })
893            .collect();
894        output.push_str(&lang_strs.join(", "));
895        output.push('\n');
896    }
897
898    output.push('\n');
899
900    // STRUCTURE (depth 1) block
901    output.push_str("STRUCTURE (depth 1):\n");
902
903    // Build a map of path -> analysis for quick lookup
904    let analysis_map: HashMap<String, &FileInfo> = analysis_results
905        .iter()
906        .map(|a| (a.path.clone(), a))
907        .collect();
908
909    // Collect depth-1 entries (directories and files at depth 1)
910    let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
911    depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
912
913    // Track largest non-excluded directory for SUGGESTION
914    let mut largest_dir_name: Option<String> = None;
915    let mut largest_dir_path: Option<String> = None;
916    let mut largest_dir_count: usize = 0;
917
918    for entry in depth1_entries {
919        let name = entry
920            .path
921            .file_name()
922            .and_then(|n| n.to_str())
923            .unwrap_or("?");
924
925        if entry.is_dir {
926            // For directories, aggregate stats from all files under this directory
927            let dir_path_str = entry.path.display().to_string();
928            let files_in_dir: Vec<&FileInfo> = analysis_results
929                .iter()
930                .filter(|f| Path::new(&f.path).starts_with(&entry.path))
931                .collect();
932
933            if files_in_dir.is_empty() {
934                // No analyzed files at this depth, but subtree_counts may have a true count
935                let entry_name_str = name.to_string();
936                if let Some(counts) = subtree_counts {
937                    let true_count = counts
938                        .binary_search_by_key(&&entry.path, |(p, _)| p)
939                        .ok()
940                        .map_or(0, |i| counts[i].1);
941                    if true_count > 0 {
942                        // Track for SUGGESTION
943                        if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
944                            && true_count > largest_dir_count
945                        {
946                            largest_dir_count = true_count;
947                            largest_dir_name = Some(entry_name_str);
948                            largest_dir_path = Some(
949                                entry
950                                    .path
951                                    .canonicalize()
952                                    .unwrap_or_else(|_| entry.path.clone())
953                                    .display()
954                                    .to_string(),
955                            );
956                        }
957                        let depth_val = max_depth.unwrap_or(0);
958                        let _ = writeln!(
959                            output,
960                            "  {name}/ [{true_count} files total; showing 0 at depth={depth_val}, 0L, 0F, 0C]"
961                        );
962                    } else {
963                        let _ = writeln!(output, "  {name}/");
964                    }
965                } else {
966                    let _ = writeln!(output, "  {name}/");
967                }
968            } else {
969                let dir_file_count = files_in_dir.len();
970                let dir_loc: usize = files_in_dir.iter().map(|f| f.line_count).sum();
971                let dir_functions: usize = files_in_dir.iter().map(|f| f.function_count).sum();
972                let dir_classes: usize = files_in_dir.iter().map(|f| f.class_count).sum();
973
974                // Track largest non-excluded directory for SUGGESTION
975                let entry_name_str = name.to_string();
976                let effective_count = if let Some(counts) = subtree_counts {
977                    counts
978                        .binary_search_by_key(&&entry.path, |(p, _)| p)
979                        .ok()
980                        .map_or(dir_file_count, |i| counts[i].1)
981                } else {
982                    dir_file_count
983                };
984                if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
985                    && effective_count > largest_dir_count
986                {
987                    largest_dir_count = effective_count;
988                    largest_dir_name = Some(entry_name_str);
989                    largest_dir_path = Some(
990                        entry
991                            .path
992                            .canonicalize()
993                            .unwrap_or_else(|_| entry.path.clone())
994                            .display()
995                            .to_string(),
996                    );
997                }
998
999                // Build hint: top-N files sorted by class_count desc, fallback to function_count
1000                let hint = if files_in_dir.len() > 1 && (dir_classes > 0 || dir_functions > 0) {
1001                    let mut top_files = files_in_dir.clone();
1002                    top_files.sort_unstable_by(|a, b| {
1003                        b.class_count
1004                            .cmp(&a.class_count)
1005                            .then(b.function_count.cmp(&a.function_count))
1006                            .then(a.path.cmp(&b.path))
1007                    });
1008
1009                    let has_classes = top_files.iter().any(|f| f.class_count > 0);
1010
1011                    // Re-sort for function fallback if no classes
1012                    if !has_classes {
1013                        top_files.sort_unstable_by(|a, b| {
1014                            b.function_count
1015                                .cmp(&a.function_count)
1016                                .then(a.path.cmp(&b.path))
1017                        });
1018                    }
1019
1020                    let dir_path = Path::new(&dir_path_str);
1021                    let top_n: Vec<String> = top_files
1022                        .iter()
1023                        .take(3)
1024                        .filter(|f| {
1025                            if has_classes {
1026                                f.class_count > 0
1027                            } else {
1028                                f.function_count > 0
1029                            }
1030                        })
1031                        .map(|f| {
1032                            let rel = Path::new(&f.path).strip_prefix(dir_path).map_or_else(
1033                                |_| {
1034                                    Path::new(&f.path)
1035                                        .file_name()
1036                                        .and_then(|n| n.to_str())
1037                                        .map_or_else(
1038                                            || "?".to_owned(),
1039                                            std::borrow::ToOwned::to_owned,
1040                                        )
1041                                },
1042                                |p| p.to_string_lossy().into_owned(),
1043                            );
1044                            let count = if has_classes {
1045                                f.class_count
1046                            } else {
1047                                f.function_count
1048                            };
1049                            let suffix = if has_classes { 'C' } else { 'F' };
1050                            format!("{rel}({count}{suffix})")
1051                        })
1052                        .collect();
1053                    if top_n.is_empty() {
1054                        String::new()
1055                    } else {
1056                        let joined = top_n.join(", ");
1057                        format!(" top: {joined}")
1058                    }
1059                } else {
1060                    String::new()
1061                };
1062
1063                // Collect depth-2 sub-package directories (immediate children of this directory)
1064                let mut subdirs: Vec<String> = entries
1065                    .iter()
1066                    .filter(|e| e.depth == 2 && e.is_dir && e.path.starts_with(&entry.path))
1067                    .filter_map(|e| {
1068                        e.path
1069                            .file_name()
1070                            .and_then(|n| n.to_str())
1071                            .map(std::borrow::ToOwned::to_owned)
1072                    })
1073                    .collect();
1074                subdirs.sort();
1075                subdirs.dedup();
1076                let subdir_suffix = if subdirs.is_empty() {
1077                    String::new()
1078                } else {
1079                    let subdirs_capped: Vec<String> =
1080                        subdirs.iter().take(5).map(|s| format!("{s}/")).collect();
1081                    let joined = subdirs_capped.join(", ");
1082                    format!("  sub: {joined}")
1083                };
1084
1085                let files_label = if let Some(counts) = subtree_counts {
1086                    let true_count = counts
1087                        .binary_search_by_key(&&entry.path, |(p, _)| p)
1088                        .ok()
1089                        .map_or(dir_file_count, |i| counts[i].1);
1090                    if true_count == dir_file_count {
1091                        format!(
1092                            "{dir_file_count} files, {dir_loc}L, {dir_functions}F, {dir_classes}C"
1093                        )
1094                    } else {
1095                        let depth_val = max_depth.unwrap_or(0);
1096                        format!(
1097                            "{true_count} files total; showing {dir_file_count} at depth={depth_val}, {dir_loc}L, {dir_functions}F, {dir_classes}C"
1098                        )
1099                    }
1100                } else {
1101                    format!("{dir_file_count} files, {dir_loc}L, {dir_functions}F, {dir_classes}C")
1102                };
1103                let _ = writeln!(output, "  {name}/ [{files_label}]{hint}{subdir_suffix}");
1104            }
1105        } else {
1106            // For files, show individual stats
1107            if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
1108                if let Some(info_str) = format_file_info_parts(
1109                    analysis.line_count,
1110                    analysis.function_count,
1111                    analysis.class_count,
1112                ) {
1113                    let _ = writeln!(output, "  {name} {info_str}");
1114                } else {
1115                    let _ = writeln!(output, "  {name}");
1116                }
1117            }
1118        }
1119    }
1120
1121    output.push('\n');
1122
1123    // SUGGESTION block
1124    if let (Some(name), Some(path)) = (largest_dir_name, largest_dir_path) {
1125        let _ = writeln!(
1126            output,
1127            "SUGGESTION: Largest source directory: {name}/ ({largest_dir_count} files total). For module details, re-run with path={path} and max_depth=2."
1128        );
1129    } else {
1130        output.push_str("SUGGESTION:\n");
1131        output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
1132    }
1133
1134    output
1135}
1136
1137/// Format a compact summary of file details for large `FileDetails` output.
1138///
1139/// Returns `FILE` header with path/LOC/counts, top 10 functions by line span descending,
1140/// classes inline if <=10, import count, and suggestion block.
1141#[instrument(skip_all)]
1142pub fn format_file_details_summary(
1143    semantic: &SemanticAnalysis,
1144    path: &str,
1145    line_count: usize,
1146) -> String {
1147    let mut output = String::new();
1148
1149    // FILE header
1150    output.push_str("FILE:\n");
1151    let _ = writeln!(output, "  path: {path}");
1152    let fn_count = semantic.functions.len();
1153    let class_count = semantic.classes.len();
1154    let _ = writeln!(output, "  {line_count}L, {fn_count}F, {class_count}C");
1155    output.push('\n');
1156
1157    // Top 10 functions by line span (end_line - start_line) descending
1158    if !semantic.functions.is_empty() {
1159        output.push_str("TOP FUNCTIONS BY SIZE:\n");
1160        let mut funcs: Vec<&crate::types::FunctionInfo> = semantic.functions.iter().collect();
1161        let k = funcs.len().min(10);
1162        if k > 0 {
1163            funcs.select_nth_unstable_by(k.saturating_sub(1), |a, b| {
1164                let a_span = a.end_line.saturating_sub(a.line);
1165                let b_span = b.end_line.saturating_sub(b.line);
1166                b_span.cmp(&a_span)
1167            });
1168            funcs[..k].sort_by(|a, b| {
1169                let a_span = a.end_line.saturating_sub(a.line);
1170                let b_span = b.end_line.saturating_sub(b.line);
1171                b_span.cmp(&a_span)
1172            });
1173        }
1174
1175        for func in &funcs[..k] {
1176            let span = func.end_line.saturating_sub(func.line);
1177            let params = if func.parameters.is_empty() {
1178                String::new()
1179            } else {
1180                format!("({})", func.parameters.join(", "))
1181            };
1182            let _ = writeln!(
1183                output,
1184                "  {}:{}: {} {} [{}L]",
1185                func.line, func.end_line, func.name, params, span
1186            );
1187        }
1188        output.push('\n');
1189    }
1190
1191    // Classes inline if <=10, else multiline with method count
1192    if !semantic.classes.is_empty() {
1193        output.push_str("CLASSES:\n");
1194        if semantic.classes.len() <= 10 {
1195            // Inline format: one class per line with method count
1196            for class in &semantic.classes {
1197                let methods_count = class.methods.len();
1198                let _ = writeln!(output, "  {}: {}M", class.name, methods_count);
1199            }
1200        } else {
1201            // Multiline format with summary
1202            let _ = writeln!(output, "  {} classes total", semantic.classes.len());
1203            for class in semantic.classes.iter().take(5) {
1204                let _ = writeln!(output, "    {}", class.name);
1205            }
1206            if semantic.classes.len() > 5 {
1207                let _ = writeln!(output, "    ... and {} more", semantic.classes.len() - 5);
1208            }
1209        }
1210        output.push('\n');
1211    }
1212
1213    // Import count only
1214    let _ = writeln!(output, "Imports: {}", semantic.imports.len());
1215    output.push('\n');
1216
1217    // SUGGESTION block
1218    output.push_str("SUGGESTION:\n");
1219    output.push_str("Use force=true for full output, or narrow your scope\n");
1220
1221    output
1222}
1223
1224/// Format a paginated subset of files for Overview mode.
1225#[instrument(skip_all)]
1226pub fn format_structure_paginated(
1227    paginated_files: &[FileInfo],
1228    total_files: usize,
1229    max_depth: Option<u32>,
1230    base_path: Option<&Path>,
1231    verbose: bool,
1232) -> String {
1233    let mut output = String::new();
1234
1235    let depth_label = match max_depth {
1236        Some(n) if n > 0 => format!(" (max_depth={n})"),
1237        _ => String::new(),
1238    };
1239    let _ = writeln!(
1240        output,
1241        "PAGINATED: showing {} of {} files{}\n",
1242        paginated_files.len(),
1243        total_files,
1244        depth_label
1245    );
1246
1247    let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
1248    let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
1249
1250    if !prod_files.is_empty() {
1251        if verbose {
1252            output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
1253        }
1254        for file in &prod_files {
1255            output.push_str(&format_file_entry(file, base_path));
1256        }
1257    }
1258
1259    if !test_files.is_empty() {
1260        if verbose {
1261            output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
1262        } else if !prod_files.is_empty() {
1263            output.push('\n');
1264        }
1265        for file in &test_files {
1266            output.push_str(&format_file_entry(file, base_path));
1267        }
1268    }
1269
1270    output
1271}
1272
1273/// Format a paginated subset of functions for `FileDetails` mode.
1274/// When `verbose=false` (default/compact): shows `C:` (if non-empty) and `F:` with wrapped rendering; omits `I:`.
1275/// When `verbose=true`: shows `C:`, `I:`, and `F:` with wrapped rendering on the first page (offset == 0).
1276/// Header shows position context: `FILE: path (NL, start-end/totalF, CC, II)`.
1277#[instrument(skip_all)]
1278#[allow(clippy::too_many_arguments)]
1279pub fn format_file_details_paginated(
1280    functions_page: &[FunctionInfo],
1281    total_functions: usize,
1282    semantic: &SemanticAnalysis,
1283    path: &str,
1284    line_count: usize,
1285    offset: usize,
1286    verbose: bool,
1287    fields: Option<&[AnalyzeFileField]>,
1288) -> String {
1289    let mut output = String::new();
1290
1291    let start = offset + 1; // 1-indexed for display
1292    let end = offset + functions_page.len();
1293
1294    let _ = writeln!(
1295        output,
1296        "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)",
1297        path,
1298        line_count,
1299        start,
1300        end,
1301        total_functions,
1302        semantic.classes.len(),
1303        semantic.imports.len()
1304    );
1305
1306    // Compute field visibility flags. Empty slice behaves same as None (show all).
1307    let show_all = fields.is_none_or(<[AnalyzeFileField]>::is_empty);
1308    let show_classes = show_all || fields.is_some_and(|f| f.contains(&AnalyzeFileField::Classes));
1309    let show_imports = show_all || fields.is_some_and(|f| f.contains(&AnalyzeFileField::Imports));
1310    let show_functions =
1311        show_all || fields.is_some_and(|f| f.contains(&AnalyzeFileField::Functions));
1312
1313    // Classes section on first page for both verbose and compact modes
1314    if show_classes && offset == 0 && !semantic.classes.is_empty() {
1315        output.push_str(&format_classes_section(
1316            &semantic.classes,
1317            &semantic.functions,
1318        ));
1319    }
1320
1321    // Imports section only on first page in verbose mode, or when explicitly listed in fields
1322    if show_imports && offset == 0 && (verbose || !show_all) {
1323        output.push_str(&format_imports_section(&semantic.imports));
1324    }
1325
1326    // F: section with paginated function slice (exclude methods)
1327    let top_level_functions: Vec<&FunctionInfo> = functions_page
1328        .iter()
1329        .filter(|func| {
1330            !semantic
1331                .classes
1332                .iter()
1333                .any(|class| is_method_of_class(func, class))
1334        })
1335        .collect();
1336
1337    if show_functions && !top_level_functions.is_empty() {
1338        output.push_str("F:\n");
1339        output.push_str(&format_function_list_wrapped(
1340            top_level_functions.iter().copied(),
1341            &semantic.call_frequency,
1342        ));
1343    }
1344
1345    output
1346}
1347
1348/// Format a paginated subset of callers or callees for `SymbolFocus` mode.
1349/// Mode is determined by the `mode` parameter:
1350/// - `PaginationMode::Callers`: paginate production callers; show test callers summary and callees summary.
1351/// - `PaginationMode::Callees`: paginate callees; show callers summary and test callers summary.
1352#[instrument(skip_all)]
1353#[allow(clippy::too_many_arguments)]
1354#[allow(clippy::similar_names)] // domain-appropriate pairs (callers_count/callees_count); renaming harms clarity
1355pub fn format_focused_paginated(
1356    paginated_chains: &[InternalCallChain],
1357    total: usize,
1358    mode: PaginationMode,
1359    symbol: &str,
1360    prod_chains: &[InternalCallChain],
1361    test_chains: &[InternalCallChain],
1362    outgoing_chains: &[InternalCallChain],
1363    def_count: usize,
1364    offset: usize,
1365    base_path: Option<&Path>,
1366    _verbose: bool,
1367) -> String {
1368    let start = offset + 1; // 1-indexed
1369    let end = offset + paginated_chains.len();
1370
1371    let callers_count = prod_chains.len();
1372
1373    let callees_count = outgoing_chains.len();
1374
1375    let mut output = String::new();
1376
1377    let _ = writeln!(
1378        output,
1379        "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
1380    );
1381
1382    match mode {
1383        PaginationMode::Callers => {
1384            // Paginate production callers
1385            let _ = writeln!(output, "CALLERS ({start}-{end} of {total}):");
1386
1387            let page_refs: Vec<_> = paginated_chains
1388                .iter()
1389                .filter_map(|chain| {
1390                    if chain.chain.len() >= 2 {
1391                        Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1392                    } else if chain.chain.len() == 1 {
1393                        Some((chain.chain[0].0.as_str(), ""))
1394                    } else {
1395                        None
1396                    }
1397                })
1398                .collect();
1399
1400            if page_refs.is_empty() {
1401                output.push_str("  (none)\n");
1402            } else {
1403                output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1404            }
1405
1406            // Test callers summary
1407            if !test_chains.is_empty() {
1408                let mut test_files: Vec<_> = test_chains
1409                    .iter()
1410                    .filter_map(|chain| {
1411                        chain
1412                            .chain
1413                            .first()
1414                            .map(|(_, path, _)| path.to_string_lossy().into_owned())
1415                    })
1416                    .collect();
1417                test_files.sort();
1418                test_files.dedup();
1419
1420                let display_files: Vec<_> = test_files
1421                    .iter()
1422                    .map(|f| strip_base_path(std::path::Path::new(f), base_path))
1423                    .collect();
1424
1425                let _ = writeln!(
1426                    output,
1427                    "CALLERS (test): {} test functions (in {})",
1428                    test_chains.len(),
1429                    display_files.join(", ")
1430                );
1431            }
1432
1433            // Callees summary
1434            let callee_names: Vec<_> = outgoing_chains
1435                .iter()
1436                .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1437                .collect::<std::collections::HashSet<_>>()
1438                .into_iter()
1439                .collect();
1440            if callee_names.is_empty() {
1441                output.push_str("CALLEES: (none)\n");
1442            } else {
1443                let _ = writeln!(
1444                    output,
1445                    "CALLEES: {callees_count} (use cursor for callee pagination)"
1446                );
1447            }
1448        }
1449        PaginationMode::Callees => {
1450            // Callers summary
1451            let _ = writeln!(output, "CALLERS: {callers_count} production callers");
1452
1453            // Test callers summary
1454            if !test_chains.is_empty() {
1455                let _ = writeln!(
1456                    output,
1457                    "CALLERS (test): {} test functions",
1458                    test_chains.len()
1459                );
1460            }
1461
1462            // Paginate callees
1463            let _ = writeln!(output, "CALLEES ({start}-{end} of {total}):");
1464
1465            let page_refs: Vec<_> = paginated_chains
1466                .iter()
1467                .filter_map(|chain| {
1468                    if chain.chain.len() >= 2 {
1469                        Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1470                    } else if chain.chain.len() == 1 {
1471                        Some((chain.chain[0].0.as_str(), ""))
1472                    } else {
1473                        None
1474                    }
1475                })
1476                .collect();
1477
1478            if page_refs.is_empty() {
1479                output.push_str("  (none)\n");
1480            } else {
1481                output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1482            }
1483        }
1484        PaginationMode::Default => {
1485            unreachable!("format_focused_paginated called with PaginationMode::Default")
1486        }
1487        PaginationMode::DefUse => {
1488            unreachable!("format_focused_paginated called with PaginationMode::DefUse")
1489        }
1490    }
1491
1492    output
1493}
1494
1495fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1496    let mut parts = Vec::new();
1497    if file.line_count > 0 {
1498        parts.push(format!("{}L", file.line_count));
1499    }
1500    if file.function_count > 0 {
1501        parts.push(format!("{}F", file.function_count));
1502    }
1503    if file.class_count > 0 {
1504        parts.push(format!("{}C", file.class_count));
1505    }
1506    let display_path = strip_base_path(Path::new(&file.path), base_path);
1507    if parts.is_empty() {
1508        format!("{display_path}\n")
1509    } else {
1510        format!("{display_path} [{}]\n", parts.join(", "))
1511    }
1512}
1513
1514/// Format a [`ModuleInfo`] into a compact single-block string.
1515///
1516/// Output format:
1517/// ```text
1518/// FILE: <name> (<line_count>L, <fn_count>F, <import_count>I)
1519/// F:
1520///   func1:10, func2:42
1521/// I:
1522///   module1:item1, item2; module2:item1; module3
1523/// ```
1524///
1525/// The `F:` section is omitted when there are no functions; likewise `I:` when
1526/// there are no imports.
1527#[instrument(skip_all)]
1528pub fn format_module_info(info: &ModuleInfo) -> String {
1529    use std::fmt::Write as _;
1530    let fn_count = info.functions.len();
1531    let import_count = info.imports.len();
1532    let mut out = String::with_capacity(64 + fn_count * 24 + import_count * 32);
1533    let _ = writeln!(
1534        out,
1535        "FILE: {} ({}L, {}F, {}I)",
1536        info.name, info.line_count, fn_count, import_count
1537    );
1538    if !info.functions.is_empty() {
1539        out.push_str("F:\n  ");
1540        let parts: Vec<String> = info
1541            .functions
1542            .iter()
1543            .map(|f| format!("{}:{}", f.name, f.line))
1544            .collect();
1545        out.push_str(&parts.join(", "));
1546        out.push('\n');
1547    }
1548    if !info.imports.is_empty() {
1549        out.push_str("I:\n  ");
1550        let parts: Vec<String> = info
1551            .imports
1552            .iter()
1553            .map(|i| {
1554                if i.items.is_empty() {
1555                    i.module.clone()
1556                } else {
1557                    format!("{}:{}", i.module, i.items.join(", "))
1558                }
1559            })
1560            .collect();
1561        out.push_str(&parts.join("; "));
1562        out.push('\n');
1563    }
1564    out
1565}
1566
1567#[cfg(test)]
1568mod tests {
1569    use super::*;
1570
1571    #[test]
1572    fn test_strip_base_path_relative() {
1573        let path = Path::new("/home/user/project/src/main.rs");
1574        let base = Path::new("/home/user/project");
1575        let result = strip_base_path(path, Some(base));
1576        assert_eq!(result, "src/main.rs");
1577    }
1578
1579    #[test]
1580    fn test_strip_base_path_fallback_absolute() {
1581        let path = Path::new("/other/project/src/main.rs");
1582        let base = Path::new("/home/user/project");
1583        let result = strip_base_path(path, Some(base));
1584        assert_eq!(result, "/other/project/src/main.rs");
1585    }
1586
1587    #[test]
1588    fn test_strip_base_path_none() {
1589        let path = Path::new("/home/user/project/src/main.rs");
1590        let result = strip_base_path(path, None);
1591        assert_eq!(result, "/home/user/project/src/main.rs");
1592    }
1593
1594    #[test]
1595    fn test_format_file_details_summary_empty() {
1596        use crate::types::SemanticAnalysis;
1597        use std::collections::HashMap;
1598
1599        let semantic = SemanticAnalysis {
1600            functions: vec![],
1601            classes: vec![],
1602            imports: vec![],
1603            references: vec![],
1604            call_frequency: HashMap::new(),
1605            calls: vec![],
1606            impl_traits: vec![],
1607            def_use_sites: vec![],
1608        };
1609
1610        let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1611
1612        // Should contain FILE header, Imports count, and SUGGESTION
1613        assert!(result.contains("FILE:"));
1614        assert!(result.contains("100L, 0F, 0C"));
1615        assert!(result.contains("src/main.rs"));
1616        assert!(result.contains("Imports: 0"));
1617        assert!(result.contains("SUGGESTION:"));
1618    }
1619
1620    #[test]
1621    fn test_format_file_details_summary_with_functions() {
1622        use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1623        use std::collections::HashMap;
1624
1625        let semantic = SemanticAnalysis {
1626            functions: vec![
1627                FunctionInfo {
1628                    name: "short".to_string(),
1629                    line: 10,
1630                    end_line: 12,
1631                    parameters: vec![],
1632                    return_type: None,
1633                },
1634                FunctionInfo {
1635                    name: "long_function".to_string(),
1636                    line: 20,
1637                    end_line: 50,
1638                    parameters: vec!["x".to_string(), "y".to_string()],
1639                    return_type: Some("i32".to_string()),
1640                },
1641            ],
1642            classes: vec![ClassInfo {
1643                name: "MyClass".to_string(),
1644                line: 60,
1645                end_line: 80,
1646                methods: vec![],
1647                fields: vec![],
1648                inherits: vec![],
1649            }],
1650            imports: vec![],
1651            references: vec![],
1652            call_frequency: HashMap::new(),
1653            calls: vec![],
1654            impl_traits: vec![],
1655            def_use_sites: vec![],
1656        };
1657
1658        let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1659
1660        // Should contain FILE header with counts
1661        assert!(result.contains("FILE:"));
1662        assert!(result.contains("src/lib.rs"));
1663        assert!(result.contains("250L, 2F, 1C"));
1664
1665        // Should contain TOP FUNCTIONS BY SIZE with longest first
1666        assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1667        let long_idx = result.find("long_function").unwrap_or(0);
1668        let short_idx = result.find("short").unwrap_or(0);
1669        assert!(
1670            long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1671            "long_function should appear before short"
1672        );
1673
1674        // Should contain classes inline
1675        assert!(result.contains("CLASSES:"));
1676        assert!(result.contains("MyClass:"));
1677
1678        // Should contain import count
1679        assert!(result.contains("Imports: 0"));
1680    }
1681    #[test]
1682    fn test_format_file_info_parts_all_zero() {
1683        assert_eq!(format_file_info_parts(0, 0, 0), None);
1684    }
1685
1686    #[test]
1687    fn test_format_file_info_parts_partial() {
1688        assert_eq!(
1689            format_file_info_parts(42, 0, 3),
1690            Some("[42L, 3C]".to_string())
1691        );
1692    }
1693
1694    #[test]
1695    fn test_format_file_info_parts_all_nonzero() {
1696        assert_eq!(
1697            format_file_info_parts(100, 5, 2),
1698            Some("[100L, 5F, 2C]".to_string())
1699        );
1700    }
1701
1702    #[test]
1703    fn test_format_function_list_wrapped_empty() {
1704        let freq = std::collections::HashMap::new();
1705        let result = format_function_list_wrapped(std::iter::empty(), &freq);
1706        assert_eq!(result, "");
1707    }
1708
1709    #[test]
1710    fn test_format_function_list_wrapped_bullet_annotation() {
1711        use crate::types::FunctionInfo;
1712        use std::collections::HashMap;
1713
1714        let mut freq = HashMap::new();
1715        freq.insert("frequent".to_string(), 5); // count > 3 should get bullet
1716
1717        let funcs = vec![FunctionInfo {
1718            name: "frequent".to_string(),
1719            line: 1,
1720            end_line: 10,
1721            parameters: vec![],
1722            return_type: Some("void".to_string()),
1723        }];
1724
1725        let result = format_function_list_wrapped(funcs.iter(), &freq);
1726        // Should contain bullet (U+2022) followed by count
1727        assert!(result.contains("\u{2022}5"));
1728    }
1729
1730    #[test]
1731    fn test_compact_format_omits_sections() {
1732        use crate::types::{ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
1733        use std::collections::HashMap;
1734
1735        let funcs: Vec<FunctionInfo> = (0..10)
1736            .map(|i| FunctionInfo {
1737                name: format!("fn_{}", i),
1738                line: i * 5 + 1,
1739                end_line: i * 5 + 4,
1740                parameters: vec![format!("x: u32")],
1741                return_type: Some("bool".to_string()),
1742            })
1743            .collect();
1744        let imports: Vec<ImportInfo> = vec![ImportInfo {
1745            module: "std::collections".to_string(),
1746            items: vec!["HashMap".to_string()],
1747            line: 1,
1748        }];
1749        let classes: Vec<ClassInfo> = vec![ClassInfo {
1750            name: "MyStruct".to_string(),
1751            line: 100,
1752            end_line: 150,
1753            methods: vec![],
1754            fields: vec![],
1755            inherits: vec![],
1756        }];
1757        let semantic = SemanticAnalysis {
1758            functions: funcs,
1759            classes,
1760            imports,
1761            references: vec![],
1762            call_frequency: HashMap::new(),
1763            calls: vec![],
1764            impl_traits: vec![],
1765            def_use_sites: vec![],
1766        };
1767
1768        let verbose_out = format_file_details_paginated(
1769            &semantic.functions,
1770            semantic.functions.len(),
1771            &semantic,
1772            "src/lib.rs",
1773            100,
1774            0,
1775            true,
1776            None,
1777        );
1778        let compact_out = format_file_details_paginated(
1779            &semantic.functions,
1780            semantic.functions.len(),
1781            &semantic,
1782            "src/lib.rs",
1783            100,
1784            0,
1785            false,
1786            None,
1787        );
1788
1789        // Verbose includes C:, I:, F: section headers
1790        assert!(verbose_out.contains("C:\n"), "verbose must have C: section");
1791        assert!(verbose_out.contains("I:\n"), "verbose must have I: section");
1792        assert!(verbose_out.contains("F:\n"), "verbose must have F: section");
1793
1794        // Compact includes C: and F: but omits I: (imports)
1795        assert!(
1796            compact_out.contains("C:\n"),
1797            "compact must have C: section (restored)"
1798        );
1799        assert!(
1800            !compact_out.contains("I:\n"),
1801            "compact must not have I: section (imports omitted)"
1802        );
1803        assert!(
1804            compact_out.contains("F:\n"),
1805            "compact must have F: section with wrapped formatting"
1806        );
1807
1808        // Compact functions are wrapped: fn_0 and fn_1 must appear on the same line
1809        assert!(compact_out.contains("fn_0"), "compact must list functions");
1810        let has_two_on_same_line = compact_out
1811            .lines()
1812            .any(|l| l.contains("fn_0") && l.contains("fn_1"));
1813        assert!(
1814            has_two_on_same_line,
1815            "compact must render multiple functions per line (wrapped), not one-per-line"
1816        );
1817    }
1818
1819    /// Regression test: compact mode must be <= verbose for function-heavy files (no imports to mask regression).
1820    #[test]
1821    fn test_compact_mode_consistent_token_reduction() {
1822        use crate::types::{FunctionInfo, SemanticAnalysis};
1823        use std::collections::HashMap;
1824
1825        let funcs: Vec<FunctionInfo> = (0..50)
1826            .map(|i| FunctionInfo {
1827                name: format!("function_name_{}", i),
1828                line: i * 10 + 1,
1829                end_line: i * 10 + 8,
1830                parameters: vec![
1831                    "arg1: u32".to_string(),
1832                    "arg2: String".to_string(),
1833                    "arg3: Option<bool>".to_string(),
1834                ],
1835                return_type: Some("Result<Vec<String>, Error>".to_string()),
1836            })
1837            .collect();
1838
1839        let semantic = SemanticAnalysis {
1840            functions: funcs,
1841            classes: vec![],
1842            imports: vec![],
1843            references: vec![],
1844            call_frequency: HashMap::new(),
1845            calls: vec![],
1846            impl_traits: vec![],
1847            def_use_sites: vec![],
1848        };
1849
1850        let verbose_out = format_file_details_paginated(
1851            &semantic.functions,
1852            semantic.functions.len(),
1853            &semantic,
1854            "src/large_file.rs",
1855            1000,
1856            0,
1857            true,
1858            None,
1859        );
1860        let compact_out = format_file_details_paginated(
1861            &semantic.functions,
1862            semantic.functions.len(),
1863            &semantic,
1864            "src/large_file.rs",
1865            1000,
1866            0,
1867            false,
1868            None,
1869        );
1870
1871        assert!(
1872            compact_out.len() <= verbose_out.len(),
1873            "compact ({} chars) must be <= verbose ({} chars)",
1874            compact_out.len(),
1875            verbose_out.len(),
1876        );
1877    }
1878
1879    /// Edge case test: Compact mode with empty classes should not emit C: header.
1880    #[test]
1881    fn test_format_module_info_happy_path() {
1882        use crate::types::{ModuleFunctionInfo, ModuleImportInfo, ModuleInfo};
1883        let info = ModuleInfo {
1884            name: "parser.rs".to_string(),
1885            line_count: 312,
1886            language: "rust".to_string(),
1887            functions: vec![
1888                ModuleFunctionInfo {
1889                    name: "parse_file".to_string(),
1890                    line: 24,
1891                },
1892                ModuleFunctionInfo {
1893                    name: "parse_block".to_string(),
1894                    line: 58,
1895                },
1896            ],
1897            imports: vec![
1898                ModuleImportInfo {
1899                    module: "crate::types".to_string(),
1900                    items: vec!["Token".to_string(), "Expr".to_string()],
1901                },
1902                ModuleImportInfo {
1903                    module: "std::io".to_string(),
1904                    items: vec!["BufReader".to_string()],
1905                },
1906            ],
1907        };
1908        let result = format_module_info(&info);
1909        assert!(result.starts_with("FILE: parser.rs (312L, 2F, 2I)"));
1910        assert!(result.contains("F:"));
1911        assert!(result.contains("parse_file:24"));
1912        assert!(result.contains("parse_block:58"));
1913        assert!(result.contains("I:"));
1914        assert!(result.contains("crate::types:Token, Expr"));
1915        assert!(result.contains("std::io:BufReader"));
1916        assert!(result.contains("; "));
1917        assert!(!result.contains('{'));
1918    }
1919
1920    #[test]
1921    fn test_format_module_info_empty() {
1922        use crate::types::ModuleInfo;
1923        let info = ModuleInfo {
1924            name: "empty.rs".to_string(),
1925            line_count: 0,
1926            language: "rust".to_string(),
1927            functions: vec![],
1928            imports: vec![],
1929        };
1930        let result = format_module_info(&info);
1931        assert!(result.starts_with("FILE: empty.rs (0L, 0F, 0I)"));
1932        assert!(!result.contains("F:"));
1933        assert!(!result.contains("I:"));
1934    }
1935
1936    #[test]
1937    fn test_compact_mode_empty_classes_no_header() {
1938        use crate::types::{FunctionInfo, SemanticAnalysis};
1939        use std::collections::HashMap;
1940
1941        let funcs: Vec<FunctionInfo> = (0..5)
1942            .map(|i| FunctionInfo {
1943                name: format!("fn_{}", i),
1944                line: i * 5 + 1,
1945                end_line: i * 5 + 4,
1946                parameters: vec![],
1947                return_type: None,
1948            })
1949            .collect();
1950
1951        let semantic = SemanticAnalysis {
1952            functions: funcs,
1953            classes: vec![], // Empty classes
1954            imports: vec![],
1955            references: vec![],
1956            call_frequency: HashMap::new(),
1957            calls: vec![],
1958            impl_traits: vec![],
1959            def_use_sites: vec![],
1960        };
1961
1962        let compact_out = format_file_details_paginated(
1963            &semantic.functions,
1964            semantic.functions.len(),
1965            &semantic,
1966            "src/simple.rs",
1967            100,
1968            0,
1969            false,
1970            None,
1971        );
1972
1973        // Should not have stray C: header when classes are empty
1974        assert!(
1975            !compact_out.contains("C:\n"),
1976            "compact mode must not emit C: header when classes are empty"
1977        );
1978    }
1979
1980    #[test]
1981    fn test_format_classes_with_methods() {
1982        use crate::types::{ClassInfo, FunctionInfo};
1983
1984        let functions = vec![
1985            FunctionInfo {
1986                name: "method_a".to_string(),
1987                line: 5,
1988                end_line: 8,
1989                parameters: vec![],
1990                return_type: None,
1991            },
1992            FunctionInfo {
1993                name: "method_b".to_string(),
1994                line: 10,
1995                end_line: 12,
1996                parameters: vec![],
1997                return_type: None,
1998            },
1999            FunctionInfo {
2000                name: "top_level_func".to_string(),
2001                line: 50,
2002                end_line: 55,
2003                parameters: vec![],
2004                return_type: None,
2005            },
2006        ];
2007
2008        let classes = vec![ClassInfo {
2009            name: "MyClass".to_string(),
2010            line: 1,
2011            end_line: 30,
2012            methods: vec![],
2013            fields: vec![],
2014            inherits: vec![],
2015        }];
2016
2017        let output = format_classes_section(&classes, &functions);
2018
2019        assert!(
2020            output.contains("MyClass:1-30"),
2021            "class header should show start-end range"
2022        );
2023        assert!(output.contains("method_a:5"), "method_a should be listed");
2024        assert!(output.contains("method_b:10"), "method_b should be listed");
2025        assert!(
2026            !output.contains("top_level_func"),
2027            "top_level_func outside class range should not be listed"
2028        );
2029    }
2030
2031    #[test]
2032    fn test_format_classes_method_cap() {
2033        use crate::types::{ClassInfo, FunctionInfo};
2034
2035        let mut functions = Vec::new();
2036        for i in 0..15 {
2037            functions.push(FunctionInfo {
2038                name: format!("method_{}", i),
2039                line: 2 + i,
2040                end_line: 3 + i,
2041                parameters: vec![],
2042                return_type: None,
2043            });
2044        }
2045
2046        let classes = vec![ClassInfo {
2047            name: "LargeClass".to_string(),
2048            line: 1,
2049            end_line: 50,
2050            methods: vec![],
2051            fields: vec![],
2052            inherits: vec![],
2053        }];
2054
2055        let output = format_classes_section(&classes, &functions);
2056
2057        assert!(output.contains("method_0"), "first method should be listed");
2058        assert!(output.contains("method_9"), "10th method should be listed");
2059        assert!(
2060            !output.contains("method_10"),
2061            "11th method should not be listed (cap at 10)"
2062        );
2063        assert!(
2064            output.contains("... (5 more)"),
2065            "truncation message should show remaining count"
2066        );
2067    }
2068
2069    #[test]
2070    fn test_format_classes_no_methods() {
2071        use crate::types::{ClassInfo, FunctionInfo};
2072
2073        let functions = vec![FunctionInfo {
2074            name: "top_level".to_string(),
2075            line: 100,
2076            end_line: 105,
2077            parameters: vec![],
2078            return_type: None,
2079        }];
2080
2081        let classes = vec![ClassInfo {
2082            name: "EmptyClass".to_string(),
2083            line: 1,
2084            end_line: 50,
2085            methods: vec![],
2086            fields: vec![],
2087            inherits: vec![],
2088        }];
2089
2090        let output = format_classes_section(&classes, &functions);
2091
2092        assert!(
2093            output.contains("EmptyClass:1-50"),
2094            "empty class header should appear"
2095        );
2096        assert!(
2097            !output.contains("top_level"),
2098            "top-level functions outside class should not appear"
2099        );
2100    }
2101
2102    #[test]
2103    fn test_f_section_excludes_methods() {
2104        use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
2105        use std::collections::HashMap;
2106
2107        let functions = vec![
2108            FunctionInfo {
2109                name: "method_a".to_string(),
2110                line: 5,
2111                end_line: 10,
2112                parameters: vec![],
2113                return_type: None,
2114            },
2115            FunctionInfo {
2116                name: "top_level".to_string(),
2117                line: 50,
2118                end_line: 55,
2119                parameters: vec![],
2120                return_type: None,
2121            },
2122        ];
2123
2124        let semantic = SemanticAnalysis {
2125            functions,
2126            classes: vec![ClassInfo {
2127                name: "TestClass".to_string(),
2128                line: 1,
2129                end_line: 30,
2130                methods: vec![],
2131                fields: vec![],
2132                inherits: vec![],
2133            }],
2134            imports: vec![],
2135            references: vec![],
2136            call_frequency: HashMap::new(),
2137            calls: vec![],
2138            impl_traits: vec![],
2139            def_use_sites: vec![],
2140        };
2141
2142        let output = format_file_details("test.rs", &semantic, 100, false, None);
2143
2144        assert!(output.contains("C:"), "classes section should exist");
2145        assert!(
2146            output.contains("method_a:5"),
2147            "method should be in C: section"
2148        );
2149        assert!(output.contains("F:"), "F: section should exist");
2150        assert!(
2151            output.contains("top_level"),
2152            "top-level function should be in F: section"
2153        );
2154
2155        // Verify method_a is not in F: section (check sequence: C: before method_a, F: after it)
2156        let f_pos = output.find("F:").unwrap();
2157        let method_pos = output.find("method_a").unwrap();
2158        assert!(
2159            method_pos < f_pos,
2160            "method_a should appear before F: section"
2161        );
2162    }
2163
2164    #[test]
2165    fn test_format_focused_paginated_unit() {
2166        use crate::graph::InternalCallChain;
2167        use crate::pagination::PaginationMode;
2168        use std::path::PathBuf;
2169
2170        // Arrange: create mock caller chains
2171        let make_chain = |name: &str| -> InternalCallChain {
2172            InternalCallChain {
2173                chain: vec![
2174                    (name.to_string(), PathBuf::from("src/lib.rs"), 10),
2175                    ("target".to_string(), PathBuf::from("src/lib.rs"), 5),
2176                ],
2177            }
2178        };
2179
2180        let prod_chains: Vec<InternalCallChain> = (0..8)
2181            .map(|i| make_chain(&format!("caller_{}", i)))
2182            .collect();
2183        let page = &prod_chains[0..3];
2184
2185        // Act
2186        let formatted = format_focused_paginated(
2187            page,
2188            8,
2189            PaginationMode::Callers,
2190            "target",
2191            &prod_chains,
2192            &[],
2193            &[],
2194            1,
2195            0,
2196            None,
2197            true,
2198        );
2199
2200        // Assert: header present
2201        assert!(
2202            formatted.contains("CALLERS (1-3 of 8):"),
2203            "header should show 1-3 of 8, got: {}",
2204            formatted
2205        );
2206        assert!(
2207            formatted.contains("FOCUS: target"),
2208            "should have FOCUS header"
2209        );
2210    }
2211
2212    #[test]
2213    fn test_fields_none_regression() {
2214        use crate::types::SemanticAnalysis;
2215        use std::collections::HashMap;
2216
2217        let functions = vec![FunctionInfo {
2218            name: "hello".to_string(),
2219            line: 10,
2220            end_line: 15,
2221            parameters: vec![],
2222            return_type: None,
2223        }];
2224
2225        let classes = vec![ClassInfo {
2226            name: "MyClass".to_string(),
2227            line: 20,
2228            end_line: 50,
2229            methods: vec![],
2230            fields: vec![],
2231            inherits: vec![],
2232        }];
2233
2234        let imports = vec![ImportInfo {
2235            module: "std".to_string(),
2236            items: vec!["io".to_string()],
2237            line: 1,
2238        }];
2239
2240        let semantic = SemanticAnalysis {
2241            functions: functions.clone(),
2242            classes: classes.clone(),
2243            imports: imports.clone(),
2244            references: vec![],
2245            call_frequency: HashMap::new(),
2246            calls: vec![],
2247            impl_traits: vec![],
2248            def_use_sites: vec![],
2249        };
2250
2251        let output = format_file_details_paginated(
2252            &functions,
2253            functions.len(),
2254            &semantic,
2255            "test.rs",
2256            100,
2257            0,
2258            true,
2259            None,
2260        );
2261
2262        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2263        assert!(output.contains("C:"), "Classes section missing");
2264        assert!(output.contains("I:"), "Imports section missing");
2265        assert!(output.contains("F:"), "Functions section missing");
2266    }
2267
2268    #[test]
2269    fn test_fields_functions_only() {
2270        use crate::types::SemanticAnalysis;
2271        use std::collections::HashMap;
2272
2273        let functions = vec![FunctionInfo {
2274            name: "hello".to_string(),
2275            line: 10,
2276            end_line: 15,
2277            parameters: vec![],
2278            return_type: None,
2279        }];
2280
2281        let classes = vec![ClassInfo {
2282            name: "MyClass".to_string(),
2283            line: 20,
2284            end_line: 50,
2285            methods: vec![],
2286            fields: vec![],
2287            inherits: vec![],
2288        }];
2289
2290        let imports = vec![ImportInfo {
2291            module: "std".to_string(),
2292            items: vec!["io".to_string()],
2293            line: 1,
2294        }];
2295
2296        let semantic = SemanticAnalysis {
2297            functions: functions.clone(),
2298            classes: classes.clone(),
2299            imports: imports.clone(),
2300            references: vec![],
2301            call_frequency: HashMap::new(),
2302            calls: vec![],
2303            impl_traits: vec![],
2304            def_use_sites: vec![],
2305        };
2306
2307        let fields = Some(vec![AnalyzeFileField::Functions]);
2308        let output = format_file_details_paginated(
2309            &functions,
2310            functions.len(),
2311            &semantic,
2312            "test.rs",
2313            100,
2314            0,
2315            true,
2316            fields.as_deref(),
2317        );
2318
2319        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2320        assert!(!output.contains("C:"), "Classes section should not appear");
2321        assert!(!output.contains("I:"), "Imports section should not appear");
2322        assert!(output.contains("F:"), "Functions section missing");
2323    }
2324
2325    #[test]
2326    fn test_fields_classes_only() {
2327        use crate::types::SemanticAnalysis;
2328        use std::collections::HashMap;
2329
2330        let functions = vec![FunctionInfo {
2331            name: "hello".to_string(),
2332            line: 10,
2333            end_line: 15,
2334            parameters: vec![],
2335            return_type: None,
2336        }];
2337
2338        let classes = vec![ClassInfo {
2339            name: "MyClass".to_string(),
2340            line: 20,
2341            end_line: 50,
2342            methods: vec![],
2343            fields: vec![],
2344            inherits: vec![],
2345        }];
2346
2347        let imports = vec![ImportInfo {
2348            module: "std".to_string(),
2349            items: vec!["io".to_string()],
2350            line: 1,
2351        }];
2352
2353        let semantic = SemanticAnalysis {
2354            functions: functions.clone(),
2355            classes: classes.clone(),
2356            imports: imports.clone(),
2357            references: vec![],
2358            call_frequency: HashMap::new(),
2359            calls: vec![],
2360            impl_traits: vec![],
2361            def_use_sites: vec![],
2362        };
2363
2364        let fields = Some(vec![AnalyzeFileField::Classes]);
2365        let output = format_file_details_paginated(
2366            &functions,
2367            functions.len(),
2368            &semantic,
2369            "test.rs",
2370            100,
2371            0,
2372            true,
2373            fields.as_deref(),
2374        );
2375
2376        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2377        assert!(output.contains("C:"), "Classes section missing");
2378        assert!(!output.contains("I:"), "Imports section should not appear");
2379        assert!(
2380            !output.contains("F:"),
2381            "Functions section should not appear"
2382        );
2383    }
2384
2385    #[test]
2386    fn test_fields_imports_verbose() {
2387        use crate::types::SemanticAnalysis;
2388        use std::collections::HashMap;
2389
2390        let functions = vec![FunctionInfo {
2391            name: "hello".to_string(),
2392            line: 10,
2393            end_line: 15,
2394            parameters: vec![],
2395            return_type: None,
2396        }];
2397
2398        let classes = vec![ClassInfo {
2399            name: "MyClass".to_string(),
2400            line: 20,
2401            end_line: 50,
2402            methods: vec![],
2403            fields: vec![],
2404            inherits: vec![],
2405        }];
2406
2407        let imports = vec![ImportInfo {
2408            module: "std".to_string(),
2409            items: vec!["io".to_string()],
2410            line: 1,
2411        }];
2412
2413        let semantic = SemanticAnalysis {
2414            functions: functions.clone(),
2415            classes: classes.clone(),
2416            imports: imports.clone(),
2417            references: vec![],
2418            call_frequency: HashMap::new(),
2419            calls: vec![],
2420            impl_traits: vec![],
2421            def_use_sites: vec![],
2422        };
2423
2424        let fields = Some(vec![AnalyzeFileField::Imports]);
2425        let output = format_file_details_paginated(
2426            &functions,
2427            functions.len(),
2428            &semantic,
2429            "test.rs",
2430            100,
2431            0,
2432            true,
2433            fields.as_deref(),
2434        );
2435
2436        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2437        assert!(!output.contains("C:"), "Classes section should not appear");
2438        assert!(output.contains("I:"), "Imports section missing");
2439        assert!(
2440            !output.contains("F:"),
2441            "Functions section should not appear"
2442        );
2443    }
2444
2445    #[test]
2446    fn test_fields_imports_no_verbose() {
2447        use crate::types::SemanticAnalysis;
2448        use std::collections::HashMap;
2449
2450        let functions = vec![FunctionInfo {
2451            name: "hello".to_string(),
2452            line: 10,
2453            end_line: 15,
2454            parameters: vec![],
2455            return_type: None,
2456        }];
2457
2458        let classes = vec![ClassInfo {
2459            name: "MyClass".to_string(),
2460            line: 20,
2461            end_line: 50,
2462            methods: vec![],
2463            fields: vec![],
2464            inherits: vec![],
2465        }];
2466
2467        let imports = vec![ImportInfo {
2468            module: "std".to_string(),
2469            items: vec!["io".to_string()],
2470            line: 1,
2471        }];
2472
2473        let semantic = SemanticAnalysis {
2474            functions: functions.clone(),
2475            classes: classes.clone(),
2476            imports: imports.clone(),
2477            references: vec![],
2478            call_frequency: HashMap::new(),
2479            calls: vec![],
2480            impl_traits: vec![],
2481            def_use_sites: vec![],
2482        };
2483
2484        let fields = Some(vec![AnalyzeFileField::Imports]);
2485        let output = format_file_details_paginated(
2486            &functions,
2487            functions.len(),
2488            &semantic,
2489            "test.rs",
2490            100,
2491            0,
2492            false,
2493            fields.as_deref(),
2494        );
2495
2496        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2497        assert!(!output.contains("C:"), "Classes section should not appear");
2498        assert!(
2499            output.contains("I:"),
2500            "Imports section should appear (explicitly listed in fields)"
2501        );
2502        assert!(
2503            !output.contains("F:"),
2504            "Functions section should not appear"
2505        );
2506    }
2507
2508    #[test]
2509    fn test_fields_empty_array() {
2510        use crate::types::SemanticAnalysis;
2511        use std::collections::HashMap;
2512
2513        let functions = vec![FunctionInfo {
2514            name: "hello".to_string(),
2515            line: 10,
2516            end_line: 15,
2517            parameters: vec![],
2518            return_type: None,
2519        }];
2520
2521        let classes = vec![ClassInfo {
2522            name: "MyClass".to_string(),
2523            line: 20,
2524            end_line: 50,
2525            methods: vec![],
2526            fields: vec![],
2527            inherits: vec![],
2528        }];
2529
2530        let imports = vec![ImportInfo {
2531            module: "std".to_string(),
2532            items: vec!["io".to_string()],
2533            line: 1,
2534        }];
2535
2536        let semantic = SemanticAnalysis {
2537            functions: functions.clone(),
2538            classes: classes.clone(),
2539            imports: imports.clone(),
2540            references: vec![],
2541            call_frequency: HashMap::new(),
2542            calls: vec![],
2543            impl_traits: vec![],
2544            def_use_sites: vec![],
2545        };
2546
2547        let fields = Some(vec![]);
2548        let output = format_file_details_paginated(
2549            &functions,
2550            functions.len(),
2551            &semantic,
2552            "test.rs",
2553            100,
2554            0,
2555            true,
2556            fields.as_deref(),
2557        );
2558
2559        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2560        assert!(
2561            output.contains("C:"),
2562            "Classes section missing (empty fields = show all)"
2563        );
2564        assert!(
2565            output.contains("I:"),
2566            "Imports section missing (empty fields = show all)"
2567        );
2568        assert!(
2569            output.contains("F:"),
2570            "Functions section missing (empty fields = show all)"
2571        );
2572    }
2573
2574    #[test]
2575    fn test_fields_pagination_no_functions() {
2576        use crate::types::SemanticAnalysis;
2577        use std::collections::HashMap;
2578
2579        let functions = vec![FunctionInfo {
2580            name: "hello".to_string(),
2581            line: 10,
2582            end_line: 15,
2583            parameters: vec![],
2584            return_type: None,
2585        }];
2586
2587        let classes = vec![ClassInfo {
2588            name: "MyClass".to_string(),
2589            line: 20,
2590            end_line: 50,
2591            methods: vec![],
2592            fields: vec![],
2593            inherits: vec![],
2594        }];
2595
2596        let imports = vec![ImportInfo {
2597            module: "std".to_string(),
2598            items: vec!["io".to_string()],
2599            line: 1,
2600        }];
2601
2602        let semantic = SemanticAnalysis {
2603            functions: functions.clone(),
2604            classes: classes.clone(),
2605            imports: imports.clone(),
2606            references: vec![],
2607            call_frequency: HashMap::new(),
2608            calls: vec![],
2609            impl_traits: vec![],
2610            def_use_sites: vec![],
2611        };
2612
2613        let fields = Some(vec![AnalyzeFileField::Classes, AnalyzeFileField::Imports]);
2614        let output = format_file_details_paginated(
2615            &functions,
2616            functions.len(),
2617            &semantic,
2618            "test.rs",
2619            100,
2620            0,
2621            true,
2622            fields.as_deref(),
2623        );
2624
2625        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2626        assert!(
2627            output.contains("1-1/1F"),
2628            "FILE header should contain valid range (1-1/1F)"
2629        );
2630        assert!(output.contains("C:"), "Classes section missing");
2631        assert!(output.contains("I:"), "Imports section missing");
2632        assert!(
2633            !output.contains("F:"),
2634            "Functions section should not appear (filtered by fields)"
2635        );
2636    }
2637
2638    #[test]
2639    fn test_snippet_one_line_short() {
2640        let snippet = "prev line\nlet x = 1;\nnext line";
2641        let result = snippet_one_line(snippet);
2642        assert_eq!(result, "let x = 1;");
2643    }
2644
2645    #[test]
2646    fn test_snippet_one_line_single_line() {
2647        let result = snippet_one_line("only line");
2648        assert_eq!(result, "only line");
2649    }
2650
2651    #[test]
2652    fn test_format_focused_internal_with_def_use_sites() {
2653        let mut graph = CallGraph::new();
2654        graph
2655            .definitions
2656            .insert("my_var".into(), vec![(PathBuf::from("src/lib.rs"), 5)]);
2657
2658        let site = |kind, file: &str, line, snippet: &str, scope: Option<&str>| DefUseSite {
2659            kind,
2660            symbol: "my_var".into(),
2661            file: file.into(),
2662            line,
2663            column: 0,
2664            snippet: snippet.into(),
2665            enclosing_scope: scope.map(Into::into),
2666        };
2667        let sites = vec![
2668            site(
2669                DefUseKind::Write,
2670                "src/lib.rs",
2671                5,
2672                "\nlet my_var = 42;\n",
2673                Some("init"),
2674            ),
2675            site(
2676                DefUseKind::WriteRead,
2677                "src/lib.rs",
2678                10,
2679                "\nmy_var += 1;\n",
2680                None,
2681            ),
2682            site(
2683                DefUseKind::Read,
2684                "src/main.rs",
2685                20,
2686                "\nprintln!(\"{}\", my_var);\n",
2687                Some("run"),
2688            ),
2689        ];
2690
2691        let output =
2692            format_focused_internal(&graph, "my_var", 1, None, Some(&[]), Some(&[]), &sites)
2693                .expect("format should succeed");
2694
2695        assert!(output.contains("DEF-USE SITES  my_var  (3 total: 2 writes, 1 reads)"));
2696        assert!(output.contains("WRITES"));
2697        assert!(output.contains("[write_read]"));
2698        assert!(output.contains("READS"));
2699        assert!(output.contains("run()"));
2700    }
2701
2702    #[test]
2703    fn test_format_focused_summary_internal_with_def_use_sites() {
2704        let mut graph = CallGraph::new();
2705        graph
2706            .definitions
2707            .insert("counter".into(), vec![(PathBuf::from("src/a.rs"), 1)]);
2708
2709        let sites = vec![DefUseSite {
2710            kind: DefUseKind::Read,
2711            symbol: "counter".into(),
2712            file: "src/b.rs".into(),
2713            line: 15,
2714            column: 0,
2715            snippet: "\nuse_counter(counter);\n".into(),
2716            enclosing_scope: Some("main".into()),
2717        }];
2718
2719        let output = format_focused_summary_internal(
2720            &graph,
2721            "counter",
2722            1,
2723            None,
2724            Some(&[]),
2725            Some(&[]),
2726            &sites,
2727        )
2728        .expect("format should succeed");
2729
2730        assert!(output.contains("DEF-USE SITES: 1 total (0 writes, 1 reads)"));
2731    }
2732}
2733
2734fn format_classes_section(classes: &[ClassInfo], functions: &[FunctionInfo]) -> String {
2735    let mut output = String::new();
2736    if classes.is_empty() {
2737        return output;
2738    }
2739    output.push_str("C:\n");
2740
2741    let methods_by_class = collect_class_methods(classes, functions);
2742    let has_methods = methods_by_class.values().any(|m| !m.is_empty());
2743
2744    if classes.len() <= MULTILINE_THRESHOLD && !has_methods {
2745        let class_strs: Vec<String> = classes
2746            .iter()
2747            .map(|class| {
2748                if class.inherits.is_empty() {
2749                    format!("{}:{}-{}", class.name, class.line, class.end_line)
2750                } else {
2751                    format!(
2752                        "{}:{}-{} ({})",
2753                        class.name,
2754                        class.line,
2755                        class.end_line,
2756                        class.inherits.join(", ")
2757                    )
2758                }
2759            })
2760            .collect();
2761        output.push_str("  ");
2762        output.push_str(&class_strs.join("; "));
2763        output.push('\n');
2764    } else {
2765        for class in classes {
2766            if class.inherits.is_empty() {
2767                let _ = writeln!(output, "  {}:{}-{}", class.name, class.line, class.end_line);
2768            } else {
2769                let _ = writeln!(
2770                    output,
2771                    "  {}:{}-{} ({})",
2772                    class.name,
2773                    class.line,
2774                    class.end_line,
2775                    class.inherits.join(", ")
2776                );
2777            }
2778
2779            // Append methods for each class
2780            if let Some(methods) = methods_by_class.get(&class.name)
2781                && !methods.is_empty()
2782            {
2783                for (i, method) in methods.iter().take(10).enumerate() {
2784                    let _ = writeln!(output, "    {}:{}", method.name, method.line);
2785                    if i + 1 == 10 && methods.len() > 10 {
2786                        let _ = writeln!(output, "    ... ({} more)", methods.len() - 10);
2787                        break;
2788                    }
2789                }
2790            }
2791        }
2792    }
2793    output
2794}
2795
2796/// Format related files section (incoming/outgoing imports).
2797/// Returns empty string when `import_graph` is None.
2798fn format_imports_section(imports: &[ImportInfo]) -> String {
2799    let mut output = String::new();
2800    if imports.is_empty() {
2801        return output;
2802    }
2803    output.push_str("I:\n");
2804    let mut module_map: HashMap<String, usize> = HashMap::new();
2805    for import in imports {
2806        module_map
2807            .entry(import.module.clone())
2808            .and_modify(|count| *count += 1)
2809            .or_insert(1);
2810    }
2811    let mut modules: Vec<_> = module_map.keys().cloned().collect();
2812    modules.sort();
2813    let formatted_modules: Vec<String> = modules
2814        .iter()
2815        .map(|module| format!("{}({})", module, module_map[module]))
2816        .collect();
2817    if formatted_modules.len() <= MULTILINE_THRESHOLD {
2818        output.push_str("  ");
2819        output.push_str(&formatted_modules.join("; "));
2820        output.push('\n');
2821    } else {
2822        for module_str in formatted_modules {
2823            output.push_str("  ");
2824            output.push_str(&module_str);
2825            output.push('\n');
2826        }
2827    }
2828    output
2829}