Skip to main content

code_baseline/cli/
format.rs

1use crate::config::Severity;
2use crate::rules::Violation;
3use crate::scan::ScanResult;
4use serde_json::json;
5use std::collections::BTreeMap;
6use std::collections::HashMap;
7use std::io::Write;
8use std::path::Path;
9
10/// Print violations grouped by file with ANSI colors.
11pub fn print_pretty(result: &ScanResult) {
12    let mut out = std::io::stdout();
13    write_pretty(result, &mut out);
14}
15
16fn write_pretty(result: &ScanResult, out: &mut dyn Write) {
17    if result.violations.is_empty() {
18        let _ = writeln!(
19            out,
20            "\x1b[32m✓\x1b[0m No violations found ({} files scanned, {} rules loaded)",
21            result.files_scanned, result.rules_loaded
22        );
23        write_ratchet_summary_pretty(&result.ratchet_counts, out);
24        return;
25    }
26
27    // Group violations by file
28    let mut by_file: BTreeMap<String, Vec<&Violation>> = BTreeMap::new();
29    for v in &result.violations {
30        by_file
31            .entry(v.file.display().to_string())
32            .or_default()
33            .push(v);
34    }
35
36    for (file, violations) in &by_file {
37        let _ = writeln!(out, "\n\x1b[4m{}\x1b[0m", file);
38        for v in violations {
39            let severity_str = match v.severity {
40                Severity::Error => "\x1b[31merror\x1b[0m",
41                Severity::Warning => "\x1b[33mwarn \x1b[0m",
42            };
43
44            let location = match (v.line, v.column) {
45                (Some(l), Some(c)) => format!("{}:{}", l, c),
46                (Some(l), None) => format!("{}:1", l),
47                _ => "1:1".to_string(),
48            };
49
50            let _ = writeln!(
51                out,
52                "  \x1b[90m{:<8}\x1b[0m {} \x1b[90m{:<25}\x1b[0m {}",
53                location, severity_str, v.rule_id, v.message
54            );
55
56            if let Some(ref source) = v.source_line {
57                let _ = writeln!(out, "           \x1b[90m│\x1b[0m {}", source.trim());
58            }
59
60            if let Some(ref suggest) = v.suggest {
61                let _ = writeln!(out, "           \x1b[90m└─\x1b[0m \x1b[36m{}\x1b[0m", suggest);
62            }
63        }
64    }
65
66    let errors = result
67        .violations
68        .iter()
69        .filter(|v| v.severity == Severity::Error)
70        .count();
71    let warnings = result
72        .violations
73        .iter()
74        .filter(|v| v.severity == Severity::Warning)
75        .count();
76
77    let _ = writeln!(out);
78    let _ = write!(out, "\x1b[1m");
79    if errors > 0 {
80        let _ = write!(out, "\x1b[31m{} error{}\x1b[0m\x1b[1m", errors, if errors == 1 { "" } else { "s" });
81    }
82    if errors > 0 && warnings > 0 {
83        let _ = write!(out, ", ");
84    }
85    if warnings > 0 {
86        let _ = write!(out, "\x1b[33m{} warning{}\x1b[0m\x1b[1m", warnings, if warnings == 1 { "" } else { "s" });
87    }
88    let _ = writeln!(
89        out,
90        " ({} files scanned, {} rules loaded)\x1b[0m",
91        result.files_scanned, result.rules_loaded
92    );
93
94    write_ratchet_summary_pretty(&result.ratchet_counts, out);
95}
96
97fn write_ratchet_summary_pretty(
98    ratchet_counts: &HashMap<String, (usize, usize)>,
99    out: &mut dyn Write,
100) {
101    if ratchet_counts.is_empty() {
102        return;
103    }
104
105    let _ = writeln!(out, "\n\x1b[1mRatchet rules:\x1b[0m");
106    let mut sorted: Vec<_> = ratchet_counts.iter().collect();
107    sorted.sort_by_key(|(id, _)| (*id).clone());
108
109    for (rule_id, &(found, max)) in &sorted {
110        let status = if found <= max {
111            format!("\x1b[32m✓ pass\x1b[0m ({}/{})", found, max)
112        } else {
113            format!("\x1b[31m✗ OVER\x1b[0m ({}/{})", found, max)
114        };
115        let _ = writeln!(out, "  {:<30} {}", rule_id, status);
116    }
117}
118
119/// Print violations as structured JSON.
120pub fn print_json(result: &ScanResult) {
121    let mut out = std::io::stdout();
122    write_json(result, &mut out);
123}
124
125fn write_json(result: &ScanResult, out: &mut dyn Write) {
126    let violations: Vec<_> = result
127        .violations
128        .iter()
129        .map(|v| {
130            json!({
131                "rule_id": v.rule_id,
132                "severity": match v.severity {
133                    Severity::Error => "error",
134                    Severity::Warning => "warning",
135                },
136                "file": v.file.display().to_string(),
137                "line": v.line,
138                "column": v.column,
139                "message": v.message,
140                "suggest": v.suggest,
141                "source_line": v.source_line,
142                "fix": v.fix.as_ref().map(|f| json!({
143                    "old": f.old,
144                    "new": f.new,
145                })),
146            })
147        })
148        .collect();
149
150    let ratchet: serde_json::Map<String, serde_json::Value> = result
151        .ratchet_counts
152        .iter()
153        .map(|(id, &(found, max))| {
154            (
155                id.clone(),
156                json!({ "found": found, "max": max, "pass": found <= max }),
157            )
158        })
159        .collect();
160
161    let output = json!({
162        "violations": violations,
163        "summary": {
164            "total": result.violations.len(),
165            "errors": result.violations.iter().filter(|v| v.severity == Severity::Error).count(),
166            "warnings": result.violations.iter().filter(|v| v.severity == Severity::Warning).count(),
167            "files_scanned": result.files_scanned,
168            "rules_loaded": result.rules_loaded,
169        },
170        "ratchet": ratchet,
171    });
172
173    let _ = writeln!(out, "{}", serde_json::to_string_pretty(&output).unwrap());
174}
175
176/// Print violations in compact one-line-per-violation format.
177/// Violations go to stdout; summary goes to stderr.
178pub fn print_compact(result: &ScanResult) {
179    let mut stdout = std::io::stdout();
180    let mut stderr = std::io::stderr();
181    write_compact(result, &mut stdout, &mut stderr);
182}
183
184fn write_compact(result: &ScanResult, out: &mut dyn Write, err: &mut dyn Write) {
185    for v in &result.violations {
186        let severity = match v.severity {
187            Severity::Error => "error",
188            Severity::Warning => "warning",
189        };
190        let line = v.line.unwrap_or(1);
191        let col = v.column.unwrap_or(1);
192
193        let _ = writeln!(
194            out,
195            "{}:{}:{}: {}[{}] {}",
196            v.file.display(),
197            line,
198            col,
199            severity,
200            v.rule_id,
201            v.message
202        );
203    }
204
205    write_summary_stderr(result, err);
206    write_ratchet_stderr(&result.ratchet_counts, err);
207}
208
209/// Print violations as GitHub Actions workflow commands.
210/// Violations go to stdout; summary goes to stderr.
211pub fn print_github(result: &ScanResult) {
212    let mut stdout = std::io::stdout();
213    let mut stderr = std::io::stderr();
214    write_github(result, &mut stdout, &mut stderr);
215}
216
217fn write_github(result: &ScanResult, out: &mut dyn Write, err: &mut dyn Write) {
218    for v in &result.violations {
219        let level = match v.severity {
220            Severity::Error => "error",
221            Severity::Warning => "warning",
222        };
223
224        let line = v.line.unwrap_or(1);
225        let mut props = format!("file={},line={}", v.file.display(), line);
226        if let Some(col) = v.column {
227            props.push_str(&format!(",col={}", col));
228        }
229        props.push_str(&format!(",title={}", v.rule_id));
230
231        let _ = writeln!(out, "::{} {}::{}", level, props, v.message);
232    }
233
234    // Ratchet failures as annotations
235    let mut sorted: Vec<_> = result.ratchet_counts.iter().collect();
236    sorted.sort_by_key(|(id, _)| (*id).clone());
237    for (rule_id, &(found, max)) in &sorted {
238        if found > max {
239            let _ = writeln!(
240                out,
241                "::error title=ratchet-{}::Ratchet rule '{}' exceeded budget: {} found, max {}",
242                rule_id, rule_id, found, max
243            );
244        }
245    }
246
247    write_summary_stderr(result, err);
248}
249
250fn write_summary_stderr(result: &ScanResult, err: &mut dyn Write) {
251    let errors = result
252        .violations
253        .iter()
254        .filter(|v| v.severity == Severity::Error)
255        .count();
256    let warnings = result
257        .violations
258        .iter()
259        .filter(|v| v.severity == Severity::Warning)
260        .count();
261
262    if errors > 0 || warnings > 0 {
263        let mut parts = Vec::new();
264        if errors > 0 {
265            parts.push(format!(
266                "{} error{}",
267                errors,
268                if errors == 1 { "" } else { "s" }
269            ));
270        }
271        if warnings > 0 {
272            parts.push(format!(
273                "{} warning{}",
274                warnings,
275                if warnings == 1 { "" } else { "s" }
276            ));
277        }
278        let _ = writeln!(
279            err,
280            "{} ({} files scanned, {} rules loaded)",
281            parts.join(", "),
282            result.files_scanned,
283            result.rules_loaded
284        );
285    } else {
286        let _ = writeln!(
287            err,
288            "No violations found ({} files scanned, {} rules loaded)",
289            result.files_scanned,
290            result.rules_loaded
291        );
292    }
293}
294
295fn write_ratchet_stderr(
296    ratchet_counts: &HashMap<String, (usize, usize)>,
297    err: &mut dyn Write,
298) {
299    if ratchet_counts.is_empty() {
300        return;
301    }
302
303    let mut sorted: Vec<_> = ratchet_counts.iter().collect();
304    sorted.sort_by_key(|(id, _)| (*id).clone());
305
306    for (rule_id, &(found, max)) in &sorted {
307        let status = if found <= max { "pass" } else { "OVER" };
308        let _ = writeln!(err, "ratchet: {} {} ({}/{})", rule_id, status, found, max);
309    }
310}
311
312/// Print violations in SARIF v2.1.0 format for GitHub Code Scanning.
313pub fn print_sarif(result: &ScanResult) {
314    let mut out = std::io::stdout();
315    write_sarif(result, &mut out);
316}
317
318fn write_sarif(result: &ScanResult, out: &mut dyn Write) {
319    // Collect unique rules
320    let mut rule_ids: Vec<String> = result
321        .violations
322        .iter()
323        .map(|v| v.rule_id.clone())
324        .collect::<std::collections::HashSet<_>>()
325        .into_iter()
326        .collect();
327    rule_ids.sort();
328
329    let rule_index: HashMap<&str, usize> = rule_ids
330        .iter()
331        .enumerate()
332        .map(|(i, id)| (id.as_str(), i))
333        .collect();
334
335    let rules: Vec<serde_json::Value> = rule_ids
336        .iter()
337        .map(|id| {
338            json!({
339                "id": id,
340                "shortDescription": { "text": id },
341            })
342        })
343        .collect();
344
345    let results: Vec<serde_json::Value> = result
346        .violations
347        .iter()
348        .map(|v| {
349            let level = match v.severity {
350                Severity::Error => "error",
351                Severity::Warning => "warning",
352            };
353
354            let location = json!({
355                "physicalLocation": {
356                    "artifactLocation": {
357                        "uri": v.file.display().to_string(),
358                    },
359                    "region": {
360                        "startLine": v.line.unwrap_or(1),
361                        "startColumn": v.column.unwrap_or(1),
362                    }
363                }
364            });
365
366            let mut result_obj = json!({
367                "ruleId": v.rule_id,
368                "ruleIndex": rule_index.get(v.rule_id.as_str()).unwrap_or(&0),
369                "level": level,
370                "message": { "text": v.message },
371                "locations": [location],
372            });
373
374            // Add fix if available
375            if let Some(ref fix) = v.fix {
376                result_obj["fixes"] = json!([{
377                    "description": { "text": v.suggest.as_deref().unwrap_or("Apply fix") },
378                    "artifactChanges": [{
379                        "artifactLocation": {
380                            "uri": v.file.display().to_string(),
381                        },
382                        "replacements": [{
383                            "deletedRegion": {
384                                "startLine": v.line.unwrap_or(1),
385                                "startColumn": v.column.unwrap_or(1),
386                            },
387                            "insertedContent": { "text": &fix.new }
388                        }]
389                    }]
390                }]);
391            }
392
393            result_obj
394        })
395        .collect();
396
397    let sarif = json!({
398        "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
399        "version": "2.1.0",
400        "runs": [{
401            "tool": {
402                "driver": {
403                    "name": "baseline",
404                    "version": env!("CARGO_PKG_VERSION"),
405                    "informationUri": "https://github.com/stewartjarod/baseline",
406                    "rules": rules,
407                }
408            },
409            "results": results,
410        }]
411    });
412
413    let _ = writeln!(out, "{}", serde_json::to_string_pretty(&sarif).unwrap());
414}
415
416/// Print violations as a Markdown report (for GitHub PR summaries).
417pub fn print_markdown(result: &ScanResult) {
418    let mut out = std::io::stdout();
419    write_markdown(result, &mut out);
420}
421
422fn write_markdown(result: &ScanResult, out: &mut dyn Write) {
423    let _ = writeln!(out, "## Baseline Report\n");
424
425    let errors = result
426        .violations
427        .iter()
428        .filter(|v| v.severity == Severity::Error)
429        .count();
430    let warnings = result
431        .violations
432        .iter()
433        .filter(|v| v.severity == Severity::Warning)
434        .count();
435
436    // Summary line
437    if errors == 0 && warnings == 0 {
438        let _ = writeln!(out, "\\:white_check_mark: **No violations found** ({} files scanned, {} rules loaded)\n", result.files_scanned, result.rules_loaded);
439    } else {
440        let mut parts = Vec::new();
441        if errors > 0 {
442            parts.push(format!(
443                "{} error{}",
444                errors,
445                if errors == 1 { "" } else { "s" }
446            ));
447        }
448        if warnings > 0 {
449            parts.push(format!(
450                "{} warning{}",
451                warnings,
452                if warnings == 1 { "" } else { "s" }
453            ));
454        }
455        let _ = writeln!(
456            out,
457            "**{}** in {} files ({} rules loaded)\n",
458            parts.join(", "),
459            result.files_scanned,
460            result.rules_loaded
461        );
462    }
463
464    // Changed-only context
465    if let (Some(count), Some(ref base)) = (result.changed_files_count, &result.base_ref) {
466        let _ = writeln!(
467            out,
468            "> Scanned {} changed file{} against `{}`\n",
469            count,
470            if count == 1 { "" } else { "s" },
471            base
472        );
473    }
474
475    if result.violations.is_empty() && result.ratchet_counts.is_empty() {
476        return;
477    }
478
479    // Group by severity then by file
480    let error_violations: Vec<&Violation> = result
481        .violations
482        .iter()
483        .filter(|v| v.severity == Severity::Error)
484        .collect();
485    let warning_violations: Vec<&Violation> = result
486        .violations
487        .iter()
488        .filter(|v| v.severity == Severity::Warning)
489        .collect();
490
491    if !error_violations.is_empty() {
492        write_markdown_severity_section(out, "Errors", &error_violations);
493    }
494    if !warning_violations.is_empty() {
495        write_markdown_severity_section(out, "Warnings", &warning_violations);
496    }
497
498    // Ratchet section
499    if !result.ratchet_counts.is_empty() {
500        let _ = writeln!(out, "### Ratchet Rules\n");
501        let _ = writeln!(out, "| Rule | Status | Count |");
502        let _ = writeln!(out, "|------|--------|-------|");
503
504        let mut sorted: Vec<_> = result.ratchet_counts.iter().collect();
505        sorted.sort_by_key(|(id, _)| (*id).clone());
506
507        for (rule_id, &(found, max)) in &sorted {
508            let status = if found <= max {
509                "\\:white_check_mark: pass"
510            } else {
511                "\\:x: OVER"
512            };
513            let _ = writeln!(out, "| `{}` | {} | {}/{} |", rule_id, status, found, max);
514        }
515        let _ = writeln!(out);
516    }
517}
518
519fn write_markdown_severity_section(out: &mut dyn Write, title: &str, violations: &[&Violation]) {
520    let _ = writeln!(out, "### {}\n", title);
521
522    // Group by file
523    let mut by_file: BTreeMap<String, Vec<&&Violation>> = BTreeMap::new();
524    for v in violations {
525        by_file
526            .entry(v.file.display().to_string())
527            .or_default()
528            .push(v);
529    }
530
531    for (file, file_violations) in &by_file {
532        let _ = writeln!(out, "**`{}`**\n", file);
533        let _ = writeln!(out, "| Line | Rule | Message | Suggestion |");
534        let _ = writeln!(out, "|------|------|---------|------------|");
535
536        for v in file_violations {
537            let line = v.line.map(|l| l.to_string()).unwrap_or_else(|| "-".to_string());
538            let suggest = v.suggest.as_deref().unwrap_or("");
539            let _ = writeln!(
540                out,
541                "| {} | `{}` | {} | {} |",
542                line, v.rule_id, v.message, suggest
543            );
544        }
545        let _ = writeln!(out);
546    }
547}
548
549/// Apply fixes from violations to source files. Returns the number of fixes applied.
550/// Fixes are targeted to the specific line where the violation occurred to avoid
551/// accidentally replacing a different occurrence of the same pattern.
552pub fn apply_fixes(result: &ScanResult, dry_run: bool) -> usize {
553    // Group fixable violations by file, keeping line info for targeted replacement
554    let mut fixes_by_file: BTreeMap<String, Vec<(Option<usize>, &str, &str)>> = BTreeMap::new();
555
556    for v in &result.violations {
557        if let Some(ref fix) = v.fix {
558            fixes_by_file
559                .entry(v.file.display().to_string())
560                .or_default()
561                .push((v.line, &fix.old, &fix.new));
562        }
563    }
564
565    let mut total_applied = 0;
566
567    for (file_path, fixes) in &fixes_by_file {
568        let path = Path::new(file_path);
569        let content = match std::fs::read_to_string(path) {
570            Ok(c) => c,
571            Err(_) => continue,
572        };
573
574        let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
575        // Preserve trailing newline if present
576        let trailing_newline = content.ends_with('\n');
577        let mut applied = 0;
578
579        for (line_num, old, new) in fixes {
580            if let Some(ln) = line_num {
581                // Line-targeted: only replace within the specific line (1-indexed)
582                if *ln > 0 && *ln <= lines.len() {
583                    let line = &lines[*ln - 1];
584                    if line.contains(*old) {
585                        lines[*ln - 1] = line.replacen(*old, *new, 1);
586                        applied += 1;
587                    }
588                }
589            } else {
590                // No line info — fall back to first-occurrence replacement
591                let joined = lines.join("\n");
592                if joined.contains(*old) {
593                    let modified = joined.replacen(*old, *new, 1);
594                    lines = modified.lines().map(|l| l.to_string()).collect();
595                    applied += 1;
596                }
597            }
598        }
599
600        if applied > 0 && !dry_run {
601            let mut modified = lines.join("\n");
602            if trailing_newline {
603                modified.push('\n');
604            }
605            if let Err(e) = std::fs::write(path, &modified) {
606                eprintln!(
607                    "\x1b[31merror\x1b[0m: failed to write {}: {}",
608                    file_path, e
609                );
610                continue;
611            }
612        }
613
614        total_applied += applied;
615    }
616
617    total_applied
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use crate::config::Severity;
624    use std::path::PathBuf;
625
626    fn make_result(violations: Vec<Violation>) -> ScanResult {
627        ScanResult {
628            violations,
629            files_scanned: 5,
630            rules_loaded: 2,
631            ratchet_counts: HashMap::new(),
632            changed_files_count: None,
633            base_ref: None,
634        }
635    }
636
637    fn make_violation(
638        file: &str,
639        line: usize,
640        col: usize,
641        severity: Severity,
642        rule_id: &str,
643        message: &str,
644    ) -> Violation {
645        Violation {
646            rule_id: rule_id.to_string(),
647            severity,
648            file: PathBuf::from(file),
649            line: Some(line),
650            column: Some(col),
651            message: message.to_string(),
652            suggest: None,
653            source_line: None,
654            fix: None,
655        }
656    }
657
658    #[test]
659    fn compact_single_error() {
660        let result = make_result(vec![make_violation(
661            "src/Foo.tsx",
662            12,
663            24,
664            Severity::Error,
665            "dark-mode",
666            "bg-white missing dark variant",
667        )]);
668        let mut out = Vec::new();
669        let mut err = Vec::new();
670        write_compact(&result, &mut out, &mut err);
671
672        let stdout = String::from_utf8(out).unwrap();
673        assert_eq!(
674            stdout,
675            "src/Foo.tsx:12:24: error[dark-mode] bg-white missing dark variant\n"
676        );
677    }
678
679    #[test]
680    fn compact_mixed_severities() {
681        let result = make_result(vec![
682            make_violation("a.ts", 1, 1, Severity::Error, "r1", "err msg"),
683            make_violation("b.ts", 5, 10, Severity::Warning, "r2", "warn msg"),
684        ]);
685        let mut out = Vec::new();
686        let mut err = Vec::new();
687        write_compact(&result, &mut out, &mut err);
688
689        let stdout = String::from_utf8(out).unwrap();
690        assert!(stdout.contains("a.ts:1:1: error[r1] err msg\n"));
691        assert!(stdout.contains("b.ts:5:10: warning[r2] warn msg\n"));
692
693        let stderr = String::from_utf8(err).unwrap();
694        assert!(stderr.contains("1 error, 1 warning"));
695    }
696
697    #[test]
698    fn compact_no_violations() {
699        let result = make_result(vec![]);
700        let mut out = Vec::new();
701        let mut err = Vec::new();
702        write_compact(&result, &mut out, &mut err);
703
704        let stdout = String::from_utf8(out).unwrap();
705        assert!(stdout.is_empty());
706
707        let stderr = String::from_utf8(err).unwrap();
708        assert!(stderr.contains("No violations found"));
709    }
710
711    #[test]
712    fn compact_ratchet_on_stderr() {
713        let mut result = make_result(vec![]);
714        result
715            .ratchet_counts
716            .insert("legacy-api".to_string(), (3, 5));
717        let mut out = Vec::new();
718        let mut err = Vec::new();
719        write_compact(&result, &mut out, &mut err);
720
721        let stderr = String::from_utf8(err).unwrap();
722        assert!(stderr.contains("ratchet: legacy-api pass (3/5)"));
723    }
724
725    #[test]
726    fn github_single_warning() {
727        let result = make_result(vec![make_violation(
728            "src/Foo.tsx",
729            15,
730            8,
731            Severity::Warning,
732            "theme-tokens",
733            "raw color class",
734        )]);
735        let mut out = Vec::new();
736        let mut err = Vec::new();
737        write_github(&result, &mut out, &mut err);
738
739        let stdout = String::from_utf8(out).unwrap();
740        assert_eq!(
741            stdout,
742            "::warning file=src/Foo.tsx,line=15,col=8,title=theme-tokens::raw color class\n"
743        );
744    }
745
746    #[test]
747    fn github_missing_column_omits_col() {
748        let v = Violation {
749            rule_id: "test".to_string(),
750            severity: Severity::Error,
751            file: PathBuf::from("a.ts"),
752            line: Some(3),
753            column: None,
754            message: "msg".to_string(),
755            suggest: None,
756            source_line: None,
757            fix: None,
758        };
759        let result = make_result(vec![v]);
760        let mut out = Vec::new();
761        let mut err = Vec::new();
762        write_github(&result, &mut out, &mut err);
763
764        let stdout = String::from_utf8(out).unwrap();
765        assert_eq!(stdout, "::error file=a.ts,line=3,title=test::msg\n");
766        assert!(!stdout.contains("col="));
767    }
768
769    #[test]
770    fn github_ratchet_over_budget() {
771        let mut result = make_result(vec![]);
772        result
773            .ratchet_counts
774            .insert("legacy-api".to_string(), (10, 5));
775        let mut out = Vec::new();
776        let mut err = Vec::new();
777        write_github(&result, &mut out, &mut err);
778
779        let stdout = String::from_utf8(out).unwrap();
780        assert!(stdout.contains("::error title=ratchet-legacy-api"));
781        assert!(stdout.contains("10 found, max 5"));
782    }
783
784    #[test]
785    fn github_ratchet_pass_is_silent() {
786        let mut result = make_result(vec![]);
787        result
788            .ratchet_counts
789            .insert("legacy-api".to_string(), (3, 5));
790        let mut out = Vec::new();
791        let mut err = Vec::new();
792        write_github(&result, &mut out, &mut err);
793
794        let stdout = String::from_utf8(out).unwrap();
795        assert!(stdout.is_empty());
796    }
797
798    // ── write_markdown tests ──
799
800    #[test]
801    fn markdown_no_violations() {
802        let result = make_result(vec![]);
803        let mut out = Vec::new();
804        write_markdown(&result, &mut out);
805
806        let output = String::from_utf8(out).unwrap();
807        assert!(output.contains("## Baseline Report"));
808        assert!(output.contains("No violations found"));
809        assert!(output.contains("5 files scanned"));
810    }
811
812    #[test]
813    fn markdown_errors_and_warnings() {
814        let result = make_result(vec![
815            make_violation("src/a.tsx", 10, 5, Severity::Error, "dark-mode", "missing dark variant"),
816            make_violation("src/a.tsx", 20, 1, Severity::Warning, "theme-tokens", "raw color"),
817            make_violation("src/b.tsx", 3, 1, Severity::Error, "dark-mode", "missing dark variant"),
818        ]);
819        let mut out = Vec::new();
820        write_markdown(&result, &mut out);
821
822        let output = String::from_utf8(out).unwrap();
823        assert!(output.contains("## Baseline Report"));
824        assert!(output.contains("2 errors, 1 warning"));
825        assert!(output.contains("### Errors"));
826        assert!(output.contains("### Warnings"));
827        assert!(output.contains("`src/a.tsx`"));
828        assert!(output.contains("`src/b.tsx`"));
829        assert!(output.contains("| Line | Rule | Message | Suggestion |"));
830    }
831
832    #[test]
833    fn markdown_with_ratchet() {
834        let mut result = make_result(vec![]);
835        result
836            .ratchet_counts
837            .insert("legacy-api".to_string(), (3, 5));
838        result
839            .ratchet_counts
840            .insert("old-pattern".to_string(), (10, 5));
841        let mut out = Vec::new();
842        write_markdown(&result, &mut out);
843
844        let output = String::from_utf8(out).unwrap();
845        assert!(output.contains("### Ratchet Rules"));
846        assert!(output.contains("| Rule | Status | Count |"));
847        assert!(output.contains("`legacy-api`"));
848        assert!(output.contains("pass"));
849        assert!(output.contains("`old-pattern`"));
850        assert!(output.contains("OVER"));
851    }
852
853    #[test]
854    fn markdown_with_changed_only_context() {
855        let mut result = make_result(vec![
856            make_violation("src/a.tsx", 1, 1, Severity::Error, "r1", "msg"),
857        ]);
858        result.changed_files_count = Some(3);
859        result.base_ref = Some("main".into());
860        let mut out = Vec::new();
861        write_markdown(&result, &mut out);
862
863        let output = String::from_utf8(out).unwrap();
864        assert!(output.contains("Scanned 3 changed files against `main`"));
865    }
866
867    #[test]
868    fn markdown_single_changed_file() {
869        let mut result = make_result(vec![]);
870        result.changed_files_count = Some(1);
871        result.base_ref = Some("develop".into());
872        let mut out = Vec::new();
873        write_markdown(&result, &mut out);
874
875        let output = String::from_utf8(out).unwrap();
876        assert!(output.contains("Scanned 1 changed file against `develop`"));
877    }
878
879    #[test]
880    fn markdown_violation_with_suggestion() {
881        let mut v = make_violation("src/a.tsx", 5, 1, Severity::Warning, "theme-tokens", "raw color");
882        v.suggest = Some("Use bg-background instead".into());
883        let result = make_result(vec![v]);
884        let mut out = Vec::new();
885        write_markdown(&result, &mut out);
886
887        let output = String::from_utf8(out).unwrap();
888        assert!(output.contains("Use bg-background instead"));
889    }
890
891    #[test]
892    fn markdown_violation_no_line_number() {
893        let v = Violation {
894            rule_id: "has-readme".into(),
895            severity: Severity::Error,
896            file: PathBuf::from("project"),
897            line: None,
898            column: None,
899            message: "README.md missing".into(),
900            suggest: None,
901            source_line: None,
902            fix: None,
903        };
904        let result = make_result(vec![v]);
905        let mut out = Vec::new();
906        write_markdown(&result, &mut out);
907
908        let output = String::from_utf8(out).unwrap();
909        // No line number should show "-"
910        assert!(output.contains("| - |"));
911    }
912
913    // ── write_summary_stderr tests ──
914
915    #[test]
916    fn summary_stderr_errors_only() {
917        let result = make_result(vec![
918            make_violation("a.ts", 1, 1, Severity::Error, "r1", "e1"),
919            make_violation("a.ts", 2, 1, Severity::Error, "r2", "e2"),
920        ]);
921        let mut err = Vec::new();
922        write_summary_stderr(&result, &mut err);
923
924        let stderr = String::from_utf8(err).unwrap();
925        assert!(stderr.contains("2 errors"));
926        assert!(!stderr.contains("warning"));
927    }
928
929    #[test]
930    fn summary_stderr_warnings_only() {
931        let result = make_result(vec![
932            make_violation("a.ts", 1, 1, Severity::Warning, "r1", "w1"),
933        ]);
934        let mut err = Vec::new();
935        write_summary_stderr(&result, &mut err);
936
937        let stderr = String::from_utf8(err).unwrap();
938        assert!(stderr.contains("1 warning"));
939        assert!(!stderr.contains("error"));
940    }
941
942    #[test]
943    fn summary_stderr_plural_errors_and_warnings() {
944        let result = make_result(vec![
945            make_violation("a.ts", 1, 1, Severity::Error, "r1", "e1"),
946            make_violation("a.ts", 2, 1, Severity::Error, "r2", "e2"),
947            make_violation("a.ts", 3, 1, Severity::Warning, "r3", "w1"),
948            make_violation("a.ts", 4, 1, Severity::Warning, "r4", "w2"),
949            make_violation("a.ts", 5, 1, Severity::Warning, "r5", "w3"),
950        ]);
951        let mut err = Vec::new();
952        write_summary_stderr(&result, &mut err);
953
954        let stderr = String::from_utf8(err).unwrap();
955        assert!(stderr.contains("2 errors"));
956        assert!(stderr.contains("3 warnings"));
957    }
958
959    #[test]
960    fn summary_stderr_no_violations() {
961        let result = make_result(vec![]);
962        let mut err = Vec::new();
963        write_summary_stderr(&result, &mut err);
964
965        let stderr = String::from_utf8(err).unwrap();
966        assert!(stderr.contains("No violations found"));
967    }
968
969    // ── write_ratchet_stderr tests ──
970
971    #[test]
972    fn ratchet_stderr_empty() {
973        let counts = HashMap::new();
974        let mut err = Vec::new();
975        write_ratchet_stderr(&counts, &mut err);
976
977        let stderr = String::from_utf8(err).unwrap();
978        assert!(stderr.is_empty());
979    }
980
981    #[test]
982    fn ratchet_stderr_pass_and_over() {
983        let mut counts = HashMap::new();
984        counts.insert("a-rule".to_string(), (2usize, 5usize));
985        counts.insert("b-rule".to_string(), (10, 3));
986        let mut err = Vec::new();
987        write_ratchet_stderr(&counts, &mut err);
988
989        let stderr = String::from_utf8(err).unwrap();
990        assert!(stderr.contains("ratchet: a-rule pass (2/5)"));
991        assert!(stderr.contains("ratchet: b-rule OVER (10/3)"));
992    }
993
994    // ── compact with missing line/column ──
995
996    #[test]
997    fn compact_missing_line_defaults_to_1() {
998        let v = Violation {
999            rule_id: "test".to_string(),
1000            severity: Severity::Error,
1001            file: PathBuf::from("a.ts"),
1002            line: None,
1003            column: None,
1004            message: "msg".to_string(),
1005            suggest: None,
1006            source_line: None,
1007            fix: None,
1008        };
1009        let result = make_result(vec![v]);
1010        let mut out = Vec::new();
1011        let mut err = Vec::new();
1012        write_compact(&result, &mut out, &mut err);
1013
1014        let stdout = String::from_utf8(out).unwrap();
1015        assert!(stdout.contains("a.ts:1:1: error[test] msg"));
1016    }
1017
1018    // ── github with missing line ──
1019
1020    #[test]
1021    fn github_missing_line_defaults_to_1() {
1022        let v = Violation {
1023            rule_id: "test".to_string(),
1024            severity: Severity::Warning,
1025            file: PathBuf::from("b.ts"),
1026            line: None,
1027            column: None,
1028            message: "msg".to_string(),
1029            suggest: None,
1030            source_line: None,
1031            fix: None,
1032        };
1033        let result = make_result(vec![v]);
1034        let mut out = Vec::new();
1035        let mut err = Vec::new();
1036        write_github(&result, &mut out, &mut err);
1037
1038        let stdout = String::from_utf8(out).unwrap();
1039        assert!(stdout.contains("line=1"));
1040        assert!(!stdout.contains("col="));
1041    }
1042
1043    // ── apply_fixes tests ──
1044
1045    #[test]
1046    fn apply_fixes_line_targeted() {
1047        let dir = tempfile::tempdir().unwrap();
1048        let file = dir.path().join("test.tsx");
1049        std::fs::write(&file, "let a = bg-white;\nlet b = bg-white;\n").unwrap();
1050
1051        let result = ScanResult {
1052            violations: vec![Violation {
1053                rule_id: "theme".into(),
1054                severity: Severity::Warning,
1055                file: file.clone(),
1056                line: Some(1),
1057                column: Some(9),
1058                message: "raw color".into(),
1059                suggest: Some("Use bg-background".into()),
1060                source_line: None,
1061                fix: Some(crate::rules::Fix {
1062                    old: "bg-white".into(),
1063                    new: "bg-background".into(),
1064                }),
1065            }],
1066            files_scanned: 1,
1067            rules_loaded: 1,
1068            ratchet_counts: HashMap::new(),
1069            changed_files_count: None,
1070            base_ref: None,
1071        };
1072
1073        let count = apply_fixes(&result, false);
1074        assert_eq!(count, 1);
1075
1076        let content = std::fs::read_to_string(&file).unwrap();
1077        // Only line 1 should be fixed
1078        assert!(content.starts_with("let a = bg-background;"));
1079        assert!(content.contains("let b = bg-white;"));
1080    }
1081
1082    #[test]
1083    fn apply_fixes_no_line_fallback() {
1084        let dir = tempfile::tempdir().unwrap();
1085        let file = dir.path().join("test.tsx");
1086        std::fs::write(&file, "bg-white is used here\n").unwrap();
1087
1088        let result = ScanResult {
1089            violations: vec![Violation {
1090                rule_id: "theme".into(),
1091                severity: Severity::Warning,
1092                file: file.clone(),
1093                line: None,
1094                column: None,
1095                message: "raw color".into(),
1096                suggest: None,
1097                source_line: None,
1098                fix: Some(crate::rules::Fix {
1099                    old: "bg-white".into(),
1100                    new: "bg-background".into(),
1101                }),
1102            }],
1103            files_scanned: 1,
1104            rules_loaded: 1,
1105            ratchet_counts: HashMap::new(),
1106            changed_files_count: None,
1107            base_ref: None,
1108        };
1109
1110        let count = apply_fixes(&result, false);
1111        assert_eq!(count, 1);
1112
1113        let content = std::fs::read_to_string(&file).unwrap();
1114        assert!(content.contains("bg-background"));
1115    }
1116
1117    #[test]
1118    fn apply_fixes_dry_run_no_write() {
1119        let dir = tempfile::tempdir().unwrap();
1120        let file = dir.path().join("test.tsx");
1121        std::fs::write(&file, "bg-white\n").unwrap();
1122
1123        let result = ScanResult {
1124            violations: vec![Violation {
1125                rule_id: "theme".into(),
1126                severity: Severity::Warning,
1127                file: file.clone(),
1128                line: Some(1),
1129                column: Some(1),
1130                message: "raw color".into(),
1131                suggest: None,
1132                source_line: None,
1133                fix: Some(crate::rules::Fix {
1134                    old: "bg-white".into(),
1135                    new: "bg-background".into(),
1136                }),
1137            }],
1138            files_scanned: 1,
1139            rules_loaded: 1,
1140            ratchet_counts: HashMap::new(),
1141            changed_files_count: None,
1142            base_ref: None,
1143        };
1144
1145        let count = apply_fixes(&result, true);
1146        assert_eq!(count, 1);
1147
1148        // File should not be modified
1149        let content = std::fs::read_to_string(&file).unwrap();
1150        assert!(content.contains("bg-white"));
1151    }
1152
1153    #[test]
1154    fn apply_fixes_no_fixable_violations() {
1155        let result = make_result(vec![
1156            make_violation("a.ts", 1, 1, Severity::Error, "r1", "msg"),
1157        ]);
1158        let count = apply_fixes(&result, false);
1159        assert_eq!(count, 0);
1160    }
1161
1162    #[test]
1163    fn apply_fixes_preserves_trailing_newline() {
1164        let dir = tempfile::tempdir().unwrap();
1165        let file = dir.path().join("test.tsx");
1166        std::fs::write(&file, "bg-white\n").unwrap();
1167
1168        let result = ScanResult {
1169            violations: vec![Violation {
1170                rule_id: "theme".into(),
1171                severity: Severity::Warning,
1172                file: file.clone(),
1173                line: Some(1),
1174                column: Some(1),
1175                message: "raw color".into(),
1176                suggest: None,
1177                source_line: None,
1178                fix: Some(crate::rules::Fix {
1179                    old: "bg-white".into(),
1180                    new: "bg-background".into(),
1181                }),
1182            }],
1183            files_scanned: 1,
1184            rules_loaded: 1,
1185            ratchet_counts: HashMap::new(),
1186            changed_files_count: None,
1187            base_ref: None,
1188        };
1189
1190        apply_fixes(&result, false);
1191        let content = std::fs::read_to_string(&file).unwrap();
1192        assert!(content.ends_with('\n'));
1193    }
1194
1195    #[test]
1196    fn apply_fixes_nonexistent_file_skipped() {
1197        let result = ScanResult {
1198            violations: vec![Violation {
1199                rule_id: "theme".into(),
1200                severity: Severity::Warning,
1201                file: PathBuf::from("/nonexistent/file.tsx"),
1202                line: Some(1),
1203                column: Some(1),
1204                message: "msg".into(),
1205                suggest: None,
1206                source_line: None,
1207                fix: Some(crate::rules::Fix {
1208                    old: "old".into(),
1209                    new: "new".into(),
1210                }),
1211            }],
1212            files_scanned: 1,
1213            rules_loaded: 1,
1214            ratchet_counts: HashMap::new(),
1215            changed_files_count: None,
1216            base_ref: None,
1217        };
1218
1219        let count = apply_fixes(&result, false);
1220        assert_eq!(count, 0);
1221    }
1222
1223    // ── write_json tests ──
1224
1225    #[test]
1226    fn json_with_violations_and_ratchet() {
1227        let mut v = make_violation("src/a.tsx", 10, 5, Severity::Error, "dark-mode", "missing dark");
1228        v.suggest = Some("add dark variant".into());
1229        v.source_line = Some("  <div className=\"bg-white\">".into());
1230        v.fix = Some(crate::rules::Fix {
1231            old: "bg-white".into(),
1232            new: "bg-background".into(),
1233        });
1234
1235        let mut result = make_result(vec![v]);
1236        result.ratchet_counts.insert("legacy".into(), (2, 5));
1237
1238        let mut out = Vec::new();
1239        write_json(&result, &mut out);
1240
1241        let output = String::from_utf8(out).unwrap();
1242        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
1243
1244        assert_eq!(parsed["summary"]["total"], 1);
1245        assert_eq!(parsed["summary"]["errors"], 1);
1246        assert_eq!(parsed["summary"]["warnings"], 0);
1247        assert_eq!(parsed["summary"]["files_scanned"], 5);
1248        assert_eq!(parsed["summary"]["rules_loaded"], 2);
1249        assert_eq!(parsed["violations"][0]["rule_id"], "dark-mode");
1250        assert_eq!(parsed["violations"][0]["severity"], "error");
1251        assert_eq!(parsed["violations"][0]["suggest"], "add dark variant");
1252        assert_eq!(parsed["violations"][0]["fix"]["old"], "bg-white");
1253        assert_eq!(parsed["violations"][0]["fix"]["new"], "bg-background");
1254        assert!(parsed["ratchet"]["legacy"]["pass"].as_bool().unwrap());
1255        assert_eq!(parsed["ratchet"]["legacy"]["found"], 2);
1256        assert_eq!(parsed["ratchet"]["legacy"]["max"], 5);
1257    }
1258
1259    #[test]
1260    fn json_empty_violations() {
1261        let result = make_result(vec![]);
1262        let mut out = Vec::new();
1263        write_json(&result, &mut out);
1264
1265        let output = String::from_utf8(out).unwrap();
1266        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
1267
1268        assert_eq!(parsed["summary"]["total"], 0);
1269        assert!(parsed["violations"].as_array().unwrap().is_empty());
1270    }
1271
1272    #[test]
1273    fn json_warning_severity() {
1274        let result = make_result(vec![
1275            make_violation("a.ts", 1, 1, Severity::Warning, "r1", "warn msg"),
1276        ]);
1277        let mut out = Vec::new();
1278        write_json(&result, &mut out);
1279
1280        let output = String::from_utf8(out).unwrap();
1281        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
1282
1283        assert_eq!(parsed["violations"][0]["severity"], "warning");
1284        assert_eq!(parsed["summary"]["warnings"], 1);
1285    }
1286
1287    #[test]
1288    fn json_violation_without_fix() {
1289        let result = make_result(vec![
1290            make_violation("a.ts", 1, 1, Severity::Error, "r1", "msg"),
1291        ]);
1292        let mut out = Vec::new();
1293        write_json(&result, &mut out);
1294
1295        let output = String::from_utf8(out).unwrap();
1296        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
1297
1298        assert!(parsed["violations"][0]["fix"].is_null());
1299    }
1300
1301    // ── write_sarif tests ──
1302
1303    #[test]
1304    fn sarif_full_output() {
1305        let mut v = make_violation("src/a.tsx", 10, 5, Severity::Error, "dark-mode", "missing dark");
1306        v.fix = Some(crate::rules::Fix {
1307            old: "bg-white".into(),
1308            new: "bg-background".into(),
1309        });
1310        v.suggest = Some("Use bg-background".into());
1311
1312        let result = make_result(vec![
1313            v,
1314            make_violation("src/b.tsx", 3, 1, Severity::Warning, "theme-tokens", "raw color"),
1315        ]);
1316
1317        let mut out = Vec::new();
1318        write_sarif(&result, &mut out);
1319
1320        let output = String::from_utf8(out).unwrap();
1321        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
1322
1323        assert_eq!(parsed["version"], "2.1.0");
1324        assert_eq!(parsed["runs"][0]["tool"]["driver"]["name"], "baseline");
1325
1326        // Rules should be sorted and deduplicated
1327        let rules = parsed["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
1328        assert_eq!(rules.len(), 2);
1329        assert_eq!(rules[0]["id"], "dark-mode");
1330        assert_eq!(rules[1]["id"], "theme-tokens");
1331
1332        // Results should have all violations
1333        let results = parsed["runs"][0]["results"].as_array().unwrap();
1334        assert_eq!(results.len(), 2);
1335        assert_eq!(results[0]["level"], "error");
1336        assert_eq!(results[1]["level"], "warning");
1337
1338        // First result should have fix
1339        assert!(results[0]["fixes"].is_array());
1340        assert_eq!(results[0]["fixes"][0]["artifactChanges"][0]["replacements"][0]["insertedContent"]["text"], "bg-background");
1341        assert_eq!(results[0]["fixes"][0]["description"]["text"], "Use bg-background");
1342
1343        // Second result should not have fixes key set
1344        assert!(results[1].get("fixes").is_none());
1345    }
1346
1347    #[test]
1348    fn sarif_empty_violations() {
1349        let result = make_result(vec![]);
1350        let mut out = Vec::new();
1351        write_sarif(&result, &mut out);
1352
1353        let output = String::from_utf8(out).unwrap();
1354        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
1355
1356        assert!(parsed["runs"][0]["results"].as_array().unwrap().is_empty());
1357        assert!(parsed["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap().is_empty());
1358    }
1359
1360    #[test]
1361    fn sarif_fix_without_suggest_uses_default() {
1362        let mut v = make_violation("a.tsx", 1, 1, Severity::Error, "r1", "msg");
1363        v.fix = Some(crate::rules::Fix {
1364            old: "old".into(),
1365            new: "new".into(),
1366        });
1367        // v.suggest is None
1368
1369        let result = make_result(vec![v]);
1370        let mut out = Vec::new();
1371        write_sarif(&result, &mut out);
1372
1373        let output = String::from_utf8(out).unwrap();
1374        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
1375
1376        assert_eq!(
1377            parsed["runs"][0]["results"][0]["fixes"][0]["description"]["text"],
1378            "Apply fix"
1379        );
1380    }
1381
1382    #[test]
1383    fn sarif_missing_line_col_defaults_to_1() {
1384        let v = Violation {
1385            rule_id: "r1".into(),
1386            severity: Severity::Error,
1387            file: PathBuf::from("a.tsx"),
1388            line: None,
1389            column: None,
1390            message: "msg".into(),
1391            suggest: None,
1392            source_line: None,
1393            fix: None,
1394        };
1395        let result = make_result(vec![v]);
1396        let mut out = Vec::new();
1397        write_sarif(&result, &mut out);
1398
1399        let output = String::from_utf8(out).unwrap();
1400        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
1401
1402        let region = &parsed["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"];
1403        assert_eq!(region["startLine"], 1);
1404        assert_eq!(region["startColumn"], 1);
1405    }
1406
1407    // ── compact with ratchet OVER ──
1408
1409    #[test]
1410    fn compact_ratchet_over_on_stderr() {
1411        let mut result = make_result(vec![]);
1412        result
1413            .ratchet_counts
1414            .insert("legacy-api".to_string(), (10, 5));
1415        let mut out = Vec::new();
1416        let mut err = Vec::new();
1417        write_compact(&result, &mut out, &mut err);
1418
1419        let stderr = String::from_utf8(err).unwrap();
1420        assert!(stderr.contains("ratchet: legacy-api OVER (10/5)"));
1421    }
1422
1423    // ── github with multiple violations ──
1424
1425    #[test]
1426    fn github_multiple_violations() {
1427        let result = make_result(vec![
1428            make_violation("a.ts", 1, 1, Severity::Error, "r1", "e1"),
1429            make_violation("b.ts", 5, 10, Severity::Warning, "r2", "w1"),
1430        ]);
1431        let mut out = Vec::new();
1432        let mut err = Vec::new();
1433        write_github(&result, &mut out, &mut err);
1434
1435        let stdout = String::from_utf8(out).unwrap();
1436        assert!(stdout.contains("::error file=a.ts,line=1,col=1,title=r1::e1"));
1437        assert!(stdout.contains("::warning file=b.ts,line=5,col=10,title=r2::w1"));
1438
1439        let stderr = String::from_utf8(err).unwrap();
1440        assert!(stderr.contains("1 error, 1 warning"));
1441    }
1442
1443    // ── markdown with only errors, no warnings ──
1444
1445    #[test]
1446    fn markdown_errors_only() {
1447        let result = make_result(vec![
1448            make_violation("src/a.tsx", 1, 1, Severity::Error, "r1", "err"),
1449        ]);
1450        let mut out = Vec::new();
1451        write_markdown(&result, &mut out);
1452
1453        let output = String::from_utf8(out).unwrap();
1454        assert!(output.contains("1 error"));
1455        assert!(!output.contains("warning"));
1456        assert!(output.contains("### Errors"));
1457        assert!(!output.contains("### Warnings"));
1458    }
1459
1460    // ── markdown with only warnings, no errors ──
1461
1462    #[test]
1463    fn markdown_warnings_only() {
1464        let result = make_result(vec![
1465            make_violation("src/a.tsx", 1, 1, Severity::Warning, "r1", "warn"),
1466        ]);
1467        let mut out = Vec::new();
1468        write_markdown(&result, &mut out);
1469
1470        let output = String::from_utf8(out).unwrap();
1471        assert!(output.contains("1 warning"));
1472        assert!(!output.contains("error"));
1473        assert!(!output.contains("### Errors"));
1474        assert!(output.contains("### Warnings"));
1475    }
1476
1477    // ── markdown ratchet only (no violations) ──
1478
1479    #[test]
1480    fn markdown_ratchet_only_no_violations() {
1481        let mut result = make_result(vec![]);
1482        result.ratchet_counts.insert("r1".into(), (1, 5));
1483        let mut out = Vec::new();
1484        write_markdown(&result, &mut out);
1485
1486        let output = String::from_utf8(out).unwrap();
1487        assert!(output.contains("No violations found"));
1488        assert!(output.contains("### Ratchet Rules"));
1489    }
1490
1491    // ── summary stderr: singular error and warning ──
1492
1493    #[test]
1494    fn summary_stderr_singular_counts() {
1495        let result = make_result(vec![
1496            make_violation("a.ts", 1, 1, Severity::Error, "r1", "e"),
1497            make_violation("a.ts", 2, 1, Severity::Warning, "r2", "w"),
1498        ]);
1499        let mut err = Vec::new();
1500        write_summary_stderr(&result, &mut err);
1501
1502        let stderr = String::from_utf8(err).unwrap();
1503        // Should say "1 error" not "1 errors"
1504        assert!(stderr.contains("1 error,"));
1505        assert!(stderr.contains("1 warning"));
1506        assert!(!stderr.contains("errors"));
1507        assert!(!stderr.contains("warnings"));
1508    }
1509
1510    // ── apply_fixes with multiple fixes in same file ──
1511
1512    #[test]
1513    fn apply_fixes_multiple_in_same_file() {
1514        let dir = tempfile::tempdir().unwrap();
1515        let file = dir.path().join("test.tsx");
1516        std::fs::write(&file, "bg-white text-gray-900\nbg-white text-gray-500\n").unwrap();
1517
1518        let result = ScanResult {
1519            violations: vec![
1520                Violation {
1521                    rule_id: "theme".into(),
1522                    severity: Severity::Warning,
1523                    file: file.clone(),
1524                    line: Some(1),
1525                    column: Some(1),
1526                    message: "raw color".into(),
1527                    suggest: None,
1528                    source_line: None,
1529                    fix: Some(crate::rules::Fix {
1530                        old: "bg-white".into(),
1531                        new: "bg-background".into(),
1532                    }),
1533                },
1534                Violation {
1535                    rule_id: "theme".into(),
1536                    severity: Severity::Warning,
1537                    file: file.clone(),
1538                    line: Some(2),
1539                    column: Some(1),
1540                    message: "raw color".into(),
1541                    suggest: None,
1542                    source_line: None,
1543                    fix: Some(crate::rules::Fix {
1544                        old: "bg-white".into(),
1545                        new: "bg-background".into(),
1546                    }),
1547                },
1548            ],
1549            files_scanned: 1,
1550            rules_loaded: 1,
1551            ratchet_counts: HashMap::new(),
1552            changed_files_count: None,
1553            base_ref: None,
1554        };
1555
1556        let count = apply_fixes(&result, false);
1557        assert_eq!(count, 2);
1558
1559        let content = std::fs::read_to_string(&file).unwrap();
1560        assert!(!content.contains("bg-white"));
1561        assert_eq!(content.matches("bg-background").count(), 2);
1562    }
1563
1564    // ── write_pretty tests ──
1565
1566    #[test]
1567    fn pretty_no_violations() {
1568        let result = make_result(vec![]);
1569        let mut out = Vec::new();
1570        write_pretty(&result, &mut out);
1571
1572        let output = String::from_utf8(out).unwrap();
1573        assert!(output.contains("No violations found"));
1574        assert!(output.contains("5 files scanned"));
1575        assert!(output.contains("2 rules loaded"));
1576    }
1577
1578    #[test]
1579    fn pretty_with_error_and_warning() {
1580        let result = make_result(vec![
1581            make_violation("src/a.tsx", 10, 5, Severity::Error, "dark-mode", "missing dark variant"),
1582            make_violation("src/a.tsx", 20, 1, Severity::Warning, "theme-tokens", "raw color"),
1583        ]);
1584        let mut out = Vec::new();
1585        write_pretty(&result, &mut out);
1586
1587        let output = String::from_utf8(out).unwrap();
1588        assert!(output.contains("src/a.tsx"));
1589        assert!(output.contains("10:5"));
1590        assert!(output.contains("20:1"));
1591        assert!(output.contains("error"));
1592        assert!(output.contains("warn"));
1593        assert!(output.contains("1 error"));
1594        assert!(output.contains("1 warning"));
1595    }
1596
1597    #[test]
1598    fn pretty_errors_only_no_warning_count() {
1599        let result = make_result(vec![
1600            make_violation("a.ts", 1, 1, Severity::Error, "r1", "e1"),
1601        ]);
1602        let mut out = Vec::new();
1603        write_pretty(&result, &mut out);
1604
1605        let output = String::from_utf8(out).unwrap();
1606        assert!(output.contains("1 error"));
1607        assert!(!output.contains("warning"));
1608    }
1609
1610    #[test]
1611    fn pretty_warnings_only() {
1612        let result = make_result(vec![
1613            make_violation("a.ts", 1, 1, Severity::Warning, "r1", "w1"),
1614            make_violation("a.ts", 2, 1, Severity::Warning, "r2", "w2"),
1615        ]);
1616        let mut out = Vec::new();
1617        write_pretty(&result, &mut out);
1618
1619        let output = String::from_utf8(out).unwrap();
1620        assert!(output.contains("2 warnings"));
1621        assert!(!output.contains("error"));
1622    }
1623
1624    #[test]
1625    fn pretty_with_source_line() {
1626        let mut v = make_violation("a.tsx", 5, 1, Severity::Error, "r1", "msg");
1627        v.source_line = Some("  <div className=\"bg-white\">".into());
1628        let result = make_result(vec![v]);
1629        let mut out = Vec::new();
1630        write_pretty(&result, &mut out);
1631
1632        let output = String::from_utf8(out).unwrap();
1633        assert!(output.contains("<div className=\"bg-white\">"));
1634    }
1635
1636    #[test]
1637    fn pretty_with_suggestion() {
1638        let mut v = make_violation("a.tsx", 5, 1, Severity::Error, "r1", "msg");
1639        v.suggest = Some("Use bg-background instead".into());
1640        let result = make_result(vec![v]);
1641        let mut out = Vec::new();
1642        write_pretty(&result, &mut out);
1643
1644        let output = String::from_utf8(out).unwrap();
1645        assert!(output.contains("Use bg-background instead"));
1646    }
1647
1648    #[test]
1649    fn pretty_line_only_no_column() {
1650        let v = Violation {
1651            rule_id: "r1".into(),
1652            severity: Severity::Error,
1653            file: PathBuf::from("a.ts"),
1654            line: Some(7),
1655            column: None,
1656            message: "msg".into(),
1657            suggest: None,
1658            source_line: None,
1659            fix: None,
1660        };
1661        let result = make_result(vec![v]);
1662        let mut out = Vec::new();
1663        write_pretty(&result, &mut out);
1664
1665        let output = String::from_utf8(out).unwrap();
1666        assert!(output.contains("7:1"));
1667    }
1668
1669    #[test]
1670    fn pretty_no_line_no_column() {
1671        let v = Violation {
1672            rule_id: "r1".into(),
1673            severity: Severity::Error,
1674            file: PathBuf::from("a.ts"),
1675            line: None,
1676            column: None,
1677            message: "msg".into(),
1678            suggest: None,
1679            source_line: None,
1680            fix: None,
1681        };
1682        let result = make_result(vec![v]);
1683        let mut out = Vec::new();
1684        write_pretty(&result, &mut out);
1685
1686        let output = String::from_utf8(out).unwrap();
1687        assert!(output.contains("1:1"));
1688    }
1689
1690    #[test]
1691    fn pretty_multiple_files_grouped() {
1692        let result = make_result(vec![
1693            make_violation("src/a.tsx", 1, 1, Severity::Error, "r1", "m1"),
1694            make_violation("src/b.tsx", 2, 1, Severity::Error, "r1", "m2"),
1695            make_violation("src/a.tsx", 5, 1, Severity::Warning, "r2", "m3"),
1696        ]);
1697        let mut out = Vec::new();
1698        write_pretty(&result, &mut out);
1699
1700        let output = String::from_utf8(out).unwrap();
1701        // Files should appear as group headers
1702        assert!(output.contains("src/a.tsx"));
1703        assert!(output.contains("src/b.tsx"));
1704    }
1705
1706    #[test]
1707    fn pretty_with_ratchet() {
1708        let mut result = make_result(vec![
1709            make_violation("a.ts", 1, 1, Severity::Error, "r1", "msg"),
1710        ]);
1711        result.ratchet_counts.insert("legacy".into(), (3, 5));
1712        let mut out = Vec::new();
1713        write_pretty(&result, &mut out);
1714
1715        let output = String::from_utf8(out).unwrap();
1716        assert!(output.contains("Ratchet rules:"));
1717        assert!(output.contains("legacy"));
1718        assert!(output.contains("pass"));
1719    }
1720
1721    // ── write_ratchet_summary_pretty tests ──
1722
1723    #[test]
1724    fn ratchet_summary_pretty_empty() {
1725        let counts = HashMap::new();
1726        let mut out = Vec::new();
1727        write_ratchet_summary_pretty(&counts, &mut out);
1728
1729        let output = String::from_utf8(out).unwrap();
1730        assert!(output.is_empty());
1731    }
1732
1733    #[test]
1734    fn ratchet_summary_pretty_pass_and_over() {
1735        let mut counts = HashMap::new();
1736        counts.insert("a-rule".to_string(), (2usize, 5usize));
1737        counts.insert("b-rule".to_string(), (10, 3));
1738        let mut out = Vec::new();
1739        write_ratchet_summary_pretty(&counts, &mut out);
1740
1741        let output = String::from_utf8(out).unwrap();
1742        assert!(output.contains("Ratchet rules:"));
1743        assert!(output.contains("a-rule"));
1744        assert!(output.contains("pass"));
1745        assert!(output.contains("(2/5)"));
1746        assert!(output.contains("b-rule"));
1747        assert!(output.contains("OVER"));
1748        assert!(output.contains("(10/3)"));
1749    }
1750
1751    #[test]
1752    fn pretty_no_violations_with_ratchet() {
1753        let mut result = make_result(vec![]);
1754        result.ratchet_counts.insert("legacy".into(), (2, 10));
1755        let mut out = Vec::new();
1756        write_pretty(&result, &mut out);
1757
1758        let output = String::from_utf8(out).unwrap();
1759        assert!(output.contains("No violations found"));
1760        assert!(output.contains("Ratchet rules:"));
1761        assert!(output.contains("legacy"));
1762    }
1763
1764    #[test]
1765    fn pretty_plural_errors() {
1766        let result = make_result(vec![
1767            make_violation("a.ts", 1, 1, Severity::Error, "r1", "e1"),
1768            make_violation("a.ts", 2, 1, Severity::Error, "r2", "e2"),
1769        ]);
1770        let mut out = Vec::new();
1771        write_pretty(&result, &mut out);
1772
1773        let output = String::from_utf8(out).unwrap();
1774        assert!(output.contains("2 errors"));
1775    }
1776
1777    #[test]
1778    fn pretty_mixed_with_comma() {
1779        let result = make_result(vec![
1780            make_violation("a.ts", 1, 1, Severity::Error, "r1", "e1"),
1781            make_violation("a.ts", 2, 1, Severity::Warning, "r2", "w1"),
1782        ]);
1783        let mut out = Vec::new();
1784        write_pretty(&result, &mut out);
1785
1786        let output = String::from_utf8(out).unwrap();
1787        // Should have comma between error and warning counts
1788        assert!(output.contains("1 error"));
1789        assert!(output.contains("1 warning"));
1790    }
1791}