Skip to main content

code_analyze_core/
formatter.rs

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