Skip to main content

timebomb/
trend.rs

1use crate::error::{Error, Result};
2use crate::output::OutputFormat;
3use crate::report::{Report, ReportAnnotation};
4use colored::Colorize;
5use serde::Serialize;
6use std::collections::HashSet;
7use std::path::Path;
8
9/// Summary of how fuse debt has changed between two report snapshots.
10#[derive(Debug, Serialize)]
11pub struct TrendResult {
12    pub from_timestamp: String,
13    pub to_timestamp: String,
14    /// Positive = more detonated (worse), negative = fewer (better).
15    pub detonated_delta: i64,
16    pub ticking_delta: i64,
17    pub total_delta: i64,
18    /// Fuses in B.detonated whose file:line key is not in A.detonated.
19    pub newly_detonated: Vec<ReportAnnotation>,
20    /// Fuses in A.detonated whose file:line key is absent from B entirely.
21    pub resolved: Vec<ReportAnnotation>,
22    /// Fuses in A.detonated that are now in B.ticking (deadline bumped).
23    pub snoozed: Vec<ReportAnnotation>,
24}
25
26// ─── Internal helpers ─────────────────────────────────────────────────────────
27
28fn load_report(path: &Path) -> Result<Report> {
29    let content = std::fs::read_to_string(path).map_err(|e| Error::Io {
30        source: e,
31        path: Some(path.to_path_buf()),
32    })?;
33    serde_json::from_str(&content).map_err(|e| Error::Io {
34        source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
35        path: Some(path.to_path_buf()),
36    })
37}
38
39/// A simple key identifying a unique fuse location.
40fn annotation_key(a: &ReportAnnotation) -> String {
41    format!("{}:{}", a.file, a.line)
42}
43
44fn key_set(anns: &[ReportAnnotation]) -> HashSet<String> {
45    anns.iter().map(annotation_key).collect()
46}
47
48// ─── Core computation ─────────────────────────────────────────────────────────
49
50pub fn compute_trend(a: &Report, b: &Report) -> TrendResult {
51    let a_detonated_keys = key_set(&a.detonated);
52    let b_detonated_keys = key_set(&b.detonated);
53    let b_ticking_keys = key_set(&b.ticking);
54    let b_inert_keys = key_set(&b.inert);
55
56    // All keys that exist anywhere in B.
57    // Use &str references into the already-owned keys to avoid cloning them again.
58    let b_all_keys: HashSet<&str> = b_detonated_keys
59        .iter()
60        .chain(b_ticking_keys.iter())
61        .chain(b_inert_keys.iter())
62        .map(String::as_str)
63        .collect();
64
65    let newly_detonated: Vec<ReportAnnotation> = b
66        .detonated
67        .iter()
68        .filter(|ann| !a_detonated_keys.contains(&annotation_key(ann)))
69        .cloned()
70        .collect();
71
72    let resolved: Vec<ReportAnnotation> = a
73        .detonated
74        .iter()
75        .filter(|ann| !b_all_keys.contains(annotation_key(ann).as_str()))
76        .cloned()
77        .collect();
78
79    let snoozed: Vec<ReportAnnotation> = a
80        .detonated
81        .iter()
82        .filter(|ann| b_ticking_keys.contains(&annotation_key(ann)))
83        .cloned()
84        .collect();
85
86    let detonated_delta = b.detonated.len() as i64 - a.detonated.len() as i64;
87    let ticking_delta = b.ticking.len() as i64 - a.ticking.len() as i64;
88    let a_total = (a.detonated.len() + a.ticking.len() + a.inert.len()) as i64;
89    let b_total = (b.detonated.len() + b.ticking.len() + b.inert.len()) as i64;
90    let total_delta = b_total - a_total;
91
92    TrendResult {
93        from_timestamp: a.generated_at.clone(),
94        to_timestamp: b.generated_at.clone(),
95        detonated_delta,
96        ticking_delta,
97        total_delta,
98        newly_detonated,
99        resolved,
100        snoozed,
101    }
102}
103
104// ─── Output ───────────────────────────────────────────────────────────────────
105
106fn color_enabled() -> bool {
107    std::env::var("NO_COLOR").is_err()
108}
109
110fn fmt_delta(delta: i64, use_color: bool) -> String {
111    let s = if delta > 0 {
112        format!("+{}", delta)
113    } else {
114        format!("{}", delta)
115    };
116    if use_color {
117        if delta > 0 {
118            s.red().to_string()
119        } else if delta < 0 {
120            s.green().to_string()
121        } else {
122            s
123        }
124    } else {
125        s
126    }
127}
128
129pub fn print_trend(trend: &TrendResult, format: &OutputFormat) {
130    match format {
131        OutputFormat::Json => match serde_json::to_string_pretty(trend) {
132            Ok(json) => println!("{}", json),
133            Err(e) => eprintln!("error serializing trend: {}", e),
134        },
135        OutputFormat::GitHub => print_trend_github(trend),
136        OutputFormat::Terminal | OutputFormat::Csv | OutputFormat::Table => {
137            print_trend_terminal(trend)
138        }
139    }
140}
141
142fn print_trend_terminal(trend: &TrendResult) {
143    let use_color = color_enabled();
144
145    println!("Trend: {} → {}", trend.from_timestamp, trend.to_timestamp);
146    println!();
147
148    println!(
149        "  Detonated:    {}",
150        fmt_delta(trend.detonated_delta, use_color)
151    );
152    println!(
153        "  Ticking:      {}",
154        fmt_delta(trend.ticking_delta, use_color)
155    );
156    println!(
157        "  Total:        {}",
158        fmt_delta(trend.total_delta, use_color)
159    );
160    println!();
161
162    // Newly detonated
163    let n = trend.newly_detonated.len();
164    let header = format!("  Newly detonated ({}):", n);
165    if use_color && n > 0 {
166        println!("{}", header.red().bold());
167    } else {
168        println!("{}", header);
169    }
170    if trend.newly_detonated.is_empty() {
171        println!("    (none)");
172    } else {
173        for ann in &trend.newly_detonated {
174            let line = format!(
175                "    {}:{}  {}[{}]  {}",
176                ann.file, ann.line, ann.tag, ann.date, ann.message
177            );
178            if use_color {
179                println!("{}", line.red());
180            } else {
181                println!("{}", line);
182            }
183        }
184    }
185    println!();
186
187    // Resolved
188    let n = trend.resolved.len();
189    let header = format!("  Resolved ({}):", n);
190    if use_color && n > 0 {
191        println!("{}", header.green().bold());
192    } else {
193        println!("{}", header);
194    }
195    if trend.resolved.is_empty() {
196        println!("    (none)");
197    } else {
198        for ann in &trend.resolved {
199            let line = format!(
200                "    {}:{}  {}[{}]  (removed)",
201                ann.file, ann.line, ann.tag, ann.date
202            );
203            if use_color {
204                println!("{}", line.green());
205            } else {
206                println!("{}", line);
207            }
208        }
209    }
210    println!();
211
212    // Snoozed
213    let n = trend.snoozed.len();
214    println!("  Snoozed ({}):", n);
215    if trend.snoozed.is_empty() {
216        println!("    (none)");
217    } else {
218        for ann in &trend.snoozed {
219            println!(
220                "    {}:{}  {}[{}]  {}",
221                ann.file, ann.line, ann.tag, ann.date, ann.message
222            );
223        }
224    }
225}
226
227fn print_trend_github(trend: &TrendResult) {
228    for ann in &trend.newly_detonated {
229        println!(
230            "::error file={},line={}::{} detonated on {}: {}",
231            ann.file, ann.line, ann.tag, ann.date, ann.message
232        );
233    }
234    for ann in &trend.resolved {
235        println!(
236            "::notice file={},line={}::{} fuse resolved (removed from codebase)",
237            ann.file, ann.line, ann.tag
238        );
239    }
240}
241
242// ─── Entry point ─────────────────────────────────────────────────────────────
243
244pub fn run_trend(report_a_path: &Path, report_b_path: &Path, format: &OutputFormat) -> Result<i32> {
245    let a = load_report(report_a_path)?;
246    let b = load_report(report_b_path)?;
247    let trend = compute_trend(&a, &b);
248    print_trend(&trend, format);
249    Ok(0)
250}
251
252// ─── Tests ────────────────────────────────────────────────────────────────────
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::report::Report;
258
259    fn make_report_ann(file: &str, line: usize, tag: &str, date: &str) -> ReportAnnotation {
260        ReportAnnotation {
261            file: file.to_string(),
262            line,
263            tag: tag.to_string(),
264            date: date.to_string(),
265            owner: None,
266            message: format!("message at {}:{}", file, line),
267            status: "detonated".to_string(),
268        }
269    }
270
271    fn make_report(
272        generated_at: &str,
273        detonated: Vec<ReportAnnotation>,
274        ticking: Vec<ReportAnnotation>,
275        inert: Vec<ReportAnnotation>,
276    ) -> Report {
277        let total = detonated.len() + ticking.len() + inert.len();
278        Report {
279            generated_at: generated_at.to_string(),
280            swept_files: 1,
281            total_fuses: total,
282            detonated,
283            ticking,
284            inert,
285        }
286    }
287
288    // ── compute_trend ─────────────────────────────────────────────────────────
289
290    #[test]
291    fn test_compute_trend_newly_detonated() {
292        let a = make_report("2025-01-01T00:00:00Z", vec![], vec![], vec![]);
293        // B has one detonated fuse that wasn't in A.detonated
294        let ann = make_report_ann("src/foo.rs", 10, "TODO", "2025-01-15");
295        let b = make_report("2025-02-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
296
297        let trend = compute_trend(&a, &b);
298        assert_eq!(trend.newly_detonated.len(), 1);
299        assert_eq!(trend.newly_detonated[0].file, "src/foo.rs");
300        assert_eq!(trend.detonated_delta, 1);
301    }
302
303    #[test]
304    fn test_compute_trend_resolved() {
305        // Fuse was detonated in A, gone in B.
306        let ann = make_report_ann("src/old.rs", 5, "FIXME", "2020-12-01");
307        let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
308        let b = make_report("2025-02-01T00:00:00Z", vec![], vec![], vec![]);
309
310        let trend = compute_trend(&a, &b);
311        assert_eq!(trend.resolved.len(), 1);
312        assert_eq!(trend.resolved[0].file, "src/old.rs");
313        assert_eq!(trend.detonated_delta, -1);
314    }
315
316    #[test]
317    fn test_compute_trend_snoozed() {
318        // Fuse was in A.detonated, now it's in B.ticking (date bumped).
319        let ann = make_report_ann("src/worker.rs", 88, "TODO", "2025-03-01");
320        let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
321
322        // Same file:line, now in ticking bucket.
323        let mut snoozed_ann = ann.clone();
324        snoozed_ann.date = "2026-06-01".to_string();
325        snoozed_ann.status = "ticking".to_string();
326        let b = make_report("2025-02-01T00:00:00Z", vec![], vec![snoozed_ann], vec![]);
327
328        let trend = compute_trend(&a, &b);
329        assert_eq!(trend.snoozed.len(), 1);
330        assert_eq!(trend.snoozed[0].file, "src/worker.rs");
331    }
332
333    #[test]
334    fn test_compute_trend_delta_math() {
335        let ann1 = make_report_ann("src/a.rs", 1, "TODO", "2020-01-01");
336        let ann2 = make_report_ann("src/b.rs", 2, "FIXME", "2020-06-01");
337        let ann3 = make_report_ann("src/c.rs", 3, "HACK", "2021-01-01");
338
339        let mut ticking_ann = ann1.clone();
340        ticking_ann.status = "ticking".to_string();
341
342        let a = make_report(
343            "2025-01-01T00:00:00Z",
344            vec![ann1.clone(), ann2.clone()],
345            vec![ticking_ann.clone()],
346            vec![],
347        );
348
349        // B has only 1 detonated and 0 ticking
350        let b = make_report("2025-02-01T00:00:00Z", vec![ann3.clone()], vec![], vec![]);
351
352        let trend = compute_trend(&a, &b);
353        // detonated: was 2, now 1 → delta = -1
354        assert_eq!(trend.detonated_delta, -1);
355        // ticking: was 1, now 0 → delta = -1
356        assert_eq!(trend.ticking_delta, -1);
357        // total: was 3 (2+1+0), now 1 → delta = -2
358        assert_eq!(trend.total_delta, -2);
359    }
360
361    #[test]
362    fn test_compute_trend_timestamps() {
363        let a = make_report("2025-01-01T00:00:00Z", vec![], vec![], vec![]);
364        let b = make_report("2025-03-15T12:00:00Z", vec![], vec![], vec![]);
365        let trend = compute_trend(&a, &b);
366        assert_eq!(trend.from_timestamp, "2025-01-01T00:00:00Z");
367        assert_eq!(trend.to_timestamp, "2025-03-15T12:00:00Z");
368    }
369
370    #[test]
371    fn test_compute_trend_no_change() {
372        let ann = make_report_ann("src/x.rs", 7, "TODO", "2020-01-01");
373        let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
374        let b = make_report("2025-02-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
375        let trend = compute_trend(&a, &b);
376        assert_eq!(trend.detonated_delta, 0);
377        assert!(trend.newly_detonated.is_empty());
378        assert!(trend.resolved.is_empty());
379        assert!(trend.snoozed.is_empty());
380    }
381
382    // ── print_trend smoke tests ───────────────────────────────────────────────
383
384    fn make_nontrivial_trend() -> TrendResult {
385        TrendResult {
386            from_timestamp: "2025-01-01T00:00:00Z".to_string(),
387            to_timestamp: "2025-02-01T00:00:00Z".to_string(),
388            detonated_delta: 2,
389            ticking_delta: -1,
390            total_delta: 1,
391            newly_detonated: vec![make_report_ann("src/foo.rs", 42, "TODO", "2026-01-15")],
392            resolved: vec![make_report_ann("src/old.rs", 5, "TODO", "2025-12-01")],
393            snoozed: vec![],
394        }
395    }
396
397    #[test]
398    fn test_print_trend_terminal_does_not_panic() {
399        let trend = make_nontrivial_trend();
400        print_trend(&trend, &OutputFormat::Terminal);
401    }
402
403    #[test]
404    fn test_print_trend_json_does_not_panic() {
405        let trend = make_nontrivial_trend();
406        print_trend(&trend, &OutputFormat::Json);
407    }
408
409    #[test]
410    fn test_print_trend_github_does_not_panic() {
411        let trend = make_nontrivial_trend();
412        print_trend(&trend, &OutputFormat::GitHub);
413    }
414
415    // ── run_trend (filesystem round-trip) ────────────────────────────────────
416
417    #[test]
418    fn test_run_trend_reads_json_files() {
419        use crate::annotation::{Fuse, Status};
420        use crate::report::{build_report, write_report};
421        use crate::scanner::ScanResult;
422        use chrono::NaiveDate;
423        use std::path::PathBuf;
424
425        let tmp = tempfile::tempdir().unwrap();
426
427        let make_fuse = |file: &str, line: usize, date_str: &str, status: Status| Fuse {
428            file: PathBuf::from(file),
429            line,
430            tag: "TODO".to_string(),
431            date: NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap(),
432            owner: None,
433            message: "test".to_string(),
434            status,
435            blamed_owner: None,
436        };
437
438        let result_a = ScanResult {
439            fuses: vec![make_fuse("src/a.rs", 1, "2020-01-01", Status::Detonated)],
440            swept_files: 1,
441            skipped_files: 0,
442        };
443        let report_a = build_report(&result_a, "2025-01-01T00:00:00Z");
444        let path_a = tmp.path().join("report_a.json");
445        write_report(&report_a, &path_a).unwrap();
446
447        let result_b = ScanResult {
448            fuses: vec![make_fuse("src/b.rs", 2, "2021-06-01", Status::Detonated)],
449            swept_files: 1,
450            skipped_files: 0,
451        };
452        let report_b = build_report(&result_b, "2025-02-01T00:00:00Z");
453        let path_b = tmp.path().join("report_b.json");
454        write_report(&report_b, &path_b).unwrap();
455
456        let code = run_trend(&path_a, &path_b, &OutputFormat::Json).unwrap();
457        assert_eq!(code, 0);
458    }
459
460    #[test]
461    fn test_run_trend_error_on_missing_file() {
462        let tmp = tempfile::tempdir().unwrap();
463        let missing = tmp.path().join("does_not_exist.json");
464        // Both paths missing — should return an Err, not panic.
465        let result = run_trend(&missing, &missing, &OutputFormat::Terminal);
466        assert!(result.is_err());
467    }
468
469    // ── annotation_key format ─────────────────────────────────────────────────
470
471    #[test]
472    fn test_annotation_key_format() {
473        let ann = make_report_ann("src/lib.rs", 42, "TODO", "2025-01-01");
474        assert_eq!(annotation_key(&ann), "src/lib.rs:42");
475    }
476
477    // ── same fuse stays in neither newly_detonated nor resolved ──────────────
478
479    #[test]
480    fn test_compute_trend_unchanged_detonated_is_neither_new_nor_resolved() {
481        // Same file:line is detonated in both A and B → no change.
482        let ann = make_report_ann("src/x.rs", 10, "TODO", "2020-01-01");
483        let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
484        let b = make_report("2025-02-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
485
486        let trend = compute_trend(&a, &b);
487        assert!(trend.newly_detonated.is_empty(), "not newly detonated");
488        assert!(trend.resolved.is_empty(), "not resolved");
489        assert!(trend.snoozed.is_empty(), "not snoozed");
490        assert_eq!(trend.detonated_delta, 0);
491    }
492
493    // ── multiple snoozed ─────────────────────────────────────────────────────
494
495    #[test]
496    fn test_compute_trend_multiple_snoozed() {
497        let ann1 = make_report_ann("src/a.rs", 1, "TODO", "2025-01-01");
498        let ann2 = make_report_ann("src/b.rs", 2, "FIXME", "2025-02-01");
499        let a = make_report(
500            "2025-01-01T00:00:00Z",
501            vec![ann1.clone(), ann2.clone()],
502            vec![],
503            vec![],
504        );
505
506        let mut snoozed1 = ann1.clone();
507        snoozed1.date = "2026-12-01".to_string();
508        snoozed1.status = "ticking".to_string();
509        let mut snoozed2 = ann2.clone();
510        snoozed2.date = "2027-06-01".to_string();
511        snoozed2.status = "ticking".to_string();
512
513        let b = make_report(
514            "2025-02-01T00:00:00Z",
515            vec![],
516            vec![snoozed1, snoozed2],
517            vec![],
518        );
519
520        let trend = compute_trend(&a, &b);
521        assert_eq!(trend.snoozed.len(), 2);
522        assert_eq!(trend.detonated_delta, -2);
523    }
524
525    // ── resolved vs inert (fuse moved to inert, not just ticking) ────────────
526
527    #[test]
528    fn test_compute_trend_moved_to_inert_is_resolved() {
529        // Fuse was detonated in A; now it's in B.inert (date bumped far out).
530        let ann = make_report_ann("src/z.rs", 99, "HACK", "2020-05-01");
531        let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
532
533        let mut inert_ann = ann.clone();
534        inert_ann.date = "2099-01-01".to_string();
535        inert_ann.status = "inert".to_string();
536        let b = make_report("2025-02-01T00:00:00Z", vec![], vec![], vec![inert_ann]);
537
538        let trend = compute_trend(&a, &b);
539        // Still present in B (as inert) → not resolved, not snoozed.
540        assert!(trend.resolved.is_empty());
541        assert!(trend.snoozed.is_empty());
542    }
543
544    // ── empty reports ─────────────────────────────────────────────────────────
545
546    #[test]
547    fn test_compute_trend_both_empty() {
548        let a = make_report("2025-01-01T00:00:00Z", vec![], vec![], vec![]);
549        let b = make_report("2025-02-01T00:00:00Z", vec![], vec![], vec![]);
550        let trend = compute_trend(&a, &b);
551        assert_eq!(trend.detonated_delta, 0);
552        assert_eq!(trend.ticking_delta, 0);
553        assert_eq!(trend.total_delta, 0);
554        assert!(trend.newly_detonated.is_empty());
555        assert!(trend.resolved.is_empty());
556        assert!(trend.snoozed.is_empty());
557    }
558}