use std::path::{Path, PathBuf};
use timebomb::annotation::Status;
use timebomb::config::Config;
use timebomb::scanner::scan;
use timebomb::stats::compute_stats;
fn fixtures_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
}
fn today() -> chrono::NaiveDate {
chrono::NaiveDate::parse_from_str("2025-06-01", "%Y-%m-%d").unwrap()
}
fn config_with_fuse(days: u32) -> Config {
Config {
fuse_days: days,
..Config::default()
}
}
#[test]
fn test_no_inert_removes_inert_fuses() {
let dir = fixtures_dir();
let cfg = Config::default();
let mut result = scan(&dir, &cfg, today()).unwrap();
let before = result.fuses.len();
let inert_count = result.inert().len();
assert!(inert_count > 0, "fixture dir must have inert fuses");
result
.fuses
.retain(|f| f.status != timebomb::annotation::Status::Inert);
assert_eq!(result.fuses.len(), before - inert_count);
assert!(result.fuses.iter().all(|f| f.status != Status::Inert));
}
#[test]
fn test_no_inert_does_not_affect_detonated_count() {
let dir = fixtures_dir();
let cfg = Config::default();
let mut result = scan(&dir, &cfg, today()).unwrap();
let detonated_before = result.detonated().len();
result
.fuses
.retain(|f| f.status != timebomb::annotation::Status::Inert);
assert_eq!(result.detonated().len(), detonated_before);
}
#[test]
fn test_owner_missing_keeps_only_unowned() {
let dir = fixtures_dir();
let cfg = Config::default();
let mut result = scan(&dir, &cfg, today()).unwrap();
result
.fuses
.retain(|f| f.owner.is_none() && f.blamed_owner.is_none());
assert!(result
.fuses
.iter()
.all(|f| f.owner.is_none() && f.blamed_owner.is_none()));
}
#[test]
fn test_between_filters_by_date_range() {
let dir = fixtures_dir();
let cfg = Config::default();
let result = scan(&dir, &cfg, today()).unwrap();
let all_fuses: Vec<_> = result.fuses.iter().collect();
let start = chrono::NaiveDate::from_ymd_opt(2019, 1, 1).unwrap();
let end = chrono::NaiveDate::from_ymd_opt(2021, 12, 31).unwrap();
let filtered: Vec<_> = all_fuses
.iter()
.filter(|f| f.date >= start && f.date <= end)
.collect();
assert!(filtered.iter().all(|f| f.date >= start && f.date <= end));
assert!(!filtered.is_empty(), "should have detonated fuses in range");
}
#[test]
fn test_between_empty_range() {
let dir = fixtures_dir();
let cfg = Config::default();
let result = scan(&dir, &cfg, today()).unwrap();
let all_fuses: Vec<_> = result.fuses.iter().collect();
let start = chrono::NaiveDate::from_ymd_opt(2000, 1, 1).unwrap();
let end = chrono::NaiveDate::from_ymd_opt(2000, 12, 31).unwrap();
let filtered: Vec<_> = all_fuses
.iter()
.filter(|f| f.date >= start && f.date <= end)
.collect();
assert!(filtered.is_empty());
}
#[test]
fn test_sort_by_file() {
let dir = fixtures_dir();
let cfg = Config::default();
let result = scan(&dir, &cfg, today()).unwrap();
let mut fuses: Vec<_> = result.fuses.iter().collect();
fuses.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
for window in fuses.windows(2) {
assert!(
window[0].file < window[1].file
|| (window[0].file == window[1].file && window[0].line <= window[1].line)
);
}
}
#[test]
fn test_sort_by_status() {
fn status_order(s: &Status) -> u8 {
match s {
Status::Detonated => 0,
Status::Ticking => 1,
Status::Inert => 2,
}
}
let dir = fixtures_dir();
let cfg = config_with_fuse(30);
let result = scan(&dir, &cfg, today()).unwrap();
let mut fuses: Vec<_> = result.fuses.iter().collect();
fuses.sort_by(|a, b| {
status_order(&a.status)
.cmp(&status_order(&b.status))
.then(a.date.cmp(&b.date))
});
for window in fuses.windows(2) {
assert!(status_order(&window[0].status) <= status_order(&window[1].status));
}
}
#[test]
fn test_count_matches_fuse_vec_length() {
let dir = fixtures_dir();
let cfg = Config::default();
let result = scan(&dir, &cfg, today()).unwrap();
let count = result.fuses.len();
assert_eq!(count, result.total());
}
#[test]
fn test_csv_output_has_header_and_correct_columns() {
use timebomb::output::print_csv_list;
let dir = fixtures_dir();
let cfg = Config::default();
let result = scan(&dir, &cfg, today()).unwrap();
let fuses: Vec<_> = result.fuses.iter().collect();
let mut buf = Vec::new();
{
use std::io::Write;
writeln!(buf, "file,line,tag,date,owner,status,message").unwrap();
for fuse in &fuses {
writeln!(
buf,
"{},{},{},{},{},{},{}",
fuse.file.display(),
fuse.line,
fuse.tag,
fuse.date_str(),
fuse.owner.as_deref().unwrap_or(""),
fuse.status.as_str(),
fuse.message,
)
.unwrap();
}
}
let output = String::from_utf8(buf).unwrap();
let mut lines = output.lines();
let header = lines.next().unwrap();
assert_eq!(header, "file,line,tag,date,owner,status,message");
for line in lines {
let cols: Vec<_> = line.splitn(7, ',').collect();
assert_eq!(cols.len(), 7, "expected 7 columns in: {line}");
}
print_csv_list(&fuses);
}
#[test]
fn test_timebomb_fuse_days_env_var_sets_window() {
use timebomb::annotation::Fuse;
let today = today();
let in_20_days = today + chrono::Duration::days(20);
let status_with_30d = Fuse::compute_status(in_20_days, today, 30);
let status_with_0d = Fuse::compute_status(in_20_days, today, 0);
assert_eq!(status_with_30d, Status::Ticking);
assert_eq!(status_with_0d, Status::Inert);
}
#[test]
fn test_file_filter_suffix_match() {
let dir = fixtures_dir();
let cfg = Config::default();
let result = scan(&dir, &cfg, today()).unwrap();
let rs_fuses: Vec<_> = result
.fuses
.iter()
.filter(|f| f.file.extension().and_then(|e| e.to_str()) == Some("rs"))
.collect();
assert!(!rs_fuses.is_empty());
for fuse in &rs_fuses {
assert!(fuse.file.ends_with("sample.rs") || !fuse.file.ends_with("sample.rs"));
}
}
#[test]
fn test_intel_owner_filter_limits_stats() {
let dir = fixtures_dir();
let cfg = Config::default();
let mut result = scan(&dir, &cfg, today()).unwrap();
result.fuses.retain(|f| f.owner.as_deref() == Some("alice"));
let stats = compute_stats(&result.fuses);
assert!(stats.by_owner.iter().all(|r| r.owner == "alice"));
}
#[test]
fn test_intel_tag_filter_limits_stats() {
let dir = fixtures_dir();
let cfg = Config::default();
let mut result = scan(&dir, &cfg, today()).unwrap();
result.fuses.retain(|f| f.tag.to_lowercase() == "todo");
let stats = compute_stats(&result.fuses);
assert!(stats.by_tag.iter().all(|r| r.tag.to_lowercase() == "todo"));
}
#[test]
fn test_intel_by_month_groups_correctly() {
let dir = fixtures_dir();
let cfg = Config::default();
let result = scan(&dir, &cfg, today()).unwrap();
let stats = compute_stats(&result.fuses);
for window in stats.by_month.windows(2) {
assert!(window[0].month <= window[1].month);
}
let month_total: usize = stats.by_month.iter().map(|r| r.total).sum();
assert_eq!(month_total, stats.total_fuses);
}
#[test]
fn test_intel_by_month_counts_consistent() {
let dir = fixtures_dir();
let cfg = Config::default();
let result = scan(&dir, &cfg, today()).unwrap();
let stats = compute_stats(&result.fuses);
for row in &stats.by_month {
assert_eq!(
row.detonated + row.ticking + row.inert,
row.total,
"month {} counts don't add up",
row.month
);
}
}
#[test]
fn test_manifest_output_writes_valid_json() {
use timebomb::output::print_json_list_to_writer;
let dir = fixtures_dir();
let cfg = Config::default();
let result = scan(&dir, &cfg, today()).unwrap();
let fuses: Vec<_> = result.fuses.iter().collect();
let mut buf = Vec::new();
print_json_list_to_writer(&fuses, &mut buf, today()).unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert!(parsed.is_array());
assert_eq!(parsed.as_array().unwrap().len(), fuses.len());
}