use crate::add::{find_matching_lines, parse_target};
use crate::error::{Error, Result};
use chrono::{Duration, NaiveDate};
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
#[allow(clippy::too_many_arguments)]
pub fn run_snooze(
target: &str,
date_str: Option<&str>,
in_days: Option<u32>,
reason: Option<&str>,
yes: bool,
today: NaiveDate,
search: Option<&str>,
) -> Result<i32> {
let (file_path, line_number) = if let Some(pattern) = search {
let path = PathBuf::from(target);
let matches = find_matching_lines(&path, pattern)?;
match matches.len() {
0 => {
return Err(Error::InvalidArgument(format!(
"no lines matching '{}' found in {}",
pattern, target
)));
}
1 => {
println!("matched line {}: {}", matches[0].0, matches[0].1.trim_end());
(path, matches[0].0)
}
n => {
let mut detail =
format!("pattern '{}' matched {} lines in {}:", pattern, n, target);
for (ln, content) in &matches {
detail.push_str(&format!("\n line {}: {}", ln, content.trim_end()));
}
detail.push_str("\nuse FILE:LINE to be specific");
return Err(Error::InvalidArgument(detail));
}
}
} else {
parse_target(target)?
};
let new_date = resolve_new_date(date_str, in_days, today, yes)?;
let content = std::fs::read_to_string(&file_path).map_err(|e| Error::Io {
source: e,
path: Some(file_path.clone()),
})?;
let lines: Vec<&str> = content.lines().collect();
let line_count = lines.len();
if line_number < 1 || line_number > line_count {
return Err(Error::InvalidArgument(format!(
"line {} does not exist in file (file has {} lines)",
line_number, line_count,
)));
}
let original_line = lines[line_number - 1];
let snoozed = snooze_line(original_line, new_date).ok_or_else(|| {
Error::InvalidArgument(format!(
"no timebomb date bracket found on line {} of {}",
line_number,
file_path.display(),
))
})?;
let new_line = match reason {
Some(r) => append_reason(&snoozed, r),
None => snoozed,
};
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();
}
println!(
"- {}:{} {}",
file_path.display(),
line_number,
original_line
);
println!("+ {}:{} {}", file_path.display(), line_number, new_line);
if !yes {
print!("Write change? [y/N]: ");
io::stdout().flush().map_err(|e| Error::Io {
source: e,
path: None,
})?;
let stdin = io::stdin();
let mut line_buf = String::new();
stdin
.lock()
.read_line(&mut line_buf)
.map_err(|e| Error::Io {
source: e,
path: None,
})?;
let response = line_buf.trim();
if response != "y" && response != "Y" {
return Ok(0);
}
}
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.clone()),
})?;
println!(
"snoozed {}:{} → {}",
file_path.display(),
line_number,
new_date.format("%Y-%m-%d"),
);
Ok(0)
}
pub fn resolve_new_date(
date_str: Option<&str>,
in_days: Option<u32>,
today: NaiveDate,
yes: bool,
) -> Result<NaiveDate> {
match (date_str, in_days) {
(Some(s), _) => NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| {
Error::InvalidArgument(format!("'{}' is not a valid date — expected YYYY-MM-DD", s))
}),
(None, Some(days)) => {
let new_date = today + Duration::days(days as i64);
Ok(new_date)
}
(None, None) => {
let days: u32 = if yes {
let default_date =
today
.checked_add_signed(Duration::days(90))
.ok_or_else(|| {
Error::InvalidArgument(
"90-day default overflows the calendar".to_string(),
)
})?;
println!(
"No expiry specified; defaulting to 90 days from today ({})",
default_date.format("%Y-%m-%d")
);
90
} else {
print!("Expire in how many days? [90]: ");
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();
if trimmed.is_empty() {
90
} else {
trimmed.parse::<u32>().map_err(|_| {
Error::InvalidArgument(format!(
"'{}' is not a valid number of days",
trimmed
))
})?
}
};
today
.checked_add_signed(Duration::days(days as i64))
.ok_or_else(|| {
Error::InvalidArgument(format!("--in-days {} overflows the calendar", days))
})
}
}
}
static DATE_BRACKET_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"\[(\d{4}-\d{2}-\d{2})\]").expect("hardcoded regex is valid")
});
pub fn snooze_line(line: &str, new_date: NaiveDate) -> Option<String> {
let re = &*DATE_BRACKET_RE;
let mat = re.find(line)?;
let new_bracket = format!("[{}]", new_date.format("%Y-%m-%d"));
let new_line = format!(
"{}{}{}",
&line[..mat.start()],
new_bracket,
&line[mat.end()..]
);
Some(new_line)
}
pub fn append_reason(line: &str, reason: &str) -> String {
let trimmed = line.trim_end();
format!("{} [snoozed: {}]", trimmed, reason)
}
#[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 {
NaiveDate::from_ymd_opt(2025, 6, 1).unwrap()
}
#[test]
fn test_resolve_new_date_from_str() {
let result = resolve_new_date(Some("2026-06-01"), None, today(), true).unwrap();
assert_eq!(result, date("2026-06-01"));
}
#[test]
fn test_resolve_new_date_from_in_days() {
let result = resolve_new_date(None, Some(30), today(), true).unwrap();
assert_eq!(result, date("2025-07-01"));
}
#[test]
fn test_resolve_new_date_neither_yes_defaults_90() {
let t = today();
let result = resolve_new_date(None, None, t, true).unwrap();
let expected = t + Duration::days(90);
assert_eq!(result, expected);
}
#[test]
fn test_resolve_new_date_prefers_date_str() {
let result = resolve_new_date(Some("2026-06-01"), Some(30), today(), true).unwrap();
assert_eq!(result, date("2026-06-01"));
}
#[test]
fn test_resolve_new_date_invalid_date_str() {
let result = resolve_new_date(Some("not-a-date"), None, today(), true);
assert!(result.is_err());
}
#[test]
fn test_snooze_line_basic() {
let line = " // TODO[2025-01-15]: remove legacy oauth flow";
let new_date = date("2026-03-01");
let result = snooze_line(line, new_date).unwrap();
assert_eq!(result, " // TODO[2026-03-01]: remove legacy oauth flow");
}
#[test]
fn test_snooze_line_no_bracket() {
let line = " // TODO: plain comment with no date";
let result = snooze_line(line, date("2026-01-01"));
assert!(result.is_none());
}
#[test]
fn test_snooze_line_only_replaces_first_bracket() {
let line = " // TODO[2025-01-15][alice]: remove legacy oauth flow";
let new_date = date("2026-03-01");
let result = snooze_line(line, new_date).unwrap();
assert_eq!(
result,
" // TODO[2026-03-01][alice]: remove legacy oauth flow"
);
assert!(result.contains("[alice]"));
assert!(!result.contains("2025-01-15"));
}
#[test]
fn test_snooze_line_preserves_rest_of_line() {
let line = " // FIXME[2025-03-10]: this is the message text, do not change";
let new_date = date("2026-12-01");
let result = snooze_line(line, new_date).unwrap();
assert!(result.contains("this is the message text, do not change"));
assert!(result.contains("2026-12-01"));
assert!(!result.contains("2025-03-10"));
}
#[test]
fn test_append_reason_basic() {
let line = " // TODO[2026-01-01]: msg";
let result = append_reason(line, "reason");
assert_eq!(result, " // TODO[2026-01-01]: msg [snoozed: reason]");
}
#[test]
fn test_append_reason_trims_trailing_whitespace() {
let line = " // TODO[2026-01-01]: msg ";
let result = append_reason(line, "because");
assert_eq!(result, " // TODO[2026-01-01]: msg [snoozed: because]");
}
#[test]
fn test_append_reason_trims_trailing_newline() {
let line = " // TODO[2026-01-01]: msg\n";
let result = append_reason(line, "upstream");
assert_eq!(result, " // TODO[2026-01-01]: msg [snoozed: upstream]");
}
#[test]
fn test_run_snooze_rewrites_file() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.rs");
let content = "fn foo() {}\n// TODO[2025-01-15]: remove me\nfn bar() {}\n";
fs::write(&file_path, content).unwrap();
let target = format!("{}:2", file_path.display());
let result = run_snooze(&target, Some("2026-06-01"), None, None, true, today(), None);
assert!(result.is_ok());
assert_eq!(result.unwrap(), 0);
let updated = fs::read_to_string(&file_path).unwrap();
assert!(updated.contains("2026-06-01"));
assert!(!updated.contains("2025-01-15"));
assert!(updated.contains("fn foo() {}"));
assert!(updated.contains("fn bar() {}"));
}
#[test]
fn test_run_snooze_no_annotation_on_line() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.rs");
let content = "fn foo() {}\nfn bar() {}\n";
fs::write(&file_path, content).unwrap();
let target = format!("{}:1", file_path.display());
let result = run_snooze(&target, Some("2026-06-01"), None, None, true, today(), None);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("no timebomb date bracket found") || msg.contains("date bracket"));
}
#[test]
fn test_run_snooze_line_out_of_range() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.rs");
let content = "fn foo() {}\nfn bar() {}\n";
fs::write(&file_path, content).unwrap();
let target = format!("{}:99", file_path.display());
let result = run_snooze(&target, Some("2026-06-01"), None, None, true, today(), None);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("99") && (msg.contains("does not exist") || msg.contains("out of range"))
);
}
#[test]
fn test_run_snooze_with_reason() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.rs");
let content =
"fn alpha() {}\n// TODO[2025-01-15]: remove legacy oauth flow\nfn beta() {}\n";
fs::write(&file_path, content).unwrap();
let target = format!("{}:2", file_path.display());
let result = run_snooze(
&target,
Some("2026-03-01"),
None,
Some("blocked on upstream release"),
true,
today(),
None,
);
assert!(result.is_ok());
let updated = fs::read_to_string(&file_path).unwrap();
assert!(updated.contains("2026-03-01"));
assert!(updated.contains("[snoozed: blocked on upstream release]"));
assert!(!updated.contains("2025-01-15"));
}
#[test]
fn test_run_snooze_uses_in_days() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.rs");
let content = "// TODO[2025-01-15]: something\n";
fs::write(&file_path, content).unwrap();
let target = format!("{}:1", file_path.display());
let result = run_snooze(&target, None, Some(30), None, true, today(), None);
assert!(result.is_ok());
let updated = fs::read_to_string(&file_path).unwrap();
assert!(updated.contains("2025-07-01"));
}
#[test]
fn test_run_snooze_nonexistent_file_returns_io_error() {
let result = run_snooze(
"/nonexistent/path/file.rs:1",
Some("2026-01-01"),
None,
None,
true,
today(),
None,
);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, crate::error::Error::Io { .. }));
}
#[test]
fn test_run_snooze_line_1_of_1() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("single.rs");
let content = "// TODO[2024-12-31]: single line\n";
fs::write(&file_path, content).unwrap();
let target = format!("{}:1", file_path.display());
let result = run_snooze(&target, Some("2026-01-01"), None, None, true, today(), None);
assert!(result.is_ok());
let updated = fs::read_to_string(&file_path).unwrap();
assert!(updated.contains("2026-01-01"));
assert!(!updated.contains("2024-12-31"));
}
#[test]
fn test_run_snooze_with_search_single_match() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.rs");
let content = "fn alpha() {}\n// TODO[2025-01-15]: legacy_auth remove\nfn beta() {}\n";
fs::write(&file_path, content).unwrap();
let result = run_snooze(
file_path.to_str().unwrap(),
Some("2027-01-01"),
None,
None,
true,
today(),
Some("legacy_auth"),
);
assert!(result.is_ok());
let updated = fs::read_to_string(&file_path).unwrap();
assert!(updated.contains("2027-01-01"));
assert!(!updated.contains("2025-01-15"));
}
#[test]
fn test_run_snooze_with_search_no_match() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.rs");
let content = "fn alpha() {}\nfn beta() {}\n";
fs::write(&file_path, content).unwrap();
let result = run_snooze(
file_path.to_str().unwrap(),
Some("2027-01-01"),
None,
None,
true,
today(),
Some("zzz_no_match"),
);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("no lines matching"));
}
#[test]
fn test_run_snooze_with_search_multiple_matches() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.rs");
let content = "fn foo_a() {}\n// TODO[2025-01-15]: foo remove\nfn foo_b() {}\n";
fs::write(&file_path, content).unwrap();
let result = run_snooze(
file_path.to_str().unwrap(),
Some("2027-01-01"),
None,
None,
true,
today(),
Some("foo"),
);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("matched") || msg.contains("lines"));
}
}