Skip to main content

timebomb/
remove.rs

1//! Logic for the `timebomb remove` subcommand.
2//!
3//! Provides two public entry points:
4//! - `run_remove`: remove a single annotation line by target or search pattern
5//! - `run_remove_all_expired`: remove all expired annotations found by scan
6
7use crate::add::{find_matching_lines, parse_target};
8use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::scanner::scan;
11use chrono::NaiveDate;
12use std::cmp::Reverse;
13use std::collections::HashMap;
14use std::io::{self, BufRead, Write};
15use std::path::{Path, PathBuf};
16
17// ---------------------------------------------------------------------------
18// Public entry point: remove a single annotation
19// ---------------------------------------------------------------------------
20
21/// Remove a single annotation line from a file.
22///
23/// # Parameters
24/// - `target` — `"FILE:LINE"` when search is None; plain file path when search is Some
25/// - `search` — optional pattern to locate the annotation
26/// - `yes`    — skip confirmation prompt when `true`
27pub fn run_remove(target: &str, search: Option<&str>, yes: bool) -> Result<i32> {
28    // 1. Resolve file path and line number -----------------------------------
29    let (file_path, line_number) = if let Some(pattern) = search {
30        let path = PathBuf::from(target);
31        let matches = find_matching_lines(&path, pattern)?;
32        match matches.len() {
33            0 => {
34                return Err(Error::InvalidArgument(format!(
35                    "no lines matching '{}' found in {}",
36                    pattern, target
37                )));
38            }
39            1 => (path, matches[0].0),
40            n => {
41                let mut detail =
42                    format!("pattern '{}' matched {} lines in {}:", pattern, n, target);
43                for (ln, content) in &matches {
44                    detail.push_str(&format!("\n  line {}: {}", ln, content.trim_end()));
45                }
46                detail.push_str("\nuse FILE:LINE to be specific");
47                return Err(Error::InvalidArgument(detail));
48            }
49        }
50    } else {
51        parse_target(target)?
52    };
53
54    // 2. Read the file -------------------------------------------------------
55    let content = std::fs::read_to_string(&file_path).map_err(|e| Error::Io {
56        source: e,
57        path: Some(file_path.clone()),
58    })?;
59
60    let lines: Vec<&str> = content.lines().collect();
61    let line_count = lines.len();
62
63    // 3. Validate line number ------------------------------------------------
64    if line_number < 1 || line_number > line_count {
65        return Err(Error::InvalidArgument(format!(
66            "line {} does not exist in '{}' ({} lines)",
67            line_number,
68            file_path.display(),
69            line_count,
70        )));
71    }
72
73    let line_content = lines[line_number - 1];
74
75    // 4. Verify it looks like a timebomb annotation --------------------------
76    if !is_timebomb_line(line_content) {
77        return Err(Error::InvalidArgument(format!(
78            "line {} of {} does not appear to be a timebomb annotation",
79            line_number,
80            file_path.display(),
81        )));
82    }
83
84    // 5. Display the line to be removed --------------------------------------
85    println!(
86        "- {}:{}  {}",
87        file_path.display(),
88        line_number,
89        line_content
90    );
91
92    // 6. Prompt for confirmation (unless --yes) ------------------------------
93    if !yes {
94        print!("Remove this line? [y/N]: ");
95        io::stdout().flush().map_err(|e| Error::Io {
96            source: e,
97            path: None,
98        })?;
99
100        let stdin = io::stdin();
101        let mut buf = String::new();
102        stdin.lock().read_line(&mut buf).map_err(|e| Error::Io {
103            source: e,
104            path: None,
105        })?;
106
107        let response = buf.trim();
108        if response != "y" && response != "Y" {
109            return Ok(0);
110        }
111    }
112
113    // 7. Remove the line -----------------------------------------------------
114    remove_line(&file_path, line_number)?;
115
116    println!("removed {}:{}", file_path.display(), line_number);
117
118    Ok(0)
119}
120
121// ---------------------------------------------------------------------------
122// Public entry point: remove all expired annotations
123// ---------------------------------------------------------------------------
124
125/// Remove all expired annotation lines from scanned files.
126///
127/// Groups by file, removes lines from bottom to top so line numbers don't shift.
128///
129/// # Parameters
130/// - `scan_path` — root path to scan
131/// - `cfg`       — scanner configuration
132/// - `today`     — current date (injected for testability)
133/// - `yes`       — skip confirmation prompt when `true`
134pub fn run_remove_all_expired(
135    scan_path: &Path,
136    cfg: &Config,
137    today: NaiveDate,
138    yes: bool,
139) -> Result<i32> {
140    // 1. Scan for expired annotations ----------------------------------------
141    let result = scan(scan_path, cfg, today)?;
142    let detonated: Vec<_> = result.detonated();
143
144    if detonated.is_empty() {
145        println!("No detonated fuses found.");
146        return Ok(0);
147    }
148
149    // 2. Print all fuses that will be removed --------------------------------
150    println!("Fuses to remove:");
151    for ann in &detonated {
152        println!("  - {}:{}  {}", ann.file.display(), ann.line, ann.message);
153    }
154
155    // 3. Prompt for confirmation (unless --yes) ------------------------------
156    if !yes {
157        print!("Remove {} fuse(s)? [y/N]: ", detonated.len());
158        io::stdout().flush().map_err(|e| Error::Io {
159            source: e,
160            path: None,
161        })?;
162
163        let stdin = io::stdin();
164        let mut buf = String::new();
165        stdin.lock().read_line(&mut buf).map_err(|e| Error::Io {
166            source: e,
167            path: None,
168        })?;
169
170        let response = buf.trim();
171        if response != "y" && response != "Y" {
172            return Ok(0);
173        }
174    }
175
176    // 4. Group by file, sort lines descending within each file ---------------
177    // The fuse file paths are relative to scan_path, so resolve them.
178    let mut by_file: HashMap<PathBuf, Vec<usize>> = HashMap::new();
179    for ann in &detonated {
180        let abs_path = scan_path.join(&ann.file);
181        by_file.entry(abs_path).or_default().push(ann.line);
182    }
183
184    // 5. Remove lines from each file (bottom-up to preserve line numbers) ----
185    for (file_path, mut line_numbers) in by_file {
186        // Sort descending so we remove from bottom up
187        line_numbers.sort_unstable_by_key(|line_number| Reverse(*line_number));
188        line_numbers.dedup();
189
190        for line_number in line_numbers {
191            remove_line(&file_path, line_number)?;
192        }
193        println!("cleaned {}", file_path.display());
194    }
195
196    Ok(0)
197}
198
199// ---------------------------------------------------------------------------
200// Helper: remove_line
201// ---------------------------------------------------------------------------
202
203/// Remove the line at `line_number` (1-based) from `file_path`.
204///
205/// Writes the file back without that line.
206/// Returns the original line content.
207pub fn remove_line(file_path: &Path, line_number: usize) -> Result<String> {
208    let content = std::fs::read_to_string(file_path).map_err(|e| Error::Io {
209        source: e,
210        path: Some(file_path.to_path_buf()),
211    })?;
212
213    let lines: Vec<&str> = content.lines().collect();
214    let line_count = lines.len();
215
216    if line_number < 1 || line_number > line_count {
217        return Err(Error::InvalidArgument(format!(
218            "line {} is out of range for '{}' ({} lines)",
219            line_number,
220            file_path.display(),
221            line_count,
222        )));
223    }
224
225    let original = lines[line_number - 1].to_string();
226
227    // Build new content without that line
228    let mut new_lines: Vec<&str> = lines[..line_number - 1].to_vec();
229    new_lines.extend_from_slice(&lines[line_number..]);
230
231    let mut new_content = new_lines.join("\n");
232    // Preserve trailing newline if the original had one
233    if content.ends_with('\n') && !new_content.is_empty() {
234        new_content.push('\n');
235    } else if new_content.is_empty() && content.ends_with('\n') {
236        // File is now empty but had a trailing newline — keep it empty
237    }
238
239    std::fs::write(file_path, new_content).map_err(|e| Error::Io {
240        source: e,
241        path: Some(file_path.to_path_buf()),
242    })?;
243
244    Ok(original)
245}
246
247// ---------------------------------------------------------------------------
248// Helper: is_timebomb_line
249// ---------------------------------------------------------------------------
250
251/// Returns true if the line contains a timebomb date bracket `[YYYY-MM-DD]`.
252fn is_timebomb_line(line: &str) -> bool {
253    // Simple check: look for [YYYY-MM-DD] pattern
254    let mut chars = line.chars().peekable();
255    while let Some(ch) = chars.next() {
256        if ch == '[' {
257            // Try to read YYYY-MM-DD]
258            let rest: String = chars.clone().take(11).collect();
259            if rest.len() == 11 {
260                let date_part = &rest[..10];
261                let close = rest.chars().nth(10);
262                if close == Some(']') && looks_like_date(date_part) {
263                    return true;
264                }
265            }
266        }
267    }
268    false
269}
270
271/// Quick check if a string looks like YYYY-MM-DD.
272fn looks_like_date(s: &str) -> bool {
273    if s.len() != 10 {
274        return false;
275    }
276    let bytes = s.as_bytes();
277    bytes[4] == b'-'
278        && bytes[7] == b'-'
279        && bytes[..4].iter().all(|b| b.is_ascii_digit())
280        && bytes[5..7].iter().all(|b| b.is_ascii_digit())
281        && bytes[8..10].iter().all(|b| b.is_ascii_digit())
282}
283
284// ---------------------------------------------------------------------------
285// Tests
286// ---------------------------------------------------------------------------
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use chrono::NaiveDate;
292    use tempfile::tempdir;
293
294    fn today() -> NaiveDate {
295        NaiveDate::from_ymd_opt(2026, 3, 22).unwrap()
296    }
297
298    // -- remove_line ---------------------------------------------------------
299
300    #[test]
301    fn test_remove_line_basic() {
302        let dir = tempdir().unwrap();
303        let file = dir.path().join("test.rs");
304        std::fs::write(&file, "line one\nline two\nline three\n").unwrap();
305
306        let original = remove_line(&file, 2).unwrap();
307        assert_eq!(original, "line two");
308
309        let content = std::fs::read_to_string(&file).unwrap();
310        let lines: Vec<&str> = content.lines().collect();
311        assert_eq!(lines.len(), 2);
312        assert_eq!(lines[0], "line one");
313        assert_eq!(lines[1], "line three");
314    }
315
316    #[test]
317    fn test_remove_line_first_line() {
318        let dir = tempdir().unwrap();
319        let file = dir.path().join("test.rs");
320        std::fs::write(&file, "first\nsecond\nthird\n").unwrap();
321
322        let original = remove_line(&file, 1).unwrap();
323        assert_eq!(original, "first");
324
325        let content = std::fs::read_to_string(&file).unwrap();
326        let lines: Vec<&str> = content.lines().collect();
327        assert_eq!(lines.len(), 2);
328        assert_eq!(lines[0], "second");
329        assert_eq!(lines[1], "third");
330    }
331
332    #[test]
333    fn test_remove_line_last_line() {
334        let dir = tempdir().unwrap();
335        let file = dir.path().join("test.rs");
336        std::fs::write(&file, "first\nsecond\nlast\n").unwrap();
337
338        let original = remove_line(&file, 3).unwrap();
339        assert_eq!(original, "last");
340
341        let content = std::fs::read_to_string(&file).unwrap();
342        let lines: Vec<&str> = content.lines().collect();
343        assert_eq!(lines.len(), 2);
344        assert_eq!(lines[0], "first");
345        assert_eq!(lines[1], "second");
346    }
347
348    #[test]
349    fn test_remove_line_out_of_range() {
350        let dir = tempdir().unwrap();
351        let file = dir.path().join("test.rs");
352        std::fs::write(&file, "only line\n").unwrap();
353
354        let result = remove_line(&file, 99);
355        assert!(result.is_err());
356        let msg = result.unwrap_err().to_string();
357        assert!(msg.contains("out of range") || msg.contains("99"));
358    }
359
360    // -- run_remove ----------------------------------------------------------
361
362    #[test]
363    fn test_run_remove_removes_annotation() {
364        let dir = tempdir().unwrap();
365        let file = dir.path().join("test.rs");
366        std::fs::write(
367            &file,
368            "fn alpha() {}\n// TODO[2020-01-01]: expired remove\nfn beta() {}\n",
369        )
370        .unwrap();
371
372        let target = format!("{}:2", file.display());
373        let result = run_remove(&target, None, true);
374        assert!(result.is_ok());
375
376        let content = std::fs::read_to_string(&file).unwrap();
377        let lines: Vec<&str> = content.lines().collect();
378        assert_eq!(lines.len(), 2);
379        assert_eq!(lines[0], "fn alpha() {}");
380        assert_eq!(lines[1], "fn beta() {}");
381    }
382
383    #[test]
384    fn test_run_remove_non_annotation_line() {
385        let dir = tempdir().unwrap();
386        let file = dir.path().join("test.rs");
387        std::fs::write(&file, "fn alpha() {}\nfn beta() {}\n").unwrap();
388
389        let target = format!("{}:1", file.display());
390        let result = run_remove(&target, None, true);
391        assert!(result.is_err());
392        let msg = result.unwrap_err().to_string();
393        assert!(msg.contains("does not appear to be a timebomb annotation"));
394    }
395
396    #[test]
397    fn test_run_remove_by_search_single_match() {
398        let dir = tempdir().unwrap();
399        let file = dir.path().join("test.rs");
400        std::fs::write(
401            &file,
402            "fn alpha() {}\n// TODO[2020-01-01]: legacy_auth remove\nfn beta() {}\n",
403        )
404        .unwrap();
405
406        let result = run_remove(file.to_str().unwrap(), Some("legacy_auth"), true);
407        assert!(result.is_ok());
408
409        let content = std::fs::read_to_string(&file).unwrap();
410        assert!(!content.contains("legacy_auth"));
411        assert!(content.contains("fn alpha()"));
412        assert!(content.contains("fn beta()"));
413    }
414
415    #[test]
416    fn test_run_remove_by_search_no_match() {
417        let dir = tempdir().unwrap();
418        let file = dir.path().join("test.rs");
419        std::fs::write(&file, "fn alpha() {}\nfn beta() {}\n").unwrap();
420
421        let result = run_remove(file.to_str().unwrap(), Some("zzz_no_match"), true);
422        assert!(result.is_err());
423        let msg = result.unwrap_err().to_string();
424        assert!(msg.contains("no lines matching"));
425    }
426
427    #[test]
428    fn test_run_remove_by_search_multiple_matches() {
429        let dir = tempdir().unwrap();
430        let file = dir.path().join("test.rs");
431        std::fs::write(
432            &file,
433            "// TODO[2020-01-01]: foo one\n// TODO[2020-02-01]: foo two\n",
434        )
435        .unwrap();
436
437        let result = run_remove(file.to_str().unwrap(), Some("foo"), true);
438        assert!(result.is_err());
439        let msg = result.unwrap_err().to_string();
440        assert!(msg.contains("matched") || msg.contains("lines"));
441    }
442
443    // -- run_remove_all_expired ----------------------------------------------
444
445    #[test]
446    fn test_run_remove_all_expired_no_expired() {
447        let dir = tempdir().unwrap();
448        let file = dir.path().join("ok.rs");
449        std::fs::write(&file, "// TODO[2099-01-01]: far future\n").unwrap();
450
451        let cfg = crate::config::Config::default();
452        let result = run_remove_all_expired(dir.path(), &cfg, today(), true);
453        assert!(result.is_ok());
454        assert_eq!(result.unwrap(), 0);
455    }
456
457    #[test]
458    fn test_run_remove_all_expired_removes_from_multiple_files() {
459        let dir = tempdir().unwrap();
460
461        let file_a = dir.path().join("a.rs");
462        std::fs::write(
463            &file_a,
464            "fn foo() {}\n// TODO[2020-01-01]: expired a\nfn bar() {}\n",
465        )
466        .unwrap();
467
468        let file_b = dir.path().join("b.rs");
469        std::fs::write(&file_b, "// TODO[2019-06-01]: expired b\nfn baz() {}\n").unwrap();
470
471        let cfg = crate::config::Config::default();
472        let result = run_remove_all_expired(dir.path(), &cfg, today(), true);
473        assert!(result.is_ok());
474
475        let content_a = std::fs::read_to_string(&file_a).unwrap();
476        assert!(!content_a.contains("expired a"));
477        assert!(content_a.contains("fn foo()"));
478        assert!(content_a.contains("fn bar()"));
479
480        let content_b = std::fs::read_to_string(&file_b).unwrap();
481        assert!(!content_b.contains("expired b"));
482        assert!(content_b.contains("fn baz()"));
483    }
484
485    #[test]
486    fn test_run_remove_all_expired_multiline_file_line_numbers_correct() {
487        // 3 expired annotations in one file — all should be removed cleanly
488        let dir = tempdir().unwrap();
489        let file = dir.path().join("multi.rs");
490        std::fs::write(
491            &file,
492            "fn a() {}\n\
493             // TODO[2020-01-01]: first expired\n\
494             fn b() {}\n\
495             // FIXME[2019-06-01]: second expired\n\
496             fn c() {}\n\
497             // HACK[2018-03-01]: third expired\n\
498             fn d() {}\n",
499        )
500        .unwrap();
501
502        let cfg = crate::config::Config::default();
503        let result = run_remove_all_expired(dir.path(), &cfg, today(), true);
504        assert!(result.is_ok());
505
506        let content = std::fs::read_to_string(&file).unwrap();
507        assert!(!content.contains("first expired"));
508        assert!(!content.contains("second expired"));
509        assert!(!content.contains("third expired"));
510        assert!(content.contains("fn a()"));
511        assert!(content.contains("fn b()"));
512        assert!(content.contains("fn c()"));
513        assert!(content.contains("fn d()"));
514    }
515}