use chrono::Datelike;
use std::path::{Path, PathBuf};
use timebomb::annotation::Status;
use timebomb::config::Config;
use timebomb::scanner::{build_regex, scan, scan_content};
fn scan_str(
src: &str,
path: &Path,
cfg: &Config,
today: chrono::NaiveDate,
) -> timebomb::error::Result<Vec<timebomb::annotation::Fuse>> {
let regex = build_regex(cfg)?;
scan_content(src, path, ®ex, cfg, today)
}
fn fixtures_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
}
fn fixture_path(name: &str) -> PathBuf {
fixtures_dir().join(name)
}
fn today() -> chrono::NaiveDate {
chrono::NaiveDate::parse_from_str("2025-06-01", "%Y-%m-%d").unwrap()
}
fn default_config() -> Config {
Config::default()
}
fn config_with_fuse(days: u32) -> Config {
Config {
fuse_days: days,
..Config::default()
}
}
#[test]
fn test_sample_rs_detonated_count() {
let path = fixture_path("sample.rs");
let cfg = default_config();
let regex = build_regex(&cfg).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let rel = Path::new("tests/fixtures/sample.rs");
let fuses = scan_content(&content, rel, ®ex, &cfg, today()).unwrap();
let detonated: Vec<_> = fuses.iter().filter(|a| a.is_detonated()).collect();
assert_eq!(
detonated.len(),
6,
"expected 6 detonated fuses in sample.rs, got {}: {:?}",
detonated.len(),
detonated
.iter()
.map(|a| (&a.tag, a.date_str()))
.collect::<Vec<_>>()
);
}
#[test]
fn test_sample_rs_future_count() {
let path = fixture_path("sample.rs");
let cfg = default_config();
let regex = build_regex(&cfg).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let rel = Path::new("tests/fixtures/sample.rs");
let fuses = scan_content(&content, rel, ®ex, &cfg, today()).unwrap();
let inert: Vec<_> = fuses.iter().filter(|a| a.status == Status::Inert).collect();
assert!(
inert.len() >= 4,
"expected at least 4 inert fuses in sample.rs, got {}",
inert.len()
);
}
#[test]
fn test_sample_rs_plain_todos_not_matched() {
let path = fixture_path("sample.rs");
let cfg = default_config();
let regex = build_regex(&cfg).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let rel = Path::new("tests/fixtures/sample.rs");
let fuses = scan_content(&content, rel, ®ex, &cfg, today()).unwrap();
for fuse in &fuses {
assert!(
!fuse.message.starts_with("this is a plain"),
"plain TODO should not be matched: {:?}",
fuse
);
}
}
#[test]
fn test_sample_rs_space_before_bracket_not_matched() {
let src = "// TODO [2020-01-01]: space before bracket should not match\n";
let cfg = default_config();
let regex = build_regex(&cfg).unwrap();
let fuses = scan_content(src, Path::new("test.rs"), ®ex, &cfg, today()).unwrap();
assert!(
fuses.is_empty(),
"space between tag and bracket must not produce a match"
);
}
#[test]
fn test_sample_rs_note_tag_not_matched() {
let src = "// NOTE[2020-01-01]: this should not be matched\n";
let cfg = default_config();
let fuses = scan_str(src, Path::new("test.rs"), &cfg, today()).unwrap();
assert!(
fuses.is_empty(),
"NOTE tag must not match with default config"
);
}
#[test]
fn test_sample_rs_alice_owner_detected() {
let path = fixture_path("sample.rs");
let cfg = default_config();
let regex = build_regex(&cfg).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let rel = Path::new("tests/fixtures/sample.rs");
let fuses = scan_content(&content, rel, ®ex, &cfg, today()).unwrap();
let alice_fuse = fuses
.iter()
.find(|a| a.owner.as_deref() == Some("alice"))
.expect("should find fuse owned by alice");
assert_eq!(alice_fuse.tag, "TODO");
assert!(alice_fuse.is_detonated());
}
#[test]
fn test_sample_rs_bob_owner_detected() {
let path = fixture_path("sample.rs");
let cfg = default_config();
let regex = build_regex(&cfg).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let rel = Path::new("tests/fixtures/sample.rs");
let fuses = scan_content(&content, rel, ®ex, &cfg, today()).unwrap();
let bob_fuse = fuses
.iter()
.find(|a| a.owner.as_deref() == Some("bob"))
.expect("should find fuse owned by bob");
assert_eq!(bob_fuse.tag, "TODO");
assert_eq!(bob_fuse.status, Status::Inert);
}
#[test]
fn test_sample_rs_ticking_with_wide_window() {
let path = fixture_path("sample.rs");
let cfg = config_with_fuse(30);
let regex = build_regex(&cfg).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let rel = Path::new("tests/fixtures/sample.rs");
let fuses = scan_content(&content, rel, ®ex, &cfg, today()).unwrap();
let ticking: Vec<_> = fuses.iter().filter(|a| a.is_ticking()).collect();
assert!(
ticking.len() >= 2,
"expected at least 2 ticking fuses with 30d window, got {}",
ticking.len()
);
}
#[test]
fn test_sample_rs_all_tags_present() {
let path = fixture_path("sample.rs");
let cfg = default_config();
let regex = build_regex(&cfg).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let rel = Path::new("tests/fixtures/sample.rs");
let fuses = scan_content(&content, rel, ®ex, &cfg, today()).unwrap();
let tags: std::collections::HashSet<&str> = fuses.iter().map(|a| a.tag.as_str()).collect();
for expected_tag in &["TODO", "FIXME", "HACK", "TEMP", "REMOVEME"] {
assert!(
tags.contains(expected_tag),
"expected tag {} to appear in sample.rs fuses",
expected_tag
);
}
}
#[test]
fn test_sample_py_detonated_count() {
let path = fixture_path("sample.py");
let cfg = default_config();
let regex = build_regex(&cfg).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let rel = Path::new("tests/fixtures/sample.py");
let fuses = scan_content(&content, rel, ®ex, &cfg, today()).unwrap();
let detonated: Vec<_> = fuses.iter().filter(|a| a.is_detonated()).collect();
assert_eq!(
detonated.len(),
6,
"expected 6 detonated fuses in sample.py, got {}",
detonated.len()
);
}
#[test]
fn test_sample_py_hash_prefix_tags_detected() {
let src = "# TODO[2020-01-01]: python style comment\n";
let cfg = default_config();
let fuses = scan_str(src, Path::new("test.py"), &cfg, today()).unwrap();
assert_eq!(fuses.len(), 1);
assert_eq!(fuses[0].tag, "TODO");
assert_eq!(fuses[0].message, "python style comment");
assert!(fuses[0].is_detonated());
}
#[test]
fn test_sample_py_owner_carol_detected() {
let path = fixture_path("sample.py");
let cfg = default_config();
let regex = build_regex(&cfg).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let rel = Path::new("tests/fixtures/sample.py");
let fuses = scan_content(&content, rel, ®ex, &cfg, today()).unwrap();
let carol_fuse = fuses
.iter()
.find(|a| a.owner.as_deref() == Some("carol"))
.expect("should find fuse owned by carol");
assert!(carol_fuse.is_detonated());
}
#[test]
fn test_sample_py_future_fuses_are_inert() {
let path = fixture_path("sample.py");
let cfg = default_config();
let regex = build_regex(&cfg).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let rel = Path::new("tests/fixtures/sample.py");
let fuses = scan_content(&content, rel, ®ex, &cfg, today()).unwrap();
let future: Vec<_> = fuses
.iter()
.filter(|a| a.date.year() == 2099 || a.date.year() == 2088)
.collect();
for fuse in &future {
assert_eq!(
fuse.status,
Status::Inert,
"far-future fuse should be Inert: {:?}",
fuse
);
}
}
#[test]
fn test_sample_py_plain_todos_ignored() {
let path = fixture_path("sample.py");
let cfg = default_config();
let regex = build_regex(&cfg).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let rel = Path::new("tests/fixtures/sample.py");
let fuses = scan_content(&content, rel, ®ex, &cfg, today()).unwrap();
for fuse in &fuses {
assert!(
!fuse.message.to_lowercase().starts_with("plain todo"),
"plain TODO should not be matched: {:?}",
fuse
);
}
}
#[test]
fn test_sample_sql_detonated_count() {
let path = fixture_path("sample.sql");
let cfg = default_config();
let regex = build_regex(&cfg).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let rel = Path::new("tests/fixtures/sample.sql");
let fuses = scan_content(&content, rel, ®ex, &cfg, today()).unwrap();
let detonated: Vec<_> = fuses.iter().filter(|a| a.is_detonated()).collect();
assert_eq!(
detonated.len(),
6,
"expected 6 detonated fuses in sample.sql, got {}",
detonated.len()
);
}
#[test]
fn test_sample_sql_double_dash_prefix_detected() {
let src = "-- TODO[2020-01-01]: drop this column\n";
let cfg = default_config();
let fuses = scan_str(src, Path::new("schema.sql"), &cfg, today()).unwrap();
assert_eq!(fuses.len(), 1);
assert_eq!(fuses[0].tag, "TODO");
assert_eq!(fuses[0].message, "drop this column");
}
#[test]
fn test_sample_sql_owner_eve_detected() {
let path = fixture_path("sample.sql");
let cfg = default_config();
let regex = build_regex(&cfg).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let rel = Path::new("tests/fixtures/sample.sql");
let fuses = scan_content(&content, rel, ®ex, &cfg, today()).unwrap();
let eve_fuse = fuses
.iter()
.find(|a| a.owner.as_deref() == Some("eve"))
.expect("should find fuse owned by eve");
assert!(eve_fuse.is_detonated());
}
#[test]
fn test_sample_sql_owner_frank_detected() {
let path = fixture_path("sample.sql");
let cfg = default_config();
let regex = build_regex(&cfg).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let rel = Path::new("tests/fixtures/sample.sql");
let fuses = scan_content(&content, rel, ®ex, &cfg, today()).unwrap();
let frank_fuse = fuses
.iter()
.find(|a| a.owner.as_deref() == Some("frank"))
.expect("should find fuse owned by frank");
assert_eq!(frank_fuse.status, Status::Inert);
}
#[test]
fn test_scan_fixtures_dir_finds_all_files() {
let dir = fixtures_dir();
let cfg = default_config();
let result = scan(&dir, &cfg, today()).unwrap();
assert_eq!(
result.swept_files, 25,
"expected 25 swept files in fixtures dir, got {}",
result.swept_files
);
}
#[test]
fn test_scan_fixtures_dir_has_detonated() {
let dir = fixtures_dir();
let cfg = default_config();
let result = scan(&dir, &cfg, today()).unwrap();
assert!(
result.has_detonated(),
"fixtures directory must contain detonated fuses"
);
}
#[test]
fn test_scan_fixtures_dir_total_detonated_count() {
let dir = fixtures_dir();
let cfg = default_config();
let result = scan(&dir, &cfg, today()).unwrap();
let detonated_count = result.detonated().len();
assert_eq!(
detonated_count, 100,
"expected 100 total detonated fuses across all fixture files, got {}",
detonated_count
);
}
#[test]
fn test_scan_fixtures_dir_sorted_by_date() {
let dir = fixtures_dir();
let cfg = default_config();
let result = scan(&dir, &cfg, today()).unwrap();
let dates: Vec<_> = result.fuses.iter().map(|a| a.date).collect();
let mut sorted = dates.clone();
sorted.sort();
assert_eq!(
dates, sorted,
"scan results must be sorted by date ascending"
);
}
#[test]
fn test_scan_fixtures_dir_ticking_with_wide_window() {
let dir = fixtures_dir();
let cfg = config_with_fuse(30);
let result = scan(&dir, &cfg, today()).unwrap();
let ticking_count = result.ticking().len();
assert_eq!(
ticking_count, 29,
"expected 29 ticking fuses with 30d window across fixture files, got {}",
ticking_count
);
}
#[test]
fn test_scan_fixtures_dir_has_ticking_with_wide_window() {
let dir = fixtures_dir();
let cfg = config_with_fuse(30);
let result = scan(&dir, &cfg, today()).unwrap();
assert!(
result.is_ticking(),
"should detect ticking fuses with 30d window"
);
}
#[test]
fn test_all_five_default_tags_matched() {
let src = "\
// TODO[2020-01-01]: todo detonated
// FIXME[2020-01-01]: fixme detonated
// HACK[2020-01-01]: hack detonated
// TEMP[2020-01-01]: temp detonated
// REMOVEME[2020-01-01]: removeme detonated
";
let cfg = default_config();
let fuses = scan_str(src, Path::new("tags.rs"), &cfg, today()).unwrap();
assert_eq!(fuses.len(), 5);
let tags: Vec<&str> = fuses.iter().map(|a| a.tag.as_str()).collect();
assert!(tags.contains(&"TODO"));
assert!(tags.contains(&"FIXME"));
assert!(tags.contains(&"HACK"));
assert!(tags.contains(&"TEMP"));
assert!(tags.contains(&"REMOVEME"));
}
#[test]
fn test_case_insensitive_tags() {
let src = "\
// todo[2020-01-01]: lowercase todo
// Fixme[2020-01-01]: mixed case fixme
// HACK[2020-01-01]: uppercase hack
";
let cfg = default_config();
let fuses = scan_str(src, Path::new("case.rs"), &cfg, today()).unwrap();
assert_eq!(fuses.len(), 3);
for fuse in &fuses {
assert_eq!(
fuse.tag,
fuse.tag.to_uppercase(),
"tag should be uppercased: {}",
fuse.tag
);
}
}
#[test]
fn test_fuse_on_same_line_as_code() {
let src = r#"let x = some_func(); // TODO[2020-01-01]: refactor this inline"#;
let cfg = default_config();
let fuses = scan_str(src, Path::new("inline.rs"), &cfg, today()).unwrap();
assert_eq!(fuses.len(), 1);
assert_eq!(fuses[0].message, "refactor this inline");
}
#[test]
fn test_multiple_fuses_on_separate_lines() {
let src = "\
fn foo() {
// TODO[2020-01-01]: first
let x = 1;
// FIXME[2099-12-31]: second
let y = 2;
// HACK[2020-06-15]: third
}
";
let cfg = default_config();
let fuses = scan_str(src, Path::new("multi.rs"), &cfg, today()).unwrap();
assert_eq!(fuses.len(), 3);
let lines: Vec<usize> = fuses.iter().map(|a| a.line).collect();
assert!(lines.contains(&2), "line 2 should have TODO");
assert!(lines.contains(&4), "line 4 should have FIXME");
assert!(lines.contains(&6), "line 6 should have HACK");
}
#[test]
fn test_owner_with_spaces_trimmed() {
let src = "// TODO[2020-01-01][ alice ]: trimmed owner\n";
let cfg = default_config();
let fuses = scan_str(src, Path::new("owner.rs"), &cfg, today()).unwrap();
assert_eq!(fuses.len(), 1);
assert_eq!(fuses[0].owner.as_deref(), Some("alice"));
}
#[test]
fn test_invalid_date_produces_no_fuse() {
let src = "// TODO[2026-13-01]: invalid month\n";
let cfg = default_config();
let fuses = scan_str(src, Path::new("bad.rs"), &cfg, today()).unwrap();
assert!(
fuses.is_empty(),
"invalid date should produce no fuse, got {:?}",
fuses
);
}
#[test]
fn test_invalid_day_produces_no_fuse() {
let src = "// FIXME[2026-02-30]: invalid day\n";
let cfg = default_config();
let fuses = scan_str(src, Path::new("bad.rs"), &cfg, today()).unwrap();
assert!(
fuses.is_empty(),
"invalid day should produce no fuse, got {:?}",
fuses
);
}
#[test]
fn test_message_with_colons_and_special_chars() {
let src = "// TODO[2020-01-01]: fix https://example.com/path?q=1&r=2 handling\n";
let cfg = default_config();
let fuses = scan_str(src, Path::new("url.rs"), &cfg, today()).unwrap();
assert_eq!(fuses.len(), 1);
assert!(fuses[0].message.contains("https://"));
}
#[test]
fn test_file_path_stored_as_relative() {
let src = "// TODO[2020-01-01]: check path\n";
let cfg = default_config();
let rel = Path::new("some/deep/path/file.rs");
let fuses = scan_str(src, rel, &cfg, today()).unwrap();
assert_eq!(fuses[0].file, rel);
}
#[test]
fn test_scan_skips_binary_files() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let mut text = std::fs::File::create(dir.path().join("ok.rs")).unwrap();
writeln!(text, "// TODO[2020-01-01]: detonated fuse").unwrap();
let mut bin = std::fs::File::create(dir.path().join("blob.bin")).unwrap();
bin.write_all(b"ELF\x00binary\x00data").unwrap();
let cfg = Config {
extensions: vec!["rs".to_string(), "bin".to_string()],
..Config::default()
};
let result = scan(dir.path(), &cfg, today()).unwrap();
assert_eq!(result.swept_files, 1, "only the text file should be swept");
assert_eq!(
result.skipped_files, 1,
"the binary file should be counted as skipped"
);
assert_eq!(result.fuses.len(), 1);
}
#[test]
fn test_custom_trigger_only_matches_custom() {
let src = "\
// TODO[2020-01-01]: standard tag — should NOT match
// CUSTOM[2020-01-01]: custom tag — should match
// FIXME[2020-01-01]: another standard — should NOT match
";
let cfg = Config {
triggers: vec!["CUSTOM".to_string()],
..Config::default()
};
let fuses = scan_str(src, Path::new("custom.rs"), &cfg, today()).unwrap();
assert_eq!(fuses.len(), 1, "only CUSTOM trigger should match");
assert_eq!(fuses[0].tag, "CUSTOM");
}
#[test]
fn test_empty_file_produces_no_fuses() {
let cfg = default_config();
let fuses = scan_str("", Path::new("empty.rs"), &cfg, today()).unwrap();
assert!(fuses.is_empty());
}
#[test]
fn test_file_with_only_plain_todos_produces_no_fuses() {
let src = "\
// TODO: this
// FIXME: that
// HACK: something
// TODO fix me
// TODO - do this later
";
let cfg = default_config();
let fuses = scan_str(src, Path::new("plain.rs"), &cfg, today()).unwrap();
assert!(
fuses.is_empty(),
"plain TODOs without date brackets must not produce fuses"
);
}
#[test]
fn test_scan_result_detonated_helper() {
let dir = fixtures_dir();
let cfg = default_config();
let result = scan(&dir, &cfg, today()).unwrap();
let detonated = result.detonated();
assert!(!detonated.is_empty());
for fuse in &detonated {
assert_eq!(fuse.status, Status::Detonated);
}
}
#[test]
fn test_scan_result_inert_helper() {
let dir = fixtures_dir();
let cfg = default_config();
let result = scan(&dir, &cfg, today()).unwrap();
let inert = result.inert();
assert!(!inert.is_empty());
for fuse in inert {
assert_eq!(fuse.status, Status::Inert);
}
}
#[test]
fn test_scan_result_total_equals_sum_of_parts() {
let dir = fixtures_dir();
let cfg = config_with_fuse(30);
let result = scan(&dir, &cfg, today()).unwrap();
let total = result.total();
let sum = result.detonated().len() + result.ticking().len() + result.inert().len();
assert_eq!(total, sum, "total() must equal detonated + ticking + inert");
}