1use crate::annotation::Fuse;
2use crate::config::Config;
3use crate::error::{Error, Result};
4use chrono::NaiveDate;
5use rayon::prelude::*;
6use regex::Regex;
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicUsize, Ordering};
9use walkdir::WalkDir;
10
11#[derive(Debug)]
13pub struct ScanResult {
14 pub fuses: Vec<Fuse>,
15 pub swept_files: usize,
16 pub skipped_files: usize,
17}
18
19impl ScanResult {
20 pub fn detonated(&self) -> Vec<&Fuse> {
21 self.fuses.iter().filter(|a| a.is_detonated()).collect()
22 }
23
24 pub fn ticking(&self) -> Vec<&Fuse> {
25 self.fuses.iter().filter(|a| a.is_ticking()).collect()
26 }
27
28 pub fn inert(&self) -> Vec<&Fuse> {
29 self.fuses.iter().filter(|a| a.is_inert()).collect()
30 }
31
32 pub fn has_detonated(&self) -> bool {
33 self.fuses.iter().any(|a| a.is_detonated())
34 }
35
36 pub fn is_ticking(&self) -> bool {
37 self.fuses.iter().any(|a| a.is_ticking())
38 }
39
40 pub fn total(&self) -> usize {
41 self.fuses.len()
42 }
43}
44
45const MAX_FILE_BYTES: u64 = 100 * 1_024 * 1_024;
50
51pub fn scan(root: &Path, config: &Config, today: NaiveDate) -> Result<ScanResult> {
56 let globset = config.build_exclude_globset()?;
57 let regex = build_regex(config)?;
58
59 struct Candidate {
67 abs_path: PathBuf,
68 rel_path: PathBuf,
69 }
70
71 let mut candidates: Vec<Candidate> = Vec::new();
72 let mut skipped_files: usize = 0;
73
74 for entry in WalkDir::new(root)
75 .follow_links(false)
77 .into_iter()
78 .filter_map(|e| match e {
79 Ok(entry) => Some(entry),
80 Err(err) => {
81 eprintln!("warning: skipping inaccessible path: {}", err);
82 None
83 }
84 })
85 {
86 if !entry.file_type().is_file() {
87 continue;
88 }
89
90 let abs_path = entry.path().to_path_buf();
91
92 let rel_path = abs_path
94 .strip_prefix(root)
95 .unwrap_or(&abs_path)
96 .to_path_buf();
97
98 if config.is_excluded(&rel_path, &globset) {
100 skipped_files += 1;
101 continue;
102 }
103
104 if !config.extension_allowed(&rel_path) {
106 continue;
107 }
108
109 if let Some(ref diff_files) = config.diff_files {
111 if !diff_files.contains(&rel_path) {
112 continue;
113 }
114 }
115
116 candidates.push(Candidate { abs_path, rel_path });
117 }
118
119 let binary_count = AtomicUsize::new(0);
127 let results: Result<Vec<Vec<Fuse>>> = candidates
128 .par_iter()
129 .map(|c| {
130 let bytes = std::fs::read(&c.abs_path).map_err(|e| Error::Io {
131 source: e,
132 path: Some(c.abs_path.to_path_buf()),
133 })?;
134 if bytes.len() as u64 > MAX_FILE_BYTES {
137 eprintln!(
138 "warning: skipping '{}': file size ({} MiB) exceeds {} MiB limit",
139 c.rel_path.display(),
140 bytes.len() / 1_024 / 1_024,
141 MAX_FILE_BYTES as usize / 1_024 / 1_024,
142 );
143 binary_count.fetch_add(1, Ordering::Relaxed);
144 return Ok(vec![]);
145 }
146 if bytes.contains(&0u8) {
148 binary_count.fetch_add(1, Ordering::Relaxed);
149 return Ok(vec![]);
150 }
151 let content = String::from_utf8_lossy(&bytes);
154 scan_content(&content, &c.rel_path, ®ex, config, today)
155 })
156 .collect();
157
158 let binary_skipped = binary_count.load(Ordering::Relaxed);
159 skipped_files += binary_skipped;
160 let swept_files = candidates.len() - binary_skipped;
162
163 let mut fuses: Vec<Fuse> = results?.into_iter().flatten().collect();
169 fuses.sort_unstable_by_key(|a| a.date);
172
173 Ok(ScanResult {
174 fuses,
175 swept_files,
176 skipped_files,
177 })
178}
179
180pub fn scan_file(
188 abs_path: &Path,
189 rel_path: &Path,
190 regex: &Regex,
191 config: &Config,
192 today: NaiveDate,
193) -> Result<Vec<Fuse>> {
194 let bytes = std::fs::read(abs_path).map_err(|e| Error::Io {
195 source: e,
196 path: Some(abs_path.to_path_buf()),
197 })?;
198 if bytes.len() as u64 > MAX_FILE_BYTES {
200 return Err(Error::InvalidArgument(format!(
201 "file '{}' ({} MiB) exceeds the {} MiB scan limit",
202 rel_path.display(),
203 bytes.len() / 1_024 / 1_024,
204 MAX_FILE_BYTES as usize / 1_024 / 1_024,
205 )));
206 }
207 if bytes.contains(&0u8) {
208 return Ok(vec![]);
209 }
210 let content = String::from_utf8_lossy(&bytes);
211 scan_content(&content, rel_path, regex, config, today)
212}
213
214pub fn scan_content(
216 content: &str,
217 rel_path: &Path,
218 regex: &Regex,
219 config: &Config,
220 today: NaiveDate,
221) -> Result<Vec<Fuse>> {
222 let mut fuses = Vec::new();
223
224 for (line_idx, line) in content.lines().enumerate() {
225 if !line.contains('[') {
228 continue;
229 }
230
231 if line.to_ascii_lowercase().contains("timebomb: ignore") {
234 continue;
235 }
236
237 let line_number = line_idx + 1; for caps in regex.captures_iter(line) {
240 let date_str = &caps[2];
241
242 let date = match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
245 Ok(d) => d,
246 Err(_) => {
247 eprintln!(
248 "warning: invalid date '{}' at {}:{} — skipping",
249 date_str,
250 rel_path.display(),
251 line_number
252 );
253 continue;
254 }
255 };
256
257 let tag = caps[1].to_uppercase();
258 let owner = caps.get(4).map(|m| m.as_str().trim().to_string());
259 let message = caps[5].trim().to_string();
260
261 let status = Fuse::compute_status(date, today, config.fuse_days);
262
263 fuses.push(Fuse {
264 file: rel_path.to_path_buf(),
265 line: line_number,
266 tag,
267 date,
268 owner,
269 message,
270 status,
271 blamed_owner: None,
272 });
273 }
274 }
275
276 Ok(fuses)
277}
278
279pub fn build_regex(config: &Config) -> Result<Regex> {
281 let pattern = config.fuse_regex_pattern();
282 Regex::new(&pattern).map_err(Error::RegexCompile)
283}
284
285pub fn is_binary(path: &Path) -> Result<bool> {
291 use std::io::Read;
292 let mut f = std::fs::File::open(path).map_err(|e| Error::Io {
293 source: e,
294 path: Some(path.to_path_buf()),
295 })?;
296 let mut buf = [0u8; 8192];
297 let n = f.read(&mut buf).map_err(|e| Error::Io {
299 source: e,
300 path: Some(path.to_path_buf()),
301 })?;
302 Ok(buf[..n].contains(&0u8))
303}
304
305pub fn scan_str(
308 content: &str,
309 rel_path: &Path,
310 config: &Config,
311 today: NaiveDate,
312) -> Result<Vec<Fuse>> {
313 let regex = build_regex(config)?;
314 scan_content(content, rel_path, ®ex, config, today)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::annotation::Status;
321 use crate::config::Config;
322 use std::path::{Path, PathBuf};
323
324 fn today() -> NaiveDate {
325 NaiveDate::parse_from_str("2025-06-01", "%Y-%m-%d").unwrap()
326 }
327
328 fn default_config() -> Config {
329 Config::default()
330 }
331
332 #[test]
337 fn test_scan_finds_detonated_fuse() {
338 let src = "// TODO[2020-01-01]: remove this old code\n";
339 let fuses = scan_str(src, Path::new("foo.rs"), &default_config(), today()).unwrap();
340 assert_eq!(fuses.len(), 1);
341 assert_eq!(fuses[0].tag, "TODO");
342 assert_eq!(fuses[0].status, Status::Detonated);
343 assert_eq!(fuses[0].line, 1);
344 assert_eq!(fuses[0].message, "remove this old code");
345 }
346
347 #[test]
348 fn test_scan_finds_future_fixme() {
349 let src = "# FIXME[2099-01-01]: will still be relevant\n";
350 let fuses = scan_str(src, Path::new("foo.py"), &default_config(), today()).unwrap();
351 assert_eq!(fuses.len(), 1);
352 assert_eq!(fuses[0].tag, "FIXME");
353 assert_eq!(fuses[0].status, Status::Inert);
354 }
355
356 #[test]
357 fn test_scan_ignores_plain_todo() {
358 let src = "// TODO: fix this someday\n// FIXME: also this\n";
360 let fuses = scan_str(src, Path::new("foo.rs"), &default_config(), today()).unwrap();
361 assert!(fuses.is_empty(), "plain TODOs must not be matched");
362 }
363
364 #[test]
365 fn test_scan_case_insensitive_tag() {
366 let src = "// todo[2020-01-01]: lowercase tag should match\n";
367 let fuses = scan_str(src, Path::new("foo.rs"), &default_config(), today()).unwrap();
368 assert_eq!(fuses.len(), 1);
369 assert_eq!(fuses[0].tag, "TODO"); }
371
372 #[test]
373 fn test_scan_with_owner() {
374 let src = "// TODO[2020-01-01][alice]: remove after migration\n";
375 let fuses = scan_str(src, Path::new("foo.rs"), &default_config(), today()).unwrap();
376 assert_eq!(fuses.len(), 1);
377 assert_eq!(fuses[0].owner, Some("alice".to_string()));
378 assert_eq!(fuses[0].message, "remove after migration");
379 }
380
381 #[test]
382 fn test_scan_without_owner() {
383 let src = "// TODO[2020-01-01]: no owner here\n";
384 let fuses = scan_str(src, Path::new("foo.rs"), &default_config(), today()).unwrap();
385 assert_eq!(fuses[0].owner, None);
386 }
387
388 #[test]
389 fn test_scan_ticking() {
390 let src = "// TODO[2025-06-10]: ticking fuse\n";
392 let cfg = Config {
393 fuse_days: 14,
394 ..Config::default()
395 };
396 let fuses = scan_str(src, Path::new("foo.rs"), &cfg, today()).unwrap();
397 assert_eq!(fuses[0].status, Status::Ticking);
398 }
399
400 #[test]
401 fn test_scan_multiple_fuses() {
402 let src = "\
403line 1
404// TODO[2020-01-01]: detonated item
405line 3
406# FIXME[2099-12-31]: future item
407// HACK[2025-06-08]: ticking fuse
408line 6
409";
410 let cfg = Config {
411 fuse_days: 14,
412 ..Config::default()
413 };
414 let fuses = scan_str(src, Path::new("multi.rs"), &cfg, today()).unwrap();
415 assert_eq!(fuses.len(), 3);
416 let detonated = fuses.iter().find(|a| a.tag == "TODO").unwrap();
418 assert_eq!(detonated.status, Status::Detonated);
419 assert_eq!(detonated.line, 2);
420
421 let future = fuses.iter().find(|a| a.tag == "FIXME").unwrap();
422 assert_eq!(future.status, Status::Inert);
423 assert_eq!(future.line, 4);
424
425 let soon = fuses.iter().find(|a| a.tag == "HACK").unwrap();
426 assert_eq!(soon.status, Status::Ticking);
427 assert_eq!(soon.line, 5);
428 }
429
430 #[test]
431 fn test_scan_invalid_date_skipped_with_warning() {
432 let src = "// TODO[2026-13-45]: invalid date month\n";
434 let fuses = scan_str(src, Path::new("foo.rs"), &default_config(), today()).unwrap();
435 assert!(fuses.is_empty());
436 }
437
438 #[test]
439 fn test_scan_sql_comment() {
440 let src = "-- TODO[2020-01-01]: drop this column\n";
441 let fuses = scan_str(src, Path::new("schema.sql"), &default_config(), today()).unwrap();
442 assert_eq!(fuses.len(), 1);
443 assert_eq!(fuses[0].message, "drop this column");
444 }
445
446 #[test]
447 fn test_scan_hash_comment() {
448 let src = "# REMOVEME[2020-01-01]: remove this block\n";
449 let fuses = scan_str(src, Path::new("script.py"), &default_config(), today()).unwrap();
450 assert_eq!(fuses.len(), 1);
451 assert_eq!(fuses[0].tag, "REMOVEME");
452 }
453
454 #[test]
455 fn test_scan_temp_tag() {
456 let src = "// TEMP[2020-01-01]: temporary workaround\n";
457 let fuses = scan_str(src, Path::new("foo.rs"), &default_config(), today()).unwrap();
458 assert_eq!(fuses.len(), 1);
459 assert_eq!(fuses[0].tag, "TEMP");
460 }
461
462 #[test]
463 fn test_scan_custom_triggers_only() {
464 let src = "\
465// TODO[2020-01-01]: this should not match
466// CUSTOM[2020-01-01]: this should match
467";
468 let cfg = Config {
469 triggers: vec!["CUSTOM".to_string()],
470 ..Config::default()
471 };
472 let fuses = scan_str(src, Path::new("foo.rs"), &cfg, today()).unwrap();
474 assert_eq!(fuses.len(), 1);
475 assert_eq!(fuses[0].tag, "CUSTOM");
476 }
477
478 #[test]
479 fn test_scan_empty_file() {
480 let fuses = scan_str("", Path::new("empty.rs"), &default_config(), today()).unwrap();
481 assert!(fuses.is_empty());
482 }
483
484 #[test]
485 fn test_scan_fuse_exactly_at_zero_days_remaining() {
486 let src = "// TODO[2025-06-01]: due today\n";
488 let cfg = Config {
489 fuse_days: 0,
490 ..Config::default()
491 };
492 let fuses = scan_str(src, Path::new("foo.rs"), &cfg, today()).unwrap();
493 assert_eq!(fuses[0].status, Status::Ticking);
494 }
495
496 #[test]
501 fn test_is_binary_text_file() {
502 use std::io::Write;
503 let mut f = tempfile::NamedTempFile::new().unwrap();
504 writeln!(f, "// TODO[2020-01-01]: normal text file").unwrap();
505 assert!(!is_binary(f.path()).unwrap());
506 }
507
508 #[test]
509 fn test_is_binary_binary_file() {
510 use std::io::Write;
511 let mut f = tempfile::NamedTempFile::new().unwrap();
512 f.write_all(&[0x50, 0x4b, 0x00, 0x04, 0xFF, 0xFE]).unwrap(); assert!(is_binary(f.path()).unwrap());
514 }
515
516 #[test]
521 fn test_scan_result_categorisation() {
522 let today_date = today();
523 let detonated = Fuse {
524 file: PathBuf::from("a.rs"),
525 line: 1,
526 tag: "TODO".to_string(),
527 date: NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap(),
528 owner: None,
529 message: "detonated".to_string(),
530 status: Status::Detonated,
531 blamed_owner: None,
532 };
533 let soon = Fuse {
534 file: PathBuf::from("b.rs"),
535 line: 2,
536 tag: "FIXME".to_string(),
537 date: NaiveDate::parse_from_str("2025-06-08", "%Y-%m-%d").unwrap(),
538 owner: None,
539 message: "ticking".to_string(),
540 status: Status::Ticking,
541 blamed_owner: None,
542 };
543 let inert = Fuse {
544 file: PathBuf::from("c.rs"),
545 line: 3,
546 tag: "HACK".to_string(),
547 date: NaiveDate::parse_from_str("2099-01-01", "%Y-%m-%d").unwrap(),
548 owner: None,
549 message: "fine".to_string(),
550 status: Status::Inert,
551 blamed_owner: None,
552 };
553 let _ = today_date; let result = ScanResult {
555 fuses: vec![detonated, soon, inert],
556 swept_files: 3,
557 skipped_files: 0,
558 };
559 assert_eq!(result.detonated().len(), 1);
560 assert_eq!(result.ticking().len(), 1);
561 assert_eq!(result.inert().len(), 1);
562 assert!(result.has_detonated());
563 assert!(result.is_ticking());
564 assert_eq!(result.total(), 3);
565 }
566
567 #[test]
572 fn test_scan_directory() {
573 use std::io::Write;
574 let dir = tempfile::tempdir().unwrap();
575
576 let mut f1 = std::fs::File::create(dir.path().join("main.rs")).unwrap();
577 writeln!(f1, "// TODO[2020-01-01]: detonated").unwrap();
578 writeln!(f1, "// FIXME[2099-01-01]: future").unwrap();
579
580 let result = scan(dir.path(), &default_config(), today()).unwrap();
581 assert_eq!(result.swept_files, 1);
582 assert_eq!(result.fuses.len(), 2);
583 assert!(result.has_detonated());
584 }
585
586 #[test]
587 fn test_scan_directory_skips_excluded() {
588 use std::io::Write;
589 let dir = tempfile::tempdir().unwrap();
590
591 std::fs::create_dir(dir.path().join(".git")).unwrap();
593 let mut f = std::fs::File::create(dir.path().join(".git/hooks.rs")).unwrap();
594 writeln!(f, "// TODO[2020-01-01]: should be excluded").unwrap();
595
596 let mut f2 = std::fs::File::create(dir.path().join("lib.rs")).unwrap();
598 writeln!(f2, "// FIXME[2099-01-01]: inert").unwrap();
599
600 let result = scan(dir.path(), &default_config(), today()).unwrap();
601 assert_eq!(result.swept_files, 1);
603 let tags: Vec<_> = result.fuses.iter().map(|a| a.tag.as_str()).collect();
604 assert!(!tags.contains(&"TODO"));
605 assert!(tags.contains(&"FIXME"));
606 }
607
608 #[test]
609 fn test_scan_directory_respects_extensions() {
610 use std::io::Write;
611 let dir = tempfile::tempdir().unwrap();
612
613 let mut f = std::fs::File::create(dir.path().join("data.xyz")).unwrap();
615 writeln!(f, "// TODO[2020-01-01]: should be skipped").unwrap();
616
617 let result = scan(dir.path(), &default_config(), today()).unwrap();
618 assert_eq!(result.swept_files, 0);
619 assert!(result.fuses.is_empty());
620 }
621
622 #[test]
623 fn test_scan_str_returns_fuses_in_line_order() {
624 let src = "\
626// TODO[2099-12-31]: far future
627// FIXME[2020-01-01]: detonated
628// HACK[2050-06-15]: mid future
629";
630 let fuses = scan_str(src, Path::new("foo.rs"), &default_config(), today()).unwrap();
631 assert_eq!(fuses[0].tag, "TODO");
632 assert_eq!(fuses[1].tag, "FIXME");
633 assert_eq!(fuses[2].tag, "HACK");
634 }
635
636 #[test]
641 fn test_scan_ignore_directive_skips_line() {
642 let src = "// TODO[2020-01-01]: remove this timebomb: ignore\n";
643 let fuses = scan_str(src, Path::new("foo.rs"), &default_config(), today()).unwrap();
644 assert!(fuses.is_empty(), "annotated-ignore line must be skipped");
645 }
646
647 #[test]
648 fn test_scan_ignore_directive_case_insensitive() {
649 let src = "# TODO[2020-01-01]: remove TIMEBOMB: IGNORE\n";
650 let fuses = scan_str(src, Path::new("foo.rs"), &default_config(), today()).unwrap();
651 assert!(fuses.is_empty());
652 }
653
654 #[test]
655 fn test_scan_ignore_directive_sql_style() {
656 let src = "-- TODO[2020-01-01]: drop col timebomb: ignore\n";
657 let fuses = scan_str(src, Path::new("schema.sql"), &default_config(), today()).unwrap();
658 assert!(fuses.is_empty());
659 }
660
661 #[test]
662 fn test_scan_ignore_only_affects_its_own_line() {
663 let src = "// TODO[2020-01-01]: active\n\
664 // FIXME[2021-01-01]: ignored timebomb: ignore\n\
665 // HACK[2019-01-01]: also active\n";
666 let fuses = scan_str(src, Path::new("foo.rs"), &default_config(), today()).unwrap();
667 assert_eq!(fuses.len(), 2);
668 assert!(fuses.iter().all(|f| f.tag != "FIXME"));
669 }
670
671 #[test]
672 fn test_scan_directory_sorted() {
673 use std::io::Write;
674 let dir = tempfile::tempdir().unwrap();
675 let mut f = std::fs::File::create(dir.path().join("sort.rs")).unwrap();
676 writeln!(f, "// TODO[2099-12-31]: far future").unwrap();
677 writeln!(f, "// FIXME[2020-01-01]: detonated").unwrap();
678 writeln!(f, "// HACK[2050-06-15]: mid future").unwrap();
679
680 let result = scan(dir.path(), &default_config(), today()).unwrap();
681 let dates: Vec<_> = result.fuses.iter().map(|a| a.date).collect();
682 let mut sorted = dates.clone();
683 sorted.sort();
684 assert_eq!(
685 dates, sorted,
686 "scan results should be sorted by date ascending"
687 );
688 }
689}