use std::io::Write as _;
use std::path::Path;
use chrono::NaiveDate;
use timebomb::baseline::{
check_ratchet, load_baseline, run_baseline_save, run_baseline_show, Baseline,
};
use timebomb::cli::Cli;
use timebomb::config::{load_config, CliOverrides};
fn fixed_today() -> NaiveDate {
NaiveDate::parse_from_str("2025-06-01", "%Y-%m-%d").unwrap()
}
fn no_overrides() -> CliOverrides {
CliOverrides::default()
}
fn write_config(content: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join(".timebomb.toml");
let mut f = std::fs::File::create(&config_path).unwrap();
write!(f, "{}", content).unwrap();
dir
}
#[test]
fn test_config_max_detonated_parsed() {
let dir = write_config("max_detonated = 5\n");
let cfg = load_config(dir.path(), &no_overrides()).unwrap();
assert_eq!(cfg.max_detonated, Some(5));
}
#[test]
fn test_config_max_ticking_parsed() {
let dir = write_config("max_ticking = 10\n");
let cfg = load_config(dir.path(), &no_overrides()).unwrap();
assert_eq!(cfg.max_ticking, Some(10));
}
#[test]
fn test_config_max_fields_default_none() {
let dir = tempfile::tempdir().unwrap();
let cfg = load_config(dir.path(), &no_overrides()).unwrap();
assert_eq!(cfg.max_detonated, None);
assert_eq!(cfg.max_ticking, None);
}
#[test]
fn test_config_both_max_fields_parsed() {
let dir = write_config("max_detonated = 3\nmax_ticking = 7\n");
let cfg = load_config(dir.path(), &no_overrides()).unwrap();
assert_eq!(cfg.max_detonated, Some(3));
assert_eq!(cfg.max_ticking, Some(7));
}
#[test]
fn test_baseline_save_creates_file() {
let dir = tempfile::tempdir().unwrap();
let mut f = std::fs::File::create(dir.path().join("main.rs")).unwrap();
writeln!(f, "// TODO[2020-01-01]: expired").unwrap();
let cfg = load_config(dir.path(), &no_overrides()).unwrap();
let baseline_path = dir.path().join(".timebomb-baseline.json");
let generated_at = "2025-06-01T12:00:00Z";
let code = run_baseline_save(
dir.path(),
&cfg,
fixed_today(),
&baseline_path,
generated_at,
)
.unwrap();
assert_eq!(code, 0);
assert!(baseline_path.exists());
let loaded = load_baseline(&baseline_path).unwrap().unwrap();
assert_eq!(loaded.detonated, 1);
assert_eq!(loaded.ticking, 0);
assert_eq!(loaded.generated_at, generated_at);
}
#[test]
fn test_baseline_save_empty_dir_writes_zeros() {
let dir = tempfile::tempdir().unwrap();
let cfg = load_config(dir.path(), &no_overrides()).unwrap();
let baseline_path = dir.path().join(".timebomb-baseline.json");
run_baseline_save(dir.path(), &cfg, fixed_today(), &baseline_path, "ts").unwrap();
let loaded = load_baseline(&baseline_path).unwrap().unwrap();
assert_eq!(loaded.detonated, 0);
assert_eq!(loaded.ticking, 0);
}
#[test]
fn test_sweep_exits_one_when_ratchet_violated() {
use clap::Parser;
let dir = tempfile::tempdir().unwrap();
{
let baseline = Baseline {
generated_at: "2025-01-01T00:00:00Z".to_string(),
detonated: 0,
ticking: 0,
};
timebomb::baseline::save_baseline(&baseline, &dir.path().join(".timebomb-baseline.json"))
.unwrap();
}
{
let mut f = std::fs::File::create(dir.path().join("main.rs")).unwrap();
writeln!(f, "// TODO[2020-01-01]: expired").unwrap();
}
let cli = Cli::parse_from(["timebomb", "sweep", dir.path().to_str().unwrap()]);
let cfg = load_config(dir.path(), &no_overrides()).unwrap();
let result = timebomb::scanner::scan(dir.path(), &cfg, fixed_today()).unwrap();
let baseline = load_baseline(&dir.path().join(".timebomb-baseline.json"))
.unwrap()
.unwrap();
let violations = check_ratchet(
result.detonated().len(),
result.ticking().len(),
Some(&baseline),
cfg.max_detonated,
cfg.max_ticking,
);
assert!(!violations.is_empty(), "ratchet should be violated");
let _ = cli;
}
#[test]
fn test_sweep_exits_zero_within_baseline() {
let dir = tempfile::tempdir().unwrap();
{
let baseline = Baseline {
generated_at: "2025-01-01T00:00:00Z".to_string(),
detonated: 2,
ticking: 0,
};
timebomb::baseline::save_baseline(&baseline, &dir.path().join(".timebomb-baseline.json"))
.unwrap();
}
{
let mut f = std::fs::File::create(dir.path().join("main.rs")).unwrap();
writeln!(f, "// TODO[2020-01-01]: expired one").unwrap();
writeln!(f, "// FIXME[2019-01-01]: expired two").unwrap();
}
let cfg = load_config(dir.path(), &no_overrides()).unwrap();
let result = timebomb::scanner::scan(dir.path(), &cfg, fixed_today()).unwrap();
let baseline = load_baseline(&dir.path().join(".timebomb-baseline.json"))
.unwrap()
.unwrap();
let violations = check_ratchet(
result.detonated().len(),
result.ticking().len(),
Some(&baseline),
cfg.max_detonated,
cfg.max_ticking,
);
assert!(
violations.is_empty(),
"no ratchet violation when count matches baseline: {:?}",
violations
);
}
#[test]
fn test_baseline_show_no_file_exits_zero() {
let dir = tempfile::tempdir().unwrap();
let cfg = load_config(dir.path(), &no_overrides()).unwrap();
let baseline_path = dir.path().join(".timebomb-baseline.json");
let code = run_baseline_show(dir.path(), &cfg, fixed_today(), &baseline_path).unwrap();
assert_eq!(code, 0);
}
#[test]
fn test_baseline_show_with_file_exits_zero() {
let dir = tempfile::tempdir().unwrap();
let cfg = load_config(dir.path(), &no_overrides()).unwrap();
let baseline_path = dir.path().join(".timebomb-baseline.json");
run_baseline_save(dir.path(), &cfg, fixed_today(), &baseline_path, "ts").unwrap();
let code = run_baseline_show(dir.path(), &cfg, fixed_today(), &baseline_path).unwrap();
assert_eq!(code, 0);
}
#[test]
fn test_cli_bunker_save_defaults() {
use clap::Parser;
use timebomb::cli::{BaselineCommand, Command};
let cli = Cli::parse_from(["timebomb", "bunker", "save"]);
match cli.command {
Command::Bunker(args) => match args.command {
BaselineCommand::Save(a) => {
assert_eq!(a.path, ".");
assert_eq!(a.baseline_file, ".timebomb-baseline.json");
assert!(a.config.is_none());
assert!(a.fuse.is_none());
}
_ => panic!("expected Save"),
},
_ => panic!("expected Bunker"),
}
}
#[test]
fn test_cli_bunker_show_defaults() {
use clap::Parser;
use timebomb::cli::{BaselineCommand, Command};
let cli = Cli::parse_from(["timebomb", "bunker", "show"]);
match cli.command {
Command::Bunker(args) => match args.command {
BaselineCommand::Show(a) => {
assert_eq!(a.path, ".");
assert_eq!(a.baseline_file, ".timebomb-baseline.json");
assert!(a.config.is_none());
assert!(a.fuse.is_none());
}
_ => panic!("expected Show"),
},
_ => panic!("expected Bunker"),
}
}
#[test]
fn test_cli_bunker_save_custom_file() {
use clap::Parser;
use timebomb::cli::{BaselineCommand, Command};
let cli = Cli::parse_from([
"timebomb",
"bunker",
"save",
"--baseline-file",
"custom.json",
]);
match cli.command {
Command::Bunker(args) => match args.command {
BaselineCommand::Save(a) => {
assert_eq!(a.baseline_file, "custom.json");
}
_ => panic!("expected Save"),
},
_ => panic!("expected Bunker"),
}
}
#[test]
fn test_cli_bunker_show_custom_file() {
use clap::Parser;
use timebomb::cli::{BaselineCommand, Command};
let cli = Cli::parse_from([
"timebomb",
"bunker",
"show",
"--baseline-file",
"custom.json",
]);
match cli.command {
Command::Bunker(args) => match args.command {
BaselineCommand::Show(a) => {
assert_eq!(a.baseline_file, "custom.json");
}
_ => panic!("expected Show"),
},
_ => panic!("expected Bunker"),
}
}
#[test]
fn test_cli_bunker_save_with_path_and_fuse() {
use clap::Parser;
use timebomb::cli::{BaselineCommand, Command};
let cli = Cli::parse_from(["timebomb", "bunker", "save", "./src", "--fuse", "14d"]);
match cli.command {
Command::Bunker(args) => match args.command {
BaselineCommand::Save(a) => {
assert_eq!(a.path, "./src");
assert_eq!(a.fuse, Some("14d".to_string()));
}
_ => panic!("expected Save"),
},
_ => panic!("expected Bunker"),
}
}
#[test]
fn test_ratchet_max_detonated_from_config_causes_violation() {
let dir = write_config("max_detonated = 0\n");
{
let mut f = std::fs::File::create(dir.path().join("main.rs")).unwrap();
writeln!(f, "// TODO[2020-01-01]: expired").unwrap();
}
let cfg = load_config(dir.path(), &no_overrides()).unwrap();
let result = timebomb::scanner::scan(dir.path(), &cfg, fixed_today()).unwrap();
let violations = check_ratchet(
result.detonated().len(),
result.ticking().len(),
None,
cfg.max_detonated,
cfg.max_ticking,
);
assert!(
!violations.is_empty(),
"should violate max_detonated=0 with 1 detonated"
);
}
#[test]
fn test_ratchet_max_detonated_from_config_no_violation_at_limit() {
let dir = write_config("max_detonated = 1\n");
{
let mut f = std::fs::File::create(dir.path().join("main.rs")).unwrap();
writeln!(f, "// TODO[2020-01-01]: expired").unwrap();
}
let cfg = load_config(dir.path(), &no_overrides()).unwrap();
let result = timebomb::scanner::scan(dir.path(), &cfg, fixed_today()).unwrap();
let violations = check_ratchet(
result.detonated().len(),
result.ticking().len(),
None,
cfg.max_detonated,
cfg.max_ticking,
);
assert!(
violations.is_empty(),
"should not violate max_detonated=1 with 1 detonated: {:?}",
violations
);
}
#[test]
fn test_check_ratchet_all_zero_no_baseline() {
let violations = check_ratchet(0, 0, None, None, None);
assert!(violations.is_empty());
}
#[test]
fn test_check_ratchet_four_violations_at_once() {
let baseline = Baseline {
generated_at: "2025-01-01T00:00:00Z".to_string(),
detonated: 1,
ticking: 1,
};
let violations = check_ratchet(5, 10, Some(&baseline), Some(2), Some(5));
assert_eq!(violations.len(), 4);
}
#[test]
fn test_check_ratchet_baseline_equal_not_violated() {
let baseline = Baseline {
generated_at: "2025-01-01T00:00:00Z".to_string(),
detonated: 3,
ticking: 2,
};
let violations = check_ratchet(3, 2, Some(&baseline), None, None);
assert!(violations.is_empty());
}
#[test]
fn test_load_baseline_from_saved() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".timebomb-baseline.json");
let original = Baseline {
generated_at: "2025-06-01T00:00:00+00:00".to_string(),
detonated: 7,
ticking: 3,
};
timebomb::baseline::save_baseline(&original, &path).unwrap();
let loaded = load_baseline(&path).unwrap().unwrap();
assert_eq!(loaded.detonated, 7);
assert_eq!(loaded.ticking, 3);
assert_eq!(loaded.generated_at, original.generated_at);
}
#[test]
fn test_baseline_file_is_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("baseline.json");
let baseline = Baseline {
generated_at: "2025-01-01T00:00:00Z".to_string(),
detonated: 1,
ticking: 2,
};
timebomb::baseline::save_baseline(&baseline, &path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(parsed["detonated"], 1);
assert_eq!(parsed["ticking"], 2);
assert_eq!(parsed["generated_at"], "2025-01-01T00:00:00Z");
}
#[test]
fn test_load_baseline_corrupt_file_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, b"not json at all").unwrap();
let result = load_baseline(Path::new(path.to_str().unwrap()));
assert!(result.is_err(), "corrupt JSON should return Err");
}