Skip to main content

code_analyze_mcp/
formatter.rs

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