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