Skip to main content

aptu_coder_core/
formatter.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder 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
1309        || fields.is_some_and(|f| {
1310            f.contains(&AnalyzeFileField::All) || f.contains(&AnalyzeFileField::Classes)
1311        });
1312    let show_imports = show_all
1313        || fields.is_some_and(|f| {
1314            f.contains(&AnalyzeFileField::All) || f.contains(&AnalyzeFileField::Imports)
1315        });
1316    let show_functions = show_all
1317        || fields.is_some_and(|f| {
1318            f.contains(&AnalyzeFileField::All) || f.contains(&AnalyzeFileField::Functions)
1319        });
1320
1321    // Classes section on first page for both verbose and compact modes
1322    if show_classes && offset == 0 && !semantic.classes.is_empty() {
1323        output.push_str(&format_classes_section(
1324            &semantic.classes,
1325            &semantic.functions,
1326        ));
1327    }
1328
1329    // Imports section only on first page in verbose mode, or when explicitly listed in fields
1330    if show_imports && offset == 0 && (verbose || !show_all) {
1331        output.push_str(&format_imports_section(&semantic.imports));
1332    }
1333
1334    // F: section with paginated function slice (exclude methods)
1335    let top_level_functions: Vec<&FunctionInfo> = functions_page
1336        .iter()
1337        .filter(|func| {
1338            !semantic
1339                .classes
1340                .iter()
1341                .any(|class| is_method_of_class(func, class))
1342        })
1343        .collect();
1344
1345    if show_functions && !top_level_functions.is_empty() {
1346        output.push_str("F:\n");
1347        output.push_str(&format_function_list_wrapped(
1348            top_level_functions.iter().copied(),
1349            &semantic.call_frequency,
1350        ));
1351    }
1352
1353    output
1354}
1355
1356/// Format a paginated subset of callers or callees for `SymbolFocus` mode.
1357/// Mode is determined by the `mode` parameter:
1358/// - `PaginationMode::Callers`: paginate production callers; show test callers summary and callees summary.
1359/// - `PaginationMode::Callees`: paginate callees; show callers summary and test callers summary.
1360#[instrument(skip_all)]
1361#[allow(clippy::too_many_arguments)]
1362#[allow(clippy::similar_names)] // domain-appropriate pairs (callers_count/callees_count); renaming harms clarity
1363pub fn format_focused_paginated(
1364    paginated_chains: &[InternalCallChain],
1365    total: usize,
1366    mode: PaginationMode,
1367    symbol: &str,
1368    prod_chains: &[InternalCallChain],
1369    test_chains: &[InternalCallChain],
1370    outgoing_chains: &[InternalCallChain],
1371    def_count: usize,
1372    offset: usize,
1373    base_path: Option<&Path>,
1374    _verbose: bool,
1375) -> String {
1376    let start = offset + 1; // 1-indexed
1377    let end = offset + paginated_chains.len();
1378
1379    let callers_count = prod_chains.len();
1380
1381    let callees_count = outgoing_chains.len();
1382
1383    let mut output = String::new();
1384
1385    let _ = writeln!(
1386        output,
1387        "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
1388    );
1389
1390    match mode {
1391        PaginationMode::Callers => {
1392            // Paginate production callers
1393            let _ = writeln!(output, "CALLERS ({start}-{end} of {total}):");
1394
1395            let page_refs: Vec<_> = paginated_chains
1396                .iter()
1397                .filter_map(|chain| {
1398                    if chain.chain.len() >= 2 {
1399                        Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1400                    } else if chain.chain.len() == 1 {
1401                        Some((chain.chain[0].0.as_str(), ""))
1402                    } else {
1403                        None
1404                    }
1405                })
1406                .collect();
1407
1408            if page_refs.is_empty() {
1409                output.push_str("  (none)\n");
1410            } else {
1411                output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1412            }
1413
1414            // Test callers summary
1415            if !test_chains.is_empty() {
1416                let mut test_files: Vec<_> = test_chains
1417                    .iter()
1418                    .filter_map(|chain| {
1419                        chain
1420                            .chain
1421                            .first()
1422                            .map(|(_, path, _)| path.to_string_lossy().into_owned())
1423                    })
1424                    .collect();
1425                test_files.sort();
1426                test_files.dedup();
1427
1428                let display_files: Vec<_> = test_files
1429                    .iter()
1430                    .map(|f| strip_base_path(std::path::Path::new(f), base_path))
1431                    .collect();
1432
1433                let _ = writeln!(
1434                    output,
1435                    "CALLERS (test): {} test functions (in {})",
1436                    test_chains.len(),
1437                    display_files.join(", ")
1438                );
1439            }
1440
1441            // Callees summary
1442            let callee_names: Vec<_> = outgoing_chains
1443                .iter()
1444                .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1445                .collect::<std::collections::HashSet<_>>()
1446                .into_iter()
1447                .collect();
1448            if callee_names.is_empty() {
1449                output.push_str("CALLEES: (none)\n");
1450            } else {
1451                let _ = writeln!(
1452                    output,
1453                    "CALLEES: {callees_count} (use cursor for callee pagination)"
1454                );
1455            }
1456        }
1457        PaginationMode::Callees => {
1458            // Callers summary
1459            let _ = writeln!(output, "CALLERS: {callers_count} production callers");
1460
1461            // Test callers summary
1462            if !test_chains.is_empty() {
1463                let _ = writeln!(
1464                    output,
1465                    "CALLERS (test): {} test functions",
1466                    test_chains.len()
1467                );
1468            }
1469
1470            // Paginate callees
1471            let _ = writeln!(output, "CALLEES ({start}-{end} of {total}):");
1472
1473            let page_refs: Vec<_> = paginated_chains
1474                .iter()
1475                .filter_map(|chain| {
1476                    if chain.chain.len() >= 2 {
1477                        Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1478                    } else if chain.chain.len() == 1 {
1479                        Some((chain.chain[0].0.as_str(), ""))
1480                    } else {
1481                        None
1482                    }
1483                })
1484                .collect();
1485
1486            if page_refs.is_empty() {
1487                output.push_str("  (none)\n");
1488            } else {
1489                output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1490            }
1491        }
1492        PaginationMode::Default => {
1493            unreachable!("format_focused_paginated called with PaginationMode::Default")
1494        }
1495        PaginationMode::DefUse => {
1496            unreachable!("format_focused_paginated called with PaginationMode::DefUse")
1497        }
1498    }
1499
1500    output
1501}
1502
1503fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1504    let mut parts = Vec::new();
1505    if file.line_count > 0 {
1506        parts.push(format!("{}L", file.line_count));
1507    }
1508    if file.function_count > 0 {
1509        parts.push(format!("{}F", file.function_count));
1510    }
1511    if file.class_count > 0 {
1512        parts.push(format!("{}C", file.class_count));
1513    }
1514    let display_path = strip_base_path(Path::new(&file.path), base_path);
1515    if parts.is_empty() {
1516        format!("{display_path}\n")
1517    } else {
1518        format!("{display_path} [{}]\n", parts.join(", "))
1519    }
1520}
1521
1522/// Format a [`ModuleInfo`] into a compact single-block string.
1523///
1524/// Output format:
1525/// ```text
1526/// FILE: <name> (<line_count>L, <fn_count>F, <import_count>I)
1527/// F:
1528///   func1:10, func2:42
1529/// I:
1530///   module1:item1, item2; module2:item1; module3
1531/// ```
1532///
1533/// The `F:` section is omitted when there are no functions; likewise `I:` when
1534/// there are no imports.
1535#[instrument(skip_all)]
1536pub fn format_module_info(info: &ModuleInfo) -> String {
1537    use std::fmt::Write as _;
1538    let fn_count = info.functions.len();
1539    let import_count = info.imports.len();
1540    let mut out = String::with_capacity(64 + fn_count * 24 + import_count * 32);
1541    let _ = writeln!(
1542        out,
1543        "FILE: {} ({}L, {}F, {}I)",
1544        info.name, info.line_count, fn_count, import_count
1545    );
1546    if !info.functions.is_empty() {
1547        out.push_str("F:\n  ");
1548        let parts: Vec<String> = info
1549            .functions
1550            .iter()
1551            .map(|f| format!("{}:{}", f.name, f.line))
1552            .collect();
1553        out.push_str(&parts.join(", "));
1554        out.push('\n');
1555    }
1556    if !info.imports.is_empty() {
1557        out.push_str("I:\n  ");
1558        let parts: Vec<String> = info
1559            .imports
1560            .iter()
1561            .map(|i| {
1562                if i.items.is_empty() {
1563                    i.module.clone()
1564                } else {
1565                    format!("{}:{}", i.module, i.items.join(", "))
1566                }
1567            })
1568            .collect();
1569        out.push_str(&parts.join("; "));
1570        out.push('\n');
1571    }
1572    out
1573}
1574
1575#[cfg(test)]
1576mod tests {
1577    use super::*;
1578
1579    #[test]
1580    fn test_strip_base_path_relative() {
1581        let path = Path::new("/home/user/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, "src/main.rs");
1585    }
1586
1587    #[test]
1588    fn test_strip_base_path_fallback_absolute() {
1589        let path = Path::new("/other/project/src/main.rs");
1590        let base = Path::new("/home/user/project");
1591        let result = strip_base_path(path, Some(base));
1592        assert_eq!(result, "/other/project/src/main.rs");
1593    }
1594
1595    #[test]
1596    fn test_strip_base_path_none() {
1597        let path = Path::new("/home/user/project/src/main.rs");
1598        let result = strip_base_path(path, None);
1599        assert_eq!(result, "/home/user/project/src/main.rs");
1600    }
1601
1602    #[test]
1603    fn test_format_file_details_summary_empty() {
1604        use crate::types::SemanticAnalysis;
1605        use std::collections::HashMap;
1606
1607        let semantic = SemanticAnalysis {
1608            functions: vec![],
1609            classes: vec![],
1610            imports: vec![],
1611            references: vec![],
1612            call_frequency: HashMap::new(),
1613            calls: vec![],
1614            impl_traits: vec![],
1615            def_use_sites: vec![],
1616        };
1617
1618        let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1619
1620        // Should contain FILE header, Imports count, and SUGGESTION
1621        assert!(result.contains("FILE:"));
1622        assert!(result.contains("100L, 0F, 0C"));
1623        assert!(result.contains("src/main.rs"));
1624        assert!(result.contains("Imports: 0"));
1625        assert!(result.contains("SUGGESTION:"));
1626    }
1627
1628    #[test]
1629    fn test_format_file_details_summary_with_functions() {
1630        use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1631        use std::collections::HashMap;
1632
1633        let semantic = SemanticAnalysis {
1634            functions: vec![
1635                FunctionInfo {
1636                    name: "short".to_string(),
1637                    line: 10,
1638                    end_line: 12,
1639                    parameters: vec![],
1640                    return_type: None,
1641                },
1642                FunctionInfo {
1643                    name: "long_function".to_string(),
1644                    line: 20,
1645                    end_line: 50,
1646                    parameters: vec!["x".to_string(), "y".to_string()],
1647                    return_type: Some("i32".to_string()),
1648                },
1649            ],
1650            classes: vec![ClassInfo {
1651                name: "MyClass".to_string(),
1652                line: 60,
1653                end_line: 80,
1654                methods: vec![],
1655                fields: vec![],
1656                inherits: vec![],
1657            }],
1658            imports: vec![],
1659            references: vec![],
1660            call_frequency: HashMap::new(),
1661            calls: vec![],
1662            impl_traits: vec![],
1663            def_use_sites: vec![],
1664        };
1665
1666        let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1667
1668        // Should contain FILE header with counts
1669        assert!(result.contains("FILE:"));
1670        assert!(result.contains("src/lib.rs"));
1671        assert!(result.contains("250L, 2F, 1C"));
1672
1673        // Should contain TOP FUNCTIONS BY SIZE with longest first
1674        assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1675        let long_idx = result.find("long_function").unwrap_or(0);
1676        let short_idx = result.find("short").unwrap_or(0);
1677        assert!(
1678            long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1679            "long_function should appear before short"
1680        );
1681
1682        // Should contain classes inline
1683        assert!(result.contains("CLASSES:"));
1684        assert!(result.contains("MyClass:"));
1685
1686        // Should contain import count
1687        assert!(result.contains("Imports: 0"));
1688    }
1689    #[test]
1690    fn test_format_file_info_parts_all_zero() {
1691        assert_eq!(format_file_info_parts(0, 0, 0), None);
1692    }
1693
1694    #[test]
1695    fn test_format_file_info_parts_partial() {
1696        assert_eq!(
1697            format_file_info_parts(42, 0, 3),
1698            Some("[42L, 3C]".to_string())
1699        );
1700    }
1701
1702    #[test]
1703    fn test_format_file_info_parts_all_nonzero() {
1704        assert_eq!(
1705            format_file_info_parts(100, 5, 2),
1706            Some("[100L, 5F, 2C]".to_string())
1707        );
1708    }
1709
1710    #[test]
1711    fn test_format_function_list_wrapped_empty() {
1712        let freq = std::collections::HashMap::new();
1713        let result = format_function_list_wrapped(std::iter::empty(), &freq);
1714        assert_eq!(result, "");
1715    }
1716
1717    #[test]
1718    fn test_format_function_list_wrapped_bullet_annotation() {
1719        use crate::types::FunctionInfo;
1720        use std::collections::HashMap;
1721
1722        let mut freq = HashMap::new();
1723        freq.insert("frequent".to_string(), 5); // count > 3 should get bullet
1724
1725        let funcs = vec![FunctionInfo {
1726            name: "frequent".to_string(),
1727            line: 1,
1728            end_line: 10,
1729            parameters: vec![],
1730            return_type: Some("void".to_string()),
1731        }];
1732
1733        let result = format_function_list_wrapped(funcs.iter(), &freq);
1734        // Should contain bullet (U+2022) followed by count
1735        assert!(result.contains("\u{2022}5"));
1736    }
1737
1738    #[test]
1739    fn test_compact_format_omits_sections() {
1740        use crate::types::{ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
1741        use std::collections::HashMap;
1742
1743        let funcs: Vec<FunctionInfo> = (0..10)
1744            .map(|i| FunctionInfo {
1745                name: format!("fn_{}", i),
1746                line: i * 5 + 1,
1747                end_line: i * 5 + 4,
1748                parameters: vec![format!("x: u32")],
1749                return_type: Some("bool".to_string()),
1750            })
1751            .collect();
1752        let imports: Vec<ImportInfo> = vec![ImportInfo {
1753            module: "std::collections".to_string(),
1754            items: vec!["HashMap".to_string()],
1755            line: 1,
1756        }];
1757        let classes: Vec<ClassInfo> = vec![ClassInfo {
1758            name: "MyStruct".to_string(),
1759            line: 100,
1760            end_line: 150,
1761            methods: vec![],
1762            fields: vec![],
1763            inherits: vec![],
1764        }];
1765        let semantic = SemanticAnalysis {
1766            functions: funcs,
1767            classes,
1768            imports,
1769            references: vec![],
1770            call_frequency: HashMap::new(),
1771            calls: vec![],
1772            impl_traits: vec![],
1773            def_use_sites: vec![],
1774        };
1775
1776        let verbose_out = format_file_details_paginated(
1777            &semantic.functions,
1778            semantic.functions.len(),
1779            &semantic,
1780            "src/lib.rs",
1781            100,
1782            0,
1783            true,
1784            None,
1785        );
1786        let compact_out = format_file_details_paginated(
1787            &semantic.functions,
1788            semantic.functions.len(),
1789            &semantic,
1790            "src/lib.rs",
1791            100,
1792            0,
1793            false,
1794            None,
1795        );
1796
1797        // Verbose includes C:, I:, F: section headers
1798        assert!(verbose_out.contains("C:\n"), "verbose must have C: section");
1799        assert!(verbose_out.contains("I:\n"), "verbose must have I: section");
1800        assert!(verbose_out.contains("F:\n"), "verbose must have F: section");
1801
1802        // Compact includes C: and F: but omits I: (imports)
1803        assert!(
1804            compact_out.contains("C:\n"),
1805            "compact must have C: section (restored)"
1806        );
1807        assert!(
1808            !compact_out.contains("I:\n"),
1809            "compact must not have I: section (imports omitted)"
1810        );
1811        assert!(
1812            compact_out.contains("F:\n"),
1813            "compact must have F: section with wrapped formatting"
1814        );
1815
1816        // Compact functions are wrapped: fn_0 and fn_1 must appear on the same line
1817        assert!(compact_out.contains("fn_0"), "compact must list functions");
1818        let has_two_on_same_line = compact_out
1819            .lines()
1820            .any(|l| l.contains("fn_0") && l.contains("fn_1"));
1821        assert!(
1822            has_two_on_same_line,
1823            "compact must render multiple functions per line (wrapped), not one-per-line"
1824        );
1825    }
1826
1827    /// Regression test: compact mode must be <= verbose for function-heavy files (no imports to mask regression).
1828    #[test]
1829    fn test_compact_mode_consistent_token_reduction() {
1830        use crate::types::{FunctionInfo, SemanticAnalysis};
1831        use std::collections::HashMap;
1832
1833        let funcs: Vec<FunctionInfo> = (0..50)
1834            .map(|i| FunctionInfo {
1835                name: format!("function_name_{}", i),
1836                line: i * 10 + 1,
1837                end_line: i * 10 + 8,
1838                parameters: vec![
1839                    "arg1: u32".to_string(),
1840                    "arg2: String".to_string(),
1841                    "arg3: Option<bool>".to_string(),
1842                ],
1843                return_type: Some("Result<Vec<String>, Error>".to_string()),
1844            })
1845            .collect();
1846
1847        let semantic = SemanticAnalysis {
1848            functions: funcs,
1849            classes: vec![],
1850            imports: vec![],
1851            references: vec![],
1852            call_frequency: HashMap::new(),
1853            calls: vec![],
1854            impl_traits: vec![],
1855            def_use_sites: vec![],
1856        };
1857
1858        let verbose_out = format_file_details_paginated(
1859            &semantic.functions,
1860            semantic.functions.len(),
1861            &semantic,
1862            "src/large_file.rs",
1863            1000,
1864            0,
1865            true,
1866            None,
1867        );
1868        let compact_out = format_file_details_paginated(
1869            &semantic.functions,
1870            semantic.functions.len(),
1871            &semantic,
1872            "src/large_file.rs",
1873            1000,
1874            0,
1875            false,
1876            None,
1877        );
1878
1879        assert!(
1880            compact_out.len() <= verbose_out.len(),
1881            "compact ({} chars) must be <= verbose ({} chars)",
1882            compact_out.len(),
1883            verbose_out.len(),
1884        );
1885    }
1886
1887    /// Edge case test: Compact mode with empty classes should not emit C: header.
1888    #[test]
1889    fn test_format_module_info_happy_path() {
1890        use crate::types::{ModuleFunctionInfo, ModuleImportInfo, ModuleInfo};
1891        let info = ModuleInfo {
1892            name: "parser.rs".to_string(),
1893            line_count: 312,
1894            language: "rust".to_string(),
1895            functions: vec![
1896                ModuleFunctionInfo {
1897                    name: "parse_file".to_string(),
1898                    line: 24,
1899                },
1900                ModuleFunctionInfo {
1901                    name: "parse_block".to_string(),
1902                    line: 58,
1903                },
1904            ],
1905            imports: vec![
1906                ModuleImportInfo {
1907                    module: "crate::types".to_string(),
1908                    items: vec!["Token".to_string(), "Expr".to_string()],
1909                },
1910                ModuleImportInfo {
1911                    module: "std::io".to_string(),
1912                    items: vec!["BufReader".to_string()],
1913                },
1914            ],
1915        };
1916        let result = format_module_info(&info);
1917        assert!(result.starts_with("FILE: parser.rs (312L, 2F, 2I)"));
1918        assert!(result.contains("F:"));
1919        assert!(result.contains("parse_file:24"));
1920        assert!(result.contains("parse_block:58"));
1921        assert!(result.contains("I:"));
1922        assert!(result.contains("crate::types:Token, Expr"));
1923        assert!(result.contains("std::io:BufReader"));
1924        assert!(result.contains("; "));
1925        assert!(!result.contains('{'));
1926    }
1927
1928    #[test]
1929    fn test_format_module_info_empty() {
1930        use crate::types::ModuleInfo;
1931        let info = ModuleInfo {
1932            name: "empty.rs".to_string(),
1933            line_count: 0,
1934            language: "rust".to_string(),
1935            functions: vec![],
1936            imports: vec![],
1937        };
1938        let result = format_module_info(&info);
1939        assert!(result.starts_with("FILE: empty.rs (0L, 0F, 0I)"));
1940        assert!(!result.contains("F:"));
1941        assert!(!result.contains("I:"));
1942    }
1943
1944    #[test]
1945    fn test_compact_mode_empty_classes_no_header() {
1946        use crate::types::{FunctionInfo, SemanticAnalysis};
1947        use std::collections::HashMap;
1948
1949        let funcs: Vec<FunctionInfo> = (0..5)
1950            .map(|i| FunctionInfo {
1951                name: format!("fn_{}", i),
1952                line: i * 5 + 1,
1953                end_line: i * 5 + 4,
1954                parameters: vec![],
1955                return_type: None,
1956            })
1957            .collect();
1958
1959        let semantic = SemanticAnalysis {
1960            functions: funcs,
1961            classes: vec![], // Empty classes
1962            imports: vec![],
1963            references: vec![],
1964            call_frequency: HashMap::new(),
1965            calls: vec![],
1966            impl_traits: vec![],
1967            def_use_sites: vec![],
1968        };
1969
1970        let compact_out = format_file_details_paginated(
1971            &semantic.functions,
1972            semantic.functions.len(),
1973            &semantic,
1974            "src/simple.rs",
1975            100,
1976            0,
1977            false,
1978            None,
1979        );
1980
1981        // Should not have stray C: header when classes are empty
1982        assert!(
1983            !compact_out.contains("C:\n"),
1984            "compact mode must not emit C: header when classes are empty"
1985        );
1986    }
1987
1988    #[test]
1989    fn test_format_classes_with_methods() {
1990        use crate::types::{ClassInfo, FunctionInfo};
1991
1992        let functions = vec![
1993            FunctionInfo {
1994                name: "method_a".to_string(),
1995                line: 5,
1996                end_line: 8,
1997                parameters: vec![],
1998                return_type: None,
1999            },
2000            FunctionInfo {
2001                name: "method_b".to_string(),
2002                line: 10,
2003                end_line: 12,
2004                parameters: vec![],
2005                return_type: None,
2006            },
2007            FunctionInfo {
2008                name: "top_level_func".to_string(),
2009                line: 50,
2010                end_line: 55,
2011                parameters: vec![],
2012                return_type: None,
2013            },
2014        ];
2015
2016        let classes = vec![ClassInfo {
2017            name: "MyClass".to_string(),
2018            line: 1,
2019            end_line: 30,
2020            methods: vec![],
2021            fields: vec![],
2022            inherits: vec![],
2023        }];
2024
2025        let output = format_classes_section(&classes, &functions);
2026
2027        assert!(
2028            output.contains("MyClass:1-30"),
2029            "class header should show start-end range"
2030        );
2031        assert!(output.contains("method_a:5"), "method_a should be listed");
2032        assert!(output.contains("method_b:10"), "method_b should be listed");
2033        assert!(
2034            !output.contains("top_level_func"),
2035            "top_level_func outside class range should not be listed"
2036        );
2037    }
2038
2039    #[test]
2040    fn test_format_classes_method_cap() {
2041        use crate::types::{ClassInfo, FunctionInfo};
2042
2043        let mut functions = Vec::new();
2044        for i in 0..15 {
2045            functions.push(FunctionInfo {
2046                name: format!("method_{}", i),
2047                line: 2 + i,
2048                end_line: 3 + i,
2049                parameters: vec![],
2050                return_type: None,
2051            });
2052        }
2053
2054        let classes = vec![ClassInfo {
2055            name: "LargeClass".to_string(),
2056            line: 1,
2057            end_line: 50,
2058            methods: vec![],
2059            fields: vec![],
2060            inherits: vec![],
2061        }];
2062
2063        let output = format_classes_section(&classes, &functions);
2064
2065        assert!(output.contains("method_0"), "first method should be listed");
2066        assert!(output.contains("method_9"), "10th method should be listed");
2067        assert!(
2068            !output.contains("method_10"),
2069            "11th method should not be listed (cap at 10)"
2070        );
2071        assert!(
2072            output.contains("... (5 more)"),
2073            "truncation message should show remaining count"
2074        );
2075    }
2076
2077    #[test]
2078    fn test_format_classes_no_methods() {
2079        use crate::types::{ClassInfo, FunctionInfo};
2080
2081        let functions = vec![FunctionInfo {
2082            name: "top_level".to_string(),
2083            line: 100,
2084            end_line: 105,
2085            parameters: vec![],
2086            return_type: None,
2087        }];
2088
2089        let classes = vec![ClassInfo {
2090            name: "EmptyClass".to_string(),
2091            line: 1,
2092            end_line: 50,
2093            methods: vec![],
2094            fields: vec![],
2095            inherits: vec![],
2096        }];
2097
2098        let output = format_classes_section(&classes, &functions);
2099
2100        assert!(
2101            output.contains("EmptyClass:1-50"),
2102            "empty class header should appear"
2103        );
2104        assert!(
2105            !output.contains("top_level"),
2106            "top-level functions outside class should not appear"
2107        );
2108    }
2109
2110    #[test]
2111    fn test_f_section_excludes_methods() {
2112        use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
2113        use std::collections::HashMap;
2114
2115        let functions = vec![
2116            FunctionInfo {
2117                name: "method_a".to_string(),
2118                line: 5,
2119                end_line: 10,
2120                parameters: vec![],
2121                return_type: None,
2122            },
2123            FunctionInfo {
2124                name: "top_level".to_string(),
2125                line: 50,
2126                end_line: 55,
2127                parameters: vec![],
2128                return_type: None,
2129            },
2130        ];
2131
2132        let semantic = SemanticAnalysis {
2133            functions,
2134            classes: vec![ClassInfo {
2135                name: "TestClass".to_string(),
2136                line: 1,
2137                end_line: 30,
2138                methods: vec![],
2139                fields: vec![],
2140                inherits: vec![],
2141            }],
2142            imports: vec![],
2143            references: vec![],
2144            call_frequency: HashMap::new(),
2145            calls: vec![],
2146            impl_traits: vec![],
2147            def_use_sites: vec![],
2148        };
2149
2150        let output = format_file_details("test.rs", &semantic, 100, false, None);
2151
2152        assert!(output.contains("C:"), "classes section should exist");
2153        assert!(
2154            output.contains("method_a:5"),
2155            "method should be in C: section"
2156        );
2157        assert!(output.contains("F:"), "F: section should exist");
2158        assert!(
2159            output.contains("top_level"),
2160            "top-level function should be in F: section"
2161        );
2162
2163        // Verify method_a is not in F: section (check sequence: C: before method_a, F: after it)
2164        let f_pos = output.find("F:").unwrap();
2165        let method_pos = output.find("method_a").unwrap();
2166        assert!(
2167            method_pos < f_pos,
2168            "method_a should appear before F: section"
2169        );
2170    }
2171
2172    #[test]
2173    fn test_format_focused_paginated_unit() {
2174        use crate::graph::InternalCallChain;
2175        use crate::pagination::PaginationMode;
2176        use std::path::PathBuf;
2177
2178        // Arrange: create mock caller chains
2179        let make_chain = |name: &str| -> InternalCallChain {
2180            InternalCallChain {
2181                chain: vec![
2182                    (name.to_string(), PathBuf::from("src/lib.rs"), 10),
2183                    ("target".to_string(), PathBuf::from("src/lib.rs"), 5),
2184                ],
2185            }
2186        };
2187
2188        let prod_chains: Vec<InternalCallChain> = (0..8)
2189            .map(|i| make_chain(&format!("caller_{}", i)))
2190            .collect();
2191        let page = &prod_chains[0..3];
2192
2193        // Act
2194        let formatted = format_focused_paginated(
2195            page,
2196            8,
2197            PaginationMode::Callers,
2198            "target",
2199            &prod_chains,
2200            &[],
2201            &[],
2202            1,
2203            0,
2204            None,
2205            true,
2206        );
2207
2208        // Assert: header present
2209        assert!(
2210            formatted.contains("CALLERS (1-3 of 8):"),
2211            "header should show 1-3 of 8, got: {}",
2212            formatted
2213        );
2214        assert!(
2215            formatted.contains("FOCUS: target"),
2216            "should have FOCUS header"
2217        );
2218    }
2219
2220    #[test]
2221    fn test_fields_none_regression() {
2222        use crate::types::SemanticAnalysis;
2223        use std::collections::HashMap;
2224
2225        let functions = vec![FunctionInfo {
2226            name: "hello".to_string(),
2227            line: 10,
2228            end_line: 15,
2229            parameters: vec![],
2230            return_type: None,
2231        }];
2232
2233        let classes = vec![ClassInfo {
2234            name: "MyClass".to_string(),
2235            line: 20,
2236            end_line: 50,
2237            methods: vec![],
2238            fields: vec![],
2239            inherits: vec![],
2240        }];
2241
2242        let imports = vec![ImportInfo {
2243            module: "std".to_string(),
2244            items: vec!["io".to_string()],
2245            line: 1,
2246        }];
2247
2248        let semantic = SemanticAnalysis {
2249            functions: functions.clone(),
2250            classes: classes.clone(),
2251            imports: imports.clone(),
2252            references: vec![],
2253            call_frequency: HashMap::new(),
2254            calls: vec![],
2255            impl_traits: vec![],
2256            def_use_sites: vec![],
2257        };
2258
2259        let output = format_file_details_paginated(
2260            &functions,
2261            functions.len(),
2262            &semantic,
2263            "test.rs",
2264            100,
2265            0,
2266            true,
2267            None,
2268        );
2269
2270        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2271        assert!(output.contains("C:"), "Classes section missing");
2272        assert!(output.contains("I:"), "Imports section missing");
2273        assert!(output.contains("F:"), "Functions section missing");
2274    }
2275
2276    #[test]
2277    fn test_fields_functions_only() {
2278        use crate::types::SemanticAnalysis;
2279        use std::collections::HashMap;
2280
2281        let functions = vec![FunctionInfo {
2282            name: "hello".to_string(),
2283            line: 10,
2284            end_line: 15,
2285            parameters: vec![],
2286            return_type: None,
2287        }];
2288
2289        let classes = vec![ClassInfo {
2290            name: "MyClass".to_string(),
2291            line: 20,
2292            end_line: 50,
2293            methods: vec![],
2294            fields: vec![],
2295            inherits: vec![],
2296        }];
2297
2298        let imports = vec![ImportInfo {
2299            module: "std".to_string(),
2300            items: vec!["io".to_string()],
2301            line: 1,
2302        }];
2303
2304        let semantic = SemanticAnalysis {
2305            functions: functions.clone(),
2306            classes: classes.clone(),
2307            imports: imports.clone(),
2308            references: vec![],
2309            call_frequency: HashMap::new(),
2310            calls: vec![],
2311            impl_traits: vec![],
2312            def_use_sites: vec![],
2313        };
2314
2315        let fields = Some(vec![AnalyzeFileField::Functions]);
2316        let output = format_file_details_paginated(
2317            &functions,
2318            functions.len(),
2319            &semantic,
2320            "test.rs",
2321            100,
2322            0,
2323            true,
2324            fields.as_deref(),
2325        );
2326
2327        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2328        assert!(!output.contains("C:"), "Classes section should not appear");
2329        assert!(!output.contains("I:"), "Imports section should not appear");
2330        assert!(output.contains("F:"), "Functions section missing");
2331    }
2332
2333    #[test]
2334    fn test_fields_classes_only() {
2335        use crate::types::SemanticAnalysis;
2336        use std::collections::HashMap;
2337
2338        let functions = vec![FunctionInfo {
2339            name: "hello".to_string(),
2340            line: 10,
2341            end_line: 15,
2342            parameters: vec![],
2343            return_type: None,
2344        }];
2345
2346        let classes = vec![ClassInfo {
2347            name: "MyClass".to_string(),
2348            line: 20,
2349            end_line: 50,
2350            methods: vec![],
2351            fields: vec![],
2352            inherits: vec![],
2353        }];
2354
2355        let imports = vec![ImportInfo {
2356            module: "std".to_string(),
2357            items: vec!["io".to_string()],
2358            line: 1,
2359        }];
2360
2361        let semantic = SemanticAnalysis {
2362            functions: functions.clone(),
2363            classes: classes.clone(),
2364            imports: imports.clone(),
2365            references: vec![],
2366            call_frequency: HashMap::new(),
2367            calls: vec![],
2368            impl_traits: vec![],
2369            def_use_sites: vec![],
2370        };
2371
2372        let fields = Some(vec![AnalyzeFileField::Classes]);
2373        let output = format_file_details_paginated(
2374            &functions,
2375            functions.len(),
2376            &semantic,
2377            "test.rs",
2378            100,
2379            0,
2380            true,
2381            fields.as_deref(),
2382        );
2383
2384        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2385        assert!(output.contains("C:"), "Classes section missing");
2386        assert!(!output.contains("I:"), "Imports section should not appear");
2387        assert!(
2388            !output.contains("F:"),
2389            "Functions section should not appear"
2390        );
2391    }
2392
2393    #[test]
2394    fn test_fields_imports_verbose() {
2395        use crate::types::SemanticAnalysis;
2396        use std::collections::HashMap;
2397
2398        let functions = vec![FunctionInfo {
2399            name: "hello".to_string(),
2400            line: 10,
2401            end_line: 15,
2402            parameters: vec![],
2403            return_type: None,
2404        }];
2405
2406        let classes = vec![ClassInfo {
2407            name: "MyClass".to_string(),
2408            line: 20,
2409            end_line: 50,
2410            methods: vec![],
2411            fields: vec![],
2412            inherits: vec![],
2413        }];
2414
2415        let imports = vec![ImportInfo {
2416            module: "std".to_string(),
2417            items: vec!["io".to_string()],
2418            line: 1,
2419        }];
2420
2421        let semantic = SemanticAnalysis {
2422            functions: functions.clone(),
2423            classes: classes.clone(),
2424            imports: imports.clone(),
2425            references: vec![],
2426            call_frequency: HashMap::new(),
2427            calls: vec![],
2428            impl_traits: vec![],
2429            def_use_sites: vec![],
2430        };
2431
2432        let fields = Some(vec![AnalyzeFileField::Imports]);
2433        let output = format_file_details_paginated(
2434            &functions,
2435            functions.len(),
2436            &semantic,
2437            "test.rs",
2438            100,
2439            0,
2440            true,
2441            fields.as_deref(),
2442        );
2443
2444        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2445        assert!(!output.contains("C:"), "Classes section should not appear");
2446        assert!(output.contains("I:"), "Imports section missing");
2447        assert!(
2448            !output.contains("F:"),
2449            "Functions section should not appear"
2450        );
2451    }
2452
2453    #[test]
2454    fn test_fields_imports_no_verbose() {
2455        use crate::types::SemanticAnalysis;
2456        use std::collections::HashMap;
2457
2458        let functions = vec![FunctionInfo {
2459            name: "hello".to_string(),
2460            line: 10,
2461            end_line: 15,
2462            parameters: vec![],
2463            return_type: None,
2464        }];
2465
2466        let classes = vec![ClassInfo {
2467            name: "MyClass".to_string(),
2468            line: 20,
2469            end_line: 50,
2470            methods: vec![],
2471            fields: vec![],
2472            inherits: vec![],
2473        }];
2474
2475        let imports = vec![ImportInfo {
2476            module: "std".to_string(),
2477            items: vec!["io".to_string()],
2478            line: 1,
2479        }];
2480
2481        let semantic = SemanticAnalysis {
2482            functions: functions.clone(),
2483            classes: classes.clone(),
2484            imports: imports.clone(),
2485            references: vec![],
2486            call_frequency: HashMap::new(),
2487            calls: vec![],
2488            impl_traits: vec![],
2489            def_use_sites: vec![],
2490        };
2491
2492        let fields = Some(vec![AnalyzeFileField::Imports]);
2493        let output = format_file_details_paginated(
2494            &functions,
2495            functions.len(),
2496            &semantic,
2497            "test.rs",
2498            100,
2499            0,
2500            false,
2501            fields.as_deref(),
2502        );
2503
2504        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2505        assert!(!output.contains("C:"), "Classes section should not appear");
2506        assert!(
2507            output.contains("I:"),
2508            "Imports section should appear (explicitly listed in fields)"
2509        );
2510        assert!(
2511            !output.contains("F:"),
2512            "Functions section should not appear"
2513        );
2514    }
2515
2516    #[test]
2517    fn test_fields_empty_array() {
2518        use crate::types::SemanticAnalysis;
2519        use std::collections::HashMap;
2520
2521        let functions = vec![FunctionInfo {
2522            name: "hello".to_string(),
2523            line: 10,
2524            end_line: 15,
2525            parameters: vec![],
2526            return_type: None,
2527        }];
2528
2529        let classes = vec![ClassInfo {
2530            name: "MyClass".to_string(),
2531            line: 20,
2532            end_line: 50,
2533            methods: vec![],
2534            fields: vec![],
2535            inherits: vec![],
2536        }];
2537
2538        let imports = vec![ImportInfo {
2539            module: "std".to_string(),
2540            items: vec!["io".to_string()],
2541            line: 1,
2542        }];
2543
2544        let semantic = SemanticAnalysis {
2545            functions: functions.clone(),
2546            classes: classes.clone(),
2547            imports: imports.clone(),
2548            references: vec![],
2549            call_frequency: HashMap::new(),
2550            calls: vec![],
2551            impl_traits: vec![],
2552            def_use_sites: vec![],
2553        };
2554
2555        let fields = Some(vec![]);
2556        let output = format_file_details_paginated(
2557            &functions,
2558            functions.len(),
2559            &semantic,
2560            "test.rs",
2561            100,
2562            0,
2563            true,
2564            fields.as_deref(),
2565        );
2566
2567        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2568        assert!(
2569            output.contains("C:"),
2570            "Classes section missing (empty fields = show all)"
2571        );
2572        assert!(
2573            output.contains("I:"),
2574            "Imports section missing (empty fields = show all)"
2575        );
2576        assert!(
2577            output.contains("F:"),
2578            "Functions section missing (empty fields = show all)"
2579        );
2580    }
2581
2582    #[test]
2583    fn test_fields_pagination_no_functions() {
2584        use crate::types::SemanticAnalysis;
2585        use std::collections::HashMap;
2586
2587        let functions = vec![FunctionInfo {
2588            name: "hello".to_string(),
2589            line: 10,
2590            end_line: 15,
2591            parameters: vec![],
2592            return_type: None,
2593        }];
2594
2595        let classes = vec![ClassInfo {
2596            name: "MyClass".to_string(),
2597            line: 20,
2598            end_line: 50,
2599            methods: vec![],
2600            fields: vec![],
2601            inherits: vec![],
2602        }];
2603
2604        let imports = vec![ImportInfo {
2605            module: "std".to_string(),
2606            items: vec!["io".to_string()],
2607            line: 1,
2608        }];
2609
2610        let semantic = SemanticAnalysis {
2611            functions: functions.clone(),
2612            classes: classes.clone(),
2613            imports: imports.clone(),
2614            references: vec![],
2615            call_frequency: HashMap::new(),
2616            calls: vec![],
2617            impl_traits: vec![],
2618            def_use_sites: vec![],
2619        };
2620
2621        let fields = Some(vec![AnalyzeFileField::Classes, AnalyzeFileField::Imports]);
2622        let output = format_file_details_paginated(
2623            &functions,
2624            functions.len(),
2625            &semantic,
2626            "test.rs",
2627            100,
2628            0,
2629            true,
2630            fields.as_deref(),
2631        );
2632
2633        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2634        assert!(
2635            output.contains("1-1/1F"),
2636            "FILE header should contain valid range (1-1/1F)"
2637        );
2638        assert!(output.contains("C:"), "Classes section missing");
2639        assert!(output.contains("I:"), "Imports section missing");
2640        assert!(
2641            !output.contains("F:"),
2642            "Functions section should not appear (filtered by fields)"
2643        );
2644    }
2645
2646    #[test]
2647    fn test_snippet_one_line_short() {
2648        let snippet = "prev line\nlet x = 1;\nnext line";
2649        let result = snippet_one_line(snippet);
2650        assert_eq!(result, "let x = 1;");
2651    }
2652
2653    #[test]
2654    fn test_snippet_one_line_single_line() {
2655        let result = snippet_one_line("only line");
2656        assert_eq!(result, "only line");
2657    }
2658
2659    #[test]
2660    fn test_format_focused_internal_with_def_use_sites() {
2661        let mut graph = CallGraph::new();
2662        graph
2663            .definitions
2664            .insert("my_var".into(), vec![(PathBuf::from("src/lib.rs"), 5)]);
2665
2666        let site = |kind, file: &str, line, snippet: &str, scope: Option<&str>| DefUseSite {
2667            kind,
2668            symbol: "my_var".into(),
2669            file: file.into(),
2670            line,
2671            column: 0,
2672            snippet: snippet.into(),
2673            enclosing_scope: scope.map(Into::into),
2674        };
2675        let sites = vec![
2676            site(
2677                DefUseKind::Write,
2678                "src/lib.rs",
2679                5,
2680                "\nlet my_var = 42;\n",
2681                Some("init"),
2682            ),
2683            site(
2684                DefUseKind::WriteRead,
2685                "src/lib.rs",
2686                10,
2687                "\nmy_var += 1;\n",
2688                None,
2689            ),
2690            site(
2691                DefUseKind::Read,
2692                "src/main.rs",
2693                20,
2694                "\nprintln!(\"{}\", my_var);\n",
2695                Some("run"),
2696            ),
2697        ];
2698
2699        let output =
2700            format_focused_internal(&graph, "my_var", 1, None, Some(&[]), Some(&[]), &sites)
2701                .expect("format should succeed");
2702
2703        assert!(output.contains("DEF-USE SITES  my_var  (3 total: 2 writes, 1 reads)"));
2704        assert!(output.contains("WRITES"));
2705        assert!(output.contains("[write_read]"));
2706        assert!(output.contains("READS"));
2707        assert!(output.contains("run()"));
2708    }
2709
2710    #[test]
2711    fn test_format_focused_summary_internal_with_def_use_sites() {
2712        let mut graph = CallGraph::new();
2713        graph
2714            .definitions
2715            .insert("counter".into(), vec![(PathBuf::from("src/a.rs"), 1)]);
2716
2717        let sites = vec![DefUseSite {
2718            kind: DefUseKind::Read,
2719            symbol: "counter".into(),
2720            file: "src/b.rs".into(),
2721            line: 15,
2722            column: 0,
2723            snippet: "\nuse_counter(counter);\n".into(),
2724            enclosing_scope: Some("main".into()),
2725        }];
2726
2727        let output = format_focused_summary_internal(
2728            &graph,
2729            "counter",
2730            1,
2731            None,
2732            Some(&[]),
2733            Some(&[]),
2734            &sites,
2735        )
2736        .expect("format should succeed");
2737
2738        assert!(output.contains("DEF-USE SITES: 1 total (0 writes, 1 reads)"));
2739    }
2740
2741    #[test]
2742    fn test_analyze_file_field_all_equivalent_to_none() {
2743        use crate::types::AnalyzeFileField;
2744        // fields=None and fields=Some([All]) must produce the same section selections
2745        let all_fields = [AnalyzeFileField::All];
2746        // Functions section
2747        assert!(
2748            all_fields.contains(&AnalyzeFileField::All)
2749                || all_fields.contains(&AnalyzeFileField::Functions),
2750            "All variant should include Functions"
2751        );
2752        // Classes section
2753        assert!(
2754            all_fields.contains(&AnalyzeFileField::All)
2755                || all_fields.contains(&AnalyzeFileField::Classes),
2756            "All variant should include Classes"
2757        );
2758        // Imports section
2759        assert!(
2760            all_fields.contains(&AnalyzeFileField::All)
2761                || all_fields.contains(&AnalyzeFileField::Imports),
2762            "All variant should include Imports"
2763        );
2764    }
2765
2766    #[test]
2767    fn test_analyze_file_field_all_dominates() {
2768        use crate::types::AnalyzeFileField;
2769        // All + specific variant still returns all sections
2770        let mixed = [AnalyzeFileField::All, AnalyzeFileField::Functions];
2771        assert!(mixed.contains(&AnalyzeFileField::All));
2772        // All dominates: Classes and Imports should also be included
2773        assert!(
2774            mixed.contains(&AnalyzeFileField::All) || mixed.contains(&AnalyzeFileField::Classes),
2775            "All dominates: Classes included even when not explicitly listed"
2776        );
2777        assert!(
2778            mixed.contains(&AnalyzeFileField::All) || mixed.contains(&AnalyzeFileField::Imports),
2779            "All dominates: Imports included even when not explicitly listed"
2780        );
2781    }
2782}
2783
2784fn format_classes_section(classes: &[ClassInfo], functions: &[FunctionInfo]) -> String {
2785    let mut output = String::new();
2786    if classes.is_empty() {
2787        return output;
2788    }
2789    output.push_str("C:\n");
2790
2791    let methods_by_class = collect_class_methods(classes, functions);
2792    let has_methods = methods_by_class.values().any(|m| !m.is_empty());
2793
2794    if classes.len() <= MULTILINE_THRESHOLD && !has_methods {
2795        let class_strs: Vec<String> = classes
2796            .iter()
2797            .map(|class| {
2798                if class.inherits.is_empty() {
2799                    format!("{}:{}-{}", class.name, class.line, class.end_line)
2800                } else {
2801                    format!(
2802                        "{}:{}-{} ({})",
2803                        class.name,
2804                        class.line,
2805                        class.end_line,
2806                        class.inherits.join(", ")
2807                    )
2808                }
2809            })
2810            .collect();
2811        output.push_str("  ");
2812        output.push_str(&class_strs.join("; "));
2813        output.push('\n');
2814    } else {
2815        for class in classes {
2816            if class.inherits.is_empty() {
2817                let _ = writeln!(output, "  {}:{}-{}", class.name, class.line, class.end_line);
2818            } else {
2819                let _ = writeln!(
2820                    output,
2821                    "  {}:{}-{} ({})",
2822                    class.name,
2823                    class.line,
2824                    class.end_line,
2825                    class.inherits.join(", ")
2826                );
2827            }
2828
2829            // Append methods for each class
2830            if let Some(methods) = methods_by_class.get(&class.name)
2831                && !methods.is_empty()
2832            {
2833                for (i, method) in methods.iter().take(10).enumerate() {
2834                    let _ = writeln!(output, "    {}:{}", method.name, method.line);
2835                    if i + 1 == 10 && methods.len() > 10 {
2836                        let _ = writeln!(output, "    ... ({} more)", methods.len() - 10);
2837                        break;
2838                    }
2839                }
2840            }
2841        }
2842    }
2843    output
2844}
2845
2846/// Format related files section (incoming/outgoing imports).
2847/// Returns empty string when `import_graph` is None.
2848fn format_imports_section(imports: &[ImportInfo]) -> String {
2849    let mut output = String::new();
2850    if imports.is_empty() {
2851        return output;
2852    }
2853    output.push_str("I:\n");
2854    let mut module_map: HashMap<String, usize> = HashMap::new();
2855    for import in imports {
2856        module_map
2857            .entry(import.module.clone())
2858            .and_modify(|count| *count += 1)
2859            .or_insert(1);
2860    }
2861    let mut modules: Vec<_> = module_map.keys().cloned().collect();
2862    modules.sort();
2863    let formatted_modules: Vec<String> = modules
2864        .iter()
2865        .map(|module| format!("{}({})", module, module_map[module]))
2866        .collect();
2867    if formatted_modules.len() <= MULTILINE_THRESHOLD {
2868        output.push_str("  ");
2869        output.push_str(&formatted_modules.join("; "));
2870        output.push('\n');
2871    } else {
2872        for module_str in formatted_modules {
2873            output.push_str("  ");
2874            output.push_str(&module_str);
2875            output.push('\n');
2876        }
2877    }
2878    output
2879}