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