Skip to main content

code_analyze_mcp/
formatter.rs

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