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