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