Skip to main content

tldr_cli/
output.rs

1//! Output formatting for CLI commands
2//!
3//! Supports three output formats:
4//! - JSON: Structured output for programmatic use
5//! - Text: Human-readable formatted output
6//! - Compact: Minified JSON for piping
7//!
8//! # Mitigations Addressed
9//! - M19: JSON output uses serde with preserve_order for consistent field order
10//! - M20: Text output includes helpful context and suggestions
11
12use std::io::{self, Write};
13use std::path::Path;
14
15use colored::Colorize;
16use comfy_table::{presets::UTF8_FULL, Cell, Color, ContentArrangement, Table};
17use serde::Serialize;
18
19/// Compute the common directory prefix of a list of paths.
20/// Returns the longest shared directory ancestor (never a partial component).
21/// Returns empty path if paths share no common ancestor.
22pub fn common_path_prefix(paths: &[&Path]) -> std::path::PathBuf {
23    if paths.is_empty() {
24        return std::path::PathBuf::new();
25    }
26    if paths.len() == 1 {
27        return paths[0].parent().unwrap_or(Path::new("")).to_path_buf();
28    }
29
30    let first = paths[0];
31    let components: Vec<_> = first.components().collect();
32    let mut prefix_len = components.len();
33
34    for path in &paths[1..] {
35        let other: Vec<_> = path.components().collect();
36        let mut match_len = 0;
37        for (a, b) in components.iter().zip(other.iter()) {
38            if a == b {
39                match_len += 1;
40            } else {
41                break;
42            }
43        }
44        prefix_len = prefix_len.min(match_len);
45    }
46
47    // Build the prefix path from matching components
48    let mut result = std::path::PathBuf::new();
49    for comp in components.iter().take(prefix_len) {
50        result.push(comp);
51    }
52    result
53}
54
55/// Strip a common prefix from a path, returning a relative display string.
56/// If stripping fails or results in empty, returns the original path display.
57pub fn strip_prefix_display(path: &Path, prefix: &Path) -> String {
58    if prefix.as_os_str().is_empty() {
59        return path.display().to_string();
60    }
61    match path.strip_prefix(prefix) {
62        Ok(rel) if !rel.as_os_str().is_empty() => rel.display().to_string(),
63        _ => path.display().to_string(),
64    }
65}
66
67/// Output format options
68#[derive(Debug, Clone, Copy, Default, clap::ValueEnum, PartialEq, Eq)]
69pub enum OutputFormat {
70    /// JSON output (default) - machine readable with consistent field order
71    #[default]
72    Json,
73    /// Human-readable text output
74    Text,
75    /// Compact/minified JSON for piping
76    Compact,
77    /// SARIF format for IDE/CI integration (GitHub, VS Code, etc.)
78    Sarif,
79    /// DOT/Graphviz format for visualization
80    Dot,
81}
82
83/// Output writer that handles different formats
84pub struct OutputWriter {
85    format: OutputFormat,
86    quiet: bool,
87}
88
89impl OutputWriter {
90    /// Create a new output writer with the specified format
91    pub fn new(format: OutputFormat, quiet: bool) -> Self {
92        Self { format, quiet }
93    }
94
95    /// Write a serializable value to stdout
96    pub fn write<T: Serialize>(&self, value: &T) -> io::Result<()> {
97        let stdout = io::stdout();
98        let mut handle = stdout.lock();
99
100        match self.format {
101            OutputFormat::Json | OutputFormat::Sarif => {
102                // SARIF is handled by specialized methods; generic write uses JSON
103                serde_json::to_writer_pretty(&mut handle, value)?;
104                writeln!(handle)?;
105            }
106            OutputFormat::Compact => {
107                serde_json::to_writer(&mut handle, value)?;
108                writeln!(handle)?;
109            }
110            OutputFormat::Text | OutputFormat::Dot => {
111                // Text/DOT format is handled by specialized methods
112                serde_json::to_writer_pretty(&mut handle, value)?;
113                writeln!(handle)?;
114            }
115        }
116
117        Ok(())
118    }
119
120    /// Write a string directly (for text format)
121    pub fn write_text(&self, text: &str) -> io::Result<()> {
122        let stdout = io::stdout();
123        let mut handle = stdout.lock();
124        writeln!(handle, "{}", text)?;
125        Ok(())
126    }
127
128    /// Write progress message (only if not quiet)
129    pub fn progress(&self, message: &str) {
130        if !self.quiet {
131            eprintln!("{}", message.dimmed());
132        }
133    }
134
135    /// Check if we should use text format
136    pub fn is_text(&self) -> bool {
137        matches!(self.format, OutputFormat::Text)
138    }
139
140    /// Check if we should use JSON format
141    #[allow(dead_code)]
142    pub fn is_json(&self) -> bool {
143        matches!(
144            self.format,
145            OutputFormat::Json | OutputFormat::Compact | OutputFormat::Sarif
146        )
147    }
148
149    /// Check if we should use DOT format
150    pub fn is_dot(&self) -> bool {
151        matches!(self.format, OutputFormat::Dot)
152    }
153}
154
155// =============================================================================
156// Text formatters for specific types
157// =============================================================================
158
159/// Format a file tree for text output
160pub fn format_file_tree_text(tree: &tldr_core::FileTree, indent: usize) -> String {
161    let mut output = String::new();
162    format_tree_node(tree, &mut output, indent, "");
163    output
164}
165
166fn format_tree_node(tree: &tldr_core::FileTree, output: &mut String, indent: usize, prefix: &str) {
167    let indent_str = "  ".repeat(indent);
168    // Use plain text icons for non-emoji terminals
169    let icon_plain = match tree.node_type {
170        tldr_core::NodeType::Dir => "[D]".yellow().to_string(),
171        tldr_core::NodeType::File => "[F]".blue().to_string(),
172    };
173
174    output.push_str(&format!(
175        "{}{}{} {}\n",
176        prefix, indent_str, icon_plain, tree.name
177    ));
178
179    for (i, child) in tree.children.iter().enumerate() {
180        let is_last = i == tree.children.len() - 1;
181        let new_prefix = if is_last { "`-- " } else { "|-- " };
182        let cont_prefix = if is_last { "    " } else { "|   " };
183        format_tree_node(
184            child,
185            output,
186            0,
187            &format!("{}{}{}", prefix, cont_prefix, new_prefix),
188        );
189    }
190}
191
192/// Format code structure for text output
193pub fn format_structure_text(structure: &tldr_core::CodeStructure) -> String {
194    let mut output = String::new();
195
196    output.push_str(&format!(
197        "{} ({} files)\n",
198        structure.root.display().to_string().bold(),
199        structure.files.len()
200    ));
201    output.push_str(&format!(
202        "Language: {}\n\n",
203        format!("{:?}", structure.language).cyan()
204    ));
205
206    // Use root as prefix for relative path display
207    let prefix = &structure.root;
208
209    for file in &structure.files {
210        let rel = strip_prefix_display(&file.path, prefix);
211        output.push_str(&format!("{}\n", rel.green()));
212
213        if !file.functions.is_empty() {
214            output.push_str("  Functions:\n");
215            for func in &file.functions {
216                output.push_str(&format!("    - {}\n", func));
217            }
218        }
219
220        if !file.classes.is_empty() {
221            output.push_str("  Classes:\n");
222            for class in &file.classes {
223                output.push_str(&format!("    - {}\n", class));
224            }
225        }
226
227        output.push('\n');
228    }
229
230    output
231}
232
233/// Format imports for text output
234///
235/// Groups imports by module for compact, readable output:
236/// ```text
237/// file.py (12 imports)
238///
239///   from .exceptions: Abort, BadParameter, MissingParameter, UsageError
240///   from .core: Command, Group, Context
241///   import os, sys, typing
242/// ```
243pub fn format_imports_text(imports: &[tldr_core::types::ImportInfo]) -> String {
244    use std::collections::BTreeMap;
245
246    let mut output = String::new();
247
248    if imports.is_empty() {
249        output.push_str("No imports found.\n");
250        return output;
251    }
252
253    // Group: from-imports by module, bare imports separately
254    let mut from_groups: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
255    let mut bare_imports: Vec<String> = Vec::new();
256
257    for imp in imports {
258        if imp.is_from && !imp.names.is_empty() {
259            let names = from_groups.entry(&imp.module).or_default();
260            for name in &imp.names {
261                names.push(name);
262            }
263        } else if let Some(alias) = &imp.alias {
264            bare_imports.push(format!("{} as {}", imp.module, alias));
265        } else {
266            bare_imports.push(imp.module.clone());
267        }
268    }
269
270    // From-imports grouped by module
271    for (module, names) in &from_groups {
272        output.push_str(&format!(
273            "from {}: {}\n",
274            module.cyan(),
275            names.join(", ").green(),
276        ));
277    }
278
279    // Bare imports on one line
280    if !bare_imports.is_empty() {
281        if !from_groups.is_empty() {
282            output.push('\n');
283        }
284        output.push_str(&format!("import {}\n", bare_imports.join(", ").cyan()));
285    }
286
287    output
288}
289
290/// Format importers report for text output
291///
292/// Shows each importing file with line number and the import statement.
293/// Strips the common path prefix for token efficiency.
294/// ```text
295///   click/core.py:3            import os
296///   click/_compat.py:1         import os
297///   click/utils.py:5           from os.path import join
298/// ```
299pub fn format_importers_text(report: &tldr_core::types::ImportersReport) -> String {
300    let mut output = String::new();
301
302    if report.importers.is_empty() {
303        output.push_str("No files import this module.\n");
304        return output;
305    }
306
307    // Compute common path prefix for relative display
308    let paths: Vec<&Path> = report.importers.iter().map(|i| i.file.as_path()).collect();
309    let prefix = common_path_prefix(&paths);
310
311    // Find max path:line width for alignment (using stripped paths)
312    let max_loc_width = report
313        .importers
314        .iter()
315        .map(|i| format!("{}:{}", strip_prefix_display(&i.file, &prefix), i.line).len())
316        .max()
317        .unwrap_or(20);
318
319    for imp in &report.importers {
320        let rel_path = strip_prefix_display(&imp.file, &prefix);
321        let loc = format!("{}:{}", rel_path, imp.line);
322        output.push_str(&format!(
323            "  {:<width$}  {}\n",
324            loc.green(),
325            imp.import_statement.dimmed(),
326            width = max_loc_width,
327        ));
328    }
329
330    output
331}
332
333/// Format CFG info for text output
334pub fn format_cfg_text(cfg: &tldr_core::CfgInfo) -> String {
335    let mut output = String::new();
336
337    output.push_str(&format!(
338        "CFG for {} (complexity: {})\n\n",
339        cfg.function.bold().cyan(),
340        cfg.cyclomatic_complexity.to_string().yellow()
341    ));
342
343    // Create blocks table
344    let mut table = Table::new();
345    table
346        .load_preset(UTF8_FULL)
347        .set_content_arrangement(ContentArrangement::Dynamic)
348        .set_header(vec![
349            Cell::new("Block").fg(Color::Cyan),
350            Cell::new("Type").fg(Color::Cyan),
351            Cell::new("Lines").fg(Color::Cyan),
352            Cell::new("Calls").fg(Color::Cyan),
353        ]);
354
355    for block in &cfg.blocks {
356        table.add_row(vec![
357            Cell::new(block.id),
358            Cell::new(format!("{:?}", block.block_type)),
359            Cell::new(format!("{}-{}", block.lines.0, block.lines.1)),
360            Cell::new(block.calls.join(", ")),
361        ]);
362    }
363
364    output.push_str(&table.to_string());
365    output.push_str("\n\nEdges:\n");
366
367    for edge in &cfg.edges {
368        let edge_str = match edge.edge_type {
369            tldr_core::EdgeType::True => format!("{} -> {} (true)", edge.from, edge.to).green(),
370            tldr_core::EdgeType::False => format!("{} -> {} (false)", edge.from, edge.to).red(),
371            tldr_core::EdgeType::Unconditional => format!("{} -> {}", edge.from, edge.to).normal(),
372            tldr_core::EdgeType::BackEdge => {
373                format!("{} -> {} (back)", edge.from, edge.to).yellow()
374            }
375            _ => format!("{} -> {} ({:?})", edge.from, edge.to, edge.edge_type).normal(),
376        };
377        output.push_str(&format!("  {}\n", edge_str));
378    }
379
380    output
381}
382
383/// Format DFG info for text output
384pub fn format_dfg_text(dfg: &tldr_core::DfgInfo) -> String {
385    let mut output = String::new();
386
387    output.push_str(&format!(
388        "DFG for {} ({} variables)\n\n",
389        dfg.function.bold().cyan(),
390        dfg.variables.len().to_string().yellow()
391    ));
392
393    output.push_str("Variables: ");
394    output.push_str(&dfg.variables.join(", "));
395    output.push_str("\n\n");
396
397    // Create refs table
398    let mut table = Table::new();
399    table
400        .load_preset(UTF8_FULL)
401        .set_content_arrangement(ContentArrangement::Dynamic)
402        .set_header(vec![
403            Cell::new("Var").fg(Color::Cyan),
404            Cell::new("Type").fg(Color::Cyan),
405            Cell::new("Line").fg(Color::Cyan),
406            Cell::new("Col").fg(Color::Cyan),
407        ]);
408
409    for var_ref in &dfg.refs {
410        let type_str = match var_ref.ref_type {
411            tldr_core::RefType::Definition => "def",
412            tldr_core::RefType::Update => "upd",
413            tldr_core::RefType::Use => "use",
414        };
415        table.add_row(vec![
416            Cell::new(&var_ref.name),
417            Cell::new(type_str),
418            Cell::new(var_ref.line),
419            Cell::new(var_ref.column),
420        ]);
421    }
422
423    output.push_str(&table.to_string());
424    output
425}
426
427/// Collect all file paths from a caller tree recursively
428fn collect_caller_tree_paths<'a>(tree: &'a tldr_core::CallerTree, paths: &mut Vec<&'a Path>) {
429    paths.push(tree.file.as_path());
430    for caller in &tree.callers {
431        collect_caller_tree_paths(caller, paths);
432    }
433}
434
435/// Format impact report for text output
436pub fn format_impact_text(report: &tldr_core::ImpactReport, type_aware: bool) -> String {
437    let mut output = String::new();
438
439    let type_aware_suffix = if type_aware { " (type-aware)" } else { "" };
440    output.push_str(&format!(
441        "Impact Analysis{} ({} targets)\n\n",
442        type_aware_suffix,
443        report.total_targets.to_string().yellow()
444    ));
445
446    // Show type resolution stats if enabled
447    if let Some(ref stats) = report.type_resolution {
448        if stats.enabled {
449            output.push_str(&stats.summary());
450            output.push_str("\n\n");
451        }
452    }
453
454    // Collect all paths from all trees for common prefix
455    let mut all_paths = Vec::new();
456    for tree in report.targets.values() {
457        collect_caller_tree_paths(tree, &mut all_paths);
458    }
459    let prefix = common_path_prefix(&all_paths);
460
461    for (key, tree) in &report.targets {
462        output.push_str(&format!("{}\n", key.bold().cyan()));
463        format_caller_tree(tree, &mut output, 1, type_aware, &prefix);
464        output.push('\n');
465    }
466
467    output
468}
469
470fn format_caller_tree(
471    tree: &tldr_core::CallerTree,
472    output: &mut String,
473    depth: usize,
474    type_aware: bool,
475    prefix: &Path,
476) {
477    let indent = "  ".repeat(depth);
478    let file_str = strip_prefix_display(&tree.file, prefix);
479
480    // Show confidence if type-aware and available
481    let confidence_str = if type_aware {
482        if let Some(confidence) = &tree.confidence {
483            format!(" [{}]", confidence)
484        } else {
485            String::new()
486        }
487    } else {
488        String::new()
489    };
490
491    output.push_str(&format!(
492        "{}{}:{} ({} callers){}\n",
493        indent,
494        file_str.dimmed(),
495        tree.function.green(),
496        tree.caller_count,
497        confidence_str
498    ));
499
500    if tree.truncated {
501        output.push_str(&format!("{}  [truncated - cycle detected]\n", indent));
502    }
503
504    if let Some(note) = &tree.note {
505        output.push_str(&format!("{}  Note: {}\n", indent, note.dimmed()));
506    }
507
508    for caller in &tree.callers {
509        format_caller_tree(caller, output, depth + 1, type_aware, prefix);
510    }
511}
512
513/// Format dead code report for text output
514pub fn format_dead_code_text(report: &tldr_core::DeadCodeReport) -> String {
515    let mut output = String::new();
516
517    output.push_str(&format!(
518        "Dead Code Analysis\n\nDefinitely dead: {} / {} functions ({:.1}% dead)\n",
519        report.total_dead.to_string().red(),
520        report.total_functions,
521        report.dead_percentage
522    ));
523
524    if report.total_possibly_dead > 0 {
525        output.push_str(&format!(
526            "Possibly dead (public but uncalled): {}\n",
527            report.total_possibly_dead.to_string().yellow()
528        ));
529    }
530
531    output.push('\n');
532
533    if !report.by_file.is_empty() {
534        // Compute common prefix for relative display
535        let paths: Vec<&Path> = report.by_file.keys().map(|p| p.as_path()).collect();
536        let prefix = common_path_prefix(&paths);
537
538        output.push_str("Definitely dead:\n");
539        for (file, funcs) in &report.by_file {
540            let rel = strip_prefix_display(file, &prefix);
541            output.push_str(&format!("{}\n", rel.green()));
542            for func in funcs {
543                output.push_str(&format!("  - {}\n", func.red()));
544            }
545            output.push('\n');
546        }
547    }
548
549    output
550}
551
552/// Format complexity metrics for text output
553///
554/// Compact single-function report:
555/// ```text
556/// Complexity: process_request
557///   Cyclomatic:    12
558///   Cognitive:     8
559///   Nesting depth: 4
560///   Lines of code: 45
561/// ```
562pub fn format_complexity_text(metrics: &tldr_core::types::ComplexityMetrics) -> String {
563    let mut output = String::new();
564
565    output.push_str(&format!("Complexity: {}\n", metrics.function.bold().cyan()));
566    output.push_str(&format!("  Cyclomatic:    {}\n", metrics.cyclomatic));
567    output.push_str(&format!("  Cognitive:     {}\n", metrics.cognitive));
568    output.push_str(&format!("  Nesting depth: {}\n", metrics.nesting_depth));
569    output.push_str(&format!("  Lines of code: {}\n", metrics.lines_of_code));
570
571    output
572}
573
574/// Format cognitive complexity report for text output
575///
576/// Shows top-N functions ranked by cognitive complexity, with threshold violations highlighted.
577/// Strips common path prefix for compact display.
578///
579/// ```text
580/// Cognitive Complexity (12 functions, 3 violations)
581///
582///  #  Score  Nest  Status     Function                     File
583///  1     18     4  SEVERE     parse_args                   core.py:142
584///  2     15     3  VIOLATION  make_context                 core.py:1200
585///  3     12     2  ok         invoke                       core.py:987
586/// ```
587pub fn format_cognitive_text(report: &tldr_core::metrics::CognitiveReport) -> String {
588    let mut output = String::new();
589
590    let violation_count = report.violations.len();
591    output.push_str(&format!(
592        "Cognitive Complexity ({} functions, {} violations)\n\n",
593        report.summary.total_functions,
594        if violation_count > 0 {
595            violation_count.to_string().red().to_string()
596        } else {
597            "0".green().to_string()
598        }
599    ));
600
601    if report.functions.is_empty() {
602        output.push_str("  No functions found.\n");
603        return output;
604    }
605
606    // Compute common path prefix for relative display
607    // Use parent directories so single-file reports still get path stripping
608    let parents: Vec<&Path> = report
609        .functions
610        .iter()
611        .filter_map(|f| Path::new(f.file.as_str()).parent())
612        .collect();
613    let prefix = if parents.is_empty() {
614        std::path::PathBuf::new()
615    } else {
616        common_path_prefix(&parents)
617    };
618
619    // Header
620    output.push_str(&format!(
621        " {:>3}  {:>5}  {:>4}  {:<9}  {:<28}  {}\n",
622        "#", "Score", "Nest", "Status", "Function", "File"
623    ));
624
625    for (i, f) in report.functions.iter().enumerate() {
626        let rel = strip_prefix_display(Path::new(&f.file), &prefix);
627        let status = match f.threshold_status {
628            tldr_core::metrics::CognitiveThresholdStatus::Severe => {
629                "SEVERE".red().bold().to_string()
630            }
631            tldr_core::metrics::CognitiveThresholdStatus::Violation => {
632                "VIOLATION".yellow().to_string()
633            }
634            _ => "ok".green().to_string(),
635        };
636
637        // Truncate function name to 28 chars
638        let name = if f.name.len() > 28 {
639            format!("{}...", &f.name[..25])
640        } else {
641            f.name.clone()
642        };
643
644        output.push_str(&format!(
645            " {:>3}  {:>5}  {:>4}  {:<9}  {:<28}  {}:{}\n",
646            i + 1,
647            f.cognitive,
648            f.max_nesting,
649            status,
650            name,
651            rel,
652            f.line
653        ));
654    }
655
656    // Summary
657    output.push_str(&format!(
658        "\nSummary: avg={:.1}, max={}, compliance={:.1}%\n",
659        report.summary.avg_cognitive, report.summary.max_cognitive, report.summary.compliance_rate
660    ));
661
662    output
663}
664
665/// Format maintainability index report for text output
666///
667/// Shows per-file MI scores sorted worst-first, with grade distribution summary.
668/// Top 30 files shown by default (worst MI first).
669///
670/// ```text
671/// Maintainability Index (47 files, avg MI=42.3)
672///
673/// Grade distribution: A=5 B=12 C=18 D=10 F=2
674///
675///  #   MI  Grade  LOC  AvgCC  File
676///  1  10.8    F   612   18.2  core.py
677///  2  22.1    F   445   12.6  parser.py
678///  3  28.4    D   203    8.1  utils.py
679/// ```
680pub fn format_maintainability_text(
681    report: &tldr_core::quality::maintainability::MaintainabilityReport,
682) -> String {
683    let mut output = String::new();
684
685    output.push_str(&format!(
686        "Maintainability Index ({} files, avg MI={:.1})\n\n",
687        report.summary.files_analyzed, report.summary.average_mi
688    ));
689
690    // Grade distribution
691    let grades = ['A', 'B', 'C', 'D', 'F'];
692    let mut grade_parts = Vec::new();
693    for g in &grades {
694        let count = report.summary.by_grade.get(g).unwrap_or(&0);
695        if *count > 0 {
696            grade_parts.push(format!("{}={}", g, count));
697        }
698    }
699    output.push_str(&format!(
700        "Grade distribution: {}\n\n",
701        grade_parts.join(" ")
702    ));
703
704    if report.files.is_empty() {
705        output.push_str("  No files analyzed.\n");
706        return output;
707    }
708
709    // Sort files by MI ascending (worst first) — clone since report is borrowed
710    let mut files: Vec<_> = report.files.iter().collect();
711    files.sort_by(|a, b| a.mi.partial_cmp(&b.mi).unwrap_or(std::cmp::Ordering::Equal));
712
713    // Compute common path prefix
714    let paths: Vec<&Path> = files.iter().filter_map(|f| f.path.parent()).collect();
715    let prefix = common_path_prefix(&paths);
716
717    // Header
718    output.push_str(&format!(
719        " {:>3}  {:>5}  {:>5}  {:>4}  {:>5}  {}\n",
720        "#", "MI", "Grade", "LOC", "AvgCC", "File"
721    ));
722
723    // Show top 30
724    let limit = files.len().min(30);
725    for (i, f) in files.iter().take(limit).enumerate() {
726        let rel = strip_prefix_display(&f.path, &prefix);
727        let grade_str = match f.grade {
728            'F' => format!("{}", f.grade).red().bold().to_string(),
729            'D' => format!("{}", f.grade).yellow().to_string(),
730            _ => format!("{}", f.grade),
731        };
732
733        output.push_str(&format!(
734            " {:>3}  {:>5.1}  {:>5}  {:>4}  {:>5.1}  {}\n",
735            i + 1,
736            f.mi,
737            grade_str,
738            f.loc,
739            f.avg_complexity,
740            rel
741        ));
742    }
743
744    if files.len() > limit {
745        output.push_str(&format!("\n  ... and {} more files\n", files.len() - limit));
746    }
747
748    output
749}
750
751/// Format search matches for text output
752pub fn format_search_text(matches: &[tldr_core::SearchMatch]) -> String {
753    let mut output = String::new();
754
755    output.push_str(&format!(
756        "Found {} matches\n\n",
757        matches.len().to_string().yellow()
758    ));
759
760    // Compute common prefix for relative display
761    let paths: Vec<&Path> = matches.iter().map(|m| m.file.as_path()).collect();
762    let prefix = common_path_prefix(&paths);
763
764    for m in matches {
765        let rel = strip_prefix_display(&m.file, &prefix);
766        output.push_str(&format!(
767            "{}:{}: {}\n",
768            rel.green(),
769            m.line.to_string().cyan(),
770            m.content.trim()
771        ));
772
773        if let Some(context) = &m.context {
774            for line in context {
775                output.push_str(&format!("  {}\n", line.dimmed()));
776            }
777        }
778    }
779
780    output
781}
782
783/// Format enriched search report for text output.
784///
785/// Each result is a compact card showing:
786/// - Function/class name, file, line range, and score
787/// - Signature (definition line)
788/// - Callers and callees (if available)
789pub fn format_enriched_search_text(report: &tldr_core::EnrichedSearchReport) -> String {
790    let mut output = String::new();
791
792    output.push_str(&format!("query: \"{}\"\n", report.query));
793    output.push_str(&format!(
794        "{} results from {} files ({})\n\n",
795        report.results.len(),
796        report.total_files_searched,
797        report.search_mode
798    ));
799
800    if report.results.is_empty() {
801        output.push_str("  No results found.\n");
802        return output;
803    }
804
805    // Compute common path prefix for compact display
806    let paths: Vec<&Path> = report.results.iter().map(|r| r.file.as_path()).collect();
807    let prefix = common_path_prefix(&paths);
808
809    for (i, result) in report.results.iter().enumerate() {
810        let rel = strip_prefix_display(&result.file, &prefix);
811        let line_range = format!("{}-{}", result.line_range.0, result.line_range.1);
812
813        // Line 1: index. kind:name (file:lines) [score]
814        let kind_prefix = match result.kind.as_str() {
815            "function" => "fn ",
816            "method" => "method ",
817            "class" => "class ",
818            "struct" => "struct ",
819            "module" => "mod ",
820            _ => "",
821        };
822        output.push_str(&format!(
823            "{}. {}{} ({}:{}) [{:.2}]\n",
824            i + 1,
825            kind_prefix,
826            result.name,
827            rel,
828            line_range,
829            result.score
830        ));
831
832        // Line 2: signature
833        if !result.signature.is_empty() {
834            output.push_str(&format!("   {}\n", result.signature));
835        }
836
837        // Line 3: callers (if any)
838        if !result.callers.is_empty() {
839            let callers_str = format_name_list(&result.callers, 5);
840            output.push_str(&format!("   Called by: {}\n", callers_str));
841        }
842
843        // Line 4: callees (if any)
844        if !result.callees.is_empty() {
845            let callees_str = format_name_list(&result.callees, 5);
846            output.push_str(&format!("   Calls: {}\n", callees_str));
847        }
848
849        // Code preview (indented, skip first line if it matches signature)
850        if !result.preview.is_empty() && result.kind != "module" {
851            let preview_lines: Vec<&str> = result.preview.lines().collect();
852            // Skip the first line if it matches the signature (already shown above)
853            let start =
854                if preview_lines.first().map(|l| l.trim()) == Some(result.signature.as_str()) {
855                    1
856                } else {
857                    0
858                };
859            if start < preview_lines.len() {
860                output.push_str("   ---\n");
861                for line in &preview_lines[start..preview_lines.len().min(start + 4)] {
862                    output.push_str(&format!("   {}\n", line));
863                }
864            }
865        }
866
867        // Blank line between cards
868        if i < report.results.len() - 1 {
869            output.push('\n');
870        }
871    }
872
873    output
874}
875
876/// Format a list of names, showing up to `max` items then "... and N more".
877fn format_name_list(names: &[String], max: usize) -> String {
878    if names.len() <= max {
879        names.join(", ")
880    } else {
881        let shown: Vec<&str> = names[..max].iter().map(|s| s.as_str()).collect();
882        format!("{}, ... and {} more", shown.join(", "), names.len() - max)
883    }
884}
885
886/// Format smells report for text output
887pub fn format_smells_text(report: &tldr_core::SmellsReport) -> String {
888    let mut output = String::new();
889
890    output.push_str(&format!(
891        "Code Smells Report ({} issues)\n\n",
892        report.smells.len().to_string().yellow()
893    ));
894
895    if report.smells.is_empty() {
896        output.push_str("  No code smells detected.\n");
897        return output;
898    }
899
900    // Compute common path prefix for relative display
901    let paths: Vec<&Path> = report.smells.iter().map(|s| s.file.as_path()).collect();
902    let prefix = if paths.is_empty() {
903        std::path::PathBuf::new()
904    } else {
905        common_path_prefix(&paths)
906    };
907
908    // Header
909    output.push_str(&format!(
910        " {:>3}  {:>3}  {:<20}  {:<28}  {}\n",
911        "#", "Sev", "Type", "Name", "File:Line"
912    ));
913
914    for (i, smell) in report.smells.iter().enumerate() {
915        // Severity coloring
916        let sev_str = match smell.severity {
917            3 => smell.severity.to_string().red(),
918            2 => smell.severity.to_string().yellow(),
919            _ => smell.severity.to_string().white(),
920        }
921        .to_string();
922
923        // Smell type coloring
924        let type_str = {
925            let base = format!("{}", smell.smell_type);
926            let colored = match smell.smell_type {
927                tldr_core::SmellType::GodClass => base.red(),
928                tldr_core::SmellType::LongMethod => base.yellow(),
929                tldr_core::SmellType::LongParameterList => base.magenta(),
930                tldr_core::SmellType::LowCohesion => base.yellow(),
931                tldr_core::SmellType::TightCoupling => base.red(),
932                tldr_core::SmellType::DeadCode => base.dimmed(),
933                tldr_core::SmellType::CodeClone => base.cyan(),
934                tldr_core::SmellType::HighCognitiveComplexity => base.red(),
935                tldr_core::SmellType::DeepNesting => base.yellow(),
936                tldr_core::SmellType::DataClass => base.cyan(),
937                tldr_core::SmellType::LazyElement => base.dimmed(),
938                tldr_core::SmellType::MessageChain => base.magenta(),
939                tldr_core::SmellType::PrimitiveObsession => base.cyan(),
940                tldr_core::SmellType::FeatureEnvy => base.yellow(),
941                tldr_core::SmellType::MiddleMan => base.yellow(),
942                tldr_core::SmellType::RefusedBequest => base.magenta(),
943                tldr_core::SmellType::InappropriateIntimacy => base.red(),
944                tldr_core::SmellType::DataClumps => base.white(),
945            };
946            colored.to_string()
947        };
948
949        // Truncate name to 28 chars
950        let name = if smell.name.len() > 28 {
951            format!("{}...", &smell.name[..25])
952        } else {
953            smell.name.clone()
954        };
955
956        // Strip path prefix
957        let rel_file = strip_prefix_display(&smell.file, &prefix);
958
959        output.push_str(&format!(
960            " {:>3}  {:>3}  {:<20}  {:<28}  {}:{}\n",
961            i + 1,
962            sev_str,
963            type_str,
964            name,
965            rel_file,
966            smell.line
967        ));
968    }
969
970    // Summary with per-type counts
971    output.push('\n');
972
973    let sev3 = report.smells.iter().filter(|s| s.severity == 3).count();
974    let sev2 = report.smells.iter().filter(|s| s.severity == 2).count();
975    let sev1 = report.smells.iter().filter(|s| s.severity == 1).count();
976    let unique_files = report.by_file.len();
977    output.push_str(&format!(
978        "Summary: {} smells found ({} {}, {} {}, {} {}) across {} files\n",
979        report.smells.len(),
980        sev3,
981        "sev-3".red(),
982        sev2,
983        "sev-2".yellow(),
984        sev1,
985        "sev-1",
986        unique_files,
987    ));
988
989    // Per-type breakdown
990    let mut type_counts: Vec<(String, usize)> = report
991        .summary
992        .by_type
993        .iter()
994        .map(|(k, v)| (k.clone(), *v))
995        .collect();
996    type_counts.sort_by(|a, b| b.1.cmp(&a.1));
997    let breakdown: Vec<String> = type_counts
998        .iter()
999        .map(|(name, count)| format!("{}: {}", name, count))
1000        .collect();
1001    output.push_str(&format!("  {}\n", breakdown.join(", ")));
1002
1003    output
1004}
1005
1006/// Format secrets report for text output
1007pub fn format_secrets_text(report: &tldr_core::SecretsReport) -> String {
1008    let mut output = String::new();
1009
1010    output.push_str(&format!(
1011        "Secrets Scan ({} findings, {} files scanned)\n\n",
1012        report.findings.len().to_string().yellow(),
1013        report.files_scanned
1014    ));
1015
1016    if report.findings.is_empty() {
1017        output.push_str("  No secrets detected.\n");
1018        return output;
1019    }
1020
1021    // Compute common path prefix for relative display
1022    let paths: Vec<&Path> = report.findings.iter().map(|f| f.file.as_path()).collect();
1023    let prefix = if paths.is_empty() {
1024        std::path::PathBuf::new()
1025    } else {
1026        common_path_prefix(&paths)
1027    };
1028
1029    // Header
1030    output.push_str(&format!(
1031        " {:<8}  {:<14}  {:<40}  {:>5}  {}\n",
1032        "Severity", "Pattern", "File", "Line", "Value"
1033    ));
1034
1035    for finding in &report.findings {
1036        let sev_str = match finding.severity {
1037            tldr_core::Severity::Critical => finding.severity.to_string().red(),
1038            tldr_core::Severity::High => finding.severity.to_string().red(),
1039            tldr_core::Severity::Medium => finding.severity.to_string().yellow(),
1040            tldr_core::Severity::Low => finding.severity.to_string().white(),
1041        }
1042        .to_string();
1043
1044        let rel_file = strip_prefix_display(&finding.file, &prefix);
1045
1046        // Truncate file path to 40 chars
1047        let file_display = if rel_file.len() > 40 {
1048            format!("...{}", &rel_file[rel_file.len() - 37..])
1049        } else {
1050            rel_file
1051        };
1052
1053        output.push_str(&format!(
1054            " {:<8}  {:<14}  {:<40}  {:>5}  {}\n",
1055            sev_str, finding.pattern, file_display, finding.line, finding.masked_value
1056        ));
1057    }
1058
1059    // Summary by severity
1060    output.push('\n');
1061    let critical = report
1062        .findings
1063        .iter()
1064        .filter(|f| f.severity == tldr_core::Severity::Critical)
1065        .count();
1066    let high = report
1067        .findings
1068        .iter()
1069        .filter(|f| f.severity == tldr_core::Severity::High)
1070        .count();
1071    let medium = report
1072        .findings
1073        .iter()
1074        .filter(|f| f.severity == tldr_core::Severity::Medium)
1075        .count();
1076    let low = report
1077        .findings
1078        .iter()
1079        .filter(|f| f.severity == tldr_core::Severity::Low)
1080        .count();
1081    let mut parts = Vec::new();
1082    if critical > 0 {
1083        parts.push(format!("{} {}", critical, "critical".red()));
1084    }
1085    if high > 0 {
1086        parts.push(format!("{} {}", high, "high".red()));
1087    }
1088    if medium > 0 {
1089        parts.push(format!("{} {}", medium, "medium".yellow()));
1090    }
1091    if low > 0 {
1092        parts.push(format!("{} {}", low, "low"));
1093    }
1094    output.push_str(&format!("Summary: {}\n", parts.join(", ")));
1095
1096    output
1097}
1098
1099/// Format whatbreaks report for text output
1100///
1101/// Follows spec text output format:
1102/// - Header with target and detected type
1103/// - Summary statistics (callers, importers, tests)
1104/// - Sub-analysis status (success/error/skipped)
1105/// - Total elapsed time
1106///
1107/// # Example output
1108///
1109/// ```text
1110/// What Breaks: user_service.py (file)
1111/// ==================================================
1112/// Direct callers:     N/A
1113/// Transitive callers: N/A
1114/// Importing modules:  2 files
1115/// Affected tests:     2 test files
1116///
1117/// Sub-analyses:
1118///   [OK]   importers        (45ms)
1119///   [OK]   change-impact    (121ms)
1120///
1121/// Elapsed: 166ms
1122/// ```
1123pub fn format_whatbreaks_text(
1124    report: &tldr_core::analysis::whatbreaks::WhatbreaksReport,
1125) -> String {
1126    let mut output = String::new();
1127
1128    // Header with target and type
1129    output.push_str(&format!(
1130        "What Breaks: {} ({})\n",
1131        report.target.bold().cyan(),
1132        report.target_type.to_string().yellow()
1133    ));
1134    output.push('\n');
1135
1136    // Summary statistics
1137    let summary = &report.summary;
1138
1139    if summary.direct_caller_count > 0
1140        || report.target_type == tldr_core::analysis::whatbreaks::TargetType::Function
1141    {
1142        output.push_str(&format!(
1143            "Direct callers:     {}\n",
1144            if summary.direct_caller_count > 0 {
1145                summary.direct_caller_count.to_string().green().to_string()
1146            } else {
1147                "0".to_string()
1148            }
1149        ));
1150        output.push_str(&format!(
1151            "Transitive callers: {}\n",
1152            if summary.transitive_caller_count > 0 {
1153                summary
1154                    .transitive_caller_count
1155                    .to_string()
1156                    .green()
1157                    .to_string()
1158            } else {
1159                "0".to_string()
1160            }
1161        ));
1162    }
1163
1164    if summary.importer_count > 0
1165        || report.target_type != tldr_core::analysis::whatbreaks::TargetType::Function
1166    {
1167        output.push_str(&format!(
1168            "Importing modules:  {}\n",
1169            if summary.importer_count > 0 {
1170                format!("{} files", summary.importer_count)
1171                    .green()
1172                    .to_string()
1173            } else {
1174                "0 files".to_string()
1175            }
1176        ));
1177    }
1178
1179    if summary.affected_test_count > 0
1180        || report.target_type == tldr_core::analysis::whatbreaks::TargetType::File
1181    {
1182        output.push_str(&format!(
1183            "Affected tests:     {}\n",
1184            if summary.affected_test_count > 0 {
1185                format!("{} test files", summary.affected_test_count)
1186                    .yellow()
1187                    .to_string()
1188            } else {
1189                "0 test files".to_string()
1190            }
1191        ));
1192    }
1193
1194    output.push('\n');
1195
1196    // Sub-analyses status (only show errors/warnings, skip timing noise)
1197    let has_errors = report
1198        .sub_results
1199        .values()
1200        .any(|r| r.error.is_some() || !r.warnings.is_empty());
1201    if has_errors {
1202        output.push_str("Issues:\n");
1203
1204        let mut sub_results: Vec<_> = report.sub_results.iter().collect();
1205        sub_results.sort_by_key(|(name, _)| *name);
1206
1207        for (name, result) in sub_results {
1208            if let Some(error) = &result.error {
1209                output.push_str(&format!("  {} error: {}\n", name, error.red()));
1210            }
1211            for warning in &result.warnings {
1212                output.push_str(&format!("  {} warning: {}\n", name, warning.yellow()));
1213            }
1214        }
1215    }
1216
1217    output
1218}
1219
1220/// Format hubs report for text output
1221///
1222/// Plain text format (no box-drawing tables) for token efficiency:
1223/// ```text
1224/// Hub Detection (5 hubs / 120 nodes)
1225///
1226///  #  Risk      Function              File                Score  In  Out
1227///  1  CRITICAL  process_request       server/handler.py   0.92   15   8
1228///  2  HIGH      validate_input        core/validator.py   0.71   10   5
1229/// ```
1230pub fn format_hubs_text(report: &tldr_core::analysis::hubs::HubReport) -> String {
1231    let mut output = String::new();
1232
1233    // Compact header
1234    output.push_str(&format!(
1235        "Hub Detection ({} hubs / {} nodes)\n\n",
1236        report.hub_count.to_string().yellow(),
1237        report.total_nodes,
1238    ));
1239
1240    // Handle empty results
1241    if report.hubs.is_empty() {
1242        output.push_str("No hubs found.\n");
1243        return output;
1244    }
1245
1246    // Compute common path prefix for relative display
1247    let paths: Vec<&Path> = report.hubs.iter().map(|h| h.file.as_path()).collect();
1248    let prefix = common_path_prefix(&paths);
1249
1250    // Compute column widths
1251    let max_func = report
1252        .hubs
1253        .iter()
1254        .map(|h| h.name.len())
1255        .max()
1256        .unwrap_or(8)
1257        .max(8);
1258    let max_file = report
1259        .hubs
1260        .iter()
1261        .map(|h| strip_prefix_display(&h.file, &prefix).len())
1262        .max()
1263        .unwrap_or(4)
1264        .max(4);
1265
1266    // Header line
1267    output.push_str(&format!(
1268        " {:<3} {:<8}  {:<width_f$}  {:<width_p$}  {:>5}  {:>3}  {:>3}\n",
1269        "#",
1270        "Risk",
1271        "Function",
1272        "File",
1273        "Score",
1274        "In",
1275        "Out",
1276        width_f = max_func,
1277        width_p = max_file,
1278    ));
1279
1280    for (i, hub) in report.hubs.iter().enumerate() {
1281        let risk_str = format!("{}", hub.risk_level).to_uppercase();
1282        let rel_file = strip_prefix_display(&hub.file, &prefix);
1283
1284        output.push_str(&format!(
1285            " {:<3} {:<8}  {:<width_f$}  {:<width_p$}  {:>5.3}  {:>3}  {:>3}\n",
1286            i + 1,
1287            risk_str,
1288            hub.name,
1289            rel_file,
1290            hub.composite_score,
1291            hub.callers_count,
1292            hub.callees_count,
1293            width_f = max_func,
1294            width_p = max_file,
1295        ));
1296    }
1297
1298    output
1299}
1300
1301/// Format change impact report for text output
1302///
1303/// Shows changed files, affected tests, and detection method.
1304/// Session 6 Phase 1: Basic text output format.
1305pub fn format_change_impact_text(report: &tldr_core::ChangeImpactReport) -> String {
1306    let mut output = String::new();
1307
1308    // Header
1309    output.push_str(&"Change Impact Analysis\n".bold().to_string());
1310    output.push_str("======================\n\n");
1311
1312    // Detection method
1313    output.push_str(&format!("Detection: {}\n", report.detection_method.cyan()));
1314
1315    // Changed files section
1316    output.push_str(&format!(
1317        "Changed: {} files\n\n",
1318        report.changed_files.len().to_string().yellow()
1319    ));
1320
1321    if !report.changed_files.is_empty() {
1322        output.push_str(&"Changed Files:\n".bold().to_string());
1323        for file in &report.changed_files {
1324            output.push_str(&format!("  {}\n", file.display().to_string().green()));
1325        }
1326        output.push('\n');
1327    }
1328
1329    // Affected tests section with function granularity
1330    let test_func_count = report.affected_test_functions.len();
1331    output.push_str(&format!(
1332        "Affected Tests: {} files, {} functions\n",
1333        report.affected_tests.len().to_string().yellow(),
1334        test_func_count.to_string().yellow()
1335    ));
1336
1337    if !report.affected_tests.is_empty() {
1338        for test in &report.affected_tests {
1339            output.push_str(&format!("  {}\n", test.display().to_string().cyan()));
1340            // Show test functions for this file
1341            for tf in &report.affected_test_functions {
1342                if tf.file == *test {
1343                    let func_name = if let Some(ref class) = tf.class {
1344                        format!("{}::{}", class, tf.function)
1345                    } else {
1346                        tf.function.clone()
1347                    };
1348                    output.push_str(&format!("    - {} (line {})\n", func_name.green(), tf.line));
1349                }
1350            }
1351        }
1352        output.push('\n');
1353    } else {
1354        output.push_str("  No tests affected.\n\n");
1355    }
1356
1357    // Affected functions section
1358    if !report.affected_functions.is_empty() {
1359        output.push_str(&format!(
1360            "Affected Functions: {}\n",
1361            report.affected_functions.len().to_string().yellow()
1362        ));
1363        for func in &report.affected_functions {
1364            output.push_str(&format!(
1365                "  {} ({})\n",
1366                func.name.green(),
1367                func.file.display().to_string().dimmed()
1368            ));
1369        }
1370        output.push('\n');
1371    }
1372
1373    // Metadata
1374    if let Some(ref metadata) = report.metadata {
1375        output.push_str(&format!(
1376            "Call Graph: {} edges\n",
1377            metadata.call_graph_edges
1378        ));
1379        output.push_str(&format!(
1380            "Traversal Depth: {}\n",
1381            metadata.analysis_depth.unwrap_or(0)
1382        ));
1383    }
1384
1385    output
1386}
1387
1388/// Format diagnostics report for compact, token-efficient text output
1389///
1390/// R1: One-line summary header, one-line-per-diagnostic, no decorations, no ANSI colors
1391/// R2: Strips absolute paths to relative using common_path_prefix
1392/// R3: Truncates multi-line messages (pyright nested explanations) to first line
1393pub fn format_diagnostics_text(
1394    report: &tldr_core::diagnostics::DiagnosticsReport,
1395    filtered_count: usize,
1396) -> String {
1397    let mut output = String::new();
1398
1399    // --- R1: Compact one-line summary header ---
1400    // Format: "pyright + ruff | 42 files | 3 errors, 1 warning"
1401    let tool_names: Vec<&str> = report.tools_run.iter().map(|t| t.name.as_str()).collect();
1402    let tools_part = tool_names.join(" + ");
1403
1404    let summary = &report.summary;
1405    let mut counts: Vec<String> = Vec::new();
1406    if summary.errors > 0 {
1407        counts.push(format!(
1408            "{} {}",
1409            summary.errors,
1410            if summary.errors == 1 {
1411                "error"
1412            } else {
1413                "errors"
1414            }
1415        ));
1416    }
1417    if summary.warnings > 0 {
1418        counts.push(format!(
1419            "{} {}",
1420            summary.warnings,
1421            if summary.warnings == 1 {
1422                "warning"
1423            } else {
1424                "warnings"
1425            }
1426        ));
1427    }
1428    if summary.info > 0 {
1429        counts.push(format!(
1430            "{} {}",
1431            summary.info,
1432            if summary.info == 1 { "info" } else { "infos" }
1433        ));
1434    }
1435    if summary.hints > 0 {
1436        counts.push(format!(
1437            "{} {}",
1438            summary.hints,
1439            if summary.hints == 1 { "hint" } else { "hints" }
1440        ));
1441    }
1442
1443    let counts_part = if counts.is_empty() {
1444        "No issues found".to_string()
1445    } else {
1446        counts.join(", ")
1447    };
1448
1449    output.push_str(&format!(
1450        "{} | {} files | {}\n",
1451        tools_part, report.files_analyzed, counts_part
1452    ));
1453
1454    // --- Diagnostics ---
1455    if report.diagnostics.is_empty() {
1456        // Header already says "No issues found"
1457    } else {
1458        output.push('\n');
1459
1460        // R2: Compute common path prefix for relative display
1461        // Use parent directories (not file paths) to avoid the single-file bug:
1462        // when all diagnostics are from one file, common_path_prefix returns the
1463        // file itself, strip_prefix yields empty, and falls back to full path.
1464        let parents: Vec<&std::path::Path> = report
1465            .diagnostics
1466            .iter()
1467            .filter_map(|d| d.file.parent())
1468            .collect();
1469        let prefix = common_path_prefix(&parents);
1470
1471        // Sort diagnostics by file then line for consistent output
1472        let mut sorted_diags: Vec<&tldr_core::diagnostics::Diagnostic> =
1473            report.diagnostics.iter().collect();
1474        sorted_diags.sort_by(|a, b| {
1475            a.file
1476                .cmp(&b.file)
1477                .then(a.line.cmp(&b.line))
1478                .then(a.column.cmp(&b.column))
1479        });
1480
1481        for diag in &sorted_diags {
1482            let rel_path = strip_prefix_display(&diag.file, &prefix);
1483
1484            // R1: severity as plain text (no ANSI)
1485            let severity_str = match diag.severity {
1486                tldr_core::diagnostics::Severity::Error => "error",
1487                tldr_core::diagnostics::Severity::Warning => "warning",
1488                tldr_core::diagnostics::Severity::Information => "info",
1489                tldr_core::diagnostics::Severity::Hint => "hint",
1490            };
1491
1492            // Code part: [code] if present, empty string if not
1493            let code_str = diag
1494                .code
1495                .as_ref()
1496                .map(|c| format!("[{}]", c))
1497                .unwrap_or_default();
1498
1499            // R3: Truncate multi-line messages to first line only
1500            let message = diag.message.lines().next().unwrap_or(&diag.message);
1501
1502            // R1: One-line format: file:line:col: severity[code] message (tool)
1503            // No URLs emitted.
1504            output.push_str(&format!(
1505                "{}:{}:{}: {}{} {} ({})\n",
1506                rel_path, diag.line, diag.column, severity_str, code_str, message, diag.source
1507            ));
1508        }
1509    }
1510
1511    // Show filtered count if any
1512    if filtered_count > 0 {
1513        output.push_str(&format!(
1514            "\n({} issues filtered by severity/ignore settings)\n",
1515            filtered_count
1516        ));
1517    }
1518
1519    output
1520}
1521
1522// =============================================================================
1523// Clone Detection Output Formatters (Phase 11)
1524// =============================================================================
1525
1526/// Human-readable description of clone types (S8-P3-T5 mitigation)
1527///
1528/// Provides explanations that non-experts can understand, avoiding jargon
1529/// like "Type-2 parameterized clone".
1530pub fn clone_type_description(clone_type: &tldr_core::analysis::CloneType) -> &'static str {
1531    use tldr_core::analysis::CloneType;
1532    match clone_type {
1533        CloneType::Type1 => "exact match (identical code)",
1534        CloneType::Type2 => "identical structure, renamed identifiers/literals",
1535        CloneType::Type3 => "similar structure with additions/deletions",
1536    }
1537}
1538
1539/// Generate hints for empty clone detection results (S8-P3-T7 mitigation)
1540///
1541/// When no clones are found, users need guidance on why and what to try.
1542pub fn empty_results_hints(
1543    options: &tldr_core::analysis::ClonesOptions,
1544    stats: &tldr_core::analysis::CloneStats,
1545) -> Vec<String> {
1546    vec![
1547        format!(
1548            "Analyzed {} files, {} tokens",
1549            stats.files_analyzed, stats.total_tokens
1550        ),
1551        format!(
1552            "Current threshold: {:.0}% - try --threshold 0.6 for more matches",
1553            options.threshold * 100.0
1554        ),
1555        format!(
1556            "Current min-tokens: {} - try --min-tokens 30 for smaller clones",
1557            options.min_tokens
1558        ),
1559    ]
1560}
1561
1562/// Escape special characters for DOT node IDs (S8-P3-T11 mitigation)
1563///
1564/// Handles:
1565/// - Backslashes (Windows paths) -> forward slashes
1566/// - Quotes -> escaped quotes
1567/// - Spaces -> quoted node IDs
1568pub fn escape_dot_id(id: &str) -> String {
1569    // Convert backslashes to forward slashes (normalizes Windows paths)
1570    let normalized = id.replace('\\', "/");
1571
1572    // Escape internal quotes
1573    let escaped = normalized.replace('"', r#"\""#);
1574
1575    // Always quote the ID to handle spaces and special chars
1576    format!("\"{}\"", escaped)
1577}
1578
1579/// Format clone detection report as compact human-readable text
1580///
1581/// Output format:
1582/// ```text
1583/// Clone Detection: 8 pairs in 42 files (15234 tokens)
1584///
1585///  #  Sim  Type  File A                          Lines    File B                          Lines
1586///  1  92%  T2    auth/login.py                   45-62    auth/signup.py                  23-40
1587///  2  85%  T3    core.py                         112-130  helpers.py                      88-106
1588/// ```
1589///
1590/// Key design decisions for LLM-friendly output:
1591/// - No ANSI color codes (wastes tokens, garbles non-terminal contexts)
1592/// - One line per clone pair (compact table)
1593/// - Common path prefix stripped from file paths
1594/// - No configuration echo (user knows what they ran)
1595/// - Compact type column: T1/T2/T3 instead of verbose descriptions
1596pub fn format_clones_text(report: &tldr_core::analysis::ClonesReport) -> String {
1597    let mut output = String::new();
1598
1599    // Compact header with essential stats only
1600    output.push_str(&format!(
1601        "Clone Detection: {} pairs in {} files ({} tokens)\n",
1602        report.stats.clones_found, report.stats.files_analyzed, report.stats.total_tokens
1603    ));
1604
1605    if report.clone_pairs.is_empty() {
1606        output.push_str("\nNo clones found.\n");
1607        return output;
1608    }
1609
1610    output.push('\n');
1611
1612    // Collect all file paths for common prefix computation
1613    let all_paths: Vec<&Path> = report
1614        .clone_pairs
1615        .iter()
1616        .flat_map(|p| [p.fragment1.file.as_path(), p.fragment2.file.as_path()])
1617        .collect();
1618    let prefix = common_path_prefix(&all_paths);
1619
1620    // Table header
1621    output.push_str(&format!(
1622        " {:>2}  {:>3}  {:<4}  {:<30}  {:>9}  {:<30}  {:>9}\n",
1623        "#", "Sim", "Type", "File A", "Lines", "File B", "Lines"
1624    ));
1625
1626    for pair in &report.clone_pairs {
1627        let sim = (pair.similarity * 100.0) as u32;
1628        let type_short = match pair.clone_type {
1629            tldr_core::analysis::CloneType::Type1 => "T1",
1630            tldr_core::analysis::CloneType::Type2 => "T2",
1631            tldr_core::analysis::CloneType::Type3 => "T3",
1632        };
1633
1634        let file_a = strip_prefix_display(&pair.fragment1.file, &prefix);
1635        let file_b = strip_prefix_display(&pair.fragment2.file, &prefix);
1636        let lines_a = format!("{}-{}", pair.fragment1.start_line, pair.fragment1.end_line);
1637        let lines_b = format!("{}-{}", pair.fragment2.start_line, pair.fragment2.end_line);
1638
1639        // Truncate file names if too long (show tail for readability)
1640        let file_a_display = if file_a.len() > 30 {
1641            format!("...{}", &file_a[file_a.len() - 27..])
1642        } else {
1643            file_a
1644        };
1645        let file_b_display = if file_b.len() > 30 {
1646            format!("...{}", &file_b[file_b.len() - 27..])
1647        } else {
1648            file_b
1649        };
1650
1651        output.push_str(&format!(
1652            " {:>2}  {:>3}%  {:<4}  {:<30}  {:>9}  {:<30}  {:>9}\n",
1653            pair.id, sim, type_short, file_a_display, lines_a, file_b_display, lines_b
1654        ));
1655    }
1656
1657    output
1658}
1659
1660/// Format clone detection report as DOT graph for Graphviz
1661///
1662/// Output format:
1663/// ```dot
1664/// digraph clones {
1665///     rankdir=LR;
1666///     node [shape=box];
1667///
1668///     "src/auth/login.py:45-62" -> "src/auth/signup.py:23-40" [label="92%"];
1669/// }
1670/// ```
1671///
1672/// Handles special characters in paths (S8-P3-T11).
1673pub fn format_clones_dot(report: &tldr_core::analysis::ClonesReport) -> String {
1674    let mut output = String::new();
1675
1676    output.push_str("digraph clones {\n");
1677    output.push_str("    rankdir=LR;\n");
1678    output.push_str("    node [shape=box, fontname=\"Helvetica\"];\n");
1679    output.push_str("    edge [fontname=\"Helvetica\", fontsize=10];\n");
1680    output.push('\n');
1681
1682    // Add edges for each clone pair
1683    for pair in &report.clone_pairs {
1684        let node1 = format!(
1685            "{}:{}-{}",
1686            pair.fragment1.file.display(),
1687            pair.fragment1.start_line,
1688            pair.fragment1.end_line
1689        );
1690        let node2 = format!(
1691            "{}:{}-{}",
1692            pair.fragment2.file.display(),
1693            pair.fragment2.start_line,
1694            pair.fragment2.end_line
1695        );
1696
1697        // Escape node IDs for special characters (S8-P3-T11)
1698        let node1_escaped = escape_dot_id(&node1);
1699        let node2_escaped = escape_dot_id(&node2);
1700
1701        let similarity_pct = (pair.similarity * 100.0) as u32;
1702        let type_abbrev = match pair.clone_type {
1703            tldr_core::analysis::CloneType::Type1 => "T1",
1704            tldr_core::analysis::CloneType::Type2 => "T2",
1705            tldr_core::analysis::CloneType::Type3 => "T3",
1706        };
1707
1708        output.push_str(&format!(
1709            "    {} -> {} [label=\"{}% {}\"];\n",
1710            node1_escaped, node2_escaped, similarity_pct, type_abbrev
1711        ));
1712    }
1713
1714    output.push_str("}\n");
1715    output
1716}
1717
1718// =============================================================================
1719// Similarity Analysis Output Formatters (Phase 11)
1720// =============================================================================
1721
1722/// Format similarity report as human-readable text
1723///
1724/// Output format:
1725/// ```text
1726/// Similarity Analysis
1727/// ===================
1728///
1729/// Fragment 1: src/a.py (100 tokens, 20 lines)
1730/// Fragment 2: src/b.py (95 tokens, 18 lines)
1731///
1732/// Similarity Scores:
1733///   Dice:    0.85 (85%)
1734///   Jaccard: 0.74 (74%)
1735///
1736/// Interpretation: highly similar - likely refactoring candidates
1737///
1738/// Token Breakdown:
1739///   Shared tokens:  80
1740///   Unique to #1:   20
1741///   Unique to #2:   15
1742///   Total unique:   115
1743/// ```
1744pub fn format_similarity_text(report: &tldr_core::analysis::SimilarityReport) -> String {
1745    let mut output = String::new();
1746
1747    // Header
1748    output.push_str(&"Similarity Analysis\n".bold().to_string());
1749    output.push_str("===================\n\n");
1750
1751    // Fragment info
1752    output.push_str(&format!(
1753        "Fragment 1: {} ({} tokens, {} lines)\n",
1754        report.fragment1.file.display().to_string().cyan(),
1755        report.fragment1.tokens,
1756        report.fragment1.lines
1757    ));
1758    if let Some(func) = &report.fragment1.function {
1759        output.push_str(&format!("  Function: {}\n", func.green()));
1760    }
1761    if let Some((start, end)) = report.fragment1.line_range {
1762        output.push_str(&format!("  Lines: {}-{}\n", start, end));
1763    }
1764
1765    output.push_str(&format!(
1766        "Fragment 2: {} ({} tokens, {} lines)\n",
1767        report.fragment2.file.display().to_string().cyan(),
1768        report.fragment2.tokens,
1769        report.fragment2.lines
1770    ));
1771    if let Some(func) = &report.fragment2.function {
1772        output.push_str(&format!("  Function: {}\n", func.green()));
1773    }
1774    if let Some((start, end)) = report.fragment2.line_range {
1775        output.push_str(&format!("  Lines: {}-{}\n", start, end));
1776    }
1777
1778    output.push('\n');
1779
1780    // Similarity scores
1781    output.push_str(&"Similarity Scores:\n".bold().to_string());
1782    let dice_pct = (report.similarity.dice * 100.0) as u32;
1783    let jaccard_pct = (report.similarity.jaccard * 100.0) as u32;
1784
1785    output.push_str(&format!(
1786        "  Dice:    {:.4} ({}%)\n",
1787        report.similarity.dice,
1788        dice_pct.to_string().green()
1789    ));
1790    output.push_str(&format!(
1791        "  Jaccard: {:.4} ({}%)\n",
1792        report.similarity.jaccard,
1793        jaccard_pct.to_string().green()
1794    ));
1795
1796    if let Some(cosine) = report.similarity.cosine {
1797        let cosine_pct = (cosine * 100.0) as u32;
1798        output.push_str(&format!(
1799            "  Cosine:  {:.4} ({}%)\n",
1800            cosine,
1801            cosine_pct.to_string().green()
1802        ));
1803    }
1804
1805    output.push('\n');
1806
1807    // Interpretation
1808    output.push_str(&format!(
1809        "Interpretation: {}\n\n",
1810        report.similarity.interpretation.cyan()
1811    ));
1812
1813    // Token breakdown
1814    output.push_str(&"Token Breakdown:\n".bold().to_string());
1815    output.push_str(&format!(
1816        "  Shared tokens:  {}\n",
1817        report.token_breakdown.shared_tokens.to_string().green()
1818    ));
1819    output.push_str(&format!(
1820        "  Unique to #1:   {}\n",
1821        report.token_breakdown.unique_to_fragment1
1822    ));
1823    output.push_str(&format!(
1824        "  Unique to #2:   {}\n",
1825        report.token_breakdown.unique_to_fragment2
1826    ));
1827    output.push_str(&format!(
1828        "  Total unique:   {}\n",
1829        report.token_breakdown.total_unique
1830    ));
1831
1832    // Config info
1833    output.push('\n');
1834    output.push_str(&format!(
1835        "Metric: {:?}, N-gram size: {}\n",
1836        report.config.metric, report.config.ngram_size
1837    ));
1838
1839    if let Some(lang) = &report.config.language {
1840        output.push_str(&format!("Language: {}\n", lang));
1841    }
1842
1843    output
1844}
1845
1846// =============================================================================
1847// SARIF Output Format (IDE/CI Integration)
1848// =============================================================================
1849
1850/// SARIF 2.1.0 compliant output for IDE/CI integration
1851///
1852/// Supported by:
1853/// - GitHub Code Scanning
1854/// - VS Code SARIF Viewer
1855/// - Azure DevOps
1856/// - Many CI/CD systems
1857///
1858/// Reference: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
1859pub mod sarif {
1860    use serde::Serialize;
1861    use std::path::Path;
1862    use tldr_core::analysis::{CloneType, ClonesReport};
1863
1864    /// SARIF log root
1865    #[derive(Debug, Serialize)]
1866    pub struct SarifLog {
1867        #[serde(rename = "$schema")]
1868        pub schema: String,
1869        pub version: String,
1870        pub runs: Vec<SarifRun>,
1871    }
1872
1873    /// A single analysis run
1874    #[derive(Debug, Serialize)]
1875    pub struct SarifRun {
1876        pub tool: SarifTool,
1877        pub results: Vec<SarifResult>,
1878        #[serde(skip_serializing_if = "Option::is_none")]
1879        pub invocations: Option<Vec<SarifInvocation>>,
1880    }
1881
1882    /// Tool information
1883    #[derive(Debug, Serialize)]
1884    pub struct SarifTool {
1885        pub driver: SarifDriver,
1886    }
1887
1888    /// Tool driver (the actual analysis tool)
1889    #[derive(Debug, Serialize)]
1890    pub struct SarifDriver {
1891        pub name: String,
1892        pub version: String,
1893        #[serde(rename = "informationUri", skip_serializing_if = "Option::is_none")]
1894        pub information_uri: Option<String>,
1895        pub rules: Vec<SarifRule>,
1896    }
1897
1898    /// Analysis rule definition
1899    #[derive(Debug, Serialize)]
1900    pub struct SarifRule {
1901        pub id: String,
1902        pub name: String,
1903        #[serde(rename = "shortDescription")]
1904        pub short_description: SarifMessage,
1905        #[serde(rename = "fullDescription", skip_serializing_if = "Option::is_none")]
1906        pub full_description: Option<SarifMessage>,
1907        #[serde(rename = "helpUri", skip_serializing_if = "Option::is_none")]
1908        pub help_uri: Option<String>,
1909        #[serde(
1910            rename = "defaultConfiguration",
1911            skip_serializing_if = "Option::is_none"
1912        )]
1913        pub default_configuration: Option<SarifConfiguration>,
1914    }
1915
1916    /// Rule configuration
1917    #[derive(Debug, Serialize)]
1918    pub struct SarifConfiguration {
1919        pub level: String,
1920    }
1921
1922    /// A single analysis result/finding
1923    #[derive(Debug, Serialize)]
1924    pub struct SarifResult {
1925        #[serde(rename = "ruleId")]
1926        pub rule_id: String,
1927        pub level: String,
1928        pub message: SarifMessage,
1929        pub locations: Vec<SarifLocation>,
1930        #[serde(rename = "relatedLocations", skip_serializing_if = "Vec::is_empty")]
1931        pub related_locations: Vec<SarifLocation>,
1932        #[serde(
1933            rename = "partialFingerprints",
1934            skip_serializing_if = "Option::is_none"
1935        )]
1936        pub partial_fingerprints: Option<SarifFingerprints>,
1937    }
1938
1939    /// Message text
1940    #[derive(Debug, Serialize)]
1941    pub struct SarifMessage {
1942        pub text: String,
1943    }
1944
1945    /// Code location
1946    #[derive(Debug, Serialize)]
1947    pub struct SarifLocation {
1948        #[serde(rename = "physicalLocation")]
1949        pub physical_location: SarifPhysicalLocation,
1950        #[serde(skip_serializing_if = "Option::is_none")]
1951        pub id: Option<usize>,
1952    }
1953
1954    /// Physical location in a file
1955    #[derive(Debug, Serialize)]
1956    pub struct SarifPhysicalLocation {
1957        #[serde(rename = "artifactLocation")]
1958        pub artifact_location: SarifArtifactLocation,
1959        pub region: SarifRegion,
1960    }
1961
1962    /// File artifact location
1963    #[derive(Debug, Serialize)]
1964    pub struct SarifArtifactLocation {
1965        pub uri: String,
1966        #[serde(rename = "uriBaseId", skip_serializing_if = "Option::is_none")]
1967        pub uri_base_id: Option<String>,
1968    }
1969
1970    /// Code region (lines/columns)
1971    #[derive(Debug, Serialize)]
1972    pub struct SarifRegion {
1973        #[serde(rename = "startLine")]
1974        pub start_line: usize,
1975        #[serde(rename = "endLine", skip_serializing_if = "Option::is_none")]
1976        pub end_line: Option<usize>,
1977    }
1978
1979    /// Fingerprints for deduplication
1980    #[derive(Debug, Serialize)]
1981    pub struct SarifFingerprints {
1982        #[serde(
1983            rename = "primaryLocationLineHash",
1984            skip_serializing_if = "Option::is_none"
1985        )]
1986        pub primary_location_line_hash: Option<String>,
1987    }
1988
1989    /// Invocation details
1990    #[derive(Debug, Serialize)]
1991    pub struct SarifInvocation {
1992        #[serde(rename = "executionSuccessful")]
1993        pub execution_successful: bool,
1994    }
1995
1996    /// Get rule ID for clone type
1997    fn clone_type_rule_id(clone_type: CloneType) -> &'static str {
1998        match clone_type {
1999            CloneType::Type1 => "clone/type-1",
2000            CloneType::Type2 => "clone/type-2",
2001            CloneType::Type3 => "clone/type-3",
2002        }
2003    }
2004
2005    /// Get human-readable clone type description
2006    fn clone_type_description(clone_type: CloneType) -> &'static str {
2007        match clone_type {
2008            CloneType::Type1 => "Exact code clone (identical except whitespace/comments)",
2009            CloneType::Type2 => "Parameterized clone (renamed identifiers/literals)",
2010            CloneType::Type3 => "Gapped clone (structural similarity with modifications)",
2011        }
2012    }
2013
2014    /// Get severity level for clone type
2015    fn clone_type_level(clone_type: CloneType) -> &'static str {
2016        match clone_type {
2017            CloneType::Type1 => "warning", // Exact duplicates are more severe
2018            CloneType::Type2 => "warning",
2019            CloneType::Type3 => "note", // Similar code is informational
2020        }
2021    }
2022
2023    /// Convert a path to URI format
2024    fn path_to_uri(path: &Path, root: &Path) -> String {
2025        // Try to make path relative to root
2026        let relative = path.strip_prefix(root).unwrap_or(path);
2027        relative.to_string_lossy().replace('\\', "/")
2028    }
2029
2030    /// Convert ClonesReport to SARIF format
2031    pub fn format_clones_sarif(report: &ClonesReport) -> SarifLog {
2032        // Define rules for each clone type
2033        let rules = vec![
2034            SarifRule {
2035                id: "clone/type-1".to_string(),
2036                name: "ExactClone".to_string(),
2037                short_description: SarifMessage {
2038                    text: "Exact code clone detected".to_string(),
2039                },
2040                full_description: Some(SarifMessage {
2041                    text: "Type-1 clone: Identical code fragments (ignoring whitespace and comments). Consider extracting to a shared function or module.".to_string(),
2042                }),
2043                help_uri: None,
2044                default_configuration: Some(SarifConfiguration {
2045                    level: "warning".to_string(),
2046                }),
2047            },
2048            SarifRule {
2049                id: "clone/type-2".to_string(),
2050                name: "ParameterizedClone".to_string(),
2051                short_description: SarifMessage {
2052                    text: "Parameterized clone detected".to_string(),
2053                },
2054                full_description: Some(SarifMessage {
2055                    text: "Type-2 clone: Code fragments with renamed identifiers or different literal values. The structure is identical. Consider refactoring to accept parameters.".to_string(),
2056                }),
2057                help_uri: None,
2058                default_configuration: Some(SarifConfiguration {
2059                    level: "warning".to_string(),
2060                }),
2061            },
2062            SarifRule {
2063                id: "clone/type-3".to_string(),
2064                name: "GappedClone".to_string(),
2065                short_description: SarifMessage {
2066                    text: "Similar code pattern detected".to_string(),
2067                },
2068                full_description: Some(SarifMessage {
2069                    text: "Type-3 clone: Code fragments with similar structure but some statements added, removed, or modified. May indicate copy-paste programming.".to_string(),
2070                }),
2071                help_uri: None,
2072                default_configuration: Some(SarifConfiguration {
2073                    level: "note".to_string(),
2074                }),
2075            },
2076        ];
2077
2078        // Convert clone pairs to SARIF results
2079        let results: Vec<SarifResult> = report
2080            .clone_pairs
2081            .iter()
2082            .map(|pair| {
2083                let rule_id = clone_type_rule_id(pair.clone_type).to_string();
2084                let level = clone_type_level(pair.clone_type).to_string();
2085
2086                // Primary location (fragment1)
2087                let primary_location = SarifLocation {
2088                    physical_location: SarifPhysicalLocation {
2089                        artifact_location: SarifArtifactLocation {
2090                            uri: path_to_uri(&pair.fragment1.file, &report.root),
2091                            uri_base_id: Some("%SRCROOT%".to_string()),
2092                        },
2093                        region: SarifRegion {
2094                            start_line: pair.fragment1.start_line,
2095                            end_line: Some(pair.fragment1.end_line),
2096                        },
2097                    },
2098                    id: None,
2099                };
2100
2101                // Related location (fragment2)
2102                let related_location = SarifLocation {
2103                    physical_location: SarifPhysicalLocation {
2104                        artifact_location: SarifArtifactLocation {
2105                            uri: path_to_uri(&pair.fragment2.file, &report.root),
2106                            uri_base_id: Some("%SRCROOT%".to_string()),
2107                        },
2108                        region: SarifRegion {
2109                            start_line: pair.fragment2.start_line,
2110                            end_line: Some(pair.fragment2.end_line),
2111                        },
2112                    },
2113                    id: Some(1),
2114                };
2115
2116                let message = format!(
2117                    "{} ({:.0}% similar to {}:{})",
2118                    clone_type_description(pair.clone_type),
2119                    pair.similarity * 100.0,
2120                    path_to_uri(&pair.fragment2.file, &report.root),
2121                    pair.fragment2.start_line
2122                );
2123
2124                SarifResult {
2125                    rule_id,
2126                    level,
2127                    message: SarifMessage { text: message },
2128                    locations: vec![primary_location],
2129                    related_locations: vec![related_location],
2130                    partial_fingerprints: Some(SarifFingerprints {
2131                        primary_location_line_hash: Some(format!(
2132                            "{}:{}:{}:{}",
2133                            path_to_uri(&pair.fragment1.file, &report.root),
2134                            pair.fragment1.start_line,
2135                            path_to_uri(&pair.fragment2.file, &report.root),
2136                            pair.fragment2.start_line
2137                        )),
2138                    }),
2139                }
2140            })
2141            .collect();
2142
2143        SarifLog {
2144            schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
2145            version: "2.1.0".to_string(),
2146            runs: vec![SarifRun {
2147                tool: SarifTool {
2148                    driver: SarifDriver {
2149                        name: "tldr".to_string(),
2150                        version: env!("CARGO_PKG_VERSION").to_string(),
2151                        information_uri: Some("https://github.com/anthropics/claude-code".to_string()),
2152                        rules,
2153                    },
2154                },
2155                results,
2156                invocations: Some(vec![SarifInvocation {
2157                    execution_successful: true,
2158                }]),
2159            }],
2160        }
2161    }
2162}
2163
2164/// Format ModuleInfo for text output
2165///
2166/// Compact, human-readable summary of a module's contents:
2167/// ```text
2168/// /src/example.py (python)
2169///   "Example module for testing."
2170///
2171/// Imports (2)
2172///   import os
2173///   from typing: List, Optional
2174///
2175/// Functions (2)
2176///   async process_data(data: list, config: dict) -> bool  L10
2177///     "Process input data."
2178///   helper()  L25
2179///
2180/// Classes (1)
2181///   DataHandler(BaseHandler, Serializable)  L30
2182///     "Handles data processing."
2183///     Fields: config: dict
2184///     Methods: __init__(self, config: dict), async run(self) -> Result
2185///
2186/// Constants (1)
2187///   MAX_RETRIES: int = 3  L5
2188///
2189/// Call Graph (2 edges)
2190///   process_data -> helper
2191///   DataHandler.run -> process_data
2192/// ```
2193pub fn format_module_info_text(info: &tldr_core::types::ModuleInfo) -> String {
2194    let mut output = String::new();
2195
2196    // Header: file path + language
2197    output.push_str(&format!(
2198        "{} ({})\n",
2199        info.file_path.display().to_string().bold(),
2200        info.language.as_str().cyan()
2201    ));
2202
2203    // Docstring (truncated to 80 chars)
2204    if let Some(ref doc) = info.docstring {
2205        let truncated = if doc.len() > 80 {
2206            format!("{}...", &doc[..77])
2207        } else {
2208            doc.clone()
2209        };
2210        output.push_str(&format!("  \"{}\"\n", truncated.dimmed()));
2211    }
2212
2213    output.push('\n');
2214
2215    // Imports
2216    if !info.imports.is_empty() {
2217        output.push_str(&format!("{} ({})\n", "Imports".bold(), info.imports.len()));
2218        output.push_str(&format!(
2219            "  {}",
2220            format_imports_text(&info.imports)
2221                .lines()
2222                .collect::<Vec<_>>()
2223                .join("\n  ")
2224        ));
2225        output.push('\n');
2226    }
2227
2228    // Functions
2229    if !info.functions.is_empty() {
2230        output.push_str(&format!(
2231            "{} ({})\n",
2232            "Functions".bold(),
2233            info.functions.len()
2234        ));
2235        for func in &info.functions {
2236            format_function_line(&mut output, func, "  ");
2237        }
2238        output.push('\n');
2239    }
2240
2241    // Classes
2242    if !info.classes.is_empty() {
2243        output.push_str(&format!("{} ({})\n", "Classes".bold(), info.classes.len()));
2244        for class in &info.classes {
2245            // Class name with bases
2246            let bases_str = if class.bases.is_empty() {
2247                String::new()
2248            } else {
2249                format!("({})", class.bases.join(", "))
2250            };
2251            output.push_str(&format!(
2252                "  {}{}  L{}\n",
2253                class.name.green(),
2254                bases_str,
2255                class.line_number
2256            ));
2257
2258            // Class docstring
2259            if let Some(ref doc) = class.docstring {
2260                let truncated = if doc.len() > 80 {
2261                    format!("{}...", &doc[..77])
2262                } else {
2263                    doc.clone()
2264                };
2265                output.push_str(&format!("    \"{}\"\n", truncated.dimmed()));
2266            }
2267
2268            // Fields summary (compact one-liner)
2269            if !class.fields.is_empty() {
2270                let fields_summary: Vec<String> = class
2271                    .fields
2272                    .iter()
2273                    .map(|f| {
2274                        if let Some(ref ft) = f.field_type {
2275                            format!("{}: {}", f.name, ft)
2276                        } else {
2277                            f.name.clone()
2278                        }
2279                    })
2280                    .collect();
2281                output.push_str(&format!("    Fields: {}\n", fields_summary.join(", ")));
2282            }
2283
2284            // Methods summary (compact one-liner)
2285            if !class.methods.is_empty() {
2286                let methods_summary: Vec<String> = class
2287                    .methods
2288                    .iter()
2289                    .map(|m| {
2290                        let async_prefix = if m.is_async { "async " } else { "" };
2291                        let params_str = m.params.join(", ");
2292                        let ret = m
2293                            .return_type
2294                            .as_ref()
2295                            .map(|r| format!(" -> {}", r))
2296                            .unwrap_or_default();
2297                        format!("{}{}({}){}", async_prefix, m.name, params_str, ret)
2298                    })
2299                    .collect();
2300                output.push_str(&format!("    Methods: {}\n", methods_summary.join(", ")));
2301            }
2302        }
2303        output.push('\n');
2304    }
2305
2306    // Constants
2307    if !info.constants.is_empty() {
2308        output.push_str(&format!(
2309            "{} ({})\n",
2310            "Constants".bold(),
2311            info.constants.len()
2312        ));
2313        for c in &info.constants {
2314            let type_str = c
2315                .field_type
2316                .as_ref()
2317                .map(|t| format!(": {}", t))
2318                .unwrap_or_default();
2319            let val_str = c
2320                .default_value
2321                .as_ref()
2322                .map(|v| format!(" = {}", v))
2323                .unwrap_or_default();
2324            output.push_str(&format!(
2325                "  {}{}{}  L{}\n",
2326                c.name.cyan(),
2327                type_str,
2328                val_str,
2329                c.line_number
2330            ));
2331        }
2332        output.push('\n');
2333    }
2334
2335    // Call Graph summary (top 10 edges, grouped by caller)
2336    let total_edges: usize = info.call_graph.calls.values().map(|v| v.len()).sum();
2337    if total_edges > 0 {
2338        output.push_str(&format!(
2339            "{} ({} edges)\n",
2340            "Call Graph".bold(),
2341            total_edges
2342        ));
2343
2344        // Sort callers for deterministic output
2345        let mut callers: Vec<_> = info.call_graph.calls.keys().collect();
2346        callers.sort();
2347
2348        let mut shown = 0;
2349        for caller in callers {
2350            if shown >= 10 {
2351                let remaining = total_edges - shown;
2352                if remaining > 0 {
2353                    output.push_str(&format!("  ... and {} more edges\n", remaining));
2354                }
2355                break;
2356            }
2357            if let Some(callees) = info.call_graph.calls.get(caller.as_str()) {
2358                for callee in callees {
2359                    output.push_str(&format!("  {} -> {}\n", caller.dimmed(), callee.green()));
2360                    shown += 1;
2361                    if shown >= 10 {
2362                        break;
2363                    }
2364                }
2365            }
2366        }
2367    }
2368
2369    output
2370}
2371
2372/// Format a single function entry for module info text output
2373fn format_function_line(output: &mut String, func: &tldr_core::types::FunctionInfo, indent: &str) {
2374    let async_prefix = if func.is_async { "async " } else { "" };
2375    let params_str = func.params.join(", ");
2376    let ret_str = func
2377        .return_type
2378        .as_ref()
2379        .map(|r| format!(" -> {}", r))
2380        .unwrap_or_default();
2381    output.push_str(&format!(
2382        "{}{}{}({}){}  L{}\n",
2383        indent,
2384        async_prefix.cyan(),
2385        func.name.green(),
2386        params_str,
2387        ret_str,
2388        func.line_number
2389    ));
2390
2391    // Docstring preview (truncated)
2392    if let Some(ref doc) = func.docstring {
2393        let truncated = if doc.len() > 60 {
2394            format!("{}...", &doc[..57])
2395        } else {
2396            doc.clone()
2397        };
2398        output.push_str(&format!("{}  \"{}\"\n", indent, truncated.dimmed()));
2399    }
2400}
2401
2402/// Format ClonesReport as SARIF JSON
2403pub fn format_clones_sarif(report: &tldr_core::analysis::ClonesReport) -> String {
2404    let sarif_log = sarif::format_clones_sarif(report);
2405    serde_json::to_string_pretty(&sarif_log).unwrap_or_else(|_| "{}".to_string())
2406}
2407
2408// =============================================================================
2409// Tests
2410// =============================================================================
2411
2412#[cfg(test)]
2413#[path = "output_tests.rs"]
2414mod output_tests;