Skip to main content

code_analyze_core/
formatter.rs

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