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