Skip to main content

timebomb/
fix.rs

1//! Logic for the `timebomb defuse` subcommand.
2//!
3//! Walks through each detonated fuse interactively, prompting the user to
4//! extend the date, delete the line, or skip it. After processing all
5//! fuses it prints a summary.
6
7use 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
20// ---------------------------------------------------------------------------
21// Types
22// ---------------------------------------------------------------------------
23
24/// The action chosen by the user for a single detonated fuse.
25enum FixAction {
26    /// Replace the expiry date with this new date.
27    Extend(NaiveDate),
28    /// Remove the fuse line entirely.
29    Delete,
30    /// Leave this fuse unchanged.
31    Skip,
32}
33
34/// A resolved decision pairing an action with the fuse it targets.
35struct Decision {
36    action: FixAction,
37    /// Absolute path to the file containing the fuse.
38    abs_path: PathBuf,
39    /// 1-based line number of the fuse.
40    line: usize,
41}
42
43/// Summary counts returned by `run_fix`.
44pub struct FixSummary {
45    pub extended: usize,
46    pub deleted: usize,
47    pub skipped: usize,
48}
49
50// ---------------------------------------------------------------------------
51// Public entry point
52// ---------------------------------------------------------------------------
53
54/// Core logic for `timebomb defuse`.
55///
56/// Pass 1 — interactive: scan for detonated fuses and prompt the user for
57/// each one.
58///
59/// Pass 2 — apply: group decisions by file, sort line numbers descending, and
60/// apply edits bottom-up so earlier line numbers are not shifted by removals.
61///
62/// Always returns `Ok(FixSummary)`; the caller exits 0.
63pub fn run_fix(scan_path: &Path, cfg: &Config, today: NaiveDate) -> Result<FixSummary> {
64    // Pass 1: collect detonated fuses ----------------------------------------
65    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    // Pass 1: prompt the user for each fuse ---------------------------------
83    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    // Pass 2: apply decisions grouped by file, bottom-up --------------------
113    // Group decisions by absolute file path.
114    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        // Sort by line number descending so edits don't shift subsequent lines.
127        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
149// ---------------------------------------------------------------------------
150// Interactive prompt helpers
151// ---------------------------------------------------------------------------
152
153/// Prompt the user for an action on a single detonated fuse.
154///
155/// Loops until a valid response is received. Returns `FixAction`.
156fn 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                // EOF or empty line — treat as skip to avoid an infinite loop in
185                // non-interactive environments (e.g. pipes or test harnesses).
186                return Ok(FixAction::Skip);
187            }
188            other => {
189                println!("  Unknown option '{}'. Enter e, d, s, or ?.", other);
190            }
191        }
192    }
193}
194
195/// Prompt the user to enter a new expiry date. Validates that it is strictly
196/// after `today`. Loops until a valid date is entered.
197fn 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
229// ---------------------------------------------------------------------------
230// File mutation helpers
231// ---------------------------------------------------------------------------
232
233/// Replace the date bracket on the given line in `file_path` with `new_date`.
234///
235/// Reads the entire file, replaces the target line, and writes it back.
236fn 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    // Preserve the original file's trailing newline behaviour
273    if !content.ends_with('\n') {
274        new_content.pop();
275    }
276
277    // Write atomically: temp file in the same directory, then rename so a
278    // mid-write crash never leaves a partially-written source file.
279    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// ---------------------------------------------------------------------------
293// Tests
294// ---------------------------------------------------------------------------
295
296#[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        // Fixed date — well after fixture expired dates (2018–2021), so all
309        // fixture fuses are treated as detonated without depending on the wall clock.
310        date("2026-03-22")
311    }
312
313    // ── apply_extend ─────────────────────────────────────────────────────────
314
315    #[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    // ── remove_line (the Delete path in run_fix) ──────────────────────────
329
330    #[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    // ── bottom-up ordering prevents line-shift corruption ─────────────────
350
351    #[test]
352    fn test_fix_multi_file_bottom_up_order() {
353        let dir = tempdir().unwrap();
354        let file = dir.path().join("multi.rs");
355        // Lines 1, 3, 5 are context; lines 2 and 4 are expired annotations.
356        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        // Simulate what run_fix does: delete line 4 first (descending), then line 2.
367        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    // ── date validation: new date must be after today ─────────────────────
381
382    #[test]
383    fn test_fix_extend_date_before_today_rejected() {
384        // apply_extend itself does not validate the date — that is done by
385        // prompt_date. Verify that a date <= today is correctly detected as
386        // invalid at the prompt level by calling the validation logic directly.
387        let past = date("2020-01-01");
388        let t = today();
389        // past <= today must be rejected
390        assert!(
391            past <= t,
392            "sanity: 2020-01-01 should be before or equal to today"
393        );
394
395        // A future date must pass
396        let future = date("2028-01-01");
397        assert!(future > t, "sanity: 2028-01-01 should be after today");
398    }
399
400    // ── run_fix with no expired annotations ──────────────────────────────
401
402    #[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}