1use crate::error::{Error, Result};
2use crate::output::OutputFormat;
3use crate::scanner::ScanResult;
4use colored::Colorize;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::Path;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ReportAnnotation {
15 pub file: String,
16 pub line: usize,
17 pub tag: String,
18 pub date: String,
19 pub owner: Option<String>,
20 pub message: String,
21 pub status: String,
22}
23
24#[derive(Debug, Serialize, Deserialize)]
26pub struct Report {
27 pub generated_at: String,
29 pub swept_files: usize,
30 pub total_fuses: usize,
31 pub detonated: Vec<ReportAnnotation>,
32 pub ticking: Vec<ReportAnnotation>,
33 pub inert: Vec<ReportAnnotation>,
34}
35
36#[derive(Debug)]
38pub struct ReportDiff {
39 pub newly_detonated: Vec<ReportAnnotation>,
42 pub resolved: Vec<ReportAnnotation>,
44 pub new_annotations: Vec<ReportAnnotation>,
46 pub snoozed: Vec<(ReportAnnotation, ReportAnnotation)>, }
49
50#[derive(Serialize)]
53struct SnoozedPair<'a> {
54 before: &'a ReportAnnotation,
55 after: &'a ReportAnnotation,
56}
57
58#[derive(Serialize)]
59struct DiffJson<'a> {
60 newly_detonated: &'a [ReportAnnotation],
61 resolved: &'a [ReportAnnotation],
62 new_annotations: &'a [ReportAnnotation],
63 snoozed: Vec<SnoozedPair<'a>>,
64}
65
66type AnnKey = (String, usize, String);
69
70fn ann_key(a: &ReportAnnotation) -> AnnKey {
71 (a.file.clone(), a.line, a.tag.clone())
72}
73
74fn make_key_map(anns: &[ReportAnnotation]) -> HashMap<AnnKey, &ReportAnnotation> {
75 anns.iter().map(|a| (ann_key(a), a)).collect()
76}
77
78fn all_key_map(report: &Report) -> HashMap<AnnKey, &ReportAnnotation> {
80 let mut map = HashMap::new();
81 for a in &report.detonated {
82 map.insert(ann_key(a), a);
83 }
84 for a in &report.ticking {
85 map.insert(ann_key(a), a);
86 }
87 for a in &report.inert {
88 map.insert(ann_key(a), a);
89 }
90 map
91}
92
93pub fn build_report(result: &ScanResult, generated_at: &str) -> Report {
98 let to_report_ann = |a: &crate::annotation::Fuse| ReportAnnotation {
99 file: a.file.display().to_string(),
100 line: a.line,
101 tag: a.tag.clone(),
102 date: a.date_str(),
103 owner: a.owner.clone(),
104 message: a.message.clone(),
105 status: a.status.as_str().to_string(),
106 };
107
108 let mut detonated: Vec<ReportAnnotation> = Vec::new();
111 let mut ticking: Vec<ReportAnnotation> = Vec::new();
112 let mut inert: Vec<ReportAnnotation> = Vec::new();
113 for fuse in &result.fuses {
114 match fuse.status {
115 crate::annotation::Status::Detonated => detonated.push(to_report_ann(fuse)),
116 crate::annotation::Status::Ticking => ticking.push(to_report_ann(fuse)),
117 crate::annotation::Status::Inert => inert.push(to_report_ann(fuse)),
118 }
119 }
120
121 let total_fuses = detonated.len() + ticking.len() + inert.len();
122
123 Report {
124 generated_at: generated_at.to_string(),
125 swept_files: result.swept_files,
126 total_fuses,
127 detonated,
128 ticking,
129 inert,
130 }
131}
132
133pub fn write_report(report: &Report, path: &Path) -> Result<()> {
135 if let Some(parent) = path.parent() {
137 if !parent.as_os_str().is_empty() {
138 std::fs::create_dir_all(parent).map_err(|e| Error::Io {
139 source: e,
140 path: Some(parent.to_path_buf()),
141 })?;
142 }
143 }
144
145 let json = serde_json::to_string_pretty(report).map_err(|e| Error::Io {
146 source: std::io::Error::other(e.to_string()),
147 path: Some(path.to_path_buf()),
148 })?;
149
150 std::fs::write(path, json).map_err(|e| Error::Io {
151 source: e,
152 path: Some(path.to_path_buf()),
153 })
154}
155
156pub fn read_report(path: &Path) -> Result<Option<Report>> {
160 if !path.exists() {
161 return Ok(None);
162 }
163
164 let content = std::fs::read_to_string(path).map_err(|e| Error::Io {
165 source: e,
166 path: Some(path.to_path_buf()),
167 })?;
168
169 let report: Report = serde_json::from_str(&content).map_err(|e| Error::Io {
170 source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
171 path: Some(path.to_path_buf()),
172 })?;
173
174 Ok(Some(report))
175}
176
177pub fn diff_reports(old: &Report, new: &Report) -> ReportDiff {
179 let old_detonated_map = make_key_map(&old.detonated);
180 let old_all_map = all_key_map(old);
181 let new_all_map = all_key_map(new);
182
183 let newly_detonated: Vec<ReportAnnotation> = new
185 .detonated
186 .iter()
187 .filter(|a| !old_detonated_map.contains_key(&ann_key(a)))
188 .cloned()
189 .collect();
190
191 let resolved: Vec<ReportAnnotation> = old
193 .detonated
194 .iter()
195 .filter(|a| !new_all_map.contains_key(&ann_key(a)))
196 .cloned()
197 .collect();
198
199 let new_annotations: Vec<ReportAnnotation> = {
201 let mut seen_keys = std::collections::HashSet::new();
202 let mut result = Vec::new();
203 for bucket in [&new.detonated, &new.ticking, &new.inert] {
204 for a in bucket {
205 let key = ann_key(a);
206 if !old_all_map.contains_key(&key) && seen_keys.insert(key) {
207 result.push(a.clone());
208 }
209 }
210 }
211 result
212 };
213
214 let snoozed: Vec<(ReportAnnotation, ReportAnnotation)> = {
216 let mut result = Vec::new();
217 for bucket in [&new.detonated, &new.ticking, &new.inert] {
218 for new_ann in bucket {
219 let key = ann_key(new_ann);
220 if let Some(old_ann) = old_all_map.get(&key) {
221 if old_ann.date != new_ann.date {
222 result.push(((*old_ann).clone(), new_ann.clone()));
223 }
224 }
225 }
226 }
227 result
228 };
229
230 ReportDiff {
231 newly_detonated,
232 resolved,
233 new_annotations,
234 snoozed,
235 }
236}
237
238fn color_enabled() -> bool {
240 std::env::var("NO_COLOR").is_err()
241}
242
243pub fn print_diff_terminal(diff: &ReportDiff) {
245 let use_color = color_enabled();
246
247 println!("REPORT DIFF");
248 println!("-----------");
249
250 let n = diff.newly_detonated.len();
252 println!("{} newly detonated fuse(s):", n);
253 for a in &diff.newly_detonated {
254 let label = "DETONATED";
255 let location = format!("{}:{}", a.file, a.line);
256 let tag_date = format!("{}[{}]", a.tag, a.date);
257 let line = format!(
258 " {:<9} {:<30} {:<22} {}",
259 label, location, tag_date, a.message
260 );
261 if use_color {
262 println!("{}", line.red());
263 } else {
264 println!("{}", line);
265 }
266 }
267
268 println!();
269
270 let n = diff.resolved.len();
272 println!("{} resolved fuse(s):", n);
273 for a in &diff.resolved {
274 let label = "REMOVED";
275 let location = format!("{}:{}", a.file, a.line);
276 let tag_date = format!("{}[{}]", a.tag, a.date);
277 let line = format!(
278 " {:<9} {:<30} {:<22} {}",
279 label, location, tag_date, a.message
280 );
281 if use_color {
282 println!("{}", line.green());
283 } else {
284 println!("{}", line);
285 }
286 }
287
288 println!();
289
290 let n = diff.new_annotations.len();
292 println!("{} new fuse(s) added:", n);
293 for a in &diff.new_annotations {
294 let label = "NEW";
295 let location = format!("{}:{}", a.file, a.line);
296 let tag_date = format!("{}[{}]", a.tag, a.date);
297 let line = format!(
298 " {:<9} {:<30} {:<22} {}",
299 label, location, tag_date, a.message
300 );
301 if use_color {
302 println!("{}", line.yellow());
303 } else {
304 println!("{}", line);
305 }
306 }
307
308 println!();
309
310 let n = diff.snoozed.len();
312 println!("{} snoozed fuse(s):", n);
313 for (old, new) in &diff.snoozed {
314 let label = "SNOOZED";
315 let location = format!("{}:{}", new.file, new.line);
316 let tag_date = format!("{}[{}→{}]", new.tag, old.date, new.date);
317 let line = format!(
318 " {:<9} {:<30} {:<22} {}",
319 label, location, tag_date, new.message
320 );
321 if use_color {
322 println!("{}", line.cyan());
323 } else {
324 println!("{}", line);
325 }
326 }
327}
328
329pub fn print_diff_json(diff: &ReportDiff) {
331 let snoozed: Vec<SnoozedPair<'_>> = diff
332 .snoozed
333 .iter()
334 .map(|(old, new)| SnoozedPair {
335 before: old,
336 after: new,
337 })
338 .collect();
339
340 let payload = DiffJson {
341 newly_detonated: &diff.newly_detonated,
342 resolved: &diff.resolved,
343 new_annotations: &diff.new_annotations,
344 snoozed,
345 };
346
347 match serde_json::to_string_pretty(&payload) {
348 Ok(json) => println!("{}", json),
349 Err(e) => eprintln!("error serializing diff: {}", e),
350 }
351}
352
353pub fn run_report(
359 result: &ScanResult,
360 out_path: &Path,
361 diff: bool,
362 fail_on_new: bool,
363 format: &OutputFormat,
364 generated_at: &str,
365) -> Result<i32> {
366 let new_report = build_report(result, generated_at);
367
368 let mut exit_code = 0i32;
369
370 if diff {
371 match read_report(out_path)? {
372 None => {
373 println!(
374 "No previous report found at {} — writing initial report.",
375 out_path.display()
376 );
377 }
378 Some(old_report) => {
379 let report_diff = diff_reports(&old_report, &new_report);
380
381 match format {
382 OutputFormat::Json => print_diff_json(&report_diff),
383 _ => print_diff_terminal(&report_diff),
384 }
385
386 if fail_on_new && !report_diff.newly_detonated.is_empty() {
387 exit_code = 1;
388 }
389 }
390 }
391 }
392
393 write_report(&new_report, out_path)?;
394 Ok(exit_code)
395}
396
397#[cfg(test)]
400mod tests {
401 use super::*;
402 use crate::annotation::{Fuse, Status};
403 use crate::scanner::ScanResult;
404 use chrono::NaiveDate;
405 use std::path::PathBuf;
406
407 fn make_fuse(file: &str, line: usize, tag: &str, date: &str, status: Status) -> Fuse {
410 Fuse {
411 file: PathBuf::from(file),
412 line,
413 tag: tag.to_string(),
414 date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
415 owner: None,
416 message: "test".to_string(),
417 status,
418 blamed_owner: None,
419 }
420 }
421
422 fn make_scan_result(fuses: Vec<Fuse>) -> ScanResult {
423 ScanResult {
424 swept_files: 1,
425 skipped_files: 0,
426 fuses,
427 }
428 }
429
430 fn make_report_ann(
431 file: &str,
432 line: usize,
433 tag: &str,
434 date: &str,
435 status: &str,
436 ) -> ReportAnnotation {
437 ReportAnnotation {
438 file: file.to_string(),
439 line,
440 tag: tag.to_string(),
441 date: date.to_string(),
442 owner: None,
443 message: "test".to_string(),
444 status: status.to_string(),
445 }
446 }
447
448 fn make_report(
449 detonated: Vec<ReportAnnotation>,
450 ticking: Vec<ReportAnnotation>,
451 inert: Vec<ReportAnnotation>,
452 ) -> Report {
453 let total = detonated.len() + ticking.len() + inert.len();
454 Report {
455 generated_at: "2025-01-01T00:00:00+00:00".to_string(),
456 swept_files: 1,
457 total_fuses: total,
458 detonated,
459 ticking,
460 inert,
461 }
462 }
463
464 #[test]
467 fn test_build_report_empty() {
468 let result = make_scan_result(vec![]);
469 let report = build_report(&result, "2025-01-01T00:00:00+00:00");
470 assert_eq!(report.total_fuses, 0);
471 assert!(report.detonated.is_empty());
472 assert!(report.ticking.is_empty());
473 assert!(report.inert.is_empty());
474 assert_eq!(report.swept_files, 1);
475 assert_eq!(report.generated_at, "2025-01-01T00:00:00+00:00");
476 }
477
478 #[test]
479 fn test_build_report_counts() {
480 let fuses = vec![
481 make_fuse("src/a.rs", 1, "TODO", "2020-01-01", Status::Detonated),
482 make_fuse("src/b.rs", 2, "FIXME", "2020-06-01", Status::Detonated),
483 make_fuse("src/c.rs", 3, "TODO", "2025-06-10", Status::Ticking),
484 make_fuse("src/d.rs", 4, "TODO", "2099-01-01", Status::Inert),
485 ];
486 let result = make_scan_result(fuses);
487 let report = build_report(&result, "2025-01-01T00:00:00+00:00");
488
489 assert_eq!(report.detonated.len(), 2);
490 assert_eq!(report.ticking.len(), 1);
491 assert_eq!(report.inert.len(), 1);
492 assert_eq!(report.total_fuses, 4);
493 }
494
495 #[test]
498 fn test_write_and_read_report_roundtrip() {
499 let dir = tempfile::tempdir().unwrap();
500 let path = dir.path().join("report.json");
501
502 let detonated = vec![make_report_ann(
503 "src/a.rs",
504 1,
505 "TODO",
506 "2020-01-01",
507 "detonated",
508 )];
509 let report = make_report(detonated, vec![], vec![]);
510
511 write_report(&report, &path).unwrap();
512 let loaded = read_report(&path).unwrap().unwrap();
513
514 assert_eq!(loaded.generated_at, report.generated_at);
515 assert_eq!(loaded.swept_files, report.swept_files);
516 assert_eq!(loaded.total_fuses, report.total_fuses);
517 assert_eq!(loaded.detonated.len(), 1);
518 assert_eq!(loaded.detonated[0], report.detonated[0]);
519 assert!(loaded.ticking.is_empty());
520 assert!(loaded.inert.is_empty());
521 }
522
523 #[test]
524 fn test_write_report_creates_parent_dirs() {
525 let dir = tempfile::tempdir().unwrap();
526 let path = dir.path().join("nested").join("deep").join("report.json");
527 let report = make_report(vec![], vec![], vec![]);
528 write_report(&report, &path).unwrap();
529 assert!(path.exists());
530 }
531
532 #[test]
535 fn test_read_report_nonexistent_returns_none() {
536 let dir = tempfile::tempdir().unwrap();
537 let path = dir.path().join("does_not_exist.json");
538 let result = read_report(&path).unwrap();
539 assert!(result.is_none());
540 }
541
542 #[test]
543 fn test_read_report_invalid_json_returns_err() {
544 let dir = tempfile::tempdir().unwrap();
545 let path = dir.path().join("bad.json");
546 std::fs::write(&path, b"this is not json at all!!!").unwrap();
547 let result = read_report(&path);
548 assert!(result.is_err());
549 }
550
551 #[test]
554 fn test_diff_no_change() {
555 let ann = make_report_ann("src/a.rs", 1, "TODO", "2020-01-01", "detonated");
556 let old = make_report(vec![ann.clone()], vec![], vec![]);
557 let new = make_report(vec![ann], vec![], vec![]);
558 let diff = diff_reports(&old, &new);
559 assert!(diff.newly_detonated.is_empty());
560 assert!(diff.resolved.is_empty());
561 assert!(diff.new_annotations.is_empty());
562 assert!(diff.snoozed.is_empty());
563 }
564
565 #[test]
566 fn test_diff_newly_detonated() {
567 let inert_ann = make_report_ann("src/a.rs", 1, "TODO", "2020-01-01", "inert");
569 let det_ann = make_report_ann("src/a.rs", 1, "TODO", "2020-01-01", "detonated");
570
571 let old = make_report(vec![], vec![], vec![inert_ann]);
572 let new = make_report(vec![det_ann.clone()], vec![], vec![]);
573
574 let diff = diff_reports(&old, &new);
575 assert_eq!(diff.newly_detonated.len(), 1);
576 assert_eq!(diff.newly_detonated[0], det_ann);
577 assert!(diff.resolved.is_empty());
578 assert!(diff.new_annotations.is_empty());
579 }
580
581 #[test]
582 fn test_diff_newly_detonated_brand_new() {
583 let det_ann = make_report_ann("src/new.rs", 5, "FIXME", "2020-06-01", "detonated");
585
586 let old = make_report(vec![], vec![], vec![]);
587 let new = make_report(vec![det_ann.clone()], vec![], vec![]);
588
589 let diff = diff_reports(&old, &new);
590 assert_eq!(diff.newly_detonated.len(), 1);
592 assert_eq!(diff.new_annotations.len(), 1);
593 }
594
595 #[test]
596 fn test_diff_resolved() {
597 let det_ann = make_report_ann("src/a.rs", 1, "TODO", "2020-01-01", "detonated");
599
600 let old = make_report(vec![det_ann.clone()], vec![], vec![]);
601 let new = make_report(vec![], vec![], vec![]);
602
603 let diff = diff_reports(&old, &new);
604 assert_eq!(diff.resolved.len(), 1);
605 assert_eq!(diff.resolved[0], det_ann);
606 assert!(diff.newly_detonated.is_empty());
607 }
608
609 #[test]
610 fn test_diff_new_annotation() {
611 let inert_ann = make_report_ann("src/brand_new.rs", 10, "TODO", "2099-01-01", "inert");
613
614 let old = make_report(vec![], vec![], vec![]);
615 let new = make_report(vec![], vec![], vec![inert_ann.clone()]);
616
617 let diff = diff_reports(&old, &new);
618 assert_eq!(diff.new_annotations.len(), 1);
619 assert_eq!(diff.new_annotations[0], inert_ann);
620 assert!(diff.newly_detonated.is_empty());
621 assert!(diff.resolved.is_empty());
622 }
623
624 #[test]
625 fn test_diff_snoozed() {
626 let old_ann = make_report_ann("src/worker.rs", 88, "TODO", "2025-03-01", "inert");
628 let new_ann = make_report_ann("src/worker.rs", 88, "TODO", "2026-03-01", "inert");
629
630 let old = make_report(vec![], vec![], vec![old_ann.clone()]);
631 let new = make_report(vec![], vec![], vec![new_ann.clone()]);
632
633 let diff = diff_reports(&old, &new);
634 assert_eq!(diff.snoozed.len(), 1);
635 let (ref before, ref after) = diff.snoozed[0];
636 assert_eq!(before.date, "2025-03-01");
637 assert_eq!(after.date, "2026-03-01");
638 assert!(diff.new_annotations.is_empty());
639 }
640
641 #[test]
642 fn test_diff_ticking_to_detonated_is_newly_detonated() {
643 let ticking_ann = make_report_ann("src/a.rs", 1, "TODO", "2025-06-10", "ticking");
644 let detonated_ann = make_report_ann("src/a.rs", 1, "TODO", "2025-06-10", "detonated");
645
646 let old = make_report(vec![], vec![ticking_ann], vec![]);
647 let new = make_report(vec![detonated_ann], vec![], vec![]);
648
649 let diff = diff_reports(&old, &new);
650 assert_eq!(diff.newly_detonated.len(), 1);
651 assert!(diff.resolved.is_empty());
652 assert!(diff.new_annotations.is_empty());
653 }
654
655 fn make_nontrivial_diff() -> ReportDiff {
658 ReportDiff {
659 newly_detonated: vec![make_report_ann(
660 "src/auth/login.rs",
661 42,
662 "TODO",
663 "2025-01-15",
664 "detonated",
665 )],
666 resolved: vec![make_report_ann(
667 "src/db/old.sql",
668 7,
669 "TODO",
670 "2020-01-01",
671 "detonated",
672 )],
673 new_annotations: vec![make_report_ann(
674 "src/api/handler.rs",
675 12,
676 "TODO",
677 "2026-06-01",
678 "inert",
679 )],
680 snoozed: vec![(
681 make_report_ann("src/worker.rs", 88, "TODO", "2025-03-01", "inert"),
682 make_report_ann("src/worker.rs", 88, "TODO", "2026-03-01", "inert"),
683 )],
684 }
685 }
686
687 #[test]
688 fn test_print_diff_terminal_does_not_panic() {
689 let diff = make_nontrivial_diff();
690 print_diff_terminal(&diff);
692 }
693
694 #[test]
695 fn test_print_diff_json_does_not_panic() {
696 let diff = make_nontrivial_diff();
697 print_diff_json(&diff);
698 }
699
700 #[test]
703 fn test_run_report_no_previous_no_diff() {
704 let dir = tempfile::tempdir().unwrap();
705 let out_path = dir.path().join("report.json");
706
707 let result = make_scan_result(vec![]);
708 let code = run_report(
709 &result,
710 &out_path,
711 false, false, &OutputFormat::Terminal,
714 "2025-01-01T00:00:00+00:00",
715 )
716 .unwrap();
717
718 assert_eq!(code, 0);
719 assert!(out_path.exists());
720
721 let loaded = read_report(&out_path).unwrap().unwrap();
723 assert_eq!(loaded.total_fuses, 0);
724 }
725
726 #[test]
727 fn test_run_report_diff_no_previous_file() {
728 let dir = tempfile::tempdir().unwrap();
729 let out_path = dir.path().join("report.json");
730
731 let result = make_scan_result(vec![]);
732 let code = run_report(
733 &result,
734 &out_path,
735 true, false, &OutputFormat::Terminal,
738 "2025-01-01T00:00:00+00:00",
739 )
740 .unwrap();
741
742 assert_eq!(code, 0);
744 assert!(out_path.exists());
746 }
747
748 #[test]
749 fn test_run_report_fail_on_new_exits_one() {
750 let dir = tempfile::tempdir().unwrap();
751 let out_path = dir.path().join("report.json");
752
753 let old_report = make_report(vec![], vec![], vec![]);
755 write_report(&old_report, &out_path).unwrap();
756
757 let fuses = vec![make_fuse(
759 "src/a.rs",
760 1,
761 "TODO",
762 "2020-01-01",
763 Status::Detonated,
764 )];
765 let result = make_scan_result(fuses);
766
767 let code = run_report(
768 &result,
769 &out_path,
770 true, true, &OutputFormat::Terminal,
773 "2025-06-01T00:00:00+00:00",
774 )
775 .unwrap();
776
777 assert_eq!(code, 1);
778 }
779
780 #[test]
781 fn test_run_report_fail_on_new_exits_zero_when_no_new_detonated() {
782 let dir = tempfile::tempdir().unwrap();
783 let out_path = dir.path().join("report.json");
784
785 let detonated = vec![make_report_ann(
787 "src/a.rs",
788 1,
789 "TODO",
790 "2020-01-01",
791 "detonated",
792 )];
793 let old_report = make_report(detonated, vec![], vec![]);
794 write_report(&old_report, &out_path).unwrap();
795
796 let fuses = vec![make_fuse(
798 "src/a.rs",
799 1,
800 "TODO",
801 "2020-01-01",
802 Status::Detonated,
803 )];
804 let result = make_scan_result(fuses);
805
806 let code = run_report(
807 &result,
808 &out_path,
809 true, true, &OutputFormat::Terminal,
812 "2025-06-01T00:00:00+00:00",
813 )
814 .unwrap();
815
816 assert_eq!(code, 0);
817 }
818
819 #[test]
820 fn test_run_report_json_format_no_panic() {
821 let dir = tempfile::tempdir().unwrap();
822 let out_path = dir.path().join("report.json");
823
824 let old_report = make_report(vec![], vec![], vec![]);
826 write_report(&old_report, &out_path).unwrap();
827
828 let fuses = vec![make_fuse(
829 "src/b.rs",
830 99,
831 "FIXME",
832 "2099-12-01",
833 Status::Inert,
834 )];
835 let result = make_scan_result(fuses);
836
837 let code = run_report(
838 &result,
839 &out_path,
840 true,
841 false,
842 &OutputFormat::Json,
843 "2025-06-01T00:00:00+00:00",
844 )
845 .unwrap();
846
847 assert_eq!(code, 0);
848 }
849}