Skip to main content

timebomb/
report.rs

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// ─── Core types ───────────────────────────────────────────────────────────────
10
11/// A single fuse as stored in the persisted report file.
12/// Owned strings — no lifetimes — so it can be deserialized easily.
13#[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/// The persisted report file format.
25#[derive(Debug, Serialize, Deserialize)]
26pub struct Report {
27    /// RFC 3339 timestamp of when this report was generated.
28    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/// The result of diffing two reports.
37#[derive(Debug)]
38pub struct ReportDiff {
39    /// Fuses that are detonated in the new report but were not in the old one
40    /// (either newly added past their deadline, or crossed the deadline since last report).
41    pub newly_detonated: Vec<ReportAnnotation>,
42    /// Fuses present in old report but absent in new (cleaned up / deleted).
43    pub resolved: Vec<ReportAnnotation>,
44    /// Fuses in new report that weren't in old report at all (any status).
45    pub new_annotations: Vec<ReportAnnotation>,
46    /// Fuses whose date changed between old and new report (snoozed).
47    pub snoozed: Vec<(ReportAnnotation, ReportAnnotation)>, // (old, new)
48}
49
50// ─── Helper for JSON diff serialization ──────────────────────────────────────
51
52#[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
66// ─── Key type used for O(1) lookup ───────────────────────────────────────────
67
68type 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
78/// Build a map covering all three status buckets of a report.
79fn 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
93// ─── Public functions ─────────────────────────────────────────────────────────
94
95/// Convert a ScanResult into a Report. `generated_at` should be an RFC 3339 string.
96/// Accept it as a parameter for testability.
97pub 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    // Single pass over fuses — avoids three separate Vec allocations from
109    // calling result.detonated() / result.ticking() / result.inert() individually.
110    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
133/// Write a Report to a JSON file at `path`.
134pub fn write_report(report: &Report, path: &Path) -> Result<()> {
135    // Ensure parent directory exists.
136    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
156/// Read a Report from a JSON file at `path`.
157/// Returns Ok(None) if the file does not exist (first run).
158/// Returns Err if the file exists but cannot be parsed.
159pub 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
177/// Diff two reports. `old` is the previously persisted report, `new` is the freshly built one.
178pub 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    // newly_detonated: in new.detonated but key not in old.detonated
184    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    // resolved: key is in old.detonated but not found anywhere in new
192    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    // new_annotations: key present in new (any status) but not present anywhere in old
200    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    // snoozed: key present in both old and new, but old.date != new.date
215    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
238/// Whether color output should be enabled (respects NO_COLOR).
239fn color_enabled() -> bool {
240    std::env::var("NO_COLOR").is_err()
241}
242
243/// Print a ReportDiff to stdout in terminal format.
244pub fn print_diff_terminal(diff: &ReportDiff) {
245    let use_color = color_enabled();
246
247    println!("REPORT DIFF");
248    println!("-----------");
249
250    // newly detonated
251    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    // resolved
271    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    // new annotations
291    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    // snoozed
311    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
329/// Print a ReportDiff as JSON to stdout.
330pub 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
353/// Top-level entry point — called from main.rs.
354/// - Builds the new report from the scan result.
355/// - If `diff` is true and a previous report file exists, compute and print the diff.
356/// - Always writes the new report to `out_path`.
357/// - Returns exit code: 0 normally, 1 if `fail_on_new` is true and `diff.newly_detonated` is non-empty.
358pub 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// ─── Tests ────────────────────────────────────────────────────────────────────
398
399#[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    // ── Helpers ───────────────────────────────────────────────────────────────
408
409    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    // ── build_report ──────────────────────────────────────────────────────────
465
466    #[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    // ── write_report / read_report roundtrip ─────────────────────────────────
496
497    #[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    // ── read_report edge cases ────────────────────────────────────────────────
533
534    #[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    // ── diff_reports ──────────────────────────────────────────────────────────
552
553    #[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        // Fuse was inert in old report, now detonated in new report.
568        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        // Fuse did not exist at all before, and it's already detonated.
584        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        // Appears in both newly_detonated and new_annotations
591        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        // Fuse was detonated in old, gone entirely from new.
598        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        // Fuse present in new but not old (inert status).
612        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        // Same key (file, line, tag), but date changed.
627        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    // ── print functions (smoke tests) ─────────────────────────────────────────
656
657    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        // Should not panic — output goes to stdout, which is fine in tests.
691        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    // ── run_report ────────────────────────────────────────────────────────────
701
702    #[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, // diff
712            false, // fail_on_new
713            &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        // Report was written correctly.
722        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,  // diff = true, but no previous file
736            false, // fail_on_new
737            &OutputFormat::Terminal,
738            "2025-01-01T00:00:00+00:00",
739        )
740        .unwrap();
741
742        // Should print note and exit 0.
743        assert_eq!(code, 0);
744        // Should still write the file.
745        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        // Write an initial report with no detonated fuses.
754        let old_report = make_report(vec![], vec![], vec![]);
755        write_report(&old_report, &out_path).unwrap();
756
757        // New scan finds a detonated fuse.
758        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, // diff
771            true, // fail_on_new
772            &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        // Write an initial report with the same detonated fuse.
786        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        // New scan finds the same detonated fuse — not "new".
797        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, // diff
810            true, // fail_on_new
811            &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        // Seed a previous report.
825        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}