1use 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
17pub fn run_remove(target: &str, search: Option<&str>, yes: bool) -> Result<i32> {
28 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 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 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 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 println!(
86 "- {}:{} {}",
87 file_path.display(),
88 line_number,
89 line_content
90 );
91
92 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 remove_line(&file_path, line_number)?;
115
116 println!("removed {}:{}", file_path.display(), line_number);
117
118 Ok(0)
119}
120
121pub fn run_remove_all_expired(
135 scan_path: &Path,
136 cfg: &Config,
137 today: NaiveDate,
138 yes: bool,
139) -> Result<i32> {
140 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 println!("Fuses to remove:");
151 for ann in &detonated {
152 println!(" - {}:{} {}", ann.file.display(), ann.line, ann.message);
153 }
154
155 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 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 for (file_path, mut line_numbers) in by_file {
186 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
199pub 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 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 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 }
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
247fn is_timebomb_line(line: &str) -> bool {
253 let mut chars = line.chars().peekable();
255 while let Some(ch) = chars.next() {
256 if ch == '[' {
257 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
271fn 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#[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 #[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 #[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 #[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 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}