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
10pub 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 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
119pub 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
176pub 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
209pub 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 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
312pub 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 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 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
416pub 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 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 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 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 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 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
549pub fn apply_fixes(result: &ScanResult, dry_run: bool) -> usize {
553 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 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 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 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 #[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 assert!(output.contains("| - |"));
911 }
912
913 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 assert!(stderr.contains("1 error,"));
1505 assert!(stderr.contains("1 warning"));
1506 assert!(!stderr.contains("errors"));
1507 assert!(!stderr.contains("warnings"));
1508 }
1509
1510 #[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 #[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 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 #[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 assert!(output.contains("1 error"));
1789 assert!(output.contains("1 warning"));
1790 }
1791}