use crate::error::{Error, Result};
use crate::output::OutputFormat;
use crate::report::{Report, ReportAnnotation};
use colored::Colorize;
use serde::Serialize;
use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, Serialize)]
pub struct TrendResult {
pub from_timestamp: String,
pub to_timestamp: String,
pub detonated_delta: i64,
pub ticking_delta: i64,
pub total_delta: i64,
pub newly_detonated: Vec<ReportAnnotation>,
pub resolved: Vec<ReportAnnotation>,
pub snoozed: Vec<ReportAnnotation>,
}
fn load_report(path: &Path) -> Result<Report> {
let content = std::fs::read_to_string(path).map_err(|e| Error::Io {
source: e,
path: Some(path.to_path_buf()),
})?;
serde_json::from_str(&content).map_err(|e| Error::Io {
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
path: Some(path.to_path_buf()),
})
}
fn annotation_key(a: &ReportAnnotation) -> String {
format!("{}:{}", a.file, a.line)
}
fn key_set(anns: &[ReportAnnotation]) -> HashSet<String> {
anns.iter().map(annotation_key).collect()
}
pub fn compute_trend(a: &Report, b: &Report) -> TrendResult {
let a_detonated_keys = key_set(&a.detonated);
let b_detonated_keys = key_set(&b.detonated);
let b_ticking_keys = key_set(&b.ticking);
let b_inert_keys = key_set(&b.inert);
let b_all_keys: HashSet<&str> = b_detonated_keys
.iter()
.chain(b_ticking_keys.iter())
.chain(b_inert_keys.iter())
.map(String::as_str)
.collect();
let newly_detonated: Vec<ReportAnnotation> = b
.detonated
.iter()
.filter(|ann| !a_detonated_keys.contains(&annotation_key(ann)))
.cloned()
.collect();
let resolved: Vec<ReportAnnotation> = a
.detonated
.iter()
.filter(|ann| !b_all_keys.contains(annotation_key(ann).as_str()))
.cloned()
.collect();
let snoozed: Vec<ReportAnnotation> = a
.detonated
.iter()
.filter(|ann| b_ticking_keys.contains(&annotation_key(ann)))
.cloned()
.collect();
let detonated_delta = b.detonated.len() as i64 - a.detonated.len() as i64;
let ticking_delta = b.ticking.len() as i64 - a.ticking.len() as i64;
let a_total = (a.detonated.len() + a.ticking.len() + a.inert.len()) as i64;
let b_total = (b.detonated.len() + b.ticking.len() + b.inert.len()) as i64;
let total_delta = b_total - a_total;
TrendResult {
from_timestamp: a.generated_at.clone(),
to_timestamp: b.generated_at.clone(),
detonated_delta,
ticking_delta,
total_delta,
newly_detonated,
resolved,
snoozed,
}
}
fn color_enabled() -> bool {
std::env::var("NO_COLOR").is_err()
}
fn fmt_delta(delta: i64, use_color: bool) -> String {
let s = if delta > 0 {
format!("+{}", delta)
} else {
format!("{}", delta)
};
if use_color {
if delta > 0 {
s.red().to_string()
} else if delta < 0 {
s.green().to_string()
} else {
s
}
} else {
s
}
}
pub fn print_trend(trend: &TrendResult, format: &OutputFormat) {
match format {
OutputFormat::Json => match serde_json::to_string_pretty(trend) {
Ok(json) => println!("{}", json),
Err(e) => eprintln!("error serializing trend: {}", e),
},
OutputFormat::GitHub => print_trend_github(trend),
OutputFormat::Terminal | OutputFormat::Csv | OutputFormat::Table => {
print_trend_terminal(trend)
}
}
}
fn print_trend_terminal(trend: &TrendResult) {
let use_color = color_enabled();
println!("Trend: {} → {}", trend.from_timestamp, trend.to_timestamp);
println!();
println!(
" Detonated: {}",
fmt_delta(trend.detonated_delta, use_color)
);
println!(
" Ticking: {}",
fmt_delta(trend.ticking_delta, use_color)
);
println!(
" Total: {}",
fmt_delta(trend.total_delta, use_color)
);
println!();
let n = trend.newly_detonated.len();
let header = format!(" Newly detonated ({}):", n);
if use_color && n > 0 {
println!("{}", header.red().bold());
} else {
println!("{}", header);
}
if trend.newly_detonated.is_empty() {
println!(" (none)");
} else {
for ann in &trend.newly_detonated {
let line = format!(
" {}:{} {}[{}] {}",
ann.file, ann.line, ann.tag, ann.date, ann.message
);
if use_color {
println!("{}", line.red());
} else {
println!("{}", line);
}
}
}
println!();
let n = trend.resolved.len();
let header = format!(" Resolved ({}):", n);
if use_color && n > 0 {
println!("{}", header.green().bold());
} else {
println!("{}", header);
}
if trend.resolved.is_empty() {
println!(" (none)");
} else {
for ann in &trend.resolved {
let line = format!(
" {}:{} {}[{}] (removed)",
ann.file, ann.line, ann.tag, ann.date
);
if use_color {
println!("{}", line.green());
} else {
println!("{}", line);
}
}
}
println!();
let n = trend.snoozed.len();
println!(" Snoozed ({}):", n);
if trend.snoozed.is_empty() {
println!(" (none)");
} else {
for ann in &trend.snoozed {
println!(
" {}:{} {}[{}] {}",
ann.file, ann.line, ann.tag, ann.date, ann.message
);
}
}
}
fn print_trend_github(trend: &TrendResult) {
for ann in &trend.newly_detonated {
println!(
"::error file={},line={}::{} detonated on {}: {}",
ann.file, ann.line, ann.tag, ann.date, ann.message
);
}
for ann in &trend.resolved {
println!(
"::notice file={},line={}::{} fuse resolved (removed from codebase)",
ann.file, ann.line, ann.tag
);
}
}
pub fn run_trend(report_a_path: &Path, report_b_path: &Path, format: &OutputFormat) -> Result<i32> {
let a = load_report(report_a_path)?;
let b = load_report(report_b_path)?;
let trend = compute_trend(&a, &b);
print_trend(&trend, format);
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::Report;
fn make_report_ann(file: &str, line: usize, tag: &str, date: &str) -> ReportAnnotation {
ReportAnnotation {
file: file.to_string(),
line,
tag: tag.to_string(),
date: date.to_string(),
owner: None,
message: format!("message at {}:{}", file, line),
status: "detonated".to_string(),
}
}
fn make_report(
generated_at: &str,
detonated: Vec<ReportAnnotation>,
ticking: Vec<ReportAnnotation>,
inert: Vec<ReportAnnotation>,
) -> Report {
let total = detonated.len() + ticking.len() + inert.len();
Report {
generated_at: generated_at.to_string(),
swept_files: 1,
total_fuses: total,
detonated,
ticking,
inert,
}
}
#[test]
fn test_compute_trend_newly_detonated() {
let a = make_report("2025-01-01T00:00:00Z", vec![], vec![], vec![]);
let ann = make_report_ann("src/foo.rs", 10, "TODO", "2025-01-15");
let b = make_report("2025-02-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
let trend = compute_trend(&a, &b);
assert_eq!(trend.newly_detonated.len(), 1);
assert_eq!(trend.newly_detonated[0].file, "src/foo.rs");
assert_eq!(trend.detonated_delta, 1);
}
#[test]
fn test_compute_trend_resolved() {
let ann = make_report_ann("src/old.rs", 5, "FIXME", "2020-12-01");
let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
let b = make_report("2025-02-01T00:00:00Z", vec![], vec![], vec![]);
let trend = compute_trend(&a, &b);
assert_eq!(trend.resolved.len(), 1);
assert_eq!(trend.resolved[0].file, "src/old.rs");
assert_eq!(trend.detonated_delta, -1);
}
#[test]
fn test_compute_trend_snoozed() {
let ann = make_report_ann("src/worker.rs", 88, "TODO", "2025-03-01");
let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
let mut snoozed_ann = ann.clone();
snoozed_ann.date = "2026-06-01".to_string();
snoozed_ann.status = "ticking".to_string();
let b = make_report("2025-02-01T00:00:00Z", vec![], vec![snoozed_ann], vec![]);
let trend = compute_trend(&a, &b);
assert_eq!(trend.snoozed.len(), 1);
assert_eq!(trend.snoozed[0].file, "src/worker.rs");
}
#[test]
fn test_compute_trend_delta_math() {
let ann1 = make_report_ann("src/a.rs", 1, "TODO", "2020-01-01");
let ann2 = make_report_ann("src/b.rs", 2, "FIXME", "2020-06-01");
let ann3 = make_report_ann("src/c.rs", 3, "HACK", "2021-01-01");
let mut ticking_ann = ann1.clone();
ticking_ann.status = "ticking".to_string();
let a = make_report(
"2025-01-01T00:00:00Z",
vec![ann1.clone(), ann2.clone()],
vec![ticking_ann.clone()],
vec![],
);
let b = make_report("2025-02-01T00:00:00Z", vec![ann3.clone()], vec![], vec![]);
let trend = compute_trend(&a, &b);
assert_eq!(trend.detonated_delta, -1);
assert_eq!(trend.ticking_delta, -1);
assert_eq!(trend.total_delta, -2);
}
#[test]
fn test_compute_trend_timestamps() {
let a = make_report("2025-01-01T00:00:00Z", vec![], vec![], vec![]);
let b = make_report("2025-03-15T12:00:00Z", vec![], vec![], vec![]);
let trend = compute_trend(&a, &b);
assert_eq!(trend.from_timestamp, "2025-01-01T00:00:00Z");
assert_eq!(trend.to_timestamp, "2025-03-15T12:00:00Z");
}
#[test]
fn test_compute_trend_no_change() {
let ann = make_report_ann("src/x.rs", 7, "TODO", "2020-01-01");
let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
let b = make_report("2025-02-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
let trend = compute_trend(&a, &b);
assert_eq!(trend.detonated_delta, 0);
assert!(trend.newly_detonated.is_empty());
assert!(trend.resolved.is_empty());
assert!(trend.snoozed.is_empty());
}
fn make_nontrivial_trend() -> TrendResult {
TrendResult {
from_timestamp: "2025-01-01T00:00:00Z".to_string(),
to_timestamp: "2025-02-01T00:00:00Z".to_string(),
detonated_delta: 2,
ticking_delta: -1,
total_delta: 1,
newly_detonated: vec![make_report_ann("src/foo.rs", 42, "TODO", "2026-01-15")],
resolved: vec![make_report_ann("src/old.rs", 5, "TODO", "2025-12-01")],
snoozed: vec![],
}
}
#[test]
fn test_print_trend_terminal_does_not_panic() {
let trend = make_nontrivial_trend();
print_trend(&trend, &OutputFormat::Terminal);
}
#[test]
fn test_print_trend_json_does_not_panic() {
let trend = make_nontrivial_trend();
print_trend(&trend, &OutputFormat::Json);
}
#[test]
fn test_print_trend_github_does_not_panic() {
let trend = make_nontrivial_trend();
print_trend(&trend, &OutputFormat::GitHub);
}
#[test]
fn test_run_trend_reads_json_files() {
use crate::annotation::{Fuse, Status};
use crate::report::{build_report, write_report};
use crate::scanner::ScanResult;
use chrono::NaiveDate;
use std::path::PathBuf;
let tmp = tempfile::tempdir().unwrap();
let make_fuse = |file: &str, line: usize, date_str: &str, status: Status| Fuse {
file: PathBuf::from(file),
line,
tag: "TODO".to_string(),
date: NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap(),
owner: None,
message: "test".to_string(),
status,
blamed_owner: None,
};
let result_a = ScanResult {
fuses: vec![make_fuse("src/a.rs", 1, "2020-01-01", Status::Detonated)],
swept_files: 1,
skipped_files: 0,
};
let report_a = build_report(&result_a, "2025-01-01T00:00:00Z");
let path_a = tmp.path().join("report_a.json");
write_report(&report_a, &path_a).unwrap();
let result_b = ScanResult {
fuses: vec![make_fuse("src/b.rs", 2, "2021-06-01", Status::Detonated)],
swept_files: 1,
skipped_files: 0,
};
let report_b = build_report(&result_b, "2025-02-01T00:00:00Z");
let path_b = tmp.path().join("report_b.json");
write_report(&report_b, &path_b).unwrap();
let code = run_trend(&path_a, &path_b, &OutputFormat::Json).unwrap();
assert_eq!(code, 0);
}
#[test]
fn test_run_trend_error_on_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("does_not_exist.json");
let result = run_trend(&missing, &missing, &OutputFormat::Terminal);
assert!(result.is_err());
}
#[test]
fn test_annotation_key_format() {
let ann = make_report_ann("src/lib.rs", 42, "TODO", "2025-01-01");
assert_eq!(annotation_key(&ann), "src/lib.rs:42");
}
#[test]
fn test_compute_trend_unchanged_detonated_is_neither_new_nor_resolved() {
let ann = make_report_ann("src/x.rs", 10, "TODO", "2020-01-01");
let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
let b = make_report("2025-02-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
let trend = compute_trend(&a, &b);
assert!(trend.newly_detonated.is_empty(), "not newly detonated");
assert!(trend.resolved.is_empty(), "not resolved");
assert!(trend.snoozed.is_empty(), "not snoozed");
assert_eq!(trend.detonated_delta, 0);
}
#[test]
fn test_compute_trend_multiple_snoozed() {
let ann1 = make_report_ann("src/a.rs", 1, "TODO", "2025-01-01");
let ann2 = make_report_ann("src/b.rs", 2, "FIXME", "2025-02-01");
let a = make_report(
"2025-01-01T00:00:00Z",
vec![ann1.clone(), ann2.clone()],
vec![],
vec![],
);
let mut snoozed1 = ann1.clone();
snoozed1.date = "2026-12-01".to_string();
snoozed1.status = "ticking".to_string();
let mut snoozed2 = ann2.clone();
snoozed2.date = "2027-06-01".to_string();
snoozed2.status = "ticking".to_string();
let b = make_report(
"2025-02-01T00:00:00Z",
vec![],
vec![snoozed1, snoozed2],
vec![],
);
let trend = compute_trend(&a, &b);
assert_eq!(trend.snoozed.len(), 2);
assert_eq!(trend.detonated_delta, -2);
}
#[test]
fn test_compute_trend_moved_to_inert_is_resolved() {
let ann = make_report_ann("src/z.rs", 99, "HACK", "2020-05-01");
let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
let mut inert_ann = ann.clone();
inert_ann.date = "2099-01-01".to_string();
inert_ann.status = "inert".to_string();
let b = make_report("2025-02-01T00:00:00Z", vec![], vec![], vec![inert_ann]);
let trend = compute_trend(&a, &b);
assert!(trend.resolved.is_empty());
assert!(trend.snoozed.is_empty());
}
#[test]
fn test_compute_trend_both_empty() {
let a = make_report("2025-01-01T00:00:00Z", vec![], vec![], vec![]);
let b = make_report("2025-02-01T00:00:00Z", vec![], vec![], vec![]);
let trend = compute_trend(&a, &b);
assert_eq!(trend.detonated_delta, 0);
assert_eq!(trend.ticking_delta, 0);
assert_eq!(trend.total_delta, 0);
assert!(trend.newly_detonated.is_empty());
assert!(trend.resolved.is_empty());
assert!(trend.snoozed.is_empty());
}
}