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