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::CallChain;
7use crate::graph::CallGraph;
8use crate::pagination::PaginationMode;
9use crate::test_detection::is_test_file;
10use crate::traversal::WalkEntry;
11use crate::types::{ClassInfo, FileInfo, FunctionInfo, ImportInfo, ModuleInfo, SemanticAnalysis};
12use std::collections::{HashMap, HashSet};
13use std::fmt::Write;
14use std::path::Path;
15use thiserror::Error;
16use tracing::instrument;
17
18pub(crate) const EXCLUDED_DIRS: &[&str] = &[
19    "node_modules",
20    "vendor",
21    ".git",
22    "__pycache__",
23    "target",
24    "dist",
25    "build",
26    ".venv",
27];
28
29const MULTILINE_THRESHOLD: usize = 10;
30
31/// Format a list of function signatures wrapped at 100 characters with bullet annotation.
32fn format_function_list_wrapped<'a>(
33    functions: impl Iterator<Item = &'a crate::types::FunctionInfo>,
34    call_frequency: &std::collections::HashMap<String, usize>,
35) -> String {
36    let mut output = String::new();
37    let mut line = String::from("  ");
38    for (i, func) in functions.enumerate() {
39        let mut call_marker = func.compact_signature();
40
41        if let Some(&count) = call_frequency.get(&func.name)
42            && count > 3
43        {
44            call_marker.push_str(&format!("\u{2022}{}", count));
45        }
46
47        if i == 0 {
48            line.push_str(&call_marker);
49        } else if line.len() + call_marker.len() + 2 > 100 {
50            output.push_str(&line);
51            output.push('\n');
52            let mut new_line = String::with_capacity(2 + call_marker.len());
53            new_line.push_str("  ");
54            new_line.push_str(&call_marker);
55            line = new_line;
56        } else {
57            line.push_str(", ");
58            line.push_str(&call_marker);
59        }
60    }
61    if !line.trim().is_empty() {
62        output.push_str(&line);
63        output.push('\n');
64    }
65    output
66}
67
68/// Build a bracket string for file info (line count, function count, class count).
69/// Returns None if all counts are zero, otherwise returns "[42L, 7F, 2C]" format.
70fn format_file_info_parts(line_count: usize, fn_count: usize, cls_count: usize) -> Option<String> {
71    let mut parts = Vec::new();
72    if line_count > 0 {
73        parts.push(format!("{}L", line_count));
74    }
75    if fn_count > 0 {
76        parts.push(format!("{}F", fn_count));
77    }
78    if cls_count > 0 {
79        parts.push(format!("{}C", cls_count));
80    }
81    if parts.is_empty() {
82        None
83    } else {
84        Some(format!("[{}]", parts.join(", ")))
85    }
86}
87
88/// Strip a base path from a Path, returning a relative path or the original on failure.
89fn strip_base_path(path: &Path, base_path: Option<&Path>) -> String {
90    match base_path {
91        Some(base) => {
92            if let Ok(rel_path) = path.strip_prefix(base) {
93                rel_path.display().to_string()
94            } else {
95                path.display().to_string()
96            }
97        }
98        None => path.display().to_string(),
99    }
100}
101
102#[derive(Debug, Error)]
103pub enum FormatterError {
104    #[error("Graph error: {0}")]
105    GraphError(#[from] crate::graph::GraphError),
106}
107
108/// Format directory structure analysis results.
109#[instrument(skip_all)]
110pub fn format_structure(
111    entries: &[WalkEntry],
112    analysis_results: &[FileInfo],
113    max_depth: Option<u32>,
114    _base_path: Option<&Path>,
115) -> String {
116    let mut output = String::new();
117
118    // Build a map of path -> analysis for quick lookup
119    let analysis_map: HashMap<String, &FileInfo> = analysis_results
120        .iter()
121        .map(|a| (a.path.clone(), a))
122        .collect();
123
124    // Partition files into production and test
125    let (prod_files, test_files): (Vec<_>, Vec<_>) =
126        analysis_results.iter().partition(|a| !a.is_test);
127
128    // Calculate totals
129    let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
130    let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
131    let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
132
133    // Count files by language and calculate percentages
134    let mut lang_counts: HashMap<String, usize> = HashMap::new();
135    for analysis in analysis_results {
136        *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
137    }
138    let total_files = analysis_results.len();
139
140    // Leading summary line with totals
141    let primary_lang = lang_counts
142        .iter()
143        .max_by_key(|&(_, count)| count)
144        .map(|(name, count)| {
145            let percentage = if total_files > 0 {
146                (*count * 100) / total_files
147            } else {
148                0
149            };
150            format!("{} {}%", name, percentage)
151        })
152        .unwrap_or_else(|| "unknown 0%".to_string());
153
154    output.push_str(&format!(
155        "{} files, {}L, {}F, {}C ({})\n",
156        total_files, total_loc, total_functions, total_classes, primary_lang
157    ));
158
159    // SUMMARY block
160    output.push_str("SUMMARY:\n");
161    let depth_label = match max_depth {
162        Some(n) if n > 0 => format!(" (max_depth={})", n),
163        _ => String::new(),
164    };
165    output.push_str(&format!(
166        "Shown: {} files ({} prod, {} test), {}L, {}F, {}C{}\n",
167        total_files,
168        prod_files.len(),
169        test_files.len(),
170        total_loc,
171        total_functions,
172        total_classes,
173        depth_label
174    ));
175
176    if !lang_counts.is_empty() {
177        output.push_str("Languages: ");
178        let mut langs: Vec<_> = lang_counts.iter().collect();
179        langs.sort_by_key(|&(name, _)| name);
180        let lang_strs: Vec<String> = langs
181            .iter()
182            .map(|(name, count)| {
183                let percentage = if total_files > 0 {
184                    (**count * 100) / total_files
185                } else {
186                    0
187                };
188                format!("{} ({}%)", name, percentage)
189            })
190            .collect();
191        output.push_str(&lang_strs.join(", "));
192        output.push('\n');
193    }
194
195    output.push('\n');
196
197    // PATH block - tree structure (production files only)
198    output.push_str("PATH [LOC, FUNCTIONS, CLASSES]\n");
199
200    for entry in entries {
201        // Skip the root directory itself
202        if entry.depth == 0 {
203            continue;
204        }
205
206        // Calculate indentation
207        let indent = "  ".repeat(entry.depth - 1);
208
209        // Get just the filename/dirname
210        let name = entry
211            .path
212            .file_name()
213            .and_then(|n| n.to_str())
214            .unwrap_or("?");
215
216        // For files, append analysis info
217        if !entry.is_dir {
218            if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
219                // Skip test files in production section
220                if analysis.is_test {
221                    continue;
222                }
223
224                if let Some(info_str) = format_file_info_parts(
225                    analysis.line_count,
226                    analysis.function_count,
227                    analysis.class_count,
228                ) {
229                    output.push_str(&format!("{}{} {}\n", indent, name, info_str));
230                } else {
231                    output.push_str(&format!("{}{}\n", indent, name));
232                }
233            }
234            // Skip files not in analysis_map (binary/unreadable files)
235        } else {
236            output.push_str(&format!("{}{}/\n", indent, name));
237        }
238    }
239
240    // TEST FILES section (if any test files exist)
241    if !test_files.is_empty() {
242        output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
243
244        for entry in entries {
245            // Skip the root directory itself
246            if entry.depth == 0 {
247                continue;
248            }
249
250            // Calculate indentation
251            let indent = "  ".repeat(entry.depth - 1);
252
253            // Get just the filename/dirname
254            let name = entry
255                .path
256                .file_name()
257                .and_then(|n| n.to_str())
258                .unwrap_or("?");
259
260            // For files, append analysis info
261            if !entry.is_dir
262                && let Some(analysis) = analysis_map.get(&entry.path.display().to_string())
263            {
264                // Only show test files in test section
265                if !analysis.is_test {
266                    continue;
267                }
268
269                if let Some(info_str) = format_file_info_parts(
270                    analysis.line_count,
271                    analysis.function_count,
272                    analysis.class_count,
273                ) {
274                    output.push_str(&format!("{}{} {}\n", indent, name, info_str));
275                } else {
276                    output.push_str(&format!("{}{}\n", indent, name));
277                }
278            }
279        }
280    }
281
282    output
283}
284
285/// Format file-level semantic analysis results.
286#[instrument(skip_all)]
287pub fn format_file_details(
288    path: &str,
289    analysis: &SemanticAnalysis,
290    line_count: usize,
291    is_test: bool,
292    base_path: Option<&Path>,
293) -> String {
294    let mut output = String::new();
295
296    // FILE: header with counts, prepend [TEST] if applicable
297    let display_path = strip_base_path(Path::new(path), base_path);
298    if is_test {
299        output.push_str(&format!(
300            "FILE [TEST] {}({}L, {}F, {}C, {}I)\n",
301            display_path,
302            line_count,
303            analysis.functions.len(),
304            analysis.classes.len(),
305            analysis.imports.len()
306        ));
307    } else {
308        output.push_str(&format!(
309            "FILE: {}({}L, {}F, {}C, {}I)\n",
310            display_path,
311            line_count,
312            analysis.functions.len(),
313            analysis.classes.len(),
314            analysis.imports.len()
315        ));
316    }
317
318    // C: section with classes
319    output.push_str(&format_classes_section(&analysis.classes));
320
321    // F: section with functions, parameters, return types and call frequency
322    if !analysis.functions.is_empty() {
323        output.push_str("F:\n");
324        output.push_str(&format_function_list_wrapped(
325            analysis.functions.iter(),
326            &analysis.call_frequency,
327        ));
328    }
329
330    // I: section with imports grouped by module
331    output.push_str(&format_imports_section(&analysis.imports));
332
333    output
334}
335
336/// Format chains as a tree-indented output, grouped by depth-1 symbol.
337/// Groups chains by their first symbol (depth-1), deduplicates and sorts depth-2 children,
338/// then renders with 2-space indentation using the provided arrow.
339/// focus_symbol is the name of the depth-0 symbol (focus point) to prepend on depth-1 lines.
340///
341/// Indentation rules:
342/// - Depth-1: `  {focus} {arrow} {parent}` (2-space indent)
343/// - Depth-2: `    {arrow} {child}` (4-space indent)
344/// - Empty:   `  (none)` (2-space indent)
345fn format_chains_as_tree(chains: &[(&str, &str)], arrow: &str, focus_symbol: &str) -> String {
346    use std::collections::BTreeMap;
347
348    if chains.is_empty() {
349        return "  (none)\n".to_string();
350    }
351
352    let mut output = String::new();
353
354    // Group chains by depth-1 symbol, counting duplicate children
355    let mut groups: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
356    for (parent, child) in chains {
357        // Only count non-empty children
358        if !child.is_empty() {
359            *groups
360                .entry(parent.to_string())
361                .or_default()
362                .entry(child.to_string())
363                .or_insert(0) += 1;
364        } else {
365            // Ensure parent is in groups even if no children
366            groups.entry(parent.to_string()).or_default();
367        }
368    }
369
370    // Render grouped tree
371    for (parent, children) in groups {
372        let _ = writeln!(output, "  {} {} {}", focus_symbol, arrow, parent);
373        // Sort children by count descending, then alphabetically
374        let mut sorted: Vec<_> = children.into_iter().collect();
375        sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
376        for (child, count) in sorted {
377            if count > 1 {
378                let _ = writeln!(output, "    {} {} (x{})", arrow, child, count);
379            } else {
380                let _ = writeln!(output, "    {} {}", arrow, child);
381            }
382        }
383    }
384
385    output
386}
387
388/// Format focused symbol analysis with call graph.
389#[instrument(skip_all)]
390pub fn format_focused(
391    graph: &CallGraph,
392    symbol: &str,
393    follow_depth: u32,
394    base_path: Option<&Path>,
395) -> Result<String, FormatterError> {
396    let mut output = String::new();
397
398    // Compute all counts BEFORE output begins
399    let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
400    let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
401    let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
402
403    // Partition incoming_chains into production and test callers
404    let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
405        incoming_chains.clone().into_iter().partition(|chain| {
406            chain
407                .chain
408                .first()
409                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
410        });
411
412    // Count unique callers
413    let callers_count = prod_chains
414        .iter()
415        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
416        .collect::<std::collections::HashSet<_>>()
417        .len();
418
419    // Count unique callees
420    let callees_count = outgoing_chains
421        .iter()
422        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
423        .collect::<std::collections::HashSet<_>>()
424        .len();
425
426    // FOCUS section - with inline counts
427    output.push_str(&format!(
428        "FOCUS: {} ({} defs, {} callers, {} callees)\n",
429        symbol, def_count, callers_count, callees_count
430    ));
431
432    // DEPTH section
433    output.push_str(&format!("DEPTH: {}\n", follow_depth));
434
435    // DEFINED section - show where the symbol is defined
436    if let Some(definitions) = graph.definitions.get(symbol) {
437        output.push_str("DEFINED:\n");
438        for (path, line) in definitions {
439            output.push_str(&format!(
440                "  {}:{}\n",
441                strip_base_path(path, base_path),
442                line
443            ));
444        }
445    } else {
446        output.push_str("DEFINED: (not found)\n");
447    }
448
449    // CALLERS section - who calls this symbol
450    output.push_str("CALLERS:\n");
451
452    // Render production callers
453    let prod_refs: Vec<_> = prod_chains
454        .iter()
455        .filter_map(|chain| {
456            if chain.chain.len() >= 2 {
457                Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
458            } else if chain.chain.len() == 1 {
459                Some((chain.chain[0].0.as_str(), ""))
460            } else {
461                None
462            }
463        })
464        .collect();
465
466    if prod_refs.is_empty() {
467        output.push_str("  (none)\n");
468    } else {
469        output.push_str(&format_chains_as_tree(&prod_refs, "<-", symbol));
470    }
471
472    // Render test callers summary if any
473    if !test_chains.is_empty() {
474        let mut test_files: Vec<_> = test_chains
475            .iter()
476            .filter_map(|chain| {
477                chain
478                    .chain
479                    .first()
480                    .map(|(_, path, _)| path.to_string_lossy().into_owned())
481            })
482            .collect();
483        test_files.sort();
484        test_files.dedup();
485
486        // Strip base path for display
487        let display_files: Vec<_> = test_files
488            .iter()
489            .map(|f| strip_base_path(Path::new(f), base_path))
490            .collect();
491
492        let file_list = display_files.join(", ");
493        output.push_str(&format!(
494            "CALLERS (test): {} test functions (in {})\n",
495            test_chains.len(),
496            file_list
497        ));
498    }
499
500    // CALLEES section - what this symbol calls
501    output.push_str("CALLEES:\n");
502    let outgoing_refs: Vec<_> = outgoing_chains
503        .iter()
504        .filter_map(|chain| {
505            if chain.chain.len() >= 2 {
506                Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
507            } else if chain.chain.len() == 1 {
508                Some((chain.chain[0].0.as_str(), ""))
509            } else {
510                None
511            }
512        })
513        .collect();
514
515    if outgoing_refs.is_empty() {
516        output.push_str("  (none)\n");
517    } else {
518        output.push_str(&format_chains_as_tree(&outgoing_refs, "->", symbol));
519    }
520
521    // STATISTICS section
522    output.push_str("STATISTICS:\n");
523    let incoming_count = prod_refs
524        .iter()
525        .map(|(p, _)| p)
526        .collect::<std::collections::HashSet<_>>()
527        .len();
528    let outgoing_count = outgoing_refs
529        .iter()
530        .map(|(p, _)| p)
531        .collect::<std::collections::HashSet<_>>()
532        .len();
533    output.push_str(&format!("  Incoming calls: {}\n", incoming_count));
534    output.push_str(&format!("  Outgoing calls: {}\n", outgoing_count));
535
536    // FILES section - collect unique files from production chains
537    let mut files = HashSet::new();
538    for chain in &prod_chains {
539        for (_, path, _) in &chain.chain {
540            files.insert(path.clone());
541        }
542    }
543    for chain in &outgoing_chains {
544        for (_, path, _) in &chain.chain {
545            files.insert(path.clone());
546        }
547    }
548    if let Some(definitions) = graph.definitions.get(symbol) {
549        for (path, _) in definitions {
550            files.insert(path.clone());
551        }
552    }
553
554    // Partition files into production and test
555    let (prod_files, test_files): (Vec<_>, Vec<_>) =
556        files.into_iter().partition(|path| !is_test_file(path));
557
558    output.push_str("FILES:\n");
559    if prod_files.is_empty() && test_files.is_empty() {
560        output.push_str("  (none)\n");
561    } else {
562        // Show production files first
563        if !prod_files.is_empty() {
564            let mut sorted_files = prod_files;
565            sorted_files.sort();
566            for file in sorted_files {
567                output.push_str(&format!("  {}\n", strip_base_path(&file, base_path)));
568            }
569        }
570
571        // Show test files in separate subsection
572        if !test_files.is_empty() {
573            output.push_str("  TEST FILES:\n");
574            let mut sorted_files = test_files;
575            sorted_files.sort();
576            for file in sorted_files {
577                output.push_str(&format!("    {}\n", strip_base_path(&file, base_path)));
578            }
579        }
580    }
581
582    Ok(output)
583}
584
585/// Format a compact summary of focused symbol analysis.
586/// Used when output would exceed the size threshold or when explicitly requested.
587#[instrument(skip_all)]
588pub fn format_focused_summary(
589    graph: &CallGraph,
590    symbol: &str,
591    follow_depth: u32,
592    base_path: Option<&Path>,
593) -> Result<String, FormatterError> {
594    let mut output = String::new();
595
596    // Compute all counts BEFORE output begins
597    let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
598    let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
599    let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
600
601    // Partition incoming_chains into production and test callers
602    let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
603        incoming_chains.into_iter().partition(|chain| {
604            chain
605                .chain
606                .first()
607                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
608        });
609
610    // Count unique production callers
611    let callers_count = prod_chains
612        .iter()
613        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
614        .collect::<std::collections::HashSet<_>>()
615        .len();
616
617    // Count unique callees
618    let callees_count = outgoing_chains
619        .iter()
620        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
621        .collect::<std::collections::HashSet<_>>()
622        .len();
623
624    // FOCUS header
625    output.push_str(&format!(
626        "FOCUS: {} ({} defs, {} callers, {} callees)\n",
627        symbol, def_count, callers_count, callees_count
628    ));
629
630    // DEPTH line
631    output.push_str(&format!("DEPTH: {}\n", follow_depth));
632
633    // DEFINED section
634    if let Some(definitions) = graph.definitions.get(symbol) {
635        output.push_str("DEFINED:\n");
636        for (path, line) in definitions {
637            output.push_str(&format!(
638                "  {}:{}\n",
639                strip_base_path(path, base_path),
640                line
641            ));
642        }
643    } else {
644        output.push_str("DEFINED: (not found)\n");
645    }
646
647    // CALLERS (production, top 10 by frequency)
648    output.push_str("CALLERS (top 10):\n");
649    if prod_chains.is_empty() {
650        output.push_str("  (none)\n");
651    } else {
652        // Collect caller names with their file paths (from chain.chain.first())
653        let mut caller_freq: std::collections::HashMap<String, (usize, String)> =
654            std::collections::HashMap::new();
655        for chain in &prod_chains {
656            if let Some((name, path, _)) = chain.chain.first() {
657                let file_path = strip_base_path(path, base_path);
658                caller_freq
659                    .entry(name.clone())
660                    .and_modify(|(count, _)| *count += 1)
661                    .or_insert((1, file_path));
662            }
663        }
664
665        // Sort by frequency descending, take top 10
666        let mut sorted_callers: Vec<_> = caller_freq.into_iter().collect();
667        sorted_callers.sort_by(|a, b| b.1.0.cmp(&a.1.0));
668
669        for (name, (_, file_path)) in sorted_callers.into_iter().take(10) {
670            output.push_str(&format!("  {} {}\n", name, file_path));
671        }
672    }
673
674    // CALLERS (test) - summary only
675    if !test_chains.is_empty() {
676        let mut test_files: Vec<_> = test_chains
677            .iter()
678            .filter_map(|chain| {
679                chain
680                    .chain
681                    .first()
682                    .map(|(_, path, _)| path.to_string_lossy().into_owned())
683            })
684            .collect();
685        test_files.sort();
686        test_files.dedup();
687
688        output.push_str(&format!(
689            "CALLERS (test): {} test functions (in {} files)\n",
690            test_chains.len(),
691            test_files.len()
692        ));
693    }
694
695    // CALLEES (top 10 by frequency)
696    output.push_str("CALLEES (top 10):\n");
697    if outgoing_chains.is_empty() {
698        output.push_str("  (none)\n");
699    } else {
700        // Collect callee names and count frequency
701        let mut callee_freq: std::collections::HashMap<String, usize> =
702            std::collections::HashMap::new();
703        for chain in &outgoing_chains {
704            if let Some((name, _, _)) = chain.chain.first() {
705                *callee_freq.entry(name.clone()).or_insert(0) += 1;
706            }
707        }
708
709        // Sort by frequency descending, take top 10
710        let mut sorted_callees: Vec<_> = callee_freq.into_iter().collect();
711        sorted_callees.sort_by(|a, b| b.1.cmp(&a.1));
712
713        for (name, _) in sorted_callees.into_iter().take(10) {
714            output.push_str(&format!("  {}\n", name));
715        }
716    }
717
718    // SUGGESTION section
719    output.push_str("SUGGESTION:\n");
720    output.push_str("Use summary=false with force=true for full output\n");
721
722    Ok(output)
723}
724
725/// Format a compact summary for large directory analysis results.
726/// Used when output would exceed the size threshold or when explicitly requested.
727#[instrument(skip_all)]
728pub fn format_summary(
729    entries: &[WalkEntry],
730    analysis_results: &[FileInfo],
731    max_depth: Option<u32>,
732    _base_path: Option<&Path>,
733) -> String {
734    let mut output = String::new();
735
736    // Partition files into production and test
737    let (prod_files, test_files): (Vec<_>, Vec<_>) =
738        analysis_results.iter().partition(|a| !a.is_test);
739
740    // Calculate totals
741    let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
742    let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
743    let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
744
745    // Count files by language
746    let mut lang_counts: HashMap<String, usize> = HashMap::new();
747    for analysis in analysis_results {
748        *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
749    }
750    let total_files = analysis_results.len();
751
752    // SUMMARY block
753    output.push_str("SUMMARY:\n");
754    let depth_label = match max_depth {
755        Some(n) if n > 0 => format!(" (max_depth={})", n),
756        _ => String::new(),
757    };
758    output.push_str(&format!(
759        "{} files ({} prod, {} test), {}L, {}F, {}C{}\n",
760        total_files,
761        prod_files.len(),
762        test_files.len(),
763        total_loc,
764        total_functions,
765        total_classes,
766        depth_label
767    ));
768
769    if !lang_counts.is_empty() {
770        output.push_str("Languages: ");
771        let mut langs: Vec<_> = lang_counts.iter().collect();
772        langs.sort_by_key(|&(name, _)| name);
773        let lang_strs: Vec<String> = langs
774            .iter()
775            .map(|(name, count)| {
776                let percentage = if total_files > 0 {
777                    (**count * 100) / total_files
778                } else {
779                    0
780                };
781                format!("{} ({}%)", name, percentage)
782            })
783            .collect();
784        output.push_str(&lang_strs.join(", "));
785        output.push('\n');
786    }
787
788    output.push('\n');
789
790    // STRUCTURE (depth 1) block
791    output.push_str("STRUCTURE (depth 1):\n");
792
793    // Build a map of path -> analysis for quick lookup
794    let analysis_map: HashMap<String, &FileInfo> = analysis_results
795        .iter()
796        .map(|a| (a.path.clone(), a))
797        .collect();
798
799    // Collect depth-1 entries (directories and files at depth 1)
800    let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
801    depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
802
803    // Track largest non-excluded directory for SUGGESTION
804    let mut largest_dir_name: Option<String> = None;
805    let mut largest_dir_path: Option<String> = None;
806    let mut largest_dir_count: usize = 0;
807
808    for entry in depth1_entries {
809        let name = entry
810            .path
811            .file_name()
812            .and_then(|n| n.to_str())
813            .unwrap_or("?");
814
815        if entry.is_dir {
816            // For directories, aggregate stats from all files under this directory
817            let dir_path_str = entry.path.display().to_string();
818            let files_in_dir: Vec<&FileInfo> = analysis_results
819                .iter()
820                .filter(|f| Path::new(&f.path).starts_with(&entry.path))
821                .collect();
822
823            if !files_in_dir.is_empty() {
824                let dir_file_count = files_in_dir.len();
825                let dir_loc: usize = files_in_dir.iter().map(|f| f.line_count).sum();
826                let dir_functions: usize = files_in_dir.iter().map(|f| f.function_count).sum();
827                let dir_classes: usize = files_in_dir.iter().map(|f| f.class_count).sum();
828
829                // Track largest non-excluded directory for SUGGESTION
830                let entry_name_str = name.to_string();
831                if !EXCLUDED_DIRS.contains(&entry_name_str.as_str())
832                    && files_in_dir.len() > largest_dir_count
833                {
834                    largest_dir_count = files_in_dir.len();
835                    largest_dir_name = Some(entry_name_str);
836                    largest_dir_path = Some(
837                        entry
838                            .path
839                            .canonicalize()
840                            .unwrap_or_else(|_| entry.path.clone())
841                            .display()
842                            .to_string(),
843                    );
844                }
845
846                // Build hint: top-N files sorted by class_count desc, fallback to function_count
847                let hint = if files_in_dir.len() > 1 && (dir_classes > 0 || dir_functions > 0) {
848                    let mut top_files = files_in_dir.clone();
849                    top_files.sort_unstable_by(|a, b| {
850                        b.class_count
851                            .cmp(&a.class_count)
852                            .then(b.function_count.cmp(&a.function_count))
853                            .then(a.path.cmp(&b.path))
854                    });
855
856                    let has_classes = top_files.iter().any(|f| f.class_count > 0);
857
858                    // Re-sort for function fallback if no classes
859                    if !has_classes {
860                        top_files.sort_unstable_by(|a, b| {
861                            b.function_count
862                                .cmp(&a.function_count)
863                                .then(a.path.cmp(&b.path))
864                        });
865                    }
866
867                    let dir_path = Path::new(&dir_path_str);
868                    let top_n: Vec<String> = top_files
869                        .iter()
870                        .take(3)
871                        .filter(|f| {
872                            if has_classes {
873                                f.class_count > 0
874                            } else {
875                                f.function_count > 0
876                            }
877                        })
878                        .map(|f| {
879                            let rel = Path::new(&f.path)
880                                .strip_prefix(dir_path)
881                                .map(|p| p.to_string_lossy().into_owned())
882                                .unwrap_or_else(|_| {
883                                    Path::new(&f.path)
884                                        .file_name()
885                                        .and_then(|n| n.to_str())
886                                        .map(|s| s.to_owned())
887                                        .unwrap_or_else(|| "?".to_owned())
888                                });
889                            let count = if has_classes {
890                                f.class_count
891                            } else {
892                                f.function_count
893                            };
894                            let suffix = if has_classes { 'C' } else { 'F' };
895                            format!("{}({}{})", rel, count, suffix)
896                        })
897                        .collect();
898                    if top_n.is_empty() {
899                        String::new()
900                    } else {
901                        format!(" top: {}", top_n.join(", "))
902                    }
903                } else {
904                    String::new()
905                };
906
907                // Collect depth-2 sub-package directories (immediate children of this directory)
908                let mut subdirs: Vec<String> = entries
909                    .iter()
910                    .filter(|e| e.depth == 2 && e.is_dir && e.path.starts_with(&entry.path))
911                    .filter_map(|e| {
912                        e.path
913                            .file_name()
914                            .and_then(|n| n.to_str())
915                            .map(|s| s.to_owned())
916                    })
917                    .collect();
918                subdirs.sort();
919                subdirs.dedup();
920                let subdir_suffix = if subdirs.is_empty() {
921                    String::new()
922                } else {
923                    let subdirs_capped: Vec<String> =
924                        subdirs.iter().take(5).map(|s| format!("{}/", s)).collect();
925                    format!("  sub: {}", subdirs_capped.join(", "))
926                };
927
928                output.push_str(&format!(
929                    "  {}/ [{} files, {}L, {}F, {}C]{}{}\n",
930                    name, dir_file_count, dir_loc, dir_functions, dir_classes, hint, subdir_suffix
931                ));
932            } else {
933                output.push_str(&format!("  {}/\n", name));
934            }
935        } else {
936            // For files, show individual stats
937            if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
938                if let Some(info_str) = format_file_info_parts(
939                    analysis.line_count,
940                    analysis.function_count,
941                    analysis.class_count,
942                ) {
943                    output.push_str(&format!("  {} {}\n", name, info_str));
944                } else {
945                    output.push_str(&format!("  {}\n", name));
946                }
947            }
948        }
949    }
950
951    output.push('\n');
952
953    // SUGGESTION block
954    if let (Some(name), Some(path)) = (largest_dir_name, largest_dir_path) {
955        output.push_str(&format!(
956            "SUGGESTION: Largest source directory: {}/ ({} files total). For module details, re-run with path={} and max_depth=2.\n",
957            name, largest_dir_count, path
958        ));
959    } else {
960        output.push_str("SUGGESTION:\n");
961        output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
962    }
963
964    output
965}
966
967/// Format a compact summary of file details for large FileDetails output.
968///
969/// Returns FILE header with path/LOC/counts, top 10 functions by line span descending,
970/// classes inline if <=10, import count, and suggestion block.
971#[instrument(skip_all)]
972pub fn format_file_details_summary(
973    semantic: &SemanticAnalysis,
974    path: &str,
975    line_count: usize,
976) -> String {
977    let mut output = String::new();
978
979    // FILE header
980    output.push_str("FILE:\n");
981    output.push_str(&format!("  path: {}\n", path));
982    output.push_str(&format!(
983        "  {}L, {}F, {}C\n",
984        line_count,
985        semantic.functions.len(),
986        semantic.classes.len()
987    ));
988    output.push('\n');
989
990    // Top 10 functions by line span (end_line - start_line) descending
991    if !semantic.functions.is_empty() {
992        output.push_str("TOP FUNCTIONS BY SIZE:\n");
993        let mut funcs: Vec<&crate::types::FunctionInfo> = semantic.functions.iter().collect();
994        let k = funcs.len().min(10);
995        if k > 0 {
996            funcs.select_nth_unstable_by(k.saturating_sub(1), |a, b| {
997                let a_span = a.end_line.saturating_sub(a.line);
998                let b_span = b.end_line.saturating_sub(b.line);
999                b_span.cmp(&a_span)
1000            });
1001            funcs[..k].sort_by(|a, b| {
1002                let a_span = a.end_line.saturating_sub(a.line);
1003                let b_span = b.end_line.saturating_sub(b.line);
1004                b_span.cmp(&a_span)
1005            });
1006        }
1007
1008        for func in &funcs[..k] {
1009            let span = func.end_line.saturating_sub(func.line);
1010            let params = if func.parameters.is_empty() {
1011                String::new()
1012            } else {
1013                format!("({})", func.parameters.join(", "))
1014            };
1015            output.push_str(&format!(
1016                "  {}:{}: {} {} [{}L]\n",
1017                func.line, func.end_line, func.name, params, span
1018            ));
1019        }
1020        output.push('\n');
1021    }
1022
1023    // Classes inline if <=10, else multiline with method count
1024    if !semantic.classes.is_empty() {
1025        output.push_str("CLASSES:\n");
1026        if semantic.classes.len() <= 10 {
1027            // Inline format: one class per line with method count
1028            for class in &semantic.classes {
1029                let methods_count = class.methods.len();
1030                output.push_str(&format!("  {}: {}M\n", class.name, methods_count));
1031            }
1032        } else {
1033            // Multiline format with summary
1034            output.push_str(&format!("  {} classes total\n", semantic.classes.len()));
1035            for class in semantic.classes.iter().take(5) {
1036                output.push_str(&format!("    {}\n", class.name));
1037            }
1038            if semantic.classes.len() > 5 {
1039                output.push_str(&format!(
1040                    "    ... and {} more\n",
1041                    semantic.classes.len() - 5
1042                ));
1043            }
1044        }
1045        output.push('\n');
1046    }
1047
1048    // Import count only
1049    output.push_str(&format!("Imports: {}\n", semantic.imports.len()));
1050    output.push('\n');
1051
1052    // SUGGESTION block
1053    output.push_str("SUGGESTION:\n");
1054    output.push_str("Use force=true for full output, or narrow your scope\n");
1055
1056    output
1057}
1058
1059/// Format a paginated subset of files for Overview mode.
1060#[instrument(skip_all)]
1061pub fn format_structure_paginated(
1062    paginated_files: &[FileInfo],
1063    total_files: usize,
1064    max_depth: Option<u32>,
1065    base_path: Option<&Path>,
1066    verbose: bool,
1067) -> String {
1068    let mut output = String::new();
1069
1070    let depth_label = match max_depth {
1071        Some(n) if n > 0 => format!(" (max_depth={})", n),
1072        _ => String::new(),
1073    };
1074    output.push_str(&format!(
1075        "PAGINATED: showing {} of {} files{}\n\n",
1076        paginated_files.len(),
1077        total_files,
1078        depth_label
1079    ));
1080
1081    let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
1082    let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
1083
1084    if !prod_files.is_empty() {
1085        if verbose {
1086            output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
1087        }
1088        for file in &prod_files {
1089            output.push_str(&format_file_entry(file, base_path));
1090        }
1091    }
1092
1093    if !test_files.is_empty() {
1094        if verbose {
1095            output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
1096        } else if !prod_files.is_empty() {
1097            output.push('\n');
1098        }
1099        for file in &test_files {
1100            output.push_str(&format_file_entry(file, base_path));
1101        }
1102    }
1103
1104    output
1105}
1106
1107/// Format a paginated subset of functions for FileDetails mode.
1108/// When `verbose=false` (default/compact): shows `C:` (if non-empty) and `F:` with wrapped rendering; omits `I:`.
1109/// When `verbose=true`: shows `C:`, `I:`, and `F:` with wrapped rendering on the first page (offset == 0).
1110/// Header shows position context: `FILE: path (NL, start-end/totalF, CC, II)`.
1111#[instrument(skip_all)]
1112pub fn format_file_details_paginated(
1113    functions_page: &[FunctionInfo],
1114    total_functions: usize,
1115    semantic: &SemanticAnalysis,
1116    path: &str,
1117    line_count: usize,
1118    offset: usize,
1119    verbose: bool,
1120) -> String {
1121    let mut output = String::new();
1122
1123    let start = offset + 1; // 1-indexed for display
1124    let end = offset + functions_page.len();
1125
1126    output.push_str(&format!(
1127        "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)\n",
1128        path,
1129        line_count,
1130        start,
1131        end,
1132        total_functions,
1133        semantic.classes.len(),
1134        semantic.imports.len(),
1135    ));
1136
1137    // Classes section on first page for both verbose and compact modes
1138    if offset == 0 && !semantic.classes.is_empty() {
1139        output.push_str(&format_classes_section(&semantic.classes));
1140    }
1141
1142    // Imports section only on first page in verbose mode
1143    if offset == 0 && verbose {
1144        output.push_str(&format_imports_section(&semantic.imports));
1145    }
1146
1147    // F: section with paginated function slice
1148    if !functions_page.is_empty() {
1149        output.push_str("F:\n");
1150        output.push_str(&format_function_list_wrapped(
1151            functions_page.iter(),
1152            &semantic.call_frequency,
1153        ));
1154    }
1155
1156    output
1157}
1158
1159/// Parameters for `format_focused_paginated`.
1160pub struct FocusedPaginatedParams<'a> {
1161    pub paginated_chains: &'a [CallChain],
1162    pub total: usize,
1163    pub mode: PaginationMode,
1164    pub symbol: &'a str,
1165    pub prod_chains: &'a [CallChain],
1166    pub test_chains: &'a [CallChain],
1167    pub outgoing_chains: &'a [CallChain],
1168    pub def_count: usize,
1169    pub offset: usize,
1170    pub base_path: Option<&'a Path>,
1171}
1172
1173/// Format a paginated subset of callers or callees for SymbolFocus mode.
1174/// Mode is determined by the `mode` parameter:
1175/// - `PaginationMode::Callers`: paginate production callers; show test callers summary and callees summary.
1176/// - `PaginationMode::Callees`: paginate callees; show callers summary and test callers summary.
1177#[instrument(skip_all)]
1178#[allow(clippy::too_many_arguments)]
1179pub fn format_focused_paginated(
1180    paginated_chains: &[CallChain],
1181    total: usize,
1182    mode: PaginationMode,
1183    symbol: &str,
1184    prod_chains: &[CallChain],
1185    test_chains: &[CallChain],
1186    outgoing_chains: &[CallChain],
1187    def_count: usize,
1188    offset: usize,
1189    base_path: Option<&Path>,
1190    _verbose: bool,
1191) -> String {
1192    let start = offset + 1; // 1-indexed
1193    let end = offset + paginated_chains.len();
1194
1195    let callers_count = prod_chains
1196        .iter()
1197        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
1198        .collect::<std::collections::HashSet<_>>()
1199        .len();
1200
1201    let callees_count = outgoing_chains
1202        .iter()
1203        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
1204        .collect::<std::collections::HashSet<_>>()
1205        .len();
1206
1207    let mut output = String::new();
1208
1209    output.push_str(&format!(
1210        "FOCUS: {} ({} defs, {} callers, {} callees)\n",
1211        symbol, def_count, callers_count, callees_count
1212    ));
1213
1214    match mode {
1215        PaginationMode::Callers => {
1216            // Paginate production callers
1217            output.push_str(&format!("CALLERS ({}-{} of {}):\n", start, end, total));
1218
1219            let page_refs: Vec<_> = paginated_chains
1220                .iter()
1221                .filter_map(|chain| {
1222                    if chain.chain.len() >= 2 {
1223                        Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1224                    } else if chain.chain.len() == 1 {
1225                        Some((chain.chain[0].0.as_str(), ""))
1226                    } else {
1227                        None
1228                    }
1229                })
1230                .collect();
1231
1232            if page_refs.is_empty() {
1233                output.push_str("  (none)\n");
1234            } else {
1235                output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1236            }
1237
1238            // Test callers summary
1239            if !test_chains.is_empty() {
1240                let mut test_files: Vec<_> = test_chains
1241                    .iter()
1242                    .filter_map(|chain| {
1243                        chain
1244                            .chain
1245                            .first()
1246                            .map(|(_, path, _)| path.to_string_lossy().into_owned())
1247                    })
1248                    .collect();
1249                test_files.sort();
1250                test_files.dedup();
1251
1252                let display_files: Vec<_> = test_files
1253                    .iter()
1254                    .map(|f| strip_base_path(std::path::Path::new(f), base_path))
1255                    .collect();
1256
1257                output.push_str(&format!(
1258                    "CALLERS (test): {} test functions (in {})\n",
1259                    test_chains.len(),
1260                    display_files.join(", ")
1261                ));
1262            }
1263
1264            // Callees summary
1265            let callee_names: Vec<_> = outgoing_chains
1266                .iter()
1267                .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1268                .collect::<std::collections::HashSet<_>>()
1269                .into_iter()
1270                .collect();
1271            if callee_names.is_empty() {
1272                output.push_str("CALLEES: (none)\n");
1273            } else {
1274                output.push_str(&format!(
1275                    "CALLEES: {} (use cursor for callee pagination)\n",
1276                    callees_count
1277                ));
1278            }
1279        }
1280        PaginationMode::Callees => {
1281            // Callers summary
1282            output.push_str(&format!("CALLERS: {} production callers\n", callers_count));
1283
1284            // Test callers summary
1285            if !test_chains.is_empty() {
1286                output.push_str(&format!(
1287                    "CALLERS (test): {} test functions\n",
1288                    test_chains.len()
1289                ));
1290            }
1291
1292            // Paginate callees
1293            output.push_str(&format!("CALLEES ({}-{} of {}):\n", start, end, total));
1294
1295            let page_refs: Vec<_> = paginated_chains
1296                .iter()
1297                .filter_map(|chain| {
1298                    if chain.chain.len() >= 2 {
1299                        Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1300                    } else if chain.chain.len() == 1 {
1301                        Some((chain.chain[0].0.as_str(), ""))
1302                    } else {
1303                        None
1304                    }
1305                })
1306                .collect();
1307
1308            if page_refs.is_empty() {
1309                output.push_str("  (none)\n");
1310            } else {
1311                output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1312            }
1313        }
1314        PaginationMode::Default => {
1315            unreachable!("format_focused_paginated called with PaginationMode::Default")
1316        }
1317    }
1318
1319    output
1320}
1321
1322fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1323    let mut parts = Vec::new();
1324    if file.line_count > 0 {
1325        parts.push(format!("{}L", file.line_count));
1326    }
1327    if file.function_count > 0 {
1328        parts.push(format!("{}F", file.function_count));
1329    }
1330    if file.class_count > 0 {
1331        parts.push(format!("{}C", file.class_count));
1332    }
1333    let display_path = strip_base_path(Path::new(&file.path), base_path);
1334    if parts.is_empty() {
1335        format!("{}\n", display_path)
1336    } else {
1337        format!("{} [{}]\n", display_path, parts.join(", "))
1338    }
1339}
1340
1341/// Format a [`ModuleInfo`] into a compact single-block string.
1342///
1343/// Output format:
1344/// ```text
1345/// FILE: <name> (<line_count>L, <fn_count>F, <import_count>I)
1346/// F:
1347///   func1:10, func2:42
1348/// I:
1349///   module1:item1, item2; module2:item1; module3
1350/// ```
1351///
1352/// The `F:` section is omitted when there are no functions; likewise `I:` when
1353/// there are no imports.
1354#[instrument(skip_all)]
1355pub fn format_module_info(info: &ModuleInfo) -> String {
1356    use std::fmt::Write as _;
1357    let fn_count = info.functions.len();
1358    let import_count = info.imports.len();
1359    let mut out = String::with_capacity(64 + fn_count * 24 + import_count * 32);
1360    let _ = writeln!(
1361        out,
1362        "FILE: {} ({}L, {}F, {}I)",
1363        info.name, info.line_count, fn_count, import_count
1364    );
1365    if !info.functions.is_empty() {
1366        out.push_str("F:\n  ");
1367        let parts: Vec<String> = info
1368            .functions
1369            .iter()
1370            .map(|f| format!("{}:{}", f.name, f.line))
1371            .collect();
1372        out.push_str(&parts.join(", "));
1373        out.push('\n');
1374    }
1375    if !info.imports.is_empty() {
1376        out.push_str("I:\n  ");
1377        let parts: Vec<String> = info
1378            .imports
1379            .iter()
1380            .map(|i| {
1381                if i.items.is_empty() {
1382                    i.module.clone()
1383                } else {
1384                    format!("{}:{}", i.module, i.items.join(", "))
1385                }
1386            })
1387            .collect();
1388        out.push_str(&parts.join("; "));
1389        out.push('\n');
1390    }
1391    out
1392}
1393
1394#[cfg(test)]
1395mod tests {
1396    use super::*;
1397
1398    #[test]
1399    fn test_strip_base_path_relative() {
1400        let path = Path::new("/home/user/project/src/main.rs");
1401        let base = Path::new("/home/user/project");
1402        let result = strip_base_path(path, Some(base));
1403        assert_eq!(result, "src/main.rs");
1404    }
1405
1406    #[test]
1407    fn test_strip_base_path_fallback_absolute() {
1408        let path = Path::new("/other/project/src/main.rs");
1409        let base = Path::new("/home/user/project");
1410        let result = strip_base_path(path, Some(base));
1411        assert_eq!(result, "/other/project/src/main.rs");
1412    }
1413
1414    #[test]
1415    fn test_strip_base_path_none() {
1416        let path = Path::new("/home/user/project/src/main.rs");
1417        let result = strip_base_path(path, None);
1418        assert_eq!(result, "/home/user/project/src/main.rs");
1419    }
1420
1421    #[test]
1422    fn test_format_file_details_summary_empty() {
1423        use crate::types::SemanticAnalysis;
1424        use std::collections::HashMap;
1425
1426        let semantic = SemanticAnalysis {
1427            functions: vec![],
1428            classes: vec![],
1429            imports: vec![],
1430            references: vec![],
1431            call_frequency: HashMap::new(),
1432            calls: vec![],
1433            assignments: vec![],
1434            field_accesses: vec![],
1435        };
1436
1437        let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1438
1439        // Should contain FILE header, Imports count, and SUGGESTION
1440        assert!(result.contains("FILE:"));
1441        assert!(result.contains("100L, 0F, 0C"));
1442        assert!(result.contains("src/main.rs"));
1443        assert!(result.contains("Imports: 0"));
1444        assert!(result.contains("SUGGESTION:"));
1445    }
1446
1447    #[test]
1448    fn test_format_file_details_summary_with_functions() {
1449        use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1450        use std::collections::HashMap;
1451
1452        let semantic = SemanticAnalysis {
1453            functions: vec![
1454                FunctionInfo {
1455                    name: "short".to_string(),
1456                    line: 10,
1457                    end_line: 12,
1458                    parameters: vec![],
1459                    return_type: None,
1460                },
1461                FunctionInfo {
1462                    name: "long_function".to_string(),
1463                    line: 20,
1464                    end_line: 50,
1465                    parameters: vec!["x".to_string(), "y".to_string()],
1466                    return_type: Some("i32".to_string()),
1467                },
1468            ],
1469            classes: vec![ClassInfo {
1470                name: "MyClass".to_string(),
1471                line: 60,
1472                end_line: 80,
1473                methods: vec![],
1474                fields: vec![],
1475                inherits: vec![],
1476            }],
1477            imports: vec![],
1478            references: vec![],
1479            call_frequency: HashMap::new(),
1480            calls: vec![],
1481            assignments: vec![],
1482            field_accesses: vec![],
1483        };
1484
1485        let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1486
1487        // Should contain FILE header with counts
1488        assert!(result.contains("FILE:"));
1489        assert!(result.contains("src/lib.rs"));
1490        assert!(result.contains("250L, 2F, 1C"));
1491
1492        // Should contain TOP FUNCTIONS BY SIZE with longest first
1493        assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1494        let long_idx = result.find("long_function").unwrap_or(0);
1495        let short_idx = result.find("short").unwrap_or(0);
1496        assert!(
1497            long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1498            "long_function should appear before short"
1499        );
1500
1501        // Should contain classes inline
1502        assert!(result.contains("CLASSES:"));
1503        assert!(result.contains("MyClass:"));
1504
1505        // Should contain import count
1506        assert!(result.contains("Imports: 0"));
1507    }
1508    #[test]
1509    fn test_format_file_info_parts_all_zero() {
1510        assert_eq!(format_file_info_parts(0, 0, 0), None);
1511    }
1512
1513    #[test]
1514    fn test_format_file_info_parts_partial() {
1515        assert_eq!(
1516            format_file_info_parts(42, 0, 3),
1517            Some("[42L, 3C]".to_string())
1518        );
1519    }
1520
1521    #[test]
1522    fn test_format_file_info_parts_all_nonzero() {
1523        assert_eq!(
1524            format_file_info_parts(100, 5, 2),
1525            Some("[100L, 5F, 2C]".to_string())
1526        );
1527    }
1528
1529    #[test]
1530    fn test_format_function_list_wrapped_empty() {
1531        let freq = std::collections::HashMap::new();
1532        let result = format_function_list_wrapped(std::iter::empty(), &freq);
1533        assert_eq!(result, "");
1534    }
1535
1536    #[test]
1537    fn test_format_function_list_wrapped_bullet_annotation() {
1538        use crate::types::FunctionInfo;
1539        use std::collections::HashMap;
1540
1541        let mut freq = HashMap::new();
1542        freq.insert("frequent".to_string(), 5); // count > 3 should get bullet
1543
1544        let funcs = vec![FunctionInfo {
1545            name: "frequent".to_string(),
1546            line: 1,
1547            end_line: 10,
1548            parameters: vec![],
1549            return_type: Some("void".to_string()),
1550        }];
1551
1552        let result = format_function_list_wrapped(funcs.iter(), &freq);
1553        // Should contain bullet (U+2022) followed by count
1554        assert!(result.contains("\u{2022}5"));
1555    }
1556
1557    #[test]
1558    fn test_compact_format_omits_sections() {
1559        use crate::types::{ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
1560        use std::collections::HashMap;
1561
1562        let funcs: Vec<FunctionInfo> = (0..10)
1563            .map(|i| FunctionInfo {
1564                name: format!("fn_{}", i),
1565                line: i * 5 + 1,
1566                end_line: i * 5 + 4,
1567                parameters: vec![format!("x: u32")],
1568                return_type: Some("bool".to_string()),
1569            })
1570            .collect();
1571        let imports: Vec<ImportInfo> = vec![ImportInfo {
1572            module: "std::collections".to_string(),
1573            items: vec!["HashMap".to_string()],
1574            line: 1,
1575        }];
1576        let classes: Vec<ClassInfo> = vec![ClassInfo {
1577            name: "MyStruct".to_string(),
1578            line: 5,
1579            end_line: 50,
1580            methods: vec![],
1581            fields: vec![],
1582            inherits: vec![],
1583        }];
1584        let semantic = SemanticAnalysis {
1585            functions: funcs,
1586            classes,
1587            imports,
1588            references: vec![],
1589            call_frequency: HashMap::new(),
1590            calls: vec![],
1591            assignments: vec![],
1592            field_accesses: vec![],
1593        };
1594
1595        let verbose_out = format_file_details_paginated(
1596            &semantic.functions,
1597            semantic.functions.len(),
1598            &semantic,
1599            "src/lib.rs",
1600            100,
1601            0,
1602            true,
1603        );
1604        let compact_out = format_file_details_paginated(
1605            &semantic.functions,
1606            semantic.functions.len(),
1607            &semantic,
1608            "src/lib.rs",
1609            100,
1610            0,
1611            false,
1612        );
1613
1614        // Verbose includes C:, I:, F: section headers
1615        assert!(verbose_out.contains("C:\n"), "verbose must have C: section");
1616        assert!(verbose_out.contains("I:\n"), "verbose must have I: section");
1617        assert!(verbose_out.contains("F:\n"), "verbose must have F: section");
1618
1619        // Compact includes C: and F: but omits I: (imports)
1620        assert!(
1621            compact_out.contains("C:\n"),
1622            "compact must have C: section (restored)"
1623        );
1624        assert!(
1625            !compact_out.contains("I:\n"),
1626            "compact must not have I: section (imports omitted)"
1627        );
1628        assert!(
1629            compact_out.contains("F:\n"),
1630            "compact must have F: section with wrapped formatting"
1631        );
1632
1633        // Compact functions are wrapped: fn_0 and fn_1 must appear on the same line
1634        assert!(compact_out.contains("fn_0"), "compact must list functions");
1635        let has_two_on_same_line = compact_out
1636            .lines()
1637            .any(|l| l.contains("fn_0") && l.contains("fn_1"));
1638        assert!(
1639            has_two_on_same_line,
1640            "compact must render multiple functions per line (wrapped), not one-per-line"
1641        );
1642    }
1643
1644    /// Regression test: compact mode must be <= verbose for function-heavy files (no imports to mask regression).
1645    #[test]
1646    fn test_compact_mode_consistent_token_reduction() {
1647        use crate::types::{FunctionInfo, SemanticAnalysis};
1648        use std::collections::HashMap;
1649
1650        let funcs: Vec<FunctionInfo> = (0..50)
1651            .map(|i| FunctionInfo {
1652                name: format!("function_name_{}", i),
1653                line: i * 10 + 1,
1654                end_line: i * 10 + 8,
1655                parameters: vec![
1656                    "arg1: u32".to_string(),
1657                    "arg2: String".to_string(),
1658                    "arg3: Option<bool>".to_string(),
1659                ],
1660                return_type: Some("Result<Vec<String>, Error>".to_string()),
1661            })
1662            .collect();
1663
1664        let semantic = SemanticAnalysis {
1665            functions: funcs,
1666            classes: vec![],
1667            imports: vec![],
1668            references: vec![],
1669            call_frequency: HashMap::new(),
1670            calls: vec![],
1671            assignments: vec![],
1672            field_accesses: vec![],
1673        };
1674
1675        let verbose_out = format_file_details_paginated(
1676            &semantic.functions,
1677            semantic.functions.len(),
1678            &semantic,
1679            "src/large_file.rs",
1680            1000,
1681            0,
1682            true,
1683        );
1684        let compact_out = format_file_details_paginated(
1685            &semantic.functions,
1686            semantic.functions.len(),
1687            &semantic,
1688            "src/large_file.rs",
1689            1000,
1690            0,
1691            false,
1692        );
1693
1694        assert!(
1695            compact_out.len() <= verbose_out.len(),
1696            "compact ({} chars) must be <= verbose ({} chars)",
1697            compact_out.len(),
1698            verbose_out.len(),
1699        );
1700    }
1701
1702    /// Edge case test: Compact mode with empty classes should not emit C: header.
1703    #[test]
1704    fn test_format_module_info_happy_path() {
1705        use crate::types::{ModuleFunctionInfo, ModuleImportInfo, ModuleInfo};
1706        let info = ModuleInfo {
1707            name: "parser.rs".to_string(),
1708            line_count: 312,
1709            language: "rust".to_string(),
1710            functions: vec![
1711                ModuleFunctionInfo {
1712                    name: "parse_file".to_string(),
1713                    line: 24,
1714                },
1715                ModuleFunctionInfo {
1716                    name: "parse_block".to_string(),
1717                    line: 58,
1718                },
1719            ],
1720            imports: vec![
1721                ModuleImportInfo {
1722                    module: "crate::types".to_string(),
1723                    items: vec!["Token".to_string(), "Expr".to_string()],
1724                },
1725                ModuleImportInfo {
1726                    module: "std::io".to_string(),
1727                    items: vec!["BufReader".to_string()],
1728                },
1729            ],
1730        };
1731        let result = format_module_info(&info);
1732        assert!(result.starts_with("FILE: parser.rs (312L, 2F, 2I)"));
1733        assert!(result.contains("F:"));
1734        assert!(result.contains("parse_file:24"));
1735        assert!(result.contains("parse_block:58"));
1736        assert!(result.contains("I:"));
1737        assert!(result.contains("crate::types:Token, Expr"));
1738        assert!(result.contains("std::io:BufReader"));
1739        assert!(result.contains("; "));
1740        assert!(!result.contains('{'));
1741    }
1742
1743    #[test]
1744    fn test_format_module_info_empty() {
1745        use crate::types::ModuleInfo;
1746        let info = ModuleInfo {
1747            name: "empty.rs".to_string(),
1748            line_count: 0,
1749            language: "rust".to_string(),
1750            functions: vec![],
1751            imports: vec![],
1752        };
1753        let result = format_module_info(&info);
1754        assert!(result.starts_with("FILE: empty.rs (0L, 0F, 0I)"));
1755        assert!(!result.contains("F:"));
1756        assert!(!result.contains("I:"));
1757    }
1758
1759    #[test]
1760    fn test_compact_mode_empty_classes_no_header() {
1761        use crate::types::{FunctionInfo, SemanticAnalysis};
1762        use std::collections::HashMap;
1763
1764        let funcs: Vec<FunctionInfo> = (0..5)
1765            .map(|i| FunctionInfo {
1766                name: format!("fn_{}", i),
1767                line: i * 5 + 1,
1768                end_line: i * 5 + 4,
1769                parameters: vec![],
1770                return_type: None,
1771            })
1772            .collect();
1773
1774        let semantic = SemanticAnalysis {
1775            functions: funcs,
1776            classes: vec![], // Empty classes
1777            imports: vec![],
1778            references: vec![],
1779            call_frequency: HashMap::new(),
1780            calls: vec![],
1781            assignments: vec![],
1782            field_accesses: vec![],
1783        };
1784
1785        let compact_out = format_file_details_paginated(
1786            &semantic.functions,
1787            semantic.functions.len(),
1788            &semantic,
1789            "src/simple.rs",
1790            100,
1791            0,
1792            false,
1793        );
1794
1795        // Should not have stray C: header when classes are empty
1796        assert!(
1797            !compact_out.contains("C:\n"),
1798            "compact mode must not emit C: header when classes are empty"
1799        );
1800    }
1801}
1802
1803fn format_classes_section(classes: &[ClassInfo]) -> String {
1804    let mut output = String::new();
1805    if classes.is_empty() {
1806        return output;
1807    }
1808    output.push_str("C:\n");
1809    if classes.len() <= MULTILINE_THRESHOLD {
1810        let class_strs: Vec<String> = classes
1811            .iter()
1812            .map(|class| {
1813                if class.inherits.is_empty() {
1814                    format!("{}:{}", class.name, class.line)
1815                } else {
1816                    format!(
1817                        "{}:{} ({})",
1818                        class.name,
1819                        class.line,
1820                        class.inherits.join(", ")
1821                    )
1822                }
1823            })
1824            .collect();
1825        output.push_str("  ");
1826        output.push_str(&class_strs.join("; "));
1827        output.push('\n');
1828    } else {
1829        for class in classes {
1830            if class.inherits.is_empty() {
1831                output.push_str(&format!("  {}:{}\n", class.name, class.line));
1832            } else {
1833                output.push_str(&format!(
1834                    "  {}:{} ({})\n",
1835                    class.name,
1836                    class.line,
1837                    class.inherits.join(", ")
1838                ));
1839            }
1840        }
1841    }
1842    output
1843}
1844
1845fn format_imports_section(imports: &[ImportInfo]) -> String {
1846    let mut output = String::new();
1847    if imports.is_empty() {
1848        return output;
1849    }
1850    output.push_str("I:\n");
1851    let mut module_map: HashMap<String, usize> = HashMap::new();
1852    for import in imports {
1853        module_map
1854            .entry(import.module.clone())
1855            .and_modify(|count| *count += 1)
1856            .or_insert(1);
1857    }
1858    let mut modules: Vec<_> = module_map.keys().cloned().collect();
1859    modules.sort();
1860    let formatted_modules: Vec<String> = modules
1861        .iter()
1862        .map(|module| format!("{}({})", module, module_map[module]))
1863        .collect();
1864    if formatted_modules.len() <= MULTILINE_THRESHOLD {
1865        output.push_str("  ");
1866        output.push_str(&formatted_modules.join("; "));
1867        output.push('\n');
1868    } else {
1869        for module_str in formatted_modules {
1870            output.push_str("  ");
1871            output.push_str(&module_str);
1872            output.push('\n');
1873        }
1874    }
1875    output
1876}