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/// Render top files section with class/function counts and hints.
843fn render_top_files_section(
844    files_in_dir: &[&FileInfo],
845    dir_path: &Path,
846    has_classes: bool,
847) -> String {
848    let top_files_sorted: Vec<&FileInfo> = if has_classes {
849        let mut sorted = files_in_dir.to_vec();
850        sorted.sort_unstable_by(|a, b| {
851            b.class_count
852                .cmp(&a.class_count)
853                .then(b.function_count.cmp(&a.function_count))
854                .then(a.path.cmp(&b.path))
855        });
856        sorted
857    } else {
858        let mut sorted = files_in_dir.to_vec();
859        sorted.sort_unstable_by(|a, b| {
860            b.function_count
861                .cmp(&a.function_count)
862                .then(a.path.cmp(&b.path))
863        });
864        sorted
865    };
866
867    let top_n: Vec<String> = top_files_sorted
868        .iter()
869        .take(3)
870        .filter(|f| {
871            if has_classes {
872                f.class_count > 0
873            } else {
874                f.function_count > 0
875            }
876        })
877        .map(|f| {
878            let rel = Path::new(&f.path).strip_prefix(dir_path).map_or_else(
879                |_| {
880                    Path::new(&f.path)
881                        .file_name()
882                        .and_then(|n| n.to_str())
883                        .map_or_else(|| "?".to_owned(), std::borrow::ToOwned::to_owned)
884                },
885                |p| p.to_string_lossy().into_owned(),
886            );
887            let count = if has_classes {
888                f.class_count
889            } else {
890                f.function_count
891            };
892            let suffix = if has_classes { 'C' } else { 'F' };
893            format!("{rel}({count}{suffix})")
894        })
895        .collect();
896
897    if top_n.is_empty() {
898        String::new()
899    } else {
900        let joined = top_n.join(", ");
901        format!(" top: {joined}")
902    }
903}
904
905/// Aggregate directory statistics from analyzed files.
906fn aggregate_dir_stats(files_in_dir: &[&FileInfo]) -> (usize, usize, usize) {
907    let dir_loc: usize = files_in_dir.iter().map(|f| f.line_count).sum();
908    let dir_functions: usize = files_in_dir.iter().map(|f| f.function_count).sum();
909    let dir_classes: usize = files_in_dir.iter().map(|f| f.class_count).sum();
910    (dir_loc, dir_functions, dir_classes)
911}
912
913/// Format a compact summary for large directory analysis results.
914/// Used when output would exceed the size threshold or when explicitly requested.
915#[instrument(skip_all)]
916#[allow(clippy::too_many_lines)] // exhaustive directory summary formatting; splitting harms readability
917pub fn format_summary(
918    entries: &[WalkEntry],
919    analysis_results: &[FileInfo],
920    max_depth: Option<u32>,
921    subtree_counts: Option<&[(PathBuf, usize)]>,
922) -> String {
923    let mut output = String::new();
924
925    // Partition files into production and test
926    let (prod_files, test_files): (Vec<_>, Vec<_>) =
927        analysis_results.iter().partition(|a| !a.is_test);
928
929    // Calculate totals
930    let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
931    let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
932    let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
933
934    // Count files by language
935    let mut lang_counts: HashMap<String, usize> = HashMap::new();
936    for analysis in analysis_results {
937        *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
938    }
939    let total_files = analysis_results.len();
940
941    // SUMMARY block
942    output.push_str("SUMMARY:\n");
943    let depth_label = match max_depth {
944        Some(n) if n > 0 => format!(" (max_depth={n})"),
945        _ => String::new(),
946    };
947    let prod_count = prod_files.len();
948    let test_count = test_files.len();
949    let _ = writeln!(
950        output,
951        "{total_files} files ({prod_count} prod, {test_count} test), {total_loc}L, {total_functions}F, {total_classes}C{depth_label}"
952    );
953
954    if !lang_counts.is_empty() {
955        output.push_str("Languages: ");
956        let mut langs: Vec<_> = lang_counts.iter().collect();
957        langs.sort_unstable_by_key(|&(name, _)| name);
958        let lang_strs: Vec<String> = langs
959            .iter()
960            .map(|(name, count)| {
961                let percentage = (**count * 100).checked_div(total_files).unwrap_or_default();
962                format!("{name} ({percentage}%)")
963            })
964            .collect();
965        output.push_str(&lang_strs.join(", "));
966        output.push('\n');
967    }
968
969    output.push('\n');
970
971    // STRUCTURE (depth 1) block
972    output.push_str("STRUCTURE (depth 1):\n");
973
974    // Build a map of path -> analysis for quick lookup
975    let analysis_map: HashMap<String, &FileInfo> = analysis_results
976        .iter()
977        .map(|a| (a.path.clone(), a))
978        .collect();
979
980    // Collect depth-1 entries (directories and files at depth 1)
981    let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
982    depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
983
984    // Track largest non-excluded directory for SUGGESTION
985    let mut largest_dir_name: Option<String> = None;
986    let mut largest_dir_path: Option<String> = None;
987    let mut largest_dir_count: usize = 0;
988
989    for entry in depth1_entries {
990        let name = entry
991            .path
992            .file_name()
993            .and_then(|n| n.to_str())
994            .unwrap_or("?");
995
996        if entry.is_dir {
997            // For directories, aggregate stats from all files under this directory
998            let dir_path_str = entry.path.display().to_string();
999            let files_in_dir: Vec<&FileInfo> = analysis_results
1000                .iter()
1001                .filter(|f| Path::new(&f.path).starts_with(&entry.path))
1002                .collect();
1003
1004            if files_in_dir.is_empty() {
1005                // No analyzed files at this depth, but subtree_counts may have a true count
1006                let entry_name_str = name.to_string();
1007                if let Some(counts) = subtree_counts {
1008                    let true_count = counts
1009                        .binary_search_by_key(&&entry.path, |(p, _)| p)
1010                        .ok()
1011                        .map_or(0, |i| counts[i].1);
1012                    if true_count > 0 {
1013                        // Track for SUGGESTION
1014                        if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
1015                            && true_count > largest_dir_count
1016                        {
1017                            largest_dir_count = true_count;
1018                            largest_dir_name = Some(entry_name_str);
1019                            largest_dir_path = Some(
1020                                entry
1021                                    .path
1022                                    .canonicalize()
1023                                    .unwrap_or_else(|_| entry.path.clone())
1024                                    .display()
1025                                    .to_string(),
1026                            );
1027                        }
1028                        let depth_val = max_depth.unwrap_or(0);
1029                        let _ = writeln!(
1030                            output,
1031                            "  {name}/ [{true_count} files total; showing 0 at depth={depth_val}, 0L, 0F, 0C]"
1032                        );
1033                    } else {
1034                        let _ = writeln!(output, "  {name}/");
1035                    }
1036                } else {
1037                    let _ = writeln!(output, "  {name}/");
1038                }
1039            } else {
1040                let dir_file_count = files_in_dir.len();
1041                let (dir_loc, dir_functions, dir_classes) = aggregate_dir_stats(&files_in_dir);
1042
1043                // Track largest non-excluded directory for SUGGESTION
1044                let entry_name_str = name.to_string();
1045                let effective_count = if let Some(counts) = subtree_counts {
1046                    counts
1047                        .binary_search_by_key(&&entry.path, |(p, _)| p)
1048                        .ok()
1049                        .map_or(dir_file_count, |i| counts[i].1)
1050                } else {
1051                    dir_file_count
1052                };
1053                if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
1054                    && effective_count > largest_dir_count
1055                {
1056                    largest_dir_count = effective_count;
1057                    largest_dir_name = Some(entry_name_str);
1058                    largest_dir_path = Some(
1059                        entry
1060                            .path
1061                            .canonicalize()
1062                            .unwrap_or_else(|_| entry.path.clone())
1063                            .display()
1064                            .to_string(),
1065                    );
1066                }
1067
1068                // Build hint: top-N files sorted by class_count desc, fallback to function_count
1069                let hint = if files_in_dir.len() > 1 && (dir_classes > 0 || dir_functions > 0) {
1070                    let has_classes = files_in_dir.iter().any(|f| f.class_count > 0);
1071                    let dir_path = Path::new(&dir_path_str);
1072                    render_top_files_section(&files_in_dir, dir_path, has_classes)
1073                } else {
1074                    String::new()
1075                };
1076
1077                // Collect depth-2 sub-package directories (immediate children of this directory)
1078                let mut subdirs: Vec<String> = entries
1079                    .iter()
1080                    .filter(|e| e.depth == 2 && e.is_dir && e.path.starts_with(&entry.path))
1081                    .filter_map(|e| {
1082                        e.path
1083                            .file_name()
1084                            .and_then(|n| n.to_str())
1085                            .map(std::borrow::ToOwned::to_owned)
1086                    })
1087                    .collect();
1088                subdirs.sort();
1089                subdirs.dedup();
1090                let subdir_suffix = if subdirs.is_empty() {
1091                    String::new()
1092                } else {
1093                    let subdirs_capped: Vec<String> =
1094                        subdirs.iter().take(5).map(|s| format!("{s}/")).collect();
1095                    let joined = subdirs_capped.join(", ");
1096                    format!("  sub: {joined}")
1097                };
1098
1099                let files_label = if let Some(counts) = subtree_counts {
1100                    let true_count = counts
1101                        .binary_search_by_key(&&entry.path, |(p, _)| p)
1102                        .ok()
1103                        .map_or(dir_file_count, |i| counts[i].1);
1104                    if true_count == dir_file_count {
1105                        format!(
1106                            "{dir_file_count} files, {dir_loc}L, {dir_functions}F, {dir_classes}C"
1107                        )
1108                    } else {
1109                        let depth_val = max_depth.unwrap_or(0);
1110                        format!(
1111                            "{true_count} files total; showing {dir_file_count} at depth={depth_val}, {dir_loc}L, {dir_functions}F, {dir_classes}C"
1112                        )
1113                    }
1114                } else {
1115                    format!("{dir_file_count} files, {dir_loc}L, {dir_functions}F, {dir_classes}C")
1116                };
1117                let _ = writeln!(output, "  {name}/ [{files_label}]{hint}{subdir_suffix}");
1118            }
1119        } else {
1120            // For files, show individual stats
1121            if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
1122                if let Some(info_str) = format_file_info_parts(
1123                    analysis.line_count,
1124                    analysis.function_count,
1125                    analysis.class_count,
1126                ) {
1127                    let _ = writeln!(output, "  {name} {info_str}");
1128                } else {
1129                    let _ = writeln!(output, "  {name}");
1130                }
1131            }
1132        }
1133    }
1134
1135    output.push('\n');
1136
1137    // SUGGESTION block
1138    if let (Some(name), Some(path)) = (largest_dir_name, largest_dir_path) {
1139        let _ = writeln!(
1140            output,
1141            "SUGGESTION: Largest source directory: {name}/ ({largest_dir_count} files total). For module details, re-run with path={path} and max_depth=2."
1142        );
1143    } else {
1144        output.push_str("SUGGESTION:\n");
1145        output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
1146    }
1147
1148    output
1149}
1150
1151/// Format a compact summary of file details for large `FileDetails` output.
1152///
1153/// Returns `FILE` header with path/LOC/counts, top 10 functions by line span descending,
1154/// classes inline if <=10, import count, and suggestion block.
1155#[instrument(skip_all)]
1156pub fn format_file_details_summary(
1157    semantic: &SemanticAnalysis,
1158    path: &str,
1159    line_count: usize,
1160) -> String {
1161    let mut output = String::new();
1162
1163    // FILE header
1164    output.push_str("FILE:\n");
1165    let _ = writeln!(output, "  path: {path}");
1166    let fn_count = semantic.functions.len();
1167    let class_count = semantic.classes.len();
1168    let _ = writeln!(output, "  {line_count}L, {fn_count}F, {class_count}C");
1169    output.push('\n');
1170
1171    // Top 10 functions by line span (end_line - start_line) descending
1172    if !semantic.functions.is_empty() {
1173        output.push_str("TOP FUNCTIONS BY SIZE:\n");
1174        let mut funcs: Vec<&crate::types::FunctionInfo> = semantic.functions.iter().collect();
1175        let k = funcs.len().min(10);
1176        if k > 0 {
1177            funcs.select_nth_unstable_by(k.saturating_sub(1), |a, b| {
1178                let a_span = a.end_line.saturating_sub(a.line);
1179                let b_span = b.end_line.saturating_sub(b.line);
1180                b_span.cmp(&a_span)
1181            });
1182            funcs[..k].sort_by(|a, b| {
1183                let a_span = a.end_line.saturating_sub(a.line);
1184                let b_span = b.end_line.saturating_sub(b.line);
1185                b_span.cmp(&a_span)
1186            });
1187        }
1188
1189        for func in &funcs[..k] {
1190            let span = func.end_line.saturating_sub(func.line);
1191            let params = if func.parameters.is_empty() {
1192                String::new()
1193            } else {
1194                format!("({})", func.parameters.join(", "))
1195            };
1196            let _ = writeln!(
1197                output,
1198                "  {}:{}: {} {} [{}L]",
1199                func.line, func.end_line, func.name, params, span
1200            );
1201        }
1202        output.push('\n');
1203    }
1204
1205    // Classes inline if <=10, else multiline with method count
1206    if !semantic.classes.is_empty() {
1207        output.push_str("CLASSES:\n");
1208        if semantic.classes.len() <= 10 {
1209            // Inline format: one class per line with method count
1210            for class in &semantic.classes {
1211                let methods_count = class.methods.len();
1212                let _ = writeln!(output, "  {}: {}M", class.name, methods_count);
1213            }
1214        } else {
1215            // Multiline format with summary
1216            let _ = writeln!(output, "  {} classes total", semantic.classes.len());
1217            for class in semantic.classes.iter().take(5) {
1218                let _ = writeln!(output, "    {}", class.name);
1219            }
1220            if semantic.classes.len() > 5 {
1221                let _ = writeln!(output, "    ... and {} more", semantic.classes.len() - 5);
1222            }
1223        }
1224        output.push('\n');
1225    }
1226
1227    // Import count only
1228    let _ = writeln!(output, "Imports: {}", semantic.imports.len());
1229    output.push('\n');
1230
1231    // SUGGESTION block
1232    output.push_str("SUGGESTION:\n");
1233    output.push_str("Use force=true for full output, or narrow your scope\n");
1234
1235    output
1236}
1237
1238/// Format a paginated subset of files for Overview mode.
1239#[instrument(skip_all)]
1240pub fn format_structure_paginated(
1241    paginated_files: &[FileInfo],
1242    total_files: usize,
1243    max_depth: Option<u32>,
1244    base_path: Option<&Path>,
1245    verbose: bool,
1246) -> String {
1247    let mut output = String::new();
1248
1249    let depth_label = match max_depth {
1250        Some(n) if n > 0 => format!(" (max_depth={n})"),
1251        _ => String::new(),
1252    };
1253    let _ = writeln!(
1254        output,
1255        "PAGINATED: showing {} of {} files{}\n",
1256        paginated_files.len(),
1257        total_files,
1258        depth_label
1259    );
1260
1261    let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
1262    let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
1263
1264    if !prod_files.is_empty() {
1265        if verbose {
1266            output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
1267        }
1268        for file in &prod_files {
1269            output.push_str(&format_file_entry(file, base_path));
1270        }
1271    }
1272
1273    if !test_files.is_empty() {
1274        if verbose {
1275            output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
1276        } else if !prod_files.is_empty() {
1277            output.push('\n');
1278        }
1279        for file in &test_files {
1280            output.push_str(&format_file_entry(file, base_path));
1281        }
1282    }
1283
1284    output
1285}
1286
1287/// Format a paginated subset of functions for `FileDetails` mode.
1288/// When `verbose=false` (default/compact): shows `C:` (if non-empty) and `F:` with wrapped rendering; omits `I:`.
1289/// When `verbose=true`: shows `C:`, `I:`, and `F:` with wrapped rendering on the first page (offset == 0).
1290/// Header shows position context: `FILE: path (NL, start-end/totalF, CC, II)`.
1291#[instrument(skip_all)]
1292#[allow(clippy::too_many_arguments)]
1293pub fn format_file_details_paginated(
1294    functions_page: &[FunctionInfo],
1295    total_functions: usize,
1296    semantic: &SemanticAnalysis,
1297    path: &str,
1298    line_count: usize,
1299    offset: usize,
1300    verbose: bool,
1301    fields: Option<&[AnalyzeFileField]>,
1302) -> String {
1303    let mut output = String::new();
1304
1305    let start = offset + 1; // 1-indexed for display
1306    let end = offset + functions_page.len();
1307
1308    let _ = writeln!(
1309        output,
1310        "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)",
1311        path,
1312        line_count,
1313        start,
1314        end,
1315        total_functions,
1316        semantic.classes.len(),
1317        semantic.imports.len()
1318    );
1319
1320    // Compute field visibility flags. Empty slice behaves same as None (show all).
1321    let show_all = fields.is_none_or(<[AnalyzeFileField]>::is_empty);
1322    let show_classes = show_all
1323        || fields.is_some_and(|f| {
1324            f.contains(&AnalyzeFileField::All) || f.contains(&AnalyzeFileField::Classes)
1325        });
1326    let show_imports = show_all
1327        || fields.is_some_and(|f| {
1328            f.contains(&AnalyzeFileField::All) || f.contains(&AnalyzeFileField::Imports)
1329        });
1330    let show_functions = show_all
1331        || fields.is_some_and(|f| {
1332            f.contains(&AnalyzeFileField::All) || f.contains(&AnalyzeFileField::Functions)
1333        });
1334
1335    // Classes section on first page for both verbose and compact modes
1336    if show_classes && offset == 0 && !semantic.classes.is_empty() {
1337        output.push_str(&format_classes_section(
1338            &semantic.classes,
1339            &semantic.functions,
1340        ));
1341    }
1342
1343    // Imports section only on first page in verbose mode, or when explicitly listed in fields
1344    if show_imports && offset == 0 && (verbose || !show_all) {
1345        output.push_str(&format_imports_section(&semantic.imports));
1346    }
1347
1348    // F: section with paginated function slice (exclude methods)
1349    let top_level_functions: Vec<&FunctionInfo> = functions_page
1350        .iter()
1351        .filter(|func| {
1352            !semantic
1353                .classes
1354                .iter()
1355                .any(|class| is_method_of_class(func, class))
1356        })
1357        .collect();
1358
1359    if show_functions && !top_level_functions.is_empty() {
1360        output.push_str("F:\n");
1361        output.push_str(&format_function_list_wrapped(
1362            top_level_functions.iter().copied(),
1363            &semantic.call_frequency,
1364        ));
1365    }
1366
1367    output
1368}
1369
1370/// Format a paginated subset of callers or callees for `SymbolFocus` mode.
1371/// Mode is determined by the `mode` parameter:
1372/// - `PaginationMode::Callers`: paginate production callers; show test callers summary and callees summary.
1373/// - `PaginationMode::Callees`: paginate callees; show callers summary and test callers summary.
1374#[instrument(skip_all)]
1375#[allow(clippy::too_many_arguments)]
1376#[allow(clippy::similar_names)] // domain-appropriate pairs (callers_count/callees_count); renaming harms clarity
1377pub fn format_focused_paginated(
1378    paginated_chains: &[InternalCallChain],
1379    total: usize,
1380    mode: PaginationMode,
1381    symbol: &str,
1382    prod_chains: &[InternalCallChain],
1383    test_chains: &[InternalCallChain],
1384    outgoing_chains: &[InternalCallChain],
1385    def_count: usize,
1386    offset: usize,
1387    base_path: Option<&Path>,
1388    _verbose: bool,
1389) -> String {
1390    let start = offset + 1; // 1-indexed
1391    let end = offset + paginated_chains.len();
1392
1393    let callers_count = prod_chains.len();
1394
1395    let callees_count = outgoing_chains.len();
1396
1397    let mut output = String::new();
1398
1399    let _ = writeln!(
1400        output,
1401        "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
1402    );
1403
1404    match mode {
1405        PaginationMode::Callers => {
1406            // Paginate production callers
1407            let _ = writeln!(output, "CALLERS ({start}-{end} of {total}):");
1408
1409            let page_refs: Vec<_> = paginated_chains
1410                .iter()
1411                .filter_map(|chain| {
1412                    if chain.chain.len() >= 2 {
1413                        Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1414                    } else if chain.chain.len() == 1 {
1415                        Some((chain.chain[0].0.as_str(), ""))
1416                    } else {
1417                        None
1418                    }
1419                })
1420                .collect();
1421
1422            if page_refs.is_empty() {
1423                output.push_str("  (none)\n");
1424            } else {
1425                output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1426            }
1427
1428            // Test callers summary
1429            if !test_chains.is_empty() {
1430                let mut test_files: Vec<_> = test_chains
1431                    .iter()
1432                    .filter_map(|chain| {
1433                        chain
1434                            .chain
1435                            .first()
1436                            .map(|(_, path, _)| path.to_string_lossy().into_owned())
1437                    })
1438                    .collect();
1439                test_files.sort();
1440                test_files.dedup();
1441
1442                let display_files: Vec<_> = test_files
1443                    .iter()
1444                    .map(|f| strip_base_path(std::path::Path::new(f), base_path))
1445                    .collect();
1446
1447                let _ = writeln!(
1448                    output,
1449                    "CALLERS (test): {} test functions (in {})",
1450                    test_chains.len(),
1451                    display_files.join(", ")
1452                );
1453            }
1454
1455            // Callees summary
1456            let callee_names: Vec<_> = outgoing_chains
1457                .iter()
1458                .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1459                .collect::<std::collections::HashSet<_>>()
1460                .into_iter()
1461                .collect();
1462            if callee_names.is_empty() {
1463                output.push_str("CALLEES: (none)\n");
1464            } else {
1465                let _ = writeln!(
1466                    output,
1467                    "CALLEES: {callees_count} (use cursor for callee pagination)"
1468                );
1469            }
1470        }
1471        PaginationMode::Callees => {
1472            // Callers summary
1473            let _ = writeln!(output, "CALLERS: {callers_count} production callers");
1474
1475            // Test callers summary
1476            if !test_chains.is_empty() {
1477                let _ = writeln!(
1478                    output,
1479                    "CALLERS (test): {} test functions",
1480                    test_chains.len()
1481                );
1482            }
1483
1484            // Paginate callees
1485            let _ = writeln!(output, "CALLEES ({start}-{end} of {total}):");
1486
1487            let page_refs: Vec<_> = paginated_chains
1488                .iter()
1489                .filter_map(|chain| {
1490                    if chain.chain.len() >= 2 {
1491                        Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1492                    } else if chain.chain.len() == 1 {
1493                        Some((chain.chain[0].0.as_str(), ""))
1494                    } else {
1495                        None
1496                    }
1497                })
1498                .collect();
1499
1500            if page_refs.is_empty() {
1501                output.push_str("  (none)\n");
1502            } else {
1503                output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1504            }
1505        }
1506        PaginationMode::Default => {
1507            unreachable!("format_focused_paginated called with PaginationMode::Default")
1508        }
1509        PaginationMode::DefUse => {
1510            unreachable!("format_focused_paginated called with PaginationMode::DefUse")
1511        }
1512    }
1513
1514    output
1515}
1516
1517fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1518    let mut parts = Vec::new();
1519    if file.line_count > 0 {
1520        parts.push(format!("{}L", file.line_count));
1521    }
1522    if file.function_count > 0 {
1523        parts.push(format!("{}F", file.function_count));
1524    }
1525    if file.class_count > 0 {
1526        parts.push(format!("{}C", file.class_count));
1527    }
1528    let display_path = strip_base_path(Path::new(&file.path), base_path);
1529    if parts.is_empty() {
1530        format!("{display_path}\n")
1531    } else {
1532        format!("{display_path} [{}]\n", parts.join(", "))
1533    }
1534}
1535
1536/// Format a [`ModuleInfo`] into a compact single-block string.
1537///
1538/// Output format:
1539/// ```text
1540/// FILE: <name> (<line_count>L, <fn_count>F, <import_count>I)
1541/// F:
1542///   func1:10, func2:42
1543/// I:
1544///   module1:item1, item2; module2:item1; module3
1545/// ```
1546///
1547/// The `F:` section is omitted when there are no functions; likewise `I:` when
1548/// there are no imports.
1549#[instrument(skip_all)]
1550pub fn format_module_info(info: &ModuleInfo) -> String {
1551    use std::fmt::Write as _;
1552    let fn_count = info.functions.len();
1553    let import_count = info.imports.len();
1554    let mut out = String::with_capacity(64 + fn_count * 24 + import_count * 32);
1555    let _ = writeln!(
1556        out,
1557        "FILE: {} ({}L, {}F, {}I)",
1558        info.name, info.line_count, fn_count, import_count
1559    );
1560    if !info.functions.is_empty() {
1561        out.push_str("F:\n  ");
1562        let parts: Vec<String> = info
1563            .functions
1564            .iter()
1565            .map(|f| format!("{}:{}", f.name, f.line))
1566            .collect();
1567        out.push_str(&parts.join(", "));
1568        out.push('\n');
1569    }
1570    if !info.imports.is_empty() {
1571        out.push_str("I:\n  ");
1572        let parts: Vec<String> = info
1573            .imports
1574            .iter()
1575            .map(|i| {
1576                if i.items.is_empty() {
1577                    i.module.clone()
1578                } else {
1579                    format!("{}:{}", i.module, i.items.join(", "))
1580                }
1581            })
1582            .collect();
1583        out.push_str(&parts.join("; "));
1584        out.push('\n');
1585    }
1586    out
1587}
1588
1589#[cfg(test)]
1590mod tests {
1591    use super::*;
1592
1593    #[test]
1594    fn test_strip_base_path_relative() {
1595        let path = Path::new("/home/user/project/src/main.rs");
1596        let base = Path::new("/home/user/project");
1597        let result = strip_base_path(path, Some(base));
1598        assert_eq!(result, "src/main.rs");
1599    }
1600
1601    #[test]
1602    fn test_strip_base_path_fallback_absolute() {
1603        let path = Path::new("/other/project/src/main.rs");
1604        let base = Path::new("/home/user/project");
1605        let result = strip_base_path(path, Some(base));
1606        assert_eq!(result, "/other/project/src/main.rs");
1607    }
1608
1609    #[test]
1610    fn test_strip_base_path_none() {
1611        let path = Path::new("/home/user/project/src/main.rs");
1612        let result = strip_base_path(path, None);
1613        assert_eq!(result, "/home/user/project/src/main.rs");
1614    }
1615
1616    #[test]
1617    fn test_format_file_details_summary_empty() {
1618        use crate::types::SemanticAnalysis;
1619        use std::collections::HashMap;
1620
1621        let semantic = SemanticAnalysis {
1622            functions: vec![],
1623            classes: vec![],
1624            imports: vec![],
1625            references: vec![],
1626            call_frequency: HashMap::new(),
1627            calls: vec![],
1628            impl_traits: vec![],
1629            def_use_sites: vec![],
1630        };
1631
1632        let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1633
1634        // Should contain FILE header, Imports count, and SUGGESTION
1635        assert!(result.contains("FILE:"));
1636        assert!(result.contains("100L, 0F, 0C"));
1637        assert!(result.contains("src/main.rs"));
1638        assert!(result.contains("Imports: 0"));
1639        assert!(result.contains("SUGGESTION:"));
1640    }
1641
1642    #[test]
1643    fn test_format_file_details_summary_with_functions() {
1644        use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1645        use std::collections::HashMap;
1646
1647        let semantic = SemanticAnalysis {
1648            functions: vec![
1649                FunctionInfo {
1650                    name: "short".to_string(),
1651                    line: 10,
1652                    end_line: 12,
1653                    parameters: vec![],
1654                    return_type: None,
1655                },
1656                FunctionInfo {
1657                    name: "long_function".to_string(),
1658                    line: 20,
1659                    end_line: 50,
1660                    parameters: vec!["x".to_string(), "y".to_string()],
1661                    return_type: Some("i32".to_string()),
1662                },
1663            ],
1664            classes: vec![ClassInfo {
1665                name: "MyClass".to_string(),
1666                line: 60,
1667                end_line: 80,
1668                methods: vec![],
1669                fields: vec![],
1670                inherits: vec![],
1671            }],
1672            imports: vec![],
1673            references: vec![],
1674            call_frequency: HashMap::new(),
1675            calls: vec![],
1676            impl_traits: vec![],
1677            def_use_sites: vec![],
1678        };
1679
1680        let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1681
1682        // Should contain FILE header with counts
1683        assert!(result.contains("FILE:"));
1684        assert!(result.contains("src/lib.rs"));
1685        assert!(result.contains("250L, 2F, 1C"));
1686
1687        // Should contain TOP FUNCTIONS BY SIZE with longest first
1688        assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1689        let long_idx = result.find("long_function").unwrap_or(0);
1690        let short_idx = result.find("short").unwrap_or(0);
1691        assert!(
1692            long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1693            "long_function should appear before short"
1694        );
1695
1696        // Should contain classes inline
1697        assert!(result.contains("CLASSES:"));
1698        assert!(result.contains("MyClass:"));
1699
1700        // Should contain import count
1701        assert!(result.contains("Imports: 0"));
1702    }
1703    #[test]
1704    fn test_format_file_info_parts_all_zero() {
1705        assert_eq!(format_file_info_parts(0, 0, 0), None);
1706    }
1707
1708    #[test]
1709    fn test_format_file_info_parts_partial() {
1710        assert_eq!(
1711            format_file_info_parts(42, 0, 3),
1712            Some("[42L, 3C]".to_string())
1713        );
1714    }
1715
1716    #[test]
1717    fn test_format_file_info_parts_all_nonzero() {
1718        assert_eq!(
1719            format_file_info_parts(100, 5, 2),
1720            Some("[100L, 5F, 2C]".to_string())
1721        );
1722    }
1723
1724    #[test]
1725    fn test_format_function_list_wrapped_empty() {
1726        let freq = std::collections::HashMap::new();
1727        let result = format_function_list_wrapped(std::iter::empty(), &freq);
1728        assert_eq!(result, "");
1729    }
1730
1731    #[test]
1732    fn test_format_function_list_wrapped_bullet_annotation() {
1733        use crate::types::FunctionInfo;
1734        use std::collections::HashMap;
1735
1736        let mut freq = HashMap::new();
1737        freq.insert("frequent".to_string(), 5); // count > 3 should get bullet
1738
1739        let funcs = vec![FunctionInfo {
1740            name: "frequent".to_string(),
1741            line: 1,
1742            end_line: 10,
1743            parameters: vec![],
1744            return_type: Some("void".to_string()),
1745        }];
1746
1747        let result = format_function_list_wrapped(funcs.iter(), &freq);
1748        // Should contain bullet (U+2022) followed by count
1749        assert!(result.contains("\u{2022}5"));
1750    }
1751
1752    #[test]
1753    fn test_compact_format_omits_sections() {
1754        use crate::types::{ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
1755        use std::collections::HashMap;
1756
1757        let funcs: Vec<FunctionInfo> = (0..10)
1758            .map(|i| FunctionInfo {
1759                name: format!("fn_{}", i),
1760                line: i * 5 + 1,
1761                end_line: i * 5 + 4,
1762                parameters: vec![format!("x: u32")],
1763                return_type: Some("bool".to_string()),
1764            })
1765            .collect();
1766        let imports: Vec<ImportInfo> = vec![ImportInfo {
1767            module: "std::collections".to_string(),
1768            items: vec!["HashMap".to_string()],
1769            line: 1,
1770        }];
1771        let classes: Vec<ClassInfo> = vec![ClassInfo {
1772            name: "MyStruct".to_string(),
1773            line: 100,
1774            end_line: 150,
1775            methods: vec![],
1776            fields: vec![],
1777            inherits: vec![],
1778        }];
1779        let semantic = SemanticAnalysis {
1780            functions: funcs,
1781            classes,
1782            imports,
1783            references: vec![],
1784            call_frequency: HashMap::new(),
1785            calls: vec![],
1786            impl_traits: vec![],
1787            def_use_sites: vec![],
1788        };
1789
1790        let verbose_out = format_file_details_paginated(
1791            &semantic.functions,
1792            semantic.functions.len(),
1793            &semantic,
1794            "src/lib.rs",
1795            100,
1796            0,
1797            true,
1798            None,
1799        );
1800        let compact_out = format_file_details_paginated(
1801            &semantic.functions,
1802            semantic.functions.len(),
1803            &semantic,
1804            "src/lib.rs",
1805            100,
1806            0,
1807            false,
1808            None,
1809        );
1810
1811        // Verbose includes C:, I:, F: section headers
1812        assert!(verbose_out.contains("C:\n"), "verbose must have C: section");
1813        assert!(verbose_out.contains("I:\n"), "verbose must have I: section");
1814        assert!(verbose_out.contains("F:\n"), "verbose must have F: section");
1815
1816        // Compact includes C: and F: but omits I: (imports)
1817        assert!(
1818            compact_out.contains("C:\n"),
1819            "compact must have C: section (restored)"
1820        );
1821        assert!(
1822            !compact_out.contains("I:\n"),
1823            "compact must not have I: section (imports omitted)"
1824        );
1825        assert!(
1826            compact_out.contains("F:\n"),
1827            "compact must have F: section with wrapped formatting"
1828        );
1829
1830        // Compact functions are wrapped: fn_0 and fn_1 must appear on the same line
1831        assert!(compact_out.contains("fn_0"), "compact must list functions");
1832        let has_two_on_same_line = compact_out
1833            .lines()
1834            .any(|l| l.contains("fn_0") && l.contains("fn_1"));
1835        assert!(
1836            has_two_on_same_line,
1837            "compact must render multiple functions per line (wrapped), not one-per-line"
1838        );
1839    }
1840
1841    /// Regression test: compact mode must be <= verbose for function-heavy files (no imports to mask regression).
1842    #[test]
1843    fn test_compact_mode_consistent_token_reduction() {
1844        use crate::types::{FunctionInfo, SemanticAnalysis};
1845        use std::collections::HashMap;
1846
1847        let funcs: Vec<FunctionInfo> = (0..50)
1848            .map(|i| FunctionInfo {
1849                name: format!("function_name_{}", i),
1850                line: i * 10 + 1,
1851                end_line: i * 10 + 8,
1852                parameters: vec![
1853                    "arg1: u32".to_string(),
1854                    "arg2: String".to_string(),
1855                    "arg3: Option<bool>".to_string(),
1856                ],
1857                return_type: Some("Result<Vec<String>, Error>".to_string()),
1858            })
1859            .collect();
1860
1861        let semantic = SemanticAnalysis {
1862            functions: funcs,
1863            classes: vec![],
1864            imports: vec![],
1865            references: vec![],
1866            call_frequency: HashMap::new(),
1867            calls: vec![],
1868            impl_traits: vec![],
1869            def_use_sites: vec![],
1870        };
1871
1872        let verbose_out = format_file_details_paginated(
1873            &semantic.functions,
1874            semantic.functions.len(),
1875            &semantic,
1876            "src/large_file.rs",
1877            1000,
1878            0,
1879            true,
1880            None,
1881        );
1882        let compact_out = format_file_details_paginated(
1883            &semantic.functions,
1884            semantic.functions.len(),
1885            &semantic,
1886            "src/large_file.rs",
1887            1000,
1888            0,
1889            false,
1890            None,
1891        );
1892
1893        assert!(
1894            compact_out.len() <= verbose_out.len(),
1895            "compact ({} chars) must be <= verbose ({} chars)",
1896            compact_out.len(),
1897            verbose_out.len(),
1898        );
1899    }
1900
1901    /// Edge case test: Compact mode with empty classes should not emit C: header.
1902    #[test]
1903    fn test_format_module_info_happy_path() {
1904        use crate::types::{ModuleFunctionInfo, ModuleImportInfo, ModuleInfo};
1905        let info = ModuleInfo::new(
1906            "parser.rs".to_string(),
1907            312,
1908            "rust".to_string(),
1909            vec![
1910                ModuleFunctionInfo {
1911                    name: "parse_file".to_string(),
1912                    line: 24,
1913                },
1914                ModuleFunctionInfo {
1915                    name: "parse_block".to_string(),
1916                    line: 58,
1917                },
1918            ],
1919            vec![
1920                ModuleImportInfo {
1921                    module: "crate::types".to_string(),
1922                    items: vec!["Token".to_string(), "Expr".to_string()],
1923                },
1924                ModuleImportInfo {
1925                    module: "std::io".to_string(),
1926                    items: vec!["BufReader".to_string()],
1927                },
1928            ],
1929        );
1930        let result = format_module_info(&info);
1931        assert!(result.starts_with("FILE: parser.rs (312L, 2F, 2I)"));
1932        assert!(result.contains("F:"));
1933        assert!(result.contains("parse_file:24"));
1934        assert!(result.contains("parse_block:58"));
1935        assert!(result.contains("I:"));
1936        assert!(result.contains("crate::types:Token, Expr"));
1937        assert!(result.contains("std::io:BufReader"));
1938        assert!(result.contains("; "));
1939        assert!(!result.contains('{'));
1940    }
1941
1942    #[test]
1943    fn test_format_module_info_empty() {
1944        use crate::types::ModuleInfo;
1945        let info = ModuleInfo::new(
1946            "empty.rs".to_string(),
1947            0,
1948            "rust".to_string(),
1949            vec![],
1950            vec![],
1951        );
1952        let result = format_module_info(&info);
1953        assert!(result.starts_with("FILE: empty.rs (0L, 0F, 0I)"));
1954        assert!(!result.contains("F:"));
1955        assert!(!result.contains("I:"));
1956    }
1957
1958    #[test]
1959    fn test_compact_mode_empty_classes_no_header() {
1960        use crate::types::{FunctionInfo, SemanticAnalysis};
1961        use std::collections::HashMap;
1962
1963        let funcs: Vec<FunctionInfo> = (0..5)
1964            .map(|i| FunctionInfo {
1965                name: format!("fn_{}", i),
1966                line: i * 5 + 1,
1967                end_line: i * 5 + 4,
1968                parameters: vec![],
1969                return_type: None,
1970            })
1971            .collect();
1972
1973        let semantic = SemanticAnalysis {
1974            functions: funcs,
1975            classes: vec![], // Empty classes
1976            imports: vec![],
1977            references: vec![],
1978            call_frequency: HashMap::new(),
1979            calls: vec![],
1980            impl_traits: vec![],
1981            def_use_sites: vec![],
1982        };
1983
1984        let compact_out = format_file_details_paginated(
1985            &semantic.functions,
1986            semantic.functions.len(),
1987            &semantic,
1988            "src/simple.rs",
1989            100,
1990            0,
1991            false,
1992            None,
1993        );
1994
1995        // Should not have stray C: header when classes are empty
1996        assert!(
1997            !compact_out.contains("C:\n"),
1998            "compact mode must not emit C: header when classes are empty"
1999        );
2000    }
2001
2002    #[test]
2003    fn test_format_classes_with_methods() {
2004        use crate::types::{ClassInfo, FunctionInfo};
2005
2006        let functions = vec![
2007            FunctionInfo {
2008                name: "method_a".to_string(),
2009                line: 5,
2010                end_line: 8,
2011                parameters: vec![],
2012                return_type: None,
2013            },
2014            FunctionInfo {
2015                name: "method_b".to_string(),
2016                line: 10,
2017                end_line: 12,
2018                parameters: vec![],
2019                return_type: None,
2020            },
2021            FunctionInfo {
2022                name: "top_level_func".to_string(),
2023                line: 50,
2024                end_line: 55,
2025                parameters: vec![],
2026                return_type: None,
2027            },
2028        ];
2029
2030        let classes = vec![ClassInfo {
2031            name: "MyClass".to_string(),
2032            line: 1,
2033            end_line: 30,
2034            methods: vec![],
2035            fields: vec![],
2036            inherits: vec![],
2037        }];
2038
2039        let output = format_classes_section(&classes, &functions);
2040
2041        assert!(
2042            output.contains("MyClass:1-30"),
2043            "class header should show start-end range"
2044        );
2045        assert!(output.contains("method_a:5"), "method_a should be listed");
2046        assert!(output.contains("method_b:10"), "method_b should be listed");
2047        assert!(
2048            !output.contains("top_level_func"),
2049            "top_level_func outside class range should not be listed"
2050        );
2051    }
2052
2053    #[test]
2054    fn test_format_classes_method_cap() {
2055        use crate::types::{ClassInfo, FunctionInfo};
2056
2057        let mut functions = Vec::new();
2058        for i in 0..15 {
2059            functions.push(FunctionInfo {
2060                name: format!("method_{}", i),
2061                line: 2 + i,
2062                end_line: 3 + i,
2063                parameters: vec![],
2064                return_type: None,
2065            });
2066        }
2067
2068        let classes = vec![ClassInfo {
2069            name: "LargeClass".to_string(),
2070            line: 1,
2071            end_line: 50,
2072            methods: vec![],
2073            fields: vec![],
2074            inherits: vec![],
2075        }];
2076
2077        let output = format_classes_section(&classes, &functions);
2078
2079        assert!(output.contains("method_0"), "first method should be listed");
2080        assert!(output.contains("method_9"), "10th method should be listed");
2081        assert!(
2082            !output.contains("method_10"),
2083            "11th method should not be listed (cap at 10)"
2084        );
2085        assert!(
2086            output.contains("... (5 more)"),
2087            "truncation message should show remaining count"
2088        );
2089    }
2090
2091    #[test]
2092    fn test_format_classes_no_methods() {
2093        use crate::types::{ClassInfo, FunctionInfo};
2094
2095        let functions = vec![FunctionInfo {
2096            name: "top_level".to_string(),
2097            line: 100,
2098            end_line: 105,
2099            parameters: vec![],
2100            return_type: None,
2101        }];
2102
2103        let classes = vec![ClassInfo {
2104            name: "EmptyClass".to_string(),
2105            line: 1,
2106            end_line: 50,
2107            methods: vec![],
2108            fields: vec![],
2109            inherits: vec![],
2110        }];
2111
2112        let output = format_classes_section(&classes, &functions);
2113
2114        assert!(
2115            output.contains("EmptyClass:1-50"),
2116            "empty class header should appear"
2117        );
2118        assert!(
2119            !output.contains("top_level"),
2120            "top-level functions outside class should not appear"
2121        );
2122    }
2123
2124    #[test]
2125    fn test_f_section_excludes_methods() {
2126        use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
2127        use std::collections::HashMap;
2128
2129        let functions = vec![
2130            FunctionInfo {
2131                name: "method_a".to_string(),
2132                line: 5,
2133                end_line: 10,
2134                parameters: vec![],
2135                return_type: None,
2136            },
2137            FunctionInfo {
2138                name: "top_level".to_string(),
2139                line: 50,
2140                end_line: 55,
2141                parameters: vec![],
2142                return_type: None,
2143            },
2144        ];
2145
2146        let semantic = SemanticAnalysis {
2147            functions,
2148            classes: vec![ClassInfo {
2149                name: "TestClass".to_string(),
2150                line: 1,
2151                end_line: 30,
2152                methods: vec![],
2153                fields: vec![],
2154                inherits: vec![],
2155            }],
2156            imports: vec![],
2157            references: vec![],
2158            call_frequency: HashMap::new(),
2159            calls: vec![],
2160            impl_traits: vec![],
2161            def_use_sites: vec![],
2162        };
2163
2164        let output = format_file_details("test.rs", &semantic, 100, false, None);
2165
2166        assert!(output.contains("C:"), "classes section should exist");
2167        assert!(
2168            output.contains("method_a:5"),
2169            "method should be in C: section"
2170        );
2171        assert!(output.contains("F:"), "F: section should exist");
2172        assert!(
2173            output.contains("top_level"),
2174            "top-level function should be in F: section"
2175        );
2176
2177        // Verify method_a is not in F: section (check sequence: C: before method_a, F: after it)
2178        let f_pos = output.find("F:").unwrap();
2179        let method_pos = output.find("method_a").unwrap();
2180        assert!(
2181            method_pos < f_pos,
2182            "method_a should appear before F: section"
2183        );
2184    }
2185
2186    #[test]
2187    fn test_format_focused_paginated_unit() {
2188        use crate::graph::InternalCallChain;
2189        use crate::pagination::PaginationMode;
2190        use std::path::PathBuf;
2191
2192        // Arrange: create mock caller chains
2193        let make_chain = |name: &str| -> InternalCallChain {
2194            InternalCallChain {
2195                chain: vec![
2196                    (name.to_string(), PathBuf::from("src/lib.rs"), 10),
2197                    ("target".to_string(), PathBuf::from("src/lib.rs"), 5),
2198                ],
2199            }
2200        };
2201
2202        let prod_chains: Vec<InternalCallChain> = (0..8)
2203            .map(|i| make_chain(&format!("caller_{}", i)))
2204            .collect();
2205        let page = &prod_chains[0..3];
2206
2207        // Act
2208        let formatted = format_focused_paginated(
2209            page,
2210            8,
2211            PaginationMode::Callers,
2212            "target",
2213            &prod_chains,
2214            &[],
2215            &[],
2216            1,
2217            0,
2218            None,
2219            true,
2220        );
2221
2222        // Assert: header present
2223        assert!(
2224            formatted.contains("CALLERS (1-3 of 8):"),
2225            "header should show 1-3 of 8, got: {}",
2226            formatted
2227        );
2228        assert!(
2229            formatted.contains("FOCUS: target"),
2230            "should have FOCUS header"
2231        );
2232    }
2233
2234    #[test]
2235    fn test_fields_none_regression() {
2236        use crate::types::SemanticAnalysis;
2237        use std::collections::HashMap;
2238
2239        let functions = vec![FunctionInfo {
2240            name: "hello".to_string(),
2241            line: 10,
2242            end_line: 15,
2243            parameters: vec![],
2244            return_type: None,
2245        }];
2246
2247        let classes = vec![ClassInfo {
2248            name: "MyClass".to_string(),
2249            line: 20,
2250            end_line: 50,
2251            methods: vec![],
2252            fields: vec![],
2253            inherits: vec![],
2254        }];
2255
2256        let imports = vec![ImportInfo {
2257            module: "std".to_string(),
2258            items: vec!["io".to_string()],
2259            line: 1,
2260        }];
2261
2262        let semantic = SemanticAnalysis {
2263            functions: functions.clone(),
2264            classes: classes.clone(),
2265            imports: imports.clone(),
2266            references: vec![],
2267            call_frequency: HashMap::new(),
2268            calls: vec![],
2269            impl_traits: vec![],
2270            def_use_sites: vec![],
2271        };
2272
2273        let output = format_file_details_paginated(
2274            &functions,
2275            functions.len(),
2276            &semantic,
2277            "test.rs",
2278            100,
2279            0,
2280            true,
2281            None,
2282        );
2283
2284        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2285        assert!(output.contains("C:"), "Classes section missing");
2286        assert!(output.contains("I:"), "Imports section missing");
2287        assert!(output.contains("F:"), "Functions section missing");
2288    }
2289
2290    #[test]
2291    fn test_fields_functions_only() {
2292        use crate::types::SemanticAnalysis;
2293        use std::collections::HashMap;
2294
2295        let functions = vec![FunctionInfo {
2296            name: "hello".to_string(),
2297            line: 10,
2298            end_line: 15,
2299            parameters: vec![],
2300            return_type: None,
2301        }];
2302
2303        let classes = vec![ClassInfo {
2304            name: "MyClass".to_string(),
2305            line: 20,
2306            end_line: 50,
2307            methods: vec![],
2308            fields: vec![],
2309            inherits: vec![],
2310        }];
2311
2312        let imports = vec![ImportInfo {
2313            module: "std".to_string(),
2314            items: vec!["io".to_string()],
2315            line: 1,
2316        }];
2317
2318        let semantic = SemanticAnalysis {
2319            functions: functions.clone(),
2320            classes: classes.clone(),
2321            imports: imports.clone(),
2322            references: vec![],
2323            call_frequency: HashMap::new(),
2324            calls: vec![],
2325            impl_traits: vec![],
2326            def_use_sites: vec![],
2327        };
2328
2329        let fields = Some(vec![AnalyzeFileField::Functions]);
2330        let output = format_file_details_paginated(
2331            &functions,
2332            functions.len(),
2333            &semantic,
2334            "test.rs",
2335            100,
2336            0,
2337            true,
2338            fields.as_deref(),
2339        );
2340
2341        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2342        assert!(!output.contains("C:"), "Classes section should not appear");
2343        assert!(!output.contains("I:"), "Imports section should not appear");
2344        assert!(output.contains("F:"), "Functions section missing");
2345    }
2346
2347    #[test]
2348    fn test_fields_classes_only() {
2349        use crate::types::SemanticAnalysis;
2350        use std::collections::HashMap;
2351
2352        let functions = vec![FunctionInfo {
2353            name: "hello".to_string(),
2354            line: 10,
2355            end_line: 15,
2356            parameters: vec![],
2357            return_type: None,
2358        }];
2359
2360        let classes = vec![ClassInfo {
2361            name: "MyClass".to_string(),
2362            line: 20,
2363            end_line: 50,
2364            methods: vec![],
2365            fields: vec![],
2366            inherits: vec![],
2367        }];
2368
2369        let imports = vec![ImportInfo {
2370            module: "std".to_string(),
2371            items: vec!["io".to_string()],
2372            line: 1,
2373        }];
2374
2375        let semantic = SemanticAnalysis {
2376            functions: functions.clone(),
2377            classes: classes.clone(),
2378            imports: imports.clone(),
2379            references: vec![],
2380            call_frequency: HashMap::new(),
2381            calls: vec![],
2382            impl_traits: vec![],
2383            def_use_sites: vec![],
2384        };
2385
2386        let fields = Some(vec![AnalyzeFileField::Classes]);
2387        let output = format_file_details_paginated(
2388            &functions,
2389            functions.len(),
2390            &semantic,
2391            "test.rs",
2392            100,
2393            0,
2394            true,
2395            fields.as_deref(),
2396        );
2397
2398        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2399        assert!(output.contains("C:"), "Classes section missing");
2400        assert!(!output.contains("I:"), "Imports section should not appear");
2401        assert!(
2402            !output.contains("F:"),
2403            "Functions section should not appear"
2404        );
2405    }
2406
2407    #[test]
2408    fn test_fields_imports_verbose() {
2409        use crate::types::SemanticAnalysis;
2410        use std::collections::HashMap;
2411
2412        let functions = vec![FunctionInfo {
2413            name: "hello".to_string(),
2414            line: 10,
2415            end_line: 15,
2416            parameters: vec![],
2417            return_type: None,
2418        }];
2419
2420        let classes = vec![ClassInfo {
2421            name: "MyClass".to_string(),
2422            line: 20,
2423            end_line: 50,
2424            methods: vec![],
2425            fields: vec![],
2426            inherits: vec![],
2427        }];
2428
2429        let imports = vec![ImportInfo {
2430            module: "std".to_string(),
2431            items: vec!["io".to_string()],
2432            line: 1,
2433        }];
2434
2435        let semantic = SemanticAnalysis {
2436            functions: functions.clone(),
2437            classes: classes.clone(),
2438            imports: imports.clone(),
2439            references: vec![],
2440            call_frequency: HashMap::new(),
2441            calls: vec![],
2442            impl_traits: vec![],
2443            def_use_sites: vec![],
2444        };
2445
2446        let fields = Some(vec![AnalyzeFileField::Imports]);
2447        let output = format_file_details_paginated(
2448            &functions,
2449            functions.len(),
2450            &semantic,
2451            "test.rs",
2452            100,
2453            0,
2454            true,
2455            fields.as_deref(),
2456        );
2457
2458        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2459        assert!(!output.contains("C:"), "Classes section should not appear");
2460        assert!(output.contains("I:"), "Imports section missing");
2461        assert!(
2462            !output.contains("F:"),
2463            "Functions section should not appear"
2464        );
2465    }
2466
2467    #[test]
2468    fn test_fields_imports_no_verbose() {
2469        use crate::types::SemanticAnalysis;
2470        use std::collections::HashMap;
2471
2472        let functions = vec![FunctionInfo {
2473            name: "hello".to_string(),
2474            line: 10,
2475            end_line: 15,
2476            parameters: vec![],
2477            return_type: None,
2478        }];
2479
2480        let classes = vec![ClassInfo {
2481            name: "MyClass".to_string(),
2482            line: 20,
2483            end_line: 50,
2484            methods: vec![],
2485            fields: vec![],
2486            inherits: vec![],
2487        }];
2488
2489        let imports = vec![ImportInfo {
2490            module: "std".to_string(),
2491            items: vec!["io".to_string()],
2492            line: 1,
2493        }];
2494
2495        let semantic = SemanticAnalysis {
2496            functions: functions.clone(),
2497            classes: classes.clone(),
2498            imports: imports.clone(),
2499            references: vec![],
2500            call_frequency: HashMap::new(),
2501            calls: vec![],
2502            impl_traits: vec![],
2503            def_use_sites: vec![],
2504        };
2505
2506        let fields = Some(vec![AnalyzeFileField::Imports]);
2507        let output = format_file_details_paginated(
2508            &functions,
2509            functions.len(),
2510            &semantic,
2511            "test.rs",
2512            100,
2513            0,
2514            false,
2515            fields.as_deref(),
2516        );
2517
2518        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2519        assert!(!output.contains("C:"), "Classes section should not appear");
2520        assert!(
2521            output.contains("I:"),
2522            "Imports section should appear (explicitly listed in fields)"
2523        );
2524        assert!(
2525            !output.contains("F:"),
2526            "Functions section should not appear"
2527        );
2528    }
2529
2530    #[test]
2531    fn test_fields_empty_array() {
2532        use crate::types::SemanticAnalysis;
2533        use std::collections::HashMap;
2534
2535        let functions = vec![FunctionInfo {
2536            name: "hello".to_string(),
2537            line: 10,
2538            end_line: 15,
2539            parameters: vec![],
2540            return_type: None,
2541        }];
2542
2543        let classes = vec![ClassInfo {
2544            name: "MyClass".to_string(),
2545            line: 20,
2546            end_line: 50,
2547            methods: vec![],
2548            fields: vec![],
2549            inherits: vec![],
2550        }];
2551
2552        let imports = vec![ImportInfo {
2553            module: "std".to_string(),
2554            items: vec!["io".to_string()],
2555            line: 1,
2556        }];
2557
2558        let semantic = SemanticAnalysis {
2559            functions: functions.clone(),
2560            classes: classes.clone(),
2561            imports: imports.clone(),
2562            references: vec![],
2563            call_frequency: HashMap::new(),
2564            calls: vec![],
2565            impl_traits: vec![],
2566            def_use_sites: vec![],
2567        };
2568
2569        let fields = Some(vec![]);
2570        let output = format_file_details_paginated(
2571            &functions,
2572            functions.len(),
2573            &semantic,
2574            "test.rs",
2575            100,
2576            0,
2577            true,
2578            fields.as_deref(),
2579        );
2580
2581        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2582        assert!(
2583            output.contains("C:"),
2584            "Classes section missing (empty fields = show all)"
2585        );
2586        assert!(
2587            output.contains("I:"),
2588            "Imports section missing (empty fields = show all)"
2589        );
2590        assert!(
2591            output.contains("F:"),
2592            "Functions section missing (empty fields = show all)"
2593        );
2594    }
2595
2596    #[test]
2597    fn test_fields_pagination_no_functions() {
2598        use crate::types::SemanticAnalysis;
2599        use std::collections::HashMap;
2600
2601        let functions = vec![FunctionInfo {
2602            name: "hello".to_string(),
2603            line: 10,
2604            end_line: 15,
2605            parameters: vec![],
2606            return_type: None,
2607        }];
2608
2609        let classes = vec![ClassInfo {
2610            name: "MyClass".to_string(),
2611            line: 20,
2612            end_line: 50,
2613            methods: vec![],
2614            fields: vec![],
2615            inherits: vec![],
2616        }];
2617
2618        let imports = vec![ImportInfo {
2619            module: "std".to_string(),
2620            items: vec!["io".to_string()],
2621            line: 1,
2622        }];
2623
2624        let semantic = SemanticAnalysis {
2625            functions: functions.clone(),
2626            classes: classes.clone(),
2627            imports: imports.clone(),
2628            references: vec![],
2629            call_frequency: HashMap::new(),
2630            calls: vec![],
2631            impl_traits: vec![],
2632            def_use_sites: vec![],
2633        };
2634
2635        let fields = Some(vec![AnalyzeFileField::Classes, AnalyzeFileField::Imports]);
2636        let output = format_file_details_paginated(
2637            &functions,
2638            functions.len(),
2639            &semantic,
2640            "test.rs",
2641            100,
2642            0,
2643            true,
2644            fields.as_deref(),
2645        );
2646
2647        assert!(output.contains("FILE: test.rs"), "FILE header missing");
2648        assert!(
2649            output.contains("1-1/1F"),
2650            "FILE header should contain valid range (1-1/1F)"
2651        );
2652        assert!(output.contains("C:"), "Classes section missing");
2653        assert!(output.contains("I:"), "Imports section missing");
2654        assert!(
2655            !output.contains("F:"),
2656            "Functions section should not appear (filtered by fields)"
2657        );
2658    }
2659
2660    #[test]
2661    fn test_snippet_one_line_short() {
2662        let snippet = "prev line\nlet x = 1;\nnext line";
2663        let result = snippet_one_line(snippet);
2664        assert_eq!(result, "let x = 1;");
2665    }
2666
2667    #[test]
2668    fn test_snippet_one_line_single_line() {
2669        let result = snippet_one_line("only line");
2670        assert_eq!(result, "only line");
2671    }
2672
2673    #[test]
2674    fn test_format_focused_internal_with_def_use_sites() {
2675        let mut graph = CallGraph::new();
2676        graph
2677            .definitions
2678            .insert("my_var".into(), vec![(PathBuf::from("src/lib.rs"), 5)]);
2679
2680        let site = |kind, file: &str, line, snippet: &str, scope: Option<&str>| DefUseSite {
2681            kind,
2682            symbol: "my_var".into(),
2683            file: file.into(),
2684            line,
2685            column: 0,
2686            snippet: snippet.into(),
2687            enclosing_scope: scope.map(Into::into),
2688        };
2689        let sites = vec![
2690            site(
2691                DefUseKind::Write,
2692                "src/lib.rs",
2693                5,
2694                "\nlet my_var = 42;\n",
2695                Some("init"),
2696            ),
2697            site(
2698                DefUseKind::WriteRead,
2699                "src/lib.rs",
2700                10,
2701                "\nmy_var += 1;\n",
2702                None,
2703            ),
2704            site(
2705                DefUseKind::Read,
2706                "src/main.rs",
2707                20,
2708                "\nprintln!(\"{}\", my_var);\n",
2709                Some("run"),
2710            ),
2711        ];
2712
2713        let output =
2714            format_focused_internal(&graph, "my_var", 1, None, Some(&[]), Some(&[]), &sites)
2715                .expect("format should succeed");
2716
2717        assert!(output.contains("DEF-USE SITES  my_var  (3 total: 2 writes, 1 reads)"));
2718        assert!(output.contains("WRITES"));
2719        assert!(output.contains("[write_read]"));
2720        assert!(output.contains("READS"));
2721        assert!(output.contains("run()"));
2722    }
2723
2724    #[test]
2725    fn test_format_focused_summary_internal_with_def_use_sites() {
2726        let mut graph = CallGraph::new();
2727        graph
2728            .definitions
2729            .insert("counter".into(), vec![(PathBuf::from("src/a.rs"), 1)]);
2730
2731        let sites = vec![DefUseSite {
2732            kind: DefUseKind::Read,
2733            symbol: "counter".into(),
2734            file: "src/b.rs".into(),
2735            line: 15,
2736            column: 0,
2737            snippet: "\nuse_counter(counter);\n".into(),
2738            enclosing_scope: Some("main".into()),
2739        }];
2740
2741        let output = format_focused_summary_internal(
2742            &graph,
2743            "counter",
2744            1,
2745            None,
2746            Some(&[]),
2747            Some(&[]),
2748            &sites,
2749        )
2750        .expect("format should succeed");
2751
2752        assert!(output.contains("DEF-USE SITES: 1 total (0 writes, 1 reads)"));
2753    }
2754
2755    #[test]
2756    fn test_analyze_file_field_all_equivalent_to_none() {
2757        use crate::types::AnalyzeFileField;
2758        // fields=None and fields=Some([All]) must produce the same section selections
2759        let all_fields = [AnalyzeFileField::All];
2760        // Functions section
2761        assert!(
2762            all_fields.contains(&AnalyzeFileField::All)
2763                || all_fields.contains(&AnalyzeFileField::Functions),
2764            "All variant should include Functions"
2765        );
2766        // Classes section
2767        assert!(
2768            all_fields.contains(&AnalyzeFileField::All)
2769                || all_fields.contains(&AnalyzeFileField::Classes),
2770            "All variant should include Classes"
2771        );
2772        // Imports section
2773        assert!(
2774            all_fields.contains(&AnalyzeFileField::All)
2775                || all_fields.contains(&AnalyzeFileField::Imports),
2776            "All variant should include Imports"
2777        );
2778    }
2779
2780    #[test]
2781    fn test_analyze_file_field_all_dominates() {
2782        use crate::types::AnalyzeFileField;
2783        // All + specific variant still returns all sections
2784        let mixed = [AnalyzeFileField::All, AnalyzeFileField::Functions];
2785        assert!(mixed.contains(&AnalyzeFileField::All));
2786        // All dominates: Classes and Imports should also be included
2787        assert!(
2788            mixed.contains(&AnalyzeFileField::All) || mixed.contains(&AnalyzeFileField::Classes),
2789            "All dominates: Classes included even when not explicitly listed"
2790        );
2791        assert!(
2792            mixed.contains(&AnalyzeFileField::All) || mixed.contains(&AnalyzeFileField::Imports),
2793            "All dominates: Imports included even when not explicitly listed"
2794        );
2795    }
2796}
2797
2798fn format_classes_section(classes: &[ClassInfo], functions: &[FunctionInfo]) -> String {
2799    let mut output = String::new();
2800    if classes.is_empty() {
2801        return output;
2802    }
2803    output.push_str("C:\n");
2804
2805    let methods_by_class = collect_class_methods(classes, functions);
2806    let has_methods = methods_by_class.values().any(|m| !m.is_empty());
2807
2808    if classes.len() <= MULTILINE_THRESHOLD && !has_methods {
2809        let class_strs: Vec<String> = classes
2810            .iter()
2811            .map(|class| {
2812                if class.inherits.is_empty() {
2813                    format!("{}:{}-{}", class.name, class.line, class.end_line)
2814                } else {
2815                    format!(
2816                        "{}:{}-{} ({})",
2817                        class.name,
2818                        class.line,
2819                        class.end_line,
2820                        class.inherits.join(", ")
2821                    )
2822                }
2823            })
2824            .collect();
2825        output.push_str("  ");
2826        output.push_str(&class_strs.join("; "));
2827        output.push('\n');
2828    } else {
2829        for class in classes {
2830            if class.inherits.is_empty() {
2831                let _ = writeln!(output, "  {}:{}-{}", class.name, class.line, class.end_line);
2832            } else {
2833                let _ = writeln!(
2834                    output,
2835                    "  {}:{}-{} ({})",
2836                    class.name,
2837                    class.line,
2838                    class.end_line,
2839                    class.inherits.join(", ")
2840                );
2841            }
2842
2843            // Append methods for each class
2844            if let Some(methods) = methods_by_class.get(&class.name)
2845                && !methods.is_empty()
2846            {
2847                for (i, method) in methods.iter().take(10).enumerate() {
2848                    let _ = writeln!(output, "    {}:{}", method.name, method.line);
2849                    if i + 1 == 10 && methods.len() > 10 {
2850                        let _ = writeln!(output, "    ... ({} more)", methods.len() - 10);
2851                        break;
2852                    }
2853                }
2854            }
2855        }
2856    }
2857    output
2858}
2859
2860/// Format related files section (incoming/outgoing imports).
2861/// Returns empty string when `import_graph` is None.
2862fn format_imports_section(imports: &[ImportInfo]) -> String {
2863    let mut output = String::new();
2864    if imports.is_empty() {
2865        return output;
2866    }
2867    output.push_str("I:\n");
2868    let mut module_map: HashMap<String, usize> = HashMap::new();
2869    for import in imports {
2870        module_map
2871            .entry(import.module.clone())
2872            .and_modify(|count| *count += 1)
2873            .or_insert(1);
2874    }
2875    let mut modules: Vec<_> = module_map.keys().cloned().collect();
2876    modules.sort();
2877    let formatted_modules: Vec<String> = modules
2878        .iter()
2879        .map(|module| format!("{}({})", module, module_map[module]))
2880        .collect();
2881    if formatted_modules.len() <= MULTILINE_THRESHOLD {
2882        output.push_str("  ");
2883        output.push_str(&formatted_modules.join("; "));
2884        output.push('\n');
2885    } else {
2886        for module_str in formatted_modules {
2887            output.push_str("  ");
2888            output.push_str(&module_str);
2889            output.push('\n');
2890        }
2891    }
2892    output
2893}