use crate::annotation::Fuse;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::remove::remove_line;
use crate::scanner::scan;
use crate::snooze::snooze_line;
use chrono::NaiveDate;
use colored::Colorize;
use std::cmp::Reverse;
use std::collections::HashMap;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
enum FixAction {
Extend(NaiveDate),
Delete,
Skip,
}
struct Decision {
action: FixAction,
abs_path: PathBuf,
line: usize,
}
pub struct FixSummary {
pub extended: usize,
pub deleted: usize,
pub skipped: usize,
}
pub fn run_fix(scan_path: &Path, cfg: &Config, today: NaiveDate) -> Result<FixSummary> {
let result = scan(scan_path, cfg, today)?;
let detonated: Vec<&Fuse> = result.detonated();
if detonated.is_empty() {
println!("No detonated fuses found.");
return Ok(FixSummary {
extended: 0,
deleted: 0,
skipped: 0,
});
}
println!(
"{} detonated fuse(s) to review:\n",
detonated.len().to_string().red().bold()
);
let mut decisions: Vec<Decision> = Vec::new();
for ann in &detonated {
let abs_path = scan_path.join(&ann.file);
println!(
"{} {}:{}",
"[DETONATED]".red().bold(),
ann.file.display(),
ann.line
);
println!(
" {} [{}]: {}",
ann.tag.yellow(),
ann.date.format("%Y-%m-%d"),
ann.message
);
let action = prompt_action(today)?;
decisions.push(Decision {
action,
abs_path,
line: ann.line,
});
println!();
}
let mut by_file: HashMap<PathBuf, Vec<&Decision>> = HashMap::new();
for d in &decisions {
by_file.entry(d.abs_path.clone()).or_default().push(d);
}
let mut summary = FixSummary {
extended: 0,
deleted: 0,
skipped: 0,
};
for (file_path, mut file_decisions) in by_file {
file_decisions.sort_unstable_by_key(|d| Reverse(d.line));
for d in file_decisions {
match &d.action {
FixAction::Skip => {
summary.skipped += 1;
}
FixAction::Delete => {
remove_line(&file_path, d.line)?;
summary.deleted += 1;
}
FixAction::Extend(new_date) => {
apply_extend(&file_path, d.line, *new_date)?;
summary.extended += 1;
}
}
}
}
Ok(summary)
}
fn prompt_action(today: NaiveDate) -> Result<FixAction> {
loop {
print!(" Action [e=extend / d=delete / s=skip / ?=help]: ");
io::stdout().flush().map_err(|e| Error::Io {
source: e,
path: None,
})?;
let stdin = io::stdin();
let mut buf = String::new();
stdin.lock().read_line(&mut buf).map_err(|e| Error::Io {
source: e,
path: None,
})?;
match buf.trim() {
"e" | "E" => {
let new_date = prompt_date(today)?;
return Ok(FixAction::Extend(new_date));
}
"d" | "D" => return Ok(FixAction::Delete),
"s" | "S" => return Ok(FixAction::Skip),
"?" => {
println!(" e — extend: enter a new expiry date (must be after today)");
println!(" d — delete: remove the fuse line from the file");
println!(" s — skip: leave the fuse unchanged and continue");
}
"" => {
return Ok(FixAction::Skip);
}
other => {
println!(" Unknown option '{}'. Enter e, d, s, or ?.", other);
}
}
}
}
fn prompt_date(today: NaiveDate) -> Result<NaiveDate> {
loop {
print!(" New expiry date (YYYY-MM-DD): ");
io::stdout().flush().map_err(|e| Error::Io {
source: e,
path: None,
})?;
let stdin = io::stdin();
let mut buf = String::new();
stdin.lock().read_line(&mut buf).map_err(|e| Error::Io {
source: e,
path: None,
})?;
let trimmed = buf.trim();
match NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
Ok(date) if date > today => return Ok(date),
Ok(_) => {
println!(
" Date must be after today ({}). Try again.",
today.format("%Y-%m-%d")
);
}
Err(_) => {
println!(" '{}' is not a valid date. Expected YYYY-MM-DD.", trimmed);
}
}
}
}
fn apply_extend(file_path: &Path, line_number: usize, new_date: NaiveDate) -> Result<()> {
let content = std::fs::read_to_string(file_path).map_err(|e| Error::Io {
source: e,
path: Some(file_path.to_path_buf()),
})?;
let lines: Vec<&str> = content.lines().collect();
if line_number < 1 || line_number > lines.len() {
return Err(Error::InvalidArgument(format!(
"line {} is out of range for '{}' ({} lines)",
line_number,
file_path.display(),
lines.len(),
)));
}
let original = lines[line_number - 1];
let new_line = snooze_line(original, new_date).ok_or_else(|| {
Error::InvalidArgument(format!(
"no timebomb date bracket found on line {} of '{}'",
line_number,
file_path.display(),
))
})?;
let mut new_content = String::with_capacity(content.len() + new_line.len());
for (i, line) in lines.iter().enumerate() {
if i == line_number - 1 {
new_content.push_str(&new_line);
} else {
new_content.push_str(line);
}
new_content.push('\n');
}
if !content.ends_with('\n') {
new_content.pop();
}
let tmp_path = file_path.with_extension(format!("tmp.{}", std::process::id()));
std::fs::write(&tmp_path, new_content).map_err(|e| Error::Io {
source: e,
path: Some(tmp_path.clone()),
})?;
std::fs::rename(&tmp_path, file_path).map_err(|e| Error::Io {
source: e,
path: Some(file_path.to_path_buf()),
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use std::fs;
use tempfile::tempdir;
fn date(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
}
fn today() -> NaiveDate {
date("2026-03-22")
}
#[test]
fn test_fix_extend_replaces_date() {
let dir = tempdir().unwrap();
let file = dir.path().join("test.rs");
fs::write(&file, "// TODO[2020-01-01]: expired annotation\n").unwrap();
apply_extend(&file, 1, date("2027-06-01")).unwrap();
let content = fs::read_to_string(&file).unwrap();
assert!(content.contains("2027-06-01"), "new date should appear");
assert!(!content.contains("2020-01-01"), "old date should be gone");
}
#[test]
fn test_fix_delete_removes_line() {
let dir = tempdir().unwrap();
let file = dir.path().join("test.rs");
fs::write(
&file,
"fn alpha() {}\n// TODO[2020-01-01]: expired\nfn beta() {}\n",
)
.unwrap();
remove_line(&file, 2).unwrap();
let content = fs::read_to_string(&file).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], "fn alpha() {}");
assert_eq!(lines[1], "fn beta() {}");
}
#[test]
fn test_fix_multi_file_bottom_up_order() {
let dir = tempdir().unwrap();
let file = dir.path().join("multi.rs");
fs::write(
&file,
"fn a() {}\n\
// TODO[2020-01-01]: first expired\n\
fn b() {}\n\
// TODO[2019-06-01]: second expired\n\
fn c() {}\n",
)
.unwrap();
remove_line(&file, 4).unwrap();
remove_line(&file, 2).unwrap();
let content = fs::read_to_string(&file).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "fn a() {}");
assert_eq!(lines[1], "fn b() {}");
assert_eq!(lines[2], "fn c() {}");
assert!(!content.contains("first expired"));
assert!(!content.contains("second expired"));
}
#[test]
fn test_fix_extend_date_before_today_rejected() {
let past = date("2020-01-01");
let t = today();
assert!(
past <= t,
"sanity: 2020-01-01 should be before or equal to today"
);
let future = date("2028-01-01");
assert!(future > t, "sanity: 2028-01-01 should be after today");
}
#[test]
fn test_run_fix_no_expired_returns_all_zeros() {
let dir = tempdir().unwrap();
let file = dir.path().join("ok.rs");
fs::write(&file, "// TODO[2099-01-01]: far future\n").unwrap();
let cfg = crate::config::Config::default();
let summary = run_fix(dir.path(), &cfg, today()).unwrap();
assert_eq!(summary.extended, 0);
assert_eq!(summary.deleted, 0);
assert_eq!(summary.skipped, 0);
}
}