Skip to main content

code_analyze_mcp/
formatter.rs

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