1use crate::annotation::Fuse;
8use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::remove::remove_line;
11use crate::scanner::scan;
12use crate::snooze::snooze_line;
13use chrono::NaiveDate;
14use colored::Colorize;
15use std::cmp::Reverse;
16use std::collections::HashMap;
17use std::io::{self, BufRead, Write};
18use std::path::{Path, PathBuf};
19
20enum FixAction {
26 Extend(NaiveDate),
28 Delete,
30 Skip,
32}
33
34struct Decision {
36 action: FixAction,
37 abs_path: PathBuf,
39 line: usize,
41}
42
43pub struct FixSummary {
45 pub extended: usize,
46 pub deleted: usize,
47 pub skipped: usize,
48}
49
50pub fn run_fix(scan_path: &Path, cfg: &Config, today: NaiveDate) -> Result<FixSummary> {
64 let result = scan(scan_path, cfg, today)?;
66 let detonated: Vec<&Fuse> = result.detonated();
67
68 if detonated.is_empty() {
69 println!("No detonated fuses found.");
70 return Ok(FixSummary {
71 extended: 0,
72 deleted: 0,
73 skipped: 0,
74 });
75 }
76
77 println!(
78 "{} detonated fuse(s) to review:\n",
79 detonated.len().to_string().red().bold()
80 );
81
82 let mut decisions: Vec<Decision> = Vec::new();
84
85 for ann in &detonated {
86 let abs_path = scan_path.join(&ann.file);
87
88 println!(
89 "{} {}:{}",
90 "[DETONATED]".red().bold(),
91 ann.file.display(),
92 ann.line
93 );
94 println!(
95 " {} [{}]: {}",
96 ann.tag.yellow(),
97 ann.date.format("%Y-%m-%d"),
98 ann.message
99 );
100
101 let action = prompt_action(today)?;
102
103 decisions.push(Decision {
104 action,
105 abs_path,
106 line: ann.line,
107 });
108
109 println!();
110 }
111
112 let mut by_file: HashMap<PathBuf, Vec<&Decision>> = HashMap::new();
115 for d in &decisions {
116 by_file.entry(d.abs_path.clone()).or_default().push(d);
117 }
118
119 let mut summary = FixSummary {
120 extended: 0,
121 deleted: 0,
122 skipped: 0,
123 };
124
125 for (file_path, mut file_decisions) in by_file {
126 file_decisions.sort_unstable_by_key(|d| Reverse(d.line));
128
129 for d in file_decisions {
130 match &d.action {
131 FixAction::Skip => {
132 summary.skipped += 1;
133 }
134 FixAction::Delete => {
135 remove_line(&file_path, d.line)?;
136 summary.deleted += 1;
137 }
138 FixAction::Extend(new_date) => {
139 apply_extend(&file_path, d.line, *new_date)?;
140 summary.extended += 1;
141 }
142 }
143 }
144 }
145
146 Ok(summary)
147}
148
149fn prompt_action(today: NaiveDate) -> Result<FixAction> {
157 loop {
158 print!(" Action [e=extend / d=delete / s=skip / ?=help]: ");
159 io::stdout().flush().map_err(|e| Error::Io {
160 source: e,
161 path: None,
162 })?;
163
164 let stdin = io::stdin();
165 let mut buf = String::new();
166 stdin.lock().read_line(&mut buf).map_err(|e| Error::Io {
167 source: e,
168 path: None,
169 })?;
170
171 match buf.trim() {
172 "e" | "E" => {
173 let new_date = prompt_date(today)?;
174 return Ok(FixAction::Extend(new_date));
175 }
176 "d" | "D" => return Ok(FixAction::Delete),
177 "s" | "S" => return Ok(FixAction::Skip),
178 "?" => {
179 println!(" e — extend: enter a new expiry date (must be after today)");
180 println!(" d — delete: remove the fuse line from the file");
181 println!(" s — skip: leave the fuse unchanged and continue");
182 }
183 "" => {
184 return Ok(FixAction::Skip);
187 }
188 other => {
189 println!(" Unknown option '{}'. Enter e, d, s, or ?.", other);
190 }
191 }
192 }
193}
194
195fn prompt_date(today: NaiveDate) -> Result<NaiveDate> {
198 loop {
199 print!(" New expiry date (YYYY-MM-DD): ");
200 io::stdout().flush().map_err(|e| Error::Io {
201 source: e,
202 path: None,
203 })?;
204
205 let stdin = io::stdin();
206 let mut buf = String::new();
207 stdin.lock().read_line(&mut buf).map_err(|e| Error::Io {
208 source: e,
209 path: None,
210 })?;
211
212 let trimmed = buf.trim();
213
214 match NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
215 Ok(date) if date > today => return Ok(date),
216 Ok(_) => {
217 println!(
218 " Date must be after today ({}). Try again.",
219 today.format("%Y-%m-%d")
220 );
221 }
222 Err(_) => {
223 println!(" '{}' is not a valid date. Expected YYYY-MM-DD.", trimmed);
224 }
225 }
226 }
227}
228
229fn apply_extend(file_path: &Path, line_number: usize, new_date: NaiveDate) -> Result<()> {
237 let content = std::fs::read_to_string(file_path).map_err(|e| Error::Io {
238 source: e,
239 path: Some(file_path.to_path_buf()),
240 })?;
241
242 let lines: Vec<&str> = content.lines().collect();
243
244 if line_number < 1 || line_number > lines.len() {
245 return Err(Error::InvalidArgument(format!(
246 "line {} is out of range for '{}' ({} lines)",
247 line_number,
248 file_path.display(),
249 lines.len(),
250 )));
251 }
252
253 let original = lines[line_number - 1];
254
255 let new_line = snooze_line(original, new_date).ok_or_else(|| {
256 Error::InvalidArgument(format!(
257 "no timebomb date bracket found on line {} of '{}'",
258 line_number,
259 file_path.display(),
260 ))
261 })?;
262
263 let mut new_content = String::with_capacity(content.len() + new_line.len());
264 for (i, line) in lines.iter().enumerate() {
265 if i == line_number - 1 {
266 new_content.push_str(&new_line);
267 } else {
268 new_content.push_str(line);
269 }
270 new_content.push('\n');
271 }
272 if !content.ends_with('\n') {
274 new_content.pop();
275 }
276
277 let tmp_path = file_path.with_extension(format!("tmp.{}", std::process::id()));
280 std::fs::write(&tmp_path, new_content).map_err(|e| Error::Io {
281 source: e,
282 path: Some(tmp_path.clone()),
283 })?;
284 std::fs::rename(&tmp_path, file_path).map_err(|e| Error::Io {
285 source: e,
286 path: Some(file_path.to_path_buf()),
287 })?;
288
289 Ok(())
290}
291
292#[cfg(test)]
297mod tests {
298 use super::*;
299 use chrono::NaiveDate;
300 use std::fs;
301 use tempfile::tempdir;
302
303 fn date(s: &str) -> NaiveDate {
304 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
305 }
306
307 fn today() -> NaiveDate {
308 date("2026-03-22")
311 }
312
313 #[test]
316 fn test_fix_extend_replaces_date() {
317 let dir = tempdir().unwrap();
318 let file = dir.path().join("test.rs");
319 fs::write(&file, "// TODO[2020-01-01]: expired annotation\n").unwrap();
320
321 apply_extend(&file, 1, date("2027-06-01")).unwrap();
322
323 let content = fs::read_to_string(&file).unwrap();
324 assert!(content.contains("2027-06-01"), "new date should appear");
325 assert!(!content.contains("2020-01-01"), "old date should be gone");
326 }
327
328 #[test]
331 fn test_fix_delete_removes_line() {
332 let dir = tempdir().unwrap();
333 let file = dir.path().join("test.rs");
334 fs::write(
335 &file,
336 "fn alpha() {}\n// TODO[2020-01-01]: expired\nfn beta() {}\n",
337 )
338 .unwrap();
339
340 remove_line(&file, 2).unwrap();
341
342 let content = fs::read_to_string(&file).unwrap();
343 let lines: Vec<&str> = content.lines().collect();
344 assert_eq!(lines.len(), 2);
345 assert_eq!(lines[0], "fn alpha() {}");
346 assert_eq!(lines[1], "fn beta() {}");
347 }
348
349 #[test]
352 fn test_fix_multi_file_bottom_up_order() {
353 let dir = tempdir().unwrap();
354 let file = dir.path().join("multi.rs");
355 fs::write(
357 &file,
358 "fn a() {}\n\
359 // TODO[2020-01-01]: first expired\n\
360 fn b() {}\n\
361 // TODO[2019-06-01]: second expired\n\
362 fn c() {}\n",
363 )
364 .unwrap();
365
366 remove_line(&file, 4).unwrap();
368 remove_line(&file, 2).unwrap();
369
370 let content = fs::read_to_string(&file).unwrap();
371 let lines: Vec<&str> = content.lines().collect();
372 assert_eq!(lines.len(), 3);
373 assert_eq!(lines[0], "fn a() {}");
374 assert_eq!(lines[1], "fn b() {}");
375 assert_eq!(lines[2], "fn c() {}");
376 assert!(!content.contains("first expired"));
377 assert!(!content.contains("second expired"));
378 }
379
380 #[test]
383 fn test_fix_extend_date_before_today_rejected() {
384 let past = date("2020-01-01");
388 let t = today();
389 assert!(
391 past <= t,
392 "sanity: 2020-01-01 should be before or equal to today"
393 );
394
395 let future = date("2028-01-01");
397 assert!(future > t, "sanity: 2028-01-01 should be after today");
398 }
399
400 #[test]
403 fn test_run_fix_no_expired_returns_all_zeros() {
404 let dir = tempdir().unwrap();
405 let file = dir.path().join("ok.rs");
406 fs::write(&file, "// TODO[2099-01-01]: far future\n").unwrap();
407
408 let cfg = crate::config::Config::default();
409 let summary = run_fix(dir.path(), &cfg, today()).unwrap();
410
411 assert_eq!(summary.extended, 0);
412 assert_eq!(summary.deleted, 0);
413 assert_eq!(summary.skipped, 0);
414 }
415}