Skip to main content

brrr_lint/lint/
output.rs

1//! Output formatting for lint diagnostics.
2//!
3//! Provides beautiful, informative output formats for the F* linter,
4//! including diff-style dry-run previews with box drawing characters.
5
6use super::rules::{Diagnostic, DiagnosticSeverity, Fix, FixConfidence, RuleCode};
7use clap::ValueEnum;
8use serde::Serialize;
9use std::collections::HashMap;
10use std::io::{self, Write};
11use std::path::PathBuf;
12
13/// Output format for lint results.
14#[derive(Debug, Clone, Copy, ValueEnum, Default)]
15pub enum OutputFormat {
16    /// Human-readable text output (like ruff default).
17    #[default]
18    Text,
19    /// Concise one-line-per-diagnostic format.
20    Concise,
21    /// JSON output for machine consumption.
22    Json,
23    /// GitHub Actions annotation format.
24    Github,
25}
26
27/// Dry-run output format for showing what changes would be made.
28#[derive(Debug, Clone, Copy, ValueEnum, Default)]
29pub enum DryRunFormat {
30    /// Concise: Just list files and fix counts per rule.
31    Concise,
32    /// Full: Show all changes with unified diff-style output (default).
33    #[default]
34    Full,
35    /// JSON: Machine-readable output for tooling integration.
36    Json,
37}
38
39/// Summary statistics for a lint run.
40#[derive(Debug, Default)]
41pub struct LintSummary {
42    pub total_files: usize,
43    pub files_with_issues: usize,
44    pub total_diagnostics: usize,
45    pub fixable_diagnostics: usize,
46    pub errors: usize,
47    pub warnings: usize,
48}
49
50impl LintSummary {
51    pub fn add_diagnostic(&mut self, diag: &Diagnostic) {
52        self.total_diagnostics += 1;
53        if diag.fix.is_some() {
54            self.fixable_diagnostics += 1;
55        }
56        match diag.severity {
57            DiagnosticSeverity::Error => self.errors += 1,
58            DiagnosticSeverity::Warning => self.warnings += 1,
59            _ => {}
60        }
61    }
62}
63
64// ============================================================================
65// DRY-RUN OUTPUT FORMATTING
66// ============================================================================
67
68/// ANSI color codes for terminal output.
69mod colors {
70    pub const RESET: &str = "\x1b[0m";
71    pub const BOLD: &str = "\x1b[1m";
72    pub const DIM: &str = "\x1b[2m";
73    pub const ITALIC: &str = "\x1b[3m";
74    pub const RED: &str = "\x1b[31m";
75    pub const GREEN: &str = "\x1b[32m";
76    pub const YELLOW: &str = "\x1b[33m";
77    pub const BLUE: &str = "\x1b[34m";
78    pub const MAGENTA: &str = "\x1b[35m";
79    pub const CYAN: &str = "\x1b[36m";
80    pub const WHITE: &str = "\x1b[37m";
81    pub const BRIGHT_RED: &str = "\x1b[91m";
82    pub const BRIGHT_GREEN: &str = "\x1b[92m";
83    pub const BRIGHT_YELLOW: &str = "\x1b[93m";
84    pub const BRIGHT_BLUE: &str = "\x1b[94m";
85    pub const BRIGHT_CYAN: &str = "\x1b[96m";
86    pub const BG_RED: &str = "\x1b[41m";
87    pub const BG_GREEN: &str = "\x1b[42m";
88    pub const BG_YELLOW: &str = "\x1b[43m";
89}
90
91/// Box drawing characters for beautiful terminal UI.
92mod box_chars {
93    // Heavy box drawing (for headers)
94    pub const H_DOUBLE_TOP_LEFT: &str = "\u{2554}";     // ╔
95    pub const H_DOUBLE_TOP_RIGHT: &str = "\u{2557}";    // ╗
96    pub const H_DOUBLE_BOTTOM_LEFT: &str = "\u{255a}";  // ╚
97    pub const H_DOUBLE_BOTTOM_RIGHT: &str = "\u{255d}"; // ╝
98    pub const H_DOUBLE_HORIZONTAL: &str = "\u{2550}";   // ═
99    pub const H_DOUBLE_VERTICAL: &str = "\u{2551}";     // ║
100
101    // Light box drawing (for content)
102    pub const TOP_LEFT: &str = "\u{256d}";      // ╭
103    pub const TOP_RIGHT: &str = "\u{256e}";     // ╮
104    pub const BOTTOM_LEFT: &str = "\u{2570}";   // ╰
105    pub const BOTTOM_RIGHT: &str = "\u{256f}";  // ╯
106    pub const HORIZONTAL: &str = "\u{2500}";    // ─
107    pub const VERTICAL: &str = "\u{2502}";      // │
108    pub const VERTICAL_RIGHT: &str = "\u{251c}"; // ├
109    pub const VERTICAL_LEFT: &str = "\u{2524}"; // ┤
110
111    // Diff indicators
112    pub const ARROW_RIGHT: &str = "\u{25b6}";   // ▶
113    pub const CHECK: &str = "\u{2713}";         // ✓
114    pub const CROSS: &str = "\u{2717}";         // ✗
115    pub const WARNING: &str = "\u{26a0}";       // ⚠
116    pub const INFO: &str = "\u{2139}";          // ℹ
117}
118
119/// Summary statistics for dry-run output.
120#[derive(Debug, Default)]
121pub struct DryRunSummary {
122    /// Total files that would be affected.
123    pub files_affected: usize,
124    /// Total fixes that would be applied.
125    pub total_fixes: usize,
126    /// Fixes that are safe to auto-apply (high confidence).
127    pub safe_fixes: usize,
128    /// Fixes that require manual review (medium/low confidence).
129    pub review_required: usize,
130    /// Fixes by rule code.
131    pub by_rule: HashMap<RuleCode, usize>,
132    /// Fixes by file.
133    pub by_file: HashMap<PathBuf, Vec<FixPreview>>,
134    /// Lines that would be removed.
135    pub lines_removed: usize,
136    /// Lines that would be added.
137    pub lines_added: usize,
138}
139
140/// A preview of a single fix that would be applied.
141#[derive(Debug, Clone)]
142pub struct FixPreview {
143    /// The rule that produced this fix.
144    pub rule: RuleCode,
145    /// Human-readable message about what the fix does.
146    pub message: String,
147    /// Lines affected (start, end) - 1-indexed.
148    pub line_range: (usize, usize),
149    /// Content that would be removed (if any).
150    pub removed_content: Option<String>,
151    /// Content that would be added (if any).
152    pub new_content: Option<String>,
153    /// Confidence level of the fix.
154    pub confidence: FixConfidence,
155    /// Whether the fix is safe to auto-apply.
156    pub is_safe: bool,
157    /// Reason if unsafe.
158    pub unsafe_reason: Option<String>,
159}
160
161impl DryRunSummary {
162    /// Create a new dry-run summary.
163    pub fn new() -> Self {
164        Self::default()
165    }
166
167    /// Add a diagnostic with fix to the summary.
168    pub fn add_fix(&mut self, diag: &Diagnostic, original_content: &str) {
169        if let Some(fix) = &diag.fix {
170            self.total_fixes += 1;
171
172            if fix.is_safe && fix.confidence == FixConfidence::High {
173                self.safe_fixes += 1;
174            } else {
175                self.review_required += 1;
176            }
177
178            *self.by_rule.entry(diag.rule).or_insert(0) += 1;
179
180            // Create fix preview
181            let preview = create_fix_preview(diag, fix, original_content);
182
183            // Count line changes
184            if let Some(removed) = &preview.removed_content {
185                self.lines_removed += removed.lines().count();
186            }
187            if let Some(added) = &preview.new_content {
188                self.lines_added += added.lines().count();
189            }
190
191            // Track by file
192            self.by_file
193                .entry(diag.file.clone())
194                .or_default()
195                .push(preview);
196        }
197    }
198
199    /// Finalize the summary (calculate derived values).
200    pub fn finalize(&mut self) {
201        self.files_affected = self.by_file.len();
202    }
203}
204
205/// Create a fix preview from a diagnostic.
206fn create_fix_preview(diag: &Diagnostic, fix: &Fix, original_content: &str) -> FixPreview {
207    let lines: Vec<&str> = original_content.lines().collect();
208    let start_line = diag.range.start_line.saturating_sub(1);
209    let end_line = diag.range.end_line.saturating_sub(1);
210
211    // Extract content being removed
212    let removed_content = if start_line < lines.len() {
213        let end = std::cmp::min(end_line + 1, lines.len());
214        Some(lines[start_line..end].join("\n"))
215    } else {
216        None
217    };
218
219    // Extract new content from fix edits
220    let new_content = if !fix.edits.is_empty() {
221        let text = &fix.edits[0].new_text;
222        if text.is_empty() {
223            None
224        } else {
225            Some(text.clone())
226        }
227    } else {
228        None
229    };
230
231    FixPreview {
232        rule: diag.rule,
233        message: fix.message.clone(),
234        line_range: (diag.range.start_line, diag.range.end_line),
235        removed_content,
236        new_content,
237        confidence: fix.confidence,
238        is_safe: fix.is_safe,
239        unsafe_reason: fix.unsafe_reason.clone(),
240    }
241}
242
243/// Print the dry-run header banner.
244pub fn print_dry_run_header<W: Write>(w: &mut W) -> io::Result<()> {
245    use box_chars::*;
246    use colors::*;
247
248    let message = " DRY RUN - No files will be modified. Use --apply to write changes ";
249    let width = message.len() + 2;
250
251    writeln!(w)?;
252    writeln!(
253        w,
254        "{BOLD}{BRIGHT_YELLOW}{H_DOUBLE_TOP_LEFT}{}{H_DOUBLE_TOP_RIGHT}{RESET}",
255        H_DOUBLE_HORIZONTAL.repeat(width)
256    )?;
257    writeln!(
258        w,
259        "{BOLD}{BRIGHT_YELLOW}{H_DOUBLE_VERTICAL}{RESET} {BOLD}{YELLOW}{message}{RESET} {BOLD}{BRIGHT_YELLOW}{H_DOUBLE_VERTICAL}{RESET}"
260    )?;
261    writeln!(
262        w,
263        "{BOLD}{BRIGHT_YELLOW}{H_DOUBLE_BOTTOM_LEFT}{}{H_DOUBLE_BOTTOM_RIGHT}{RESET}",
264        H_DOUBLE_HORIZONTAL.repeat(width)
265    )?;
266    writeln!(w)?;
267
268    Ok(())
269}
270
271/// Print the apply mode header banner (when actually writing changes).
272pub fn print_apply_header<W: Write>(w: &mut W) -> io::Result<()> {
273    use box_chars::*;
274    use colors::*;
275
276    let message = " APPLYING CHANGES - Files will be modified! ";
277    let width = message.len() + 2;
278
279    writeln!(w)?;
280    writeln!(
281        w,
282        "{BOLD}{BRIGHT_RED}{H_DOUBLE_TOP_LEFT}{}{H_DOUBLE_TOP_RIGHT}{RESET}",
283        H_DOUBLE_HORIZONTAL.repeat(width)
284    )?;
285    writeln!(
286        w,
287        "{BOLD}{BRIGHT_RED}{H_DOUBLE_VERTICAL}{RESET} {BOLD}{RED}{message}{RESET} {BOLD}{BRIGHT_RED}{H_DOUBLE_VERTICAL}{RESET}"
288    )?;
289    writeln!(
290        w,
291        "{BOLD}{BRIGHT_RED}{H_DOUBLE_BOTTOM_LEFT}{}{H_DOUBLE_BOTTOM_RIGHT}{RESET}",
292        H_DOUBLE_HORIZONTAL.repeat(width)
293    )?;
294    writeln!(w)?;
295
296    Ok(())
297}
298
299/// Print a single fix preview in full format.
300pub fn print_fix_preview_full<W: Write>(
301    w: &mut W,
302    file: &PathBuf,
303    preview: &FixPreview,
304) -> io::Result<()> {
305    use box_chars::*;
306    use colors::*;
307
308    // Calculate box width based on content
309    let rule_line = format!("{}: {}", preview.rule, preview.rule.name());
310    let max_width = 70;
311
312    // File header
313    writeln!(w, "{BOLD}{CYAN}File: {}{RESET}", file.display())?;
314
315    // Fix box header
316    writeln!(
317        w,
318        "  {DIM}{TOP_LEFT}{}{TOP_RIGHT}{RESET}",
319        HORIZONTAL.repeat(max_width - 4)
320    )?;
321
322    // Rule info
323    writeln!(
324        w,
325        "  {DIM}{VERTICAL}{RESET} {BRIGHT_CYAN}{rule_line}{RESET}"
326    )?;
327
328    // Line range
329    let line_info = format!("Lines {}-{}", preview.line_range.0, preview.line_range.1);
330    writeln!(
331        w,
332        "  {DIM}{VERTICAL}{RESET} {DIM}{line_info}{RESET}"
333    )?;
334
335    // Confidence indicator
336    let confidence_str = match preview.confidence {
337        FixConfidence::High => format!("{GREEN}{CHECK} HIGH confidence{RESET}"),
338        FixConfidence::Medium => format!("{YELLOW}{WARNING} MEDIUM confidence{RESET}"),
339        FixConfidence::Low => format!("{RED}{CROSS} LOW confidence{RESET}"),
340    };
341    writeln!(w, "  {DIM}{VERTICAL}{RESET} {confidence_str}")?;
342
343    // Safety indicator
344    if preview.is_safe {
345        writeln!(
346            w,
347            "  {DIM}{VERTICAL}{RESET} {GREEN}Safe to auto-apply{RESET}"
348        )?;
349    } else {
350        writeln!(
351            w,
352            "  {DIM}{VERTICAL}{RESET} {RED}Requires review{RESET}"
353        )?;
354        if let Some(reason) = &preview.unsafe_reason {
355            writeln!(
356                w,
357                "  {DIM}{VERTICAL}{RESET}   {DIM}Reason: {reason}{RESET}"
358            )?;
359        }
360    }
361
362    // Separator
363    writeln!(
364        w,
365        "  {DIM}{VERTICAL_RIGHT}{}{VERTICAL_LEFT}{RESET}",
366        HORIZONTAL.repeat(max_width - 4)
367    )?;
368
369    // Show removed content (diff style)
370    if let Some(removed) = &preview.removed_content {
371        writeln!(w, "  {DIM}{VERTICAL}{RESET}")?;
372        for (i, line) in removed.lines().take(15).enumerate() {
373            let line_num = preview.line_range.0 + i;
374            let display_line = if line.len() > 60 {
375                format!("{}...", &line[..57])
376            } else {
377                line.to_string()
378            };
379            writeln!(
380                w,
381                "  {DIM}{VERTICAL}{RESET} {RED}-{line_num:>4} {VERTICAL}{RESET} {RED}{display_line}{RESET}"
382            )?;
383        }
384        let total_lines = removed.lines().count();
385        if total_lines > 15 {
386            writeln!(
387                w,
388                "  {DIM}{VERTICAL}{RESET} {RED}  ... ({} more lines){RESET}",
389                total_lines - 15
390            )?;
391        }
392    }
393
394    // Show added content (diff style)
395    if let Some(added) = &preview.new_content {
396        writeln!(w, "  {DIM}{VERTICAL}{RESET}")?;
397        for (i, line) in added.lines().take(15).enumerate() {
398            let line_num = preview.line_range.0 + i;
399            let display_line = if line.len() > 60 {
400                format!("{}...", &line[..57])
401            } else {
402                line.to_string()
403            };
404            writeln!(
405                w,
406                "  {DIM}{VERTICAL}{RESET} {GREEN}+{line_num:>4} {VERTICAL}{RESET} {GREEN}{display_line}{RESET}"
407            )?;
408        }
409        let total_lines = added.lines().count();
410        if total_lines > 15 {
411            writeln!(
412                w,
413                "  {DIM}{VERTICAL}{RESET} {GREEN}  ... ({} more lines){RESET}",
414                total_lines - 15
415            )?;
416        }
417    } else if preview.removed_content.is_some() {
418        // Lines are being deleted with no replacement
419        writeln!(w, "  {DIM}{VERTICAL}{RESET}")?;
420        writeln!(
421            w,
422            "  {DIM}{VERTICAL}{RESET} {YELLOW}{INFO} Lines will be deleted (no replacement){RESET}"
423        )?;
424    }
425
426    // Box footer
427    writeln!(
428        w,
429        "  {DIM}{BOTTOM_LEFT}{}{BOTTOM_RIGHT}{RESET}",
430        HORIZONTAL.repeat(max_width - 4)
431    )?;
432    writeln!(w)?;
433
434    Ok(())
435}
436
437/// Print a single fix in concise format.
438pub fn print_fix_preview_concise<W: Write>(
439    w: &mut W,
440    file: &PathBuf,
441    preview: &FixPreview,
442) -> io::Result<()> {
443    use colors::*;
444
445    let confidence_marker = match preview.confidence {
446        FixConfidence::High => format!("{GREEN}[H]{RESET}"),
447        FixConfidence::Medium => format!("{YELLOW}[M]{RESET}"),
448        FixConfidence::Low => format!("{RED}[L]{RESET}"),
449    };
450
451    let safe_marker = if preview.is_safe {
452        format!("{GREEN}*{RESET}")
453    } else {
454        format!("{RED}!{RESET}")
455    };
456
457    writeln!(
458        w,
459        "{safe_marker} {}:{}-{} {} {confidence_marker} {}",
460        file.display(),
461        preview.line_range.0,
462        preview.line_range.1,
463        preview.rule,
464        preview.message
465    )?;
466
467    Ok(())
468}
469
470/// Print the dry-run summary.
471pub fn print_dry_run_summary<W: Write>(w: &mut W, summary: &DryRunSummary) -> io::Result<()> {
472    use box_chars::*;
473    use colors::*;
474
475    let width = 60;
476
477    writeln!(w)?;
478    writeln!(
479        w,
480        "{BOLD}{TOP_LEFT}{}{TOP_RIGHT}{RESET}",
481        HORIZONTAL.repeat(width - 2)
482    )?;
483    writeln!(
484        w,
485        "{BOLD}{VERTICAL} Summary{}{VERTICAL}{RESET}",
486        " ".repeat(width - 10)
487    )?;
488    writeln!(
489        w,
490        "{VERTICAL_RIGHT}{}{VERTICAL_LEFT}",
491        HORIZONTAL.repeat(width - 2)
492    )?;
493
494    // Statistics
495    writeln!(
496        w,
497        "{DIM}{VERTICAL}{RESET} Files affected:        {BOLD}{CYAN}{:>6}{RESET}                        {DIM}{VERTICAL}{RESET}",
498        summary.files_affected
499    )?;
500    writeln!(
501        w,
502        "{DIM}{VERTICAL}{RESET} Total fixes:           {BOLD}{:>6}{RESET}                        {DIM}{VERTICAL}{RESET}",
503        summary.total_fixes
504    )?;
505    writeln!(
506        w,
507        "{DIM}{VERTICAL}{RESET} Safe to apply:         {BOLD}{GREEN}{:>6}{RESET}                        {DIM}{VERTICAL}{RESET}",
508        summary.safe_fixes
509    )?;
510    writeln!(
511        w,
512        "{DIM}{VERTICAL}{RESET} Requires review:       {BOLD}{YELLOW}{:>6}{RESET}                        {DIM}{VERTICAL}{RESET}",
513        summary.review_required
514    )?;
515    writeln!(
516        w,
517        "{DIM}{VERTICAL}{RESET} Lines removed:         {RED}{:>6}{RESET}                        {DIM}{VERTICAL}{RESET}",
518        summary.lines_removed
519    )?;
520    writeln!(
521        w,
522        "{DIM}{VERTICAL}{RESET} Lines added:           {GREEN}{:>6}{RESET}                        {DIM}{VERTICAL}{RESET}",
523        summary.lines_added
524    )?;
525
526    // By rule breakdown
527    if !summary.by_rule.is_empty() {
528        writeln!(
529            w,
530            "{VERTICAL_RIGHT}{}{VERTICAL_LEFT}",
531            HORIZONTAL.repeat(width - 2)
532        )?;
533        writeln!(
534            w,
535            "{DIM}{VERTICAL}{RESET} {DIM}Fixes by rule:{RESET}                                     {DIM}{VERTICAL}{RESET}"
536        )?;
537
538        let mut rules: Vec<_> = summary.by_rule.iter().collect();
539        rules.sort_by(|a, b| b.1.cmp(a.1)); // Sort by count descending
540
541        for (rule, count) in rules.iter().take(10) {
542            writeln!(
543                w,
544                "{DIM}{VERTICAL}{RESET}   {CYAN}{:<8}{RESET} {:<20} {BOLD}{:>5}{RESET}       {DIM}{VERTICAL}{RESET}",
545                rule,
546                rule.name(),
547                count
548            )?;
549        }
550        if rules.len() > 10 {
551            writeln!(
552                w,
553                "{DIM}{VERTICAL}{RESET}   {DIM}... and {} more rules{RESET}                        {DIM}{VERTICAL}{RESET}",
554                rules.len() - 10
555            )?;
556        }
557    }
558
559    writeln!(
560        w,
561        "{BOTTOM_LEFT}{}{BOTTOM_RIGHT}",
562        HORIZONTAL.repeat(width - 2)
563    )?;
564
565    Ok(())
566}
567
568/// Print instructions for applying changes.
569pub fn print_apply_instructions<W: Write>(w: &mut W, command_hint: &str) -> io::Result<()> {
570    use colors::*;
571
572    writeln!(w)?;
573    writeln!(
574        w,
575        "{BOLD}To apply these changes, run:{RESET}"
576    )?;
577    writeln!(
578        w,
579        "  {BRIGHT_GREEN}{command_hint}{RESET}"
580    )?;
581    writeln!(w)?;
582
583    Ok(())
584}
585
586/// Print all dry-run output in full format.
587pub fn print_dry_run_full(summary: &DryRunSummary) -> io::Result<()> {
588    let stdout = io::stdout();
589    let mut handle = stdout.lock();
590
591    print_dry_run_header(&mut handle)?;
592
593    // Print each file's fixes
594    for (file, previews) in &summary.by_file {
595        for preview in previews {
596            print_fix_preview_full(&mut handle, file, preview)?;
597        }
598    }
599
600    print_dry_run_summary(&mut handle, summary)?;
601    print_apply_instructions(&mut handle, "fstar-lsp fix <path> --apply")?;
602
603    Ok(())
604}
605
606/// Print all dry-run output in concise format.
607pub fn print_dry_run_concise(summary: &DryRunSummary) -> io::Result<()> {
608    use colors::*;
609
610    let stdout = io::stdout();
611    let mut handle = stdout.lock();
612
613    writeln!(handle)?;
614    writeln!(
615        handle,
616        "{BOLD}{YELLOW}DRY RUN{RESET} - No files will be modified"
617    )?;
618    writeln!(handle)?;
619
620    // Print each fix on one line
621    for (file, previews) in &summary.by_file {
622        for preview in previews {
623            print_fix_preview_concise(&mut handle, file, preview)?;
624        }
625    }
626
627    writeln!(handle)?;
628    writeln!(
629        handle,
630        "{DIM}Legend: * = safe to apply, ! = requires review, [H/M/L] = confidence{RESET}"
631    )?;
632    writeln!(handle)?;
633    writeln!(
634        handle,
635        "Files: {CYAN}{}{RESET}  Fixes: {}{RESET}  Safe: {GREEN}{}{RESET}  Review: {YELLOW}{}{RESET}",
636        summary.files_affected,
637        summary.total_fixes,
638        summary.safe_fixes,
639        summary.review_required
640    )?;
641    writeln!(handle)?;
642    writeln!(
643        handle,
644        "Run {BRIGHT_GREEN}fstar-lsp fix <path> --apply{RESET} to write changes"
645    )?;
646
647    Ok(())
648}
649
650/// JSON structure for dry-run output.
651#[derive(Serialize)]
652struct JsonDryRunOutput {
653    dry_run: bool,
654    summary: JsonDryRunSummary,
655    fixes: Vec<JsonFixPreview>,
656}
657
658#[derive(Serialize)]
659struct JsonDryRunSummary {
660    files_affected: usize,
661    total_fixes: usize,
662    safe_fixes: usize,
663    review_required: usize,
664    lines_removed: usize,
665    lines_added: usize,
666    by_rule: HashMap<String, usize>,
667}
668
669#[derive(Serialize)]
670struct JsonFixPreview {
671    file: String,
672    rule: String,
673    rule_name: String,
674    message: String,
675    start_line: usize,
676    end_line: usize,
677    confidence: String,
678    is_safe: bool,
679    unsafe_reason: Option<String>,
680    removed_content: Option<String>,
681    new_content: Option<String>,
682}
683
684/// Print all dry-run output in JSON format.
685pub fn print_dry_run_json(summary: &DryRunSummary) -> io::Result<()> {
686    let stdout = io::stdout();
687    let mut handle = stdout.lock();
688
689    let by_rule: HashMap<String, usize> = summary
690        .by_rule
691        .iter()
692        .map(|(k, v)| (k.to_string(), *v))
693        .collect();
694
695    let fixes: Vec<JsonFixPreview> = summary
696        .by_file
697        .iter()
698        .flat_map(|(file, previews)| {
699            previews.iter().map(move |p| JsonFixPreview {
700                file: file.display().to_string(),
701                rule: p.rule.to_string(),
702                rule_name: p.rule.name().to_string(),
703                message: p.message.clone(),
704                start_line: p.line_range.0,
705                end_line: p.line_range.1,
706                confidence: p.confidence.to_string(),
707                is_safe: p.is_safe,
708                unsafe_reason: p.unsafe_reason.clone(),
709                removed_content: p.removed_content.clone(),
710                new_content: p.new_content.clone(),
711            })
712        })
713        .collect();
714
715    let output = JsonDryRunOutput {
716        dry_run: true,
717        summary: JsonDryRunSummary {
718            files_affected: summary.files_affected,
719            total_fixes: summary.total_fixes,
720            safe_fixes: summary.safe_fixes,
721            review_required: summary.review_required,
722            lines_removed: summary.lines_removed,
723            lines_added: summary.lines_added,
724            by_rule,
725        },
726        fixes,
727    };
728
729    let json = serde_json::to_string_pretty(&output)
730        .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
731    writeln!(handle, "{}", json)?;
732
733    Ok(())
734}
735
736/// Print dry-run output in the specified format.
737pub fn print_dry_run(summary: &DryRunSummary, format: DryRunFormat) -> io::Result<()> {
738    match format {
739        DryRunFormat::Full => print_dry_run_full(summary),
740        DryRunFormat::Concise => print_dry_run_concise(summary),
741        DryRunFormat::Json => print_dry_run_json(summary),
742    }
743}
744
745/// Print "no fixable issues" message.
746pub fn print_no_fixes_message() -> io::Result<()> {
747    use colors::*;
748
749    let stdout = io::stdout();
750    let mut handle = stdout.lock();
751
752    writeln!(handle)?;
753    writeln!(
754        handle,
755        "{GREEN}{} No fixable issues found.{RESET}",
756        box_chars::CHECK
757    )?;
758    writeln!(handle)?;
759
760    Ok(())
761}
762
763/// Print completion message after applying fixes.
764pub fn print_fixes_applied(count: usize) -> io::Result<()> {
765    use colors::*;
766
767    let stdout = io::stdout();
768    let mut handle = stdout.lock();
769
770    writeln!(handle)?;
771    writeln!(
772        handle,
773        "{BOLD}{GREEN}{} {} file{} fixed.{RESET}",
774        box_chars::CHECK,
775        count,
776        if count == 1 { "" } else { "s" }
777    )?;
778    writeln!(handle)?;
779
780    Ok(())
781}
782
783/// Print diagnostics to stdout in the specified format.
784pub fn print_diagnostics(
785    diagnostics: &[Diagnostic],
786    format: OutputFormat,
787    show_fixes: bool,
788) -> io::Result<()> {
789    let stdout = io::stdout();
790    let mut handle = stdout.lock();
791
792    match format {
793        OutputFormat::Text => print_text(&mut handle, diagnostics, show_fixes),
794        OutputFormat::Concise => print_concise(&mut handle, diagnostics),
795        OutputFormat::Json => print_json(&mut handle, diagnostics),
796        OutputFormat::Github => print_github(&mut handle, diagnostics),
797    }
798}
799
800/// Print summary statistics.
801pub fn print_summary(summary: &LintSummary, format: OutputFormat) -> io::Result<()> {
802    let stdout = io::stdout();
803    let mut handle = stdout.lock();
804
805    match format {
806        OutputFormat::Text | OutputFormat::Concise => {
807            writeln!(handle)?;
808            if summary.total_diagnostics == 0 {
809                writeln!(handle, "All checks passed!")?;
810            } else {
811                write!(
812                    handle,
813                    "Found {} issue{}",
814                    summary.total_diagnostics,
815                    if summary.total_diagnostics == 1 {
816                        ""
817                    } else {
818                        "s"
819                    }
820                )?;
821
822                if summary.fixable_diagnostics > 0 {
823                    write!(handle, " ({} fixable)", summary.fixable_diagnostics)?;
824                }
825                writeln!(handle)?;
826
827                if summary.errors > 0 || summary.warnings > 0 {
828                    writeln!(
829                        handle,
830                        "  {} error{}, {} warning{}",
831                        summary.errors,
832                        if summary.errors == 1 { "" } else { "s" },
833                        summary.warnings,
834                        if summary.warnings == 1 { "" } else { "s" }
835                    )?;
836                }
837            }
838        }
839        OutputFormat::Json | OutputFormat::Github => {
840            // These formats have their own summary mechanisms
841        }
842    }
843
844    Ok(())
845}
846
847/// Print text format (ruff-style).
848fn print_text<W: Write>(w: &mut W, diagnostics: &[Diagnostic], show_fixes: bool) -> io::Result<()> {
849    for diag in diagnostics {
850        let severity_color = match diag.severity {
851            DiagnosticSeverity::Error => "\x1b[31m",   // red
852            DiagnosticSeverity::Warning => "\x1b[33m", // yellow
853            DiagnosticSeverity::Info => "\x1b[36m",    // cyan
854            DiagnosticSeverity::Hint => "\x1b[90m",    // gray
855        };
856        let reset = "\x1b[0m";
857        let bold = "\x1b[1m";
858
859        writeln!(
860            w,
861            "{}{}{}:{}:{}: {}{}{} {} {}",
862            bold,
863            diag.file.display(),
864            reset,
865            diag.range.start_line,
866            diag.range.start_col,
867            severity_color,
868            diag.rule,
869            reset,
870            diag.message,
871            if diag.fix.is_some() { "[*]" } else { "" }
872        )?;
873
874        // Show fix if requested
875        if show_fixes {
876            if let Some(fix) = &diag.fix {
877                writeln!(w, "  {} fix: {}{}", severity_color, fix.message, reset)?;
878                for edit in &fix.edits {
879                    if edit.new_text.is_empty() {
880                        writeln!(
881                            w,
882                            "    {}|- Delete lines {}-{}{}",
883                            severity_color, edit.range.start_line, edit.range.end_line, reset
884                        )?;
885                    } else {
886                        let preview: String = edit
887                            .new_text
888                            .lines()
889                            .take(3)
890                            .collect::<Vec<_>>()
891                            .join(" | ");
892                        let truncated = if preview.len() > 60 {
893                            format!("{}...", &preview[..60])
894                        } else {
895                            preview
896                        };
897                        writeln!(w, "    {}|+ {}{}", severity_color, truncated, reset)?;
898                    }
899                }
900            }
901        }
902    }
903
904    Ok(())
905}
906
907/// Print concise format (one line per diagnostic).
908fn print_concise<W: Write>(w: &mut W, diagnostics: &[Diagnostic]) -> io::Result<()> {
909    for diag in diagnostics {
910        writeln!(
911            w,
912            "{}:{}:{}: {} {}",
913            diag.file.display(),
914            diag.range.start_line,
915            diag.range.start_col,
916            diag.rule,
917            diag.message
918        )?;
919    }
920    Ok(())
921}
922
923/// JSON diagnostic for serialization.
924#[derive(Serialize)]
925struct JsonDiagnostic {
926    code: String,
927    message: String,
928    severity: String,
929    file: String,
930    line: usize,
931    column: usize,
932    end_line: usize,
933    end_column: usize,
934    fixable: bool,
935}
936
937/// Print JSON format.
938fn print_json<W: Write>(w: &mut W, diagnostics: &[Diagnostic]) -> io::Result<()> {
939    let json_diags: Vec<JsonDiagnostic> = diagnostics
940        .iter()
941        .map(|d| JsonDiagnostic {
942            code: d.rule.to_string(),
943            message: d.message.clone(),
944            severity: d.severity.to_string(),
945            file: d.file.display().to_string(),
946            line: d.range.start_line,
947            column: d.range.start_col,
948            end_line: d.range.end_line,
949            end_column: d.range.end_col,
950            fixable: d.fix.is_some(),
951        })
952        .collect();
953
954    let json = serde_json::to_string_pretty(&json_diags)
955        .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
956    writeln!(w, "{}", json)?;
957    Ok(())
958}
959
960/// Print GitHub Actions annotation format.
961fn print_github<W: Write>(w: &mut W, diagnostics: &[Diagnostic]) -> io::Result<()> {
962    for diag in diagnostics {
963        let level = match diag.severity {
964            DiagnosticSeverity::Error => "error",
965            DiagnosticSeverity::Warning => "warning",
966            DiagnosticSeverity::Info | DiagnosticSeverity::Hint => "notice",
967        };
968
969        writeln!(
970            w,
971            "::{} file={},line={},col={},endLine={},endColumn={}::{}: {}",
972            level,
973            diag.file.display(),
974            diag.range.start_line,
975            diag.range.start_col,
976            diag.range.end_line,
977            diag.range.end_col,
978            diag.rule,
979            diag.message
980        )?;
981    }
982    Ok(())
983}
984
985// ============================================================================
986// TESTS
987// ============================================================================
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992
993    /// Create a test fix preview for testing.
994    fn create_test_preview(
995        rule: RuleCode,
996        line_range: (usize, usize),
997        confidence: FixConfidence,
998        is_safe: bool,
999    ) -> FixPreview {
1000        FixPreview {
1001            rule,
1002            message: format!("Test fix for {:?}", rule),
1003            line_range,
1004            removed_content: Some("old code".to_string()),
1005            new_content: Some("new code".to_string()),
1006            confidence,
1007            is_safe,
1008            unsafe_reason: if !is_safe {
1009                Some("Test reason".to_string())
1010            } else {
1011                None
1012            },
1013        }
1014    }
1015
1016    #[test]
1017    fn test_dry_run_summary_new() {
1018        let summary = DryRunSummary::new();
1019        assert_eq!(summary.files_affected, 0);
1020        assert_eq!(summary.total_fixes, 0);
1021        assert_eq!(summary.safe_fixes, 0);
1022        assert_eq!(summary.review_required, 0);
1023    }
1024
1025    #[test]
1026    fn test_dry_run_summary_add_fix() {
1027        let mut summary = DryRunSummary::new();
1028
1029        // Create a diagnostic with a high-confidence safe fix
1030        let diag = Diagnostic {
1031            rule: RuleCode::FST001,
1032            severity: DiagnosticSeverity::Warning,
1033            file: PathBuf::from("test.fst"),
1034            range: super::super::rules::Range::new(1, 1, 5, 1),
1035            message: "Test diagnostic".to_string(),
1036            fix: Some(Fix::safe("Test fix", vec![])),
1037        };
1038
1039        let content = "line1\nline2\nline3\nline4\nline5";
1040        summary.add_fix(&diag, content);
1041        summary.finalize();
1042
1043        assert_eq!(summary.total_fixes, 1);
1044        assert_eq!(summary.safe_fixes, 1);
1045        assert_eq!(summary.review_required, 0);
1046        assert_eq!(summary.files_affected, 1);
1047        assert!(summary.by_rule.contains_key(&RuleCode::FST001));
1048    }
1049
1050    #[test]
1051    fn test_dry_run_summary_counts_unsafe_as_review() {
1052        let mut summary = DryRunSummary::new();
1053
1054        // Create a diagnostic with a medium-confidence fix
1055        let diag = Diagnostic {
1056            rule: RuleCode::FST005,
1057            severity: DiagnosticSeverity::Warning,
1058            file: PathBuf::from("test.fst"),
1059            range: super::super::rules::Range::new(1, 1, 5, 1),
1060            message: "Test diagnostic".to_string(),
1061            fix: Some(Fix::new("Test fix", vec![]).with_confidence(FixConfidence::Medium)),
1062        };
1063
1064        let content = "line1\nline2\nline3\nline4\nline5";
1065        summary.add_fix(&diag, content);
1066        summary.finalize();
1067
1068        assert_eq!(summary.total_fixes, 1);
1069        assert_eq!(summary.safe_fixes, 0);
1070        assert_eq!(summary.review_required, 1);
1071    }
1072
1073    #[test]
1074    fn test_dry_run_header_output() {
1075        let mut output = Vec::new();
1076        print_dry_run_header(&mut output).expect("Failed to write header");
1077        let result = String::from_utf8(output).expect("Invalid UTF-8");
1078
1079        // Should contain the dry-run message
1080        assert!(result.contains("DRY RUN"));
1081        assert!(result.contains("No files will be modified"));
1082        // Should use box drawing characters
1083        assert!(result.contains("\u{2554}")); // ╔
1084        assert!(result.contains("\u{2557}")); // ╗
1085    }
1086
1087    #[test]
1088    fn test_apply_header_output() {
1089        let mut output = Vec::new();
1090        print_apply_header(&mut output).expect("Failed to write header");
1091        let result = String::from_utf8(output).expect("Invalid UTF-8");
1092
1093        // Should contain the apply message
1094        assert!(result.contains("APPLYING CHANGES"));
1095        assert!(result.contains("Files will be modified"));
1096    }
1097
1098    #[test]
1099    fn test_fix_preview_full_output() {
1100        let mut output = Vec::new();
1101        let file = PathBuf::from("/test/path/file.fst");
1102        let preview = create_test_preview(RuleCode::FST001, (10, 15), FixConfidence::High, true);
1103
1104        print_fix_preview_full(&mut output, &file, &preview).expect("Failed to write preview");
1105        let result = String::from_utf8(output).expect("Invalid UTF-8");
1106
1107        // Should contain file path
1108        assert!(result.contains("file.fst"));
1109        // Should contain rule info
1110        assert!(result.contains("FST001"));
1111        // Should contain line info
1112        assert!(result.contains("10"));
1113        assert!(result.contains("15"));
1114        // Should indicate high confidence
1115        assert!(result.contains("HIGH"));
1116    }
1117
1118    #[test]
1119    fn test_fix_preview_concise_output() {
1120        let mut output = Vec::new();
1121        let file = PathBuf::from("/test/path/file.fst");
1122        let preview = create_test_preview(RuleCode::FST004, (5, 5), FixConfidence::High, true);
1123
1124        print_fix_preview_concise(&mut output, &file, &preview).expect("Failed to write preview");
1125        let result = String::from_utf8(output).expect("Invalid UTF-8");
1126
1127        // Should be a single line
1128        assert_eq!(result.lines().count(), 1);
1129        // Should contain file path
1130        assert!(result.contains("file.fst"));
1131        // Should contain rule code
1132        assert!(result.contains("FST004"));
1133        // Should have safe marker *
1134        assert!(result.contains("*"));
1135        // Should have confidence marker [H]
1136        assert!(result.contains("[H]"));
1137    }
1138
1139    #[test]
1140    fn test_fix_preview_concise_unsafe() {
1141        let mut output = Vec::new();
1142        let file = PathBuf::from("/test/path/file.fst");
1143        let preview = create_test_preview(RuleCode::FST005, (5, 5), FixConfidence::Low, false);
1144
1145        print_fix_preview_concise(&mut output, &file, &preview).expect("Failed to write preview");
1146        let result = String::from_utf8(output).expect("Invalid UTF-8");
1147
1148        // Should have unsafe marker !
1149        assert!(result.contains("!"));
1150        // Should have low confidence marker [L]
1151        assert!(result.contains("[L]"));
1152    }
1153
1154    #[test]
1155    fn test_dry_run_summary_output() {
1156        let mut summary = DryRunSummary::new();
1157        summary.files_affected = 5;
1158        summary.total_fixes = 10;
1159        summary.safe_fixes = 7;
1160        summary.review_required = 3;
1161        summary.lines_removed = 50;
1162        summary.lines_added = 30;
1163        summary.by_rule.insert(RuleCode::FST001, 4);
1164        summary.by_rule.insert(RuleCode::FST004, 6);
1165
1166        let mut output = Vec::new();
1167        print_dry_run_summary(&mut output, &summary).expect("Failed to write summary");
1168        let result = String::from_utf8(output).expect("Invalid UTF-8");
1169
1170        // Should contain statistics
1171        assert!(result.contains("5")); // files_affected
1172        assert!(result.contains("10")); // total_fixes
1173        assert!(result.contains("7")); // safe_fixes
1174        assert!(result.contains("3")); // review_required
1175        assert!(result.contains("50")); // lines_removed
1176        assert!(result.contains("30")); // lines_added
1177
1178        // Should contain rule breakdown
1179        assert!(result.contains("FST001"));
1180        assert!(result.contains("FST004"));
1181    }
1182
1183    #[test]
1184    fn test_apply_instructions_output() {
1185        let mut output = Vec::new();
1186        print_apply_instructions(&mut output, "fstar-lsp fix src/ --apply")
1187            .expect("Failed to write instructions");
1188        let result = String::from_utf8(output).expect("Invalid UTF-8");
1189
1190        // Should contain the command hint
1191        assert!(result.contains("fstar-lsp fix src/ --apply"));
1192        assert!(result.contains("To apply these changes"));
1193    }
1194
1195    #[test]
1196    fn test_dry_run_format_enum() {
1197        // Test that all variants can be created
1198        let _concise = DryRunFormat::Concise;
1199        let _full = DryRunFormat::Full;
1200        let _json = DryRunFormat::Json;
1201
1202        // Test default
1203        let default: DryRunFormat = Default::default();
1204        assert!(matches!(default, DryRunFormat::Full));
1205    }
1206
1207    #[test]
1208    fn test_fix_preview_with_no_new_content() {
1209        let preview = FixPreview {
1210            rule: RuleCode::FST001,
1211            message: "Delete duplicate type".to_string(),
1212            line_range: (10, 15),
1213            removed_content: Some("type old = int".to_string()),
1214            new_content: None, // Deletion, no replacement
1215            confidence: FixConfidence::High,
1216            is_safe: true,
1217            unsafe_reason: None,
1218        };
1219
1220        let mut output = Vec::new();
1221        let file = PathBuf::from("test.fst");
1222        print_fix_preview_full(&mut output, &file, &preview).expect("Failed to write preview");
1223        let result = String::from_utf8(output).expect("Invalid UTF-8");
1224
1225        // Should indicate deletion
1226        assert!(result.contains("deleted"));
1227    }
1228
1229    #[test]
1230    fn test_create_fix_preview_helper() {
1231        let diag = Diagnostic {
1232            rule: RuleCode::FST001,
1233            severity: DiagnosticSeverity::Warning,
1234            file: PathBuf::from("test.fst"),
1235            range: super::super::rules::Range::new(5, 1, 10, 1),
1236            message: "Duplicate type".to_string(),
1237            fix: Some(Fix::safe("Remove duplicate", vec![super::super::rules::Edit {
1238                file: PathBuf::from("test.fst"),
1239                range: super::super::rules::Range::new(5, 1, 10, 1),
1240                new_text: "".to_string(),
1241            }])),
1242        };
1243
1244        let content = "line1\nline2\nline3\nline4\ntype x = int\ntype x = int\nline7\nline8\nline9\nline10";
1245        let preview = create_fix_preview(&diag, diag.fix.as_ref().unwrap(), content);
1246
1247        assert_eq!(preview.rule, RuleCode::FST001);
1248        assert_eq!(preview.line_range, (5, 10));
1249        assert!(preview.removed_content.is_some());
1250        assert!(preview.new_content.is_none()); // Empty edit means deletion
1251    }
1252
1253    #[test]
1254    fn test_lines_counting() {
1255        let mut summary = DryRunSummary::new();
1256
1257        let diag = Diagnostic {
1258            rule: RuleCode::FST001,
1259            severity: DiagnosticSeverity::Warning,
1260            file: PathBuf::from("test.fst"),
1261            range: super::super::rules::Range::new(1, 1, 3, 1),
1262            message: "Test".to_string(),
1263            fix: Some(Fix::safe("Fix", vec![super::super::rules::Edit {
1264                file: PathBuf::from("test.fst"),
1265                range: super::super::rules::Range::new(1, 1, 3, 1),
1266                new_text: "new line 1\nnew line 2".to_string(),
1267            }])),
1268        };
1269
1270        let content = "line1\nline2\nline3";
1271        summary.add_fix(&diag, content);
1272
1273        // 3 lines removed
1274        assert_eq!(summary.lines_removed, 3);
1275        // 2 lines added
1276        assert_eq!(summary.lines_added, 2);
1277    }
1278}