Skip to main content

timebomb/
scanner.rs

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/// Result of a full scan run.
12#[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
45/// Maximum file size that the scanner will read into memory (100 MiB).
46///
47/// Files larger than this are skipped with a warning. This prevents memory
48/// exhaustion from accidentally (or maliciously) large files in the scan tree.
49const MAX_FILE_BYTES: u64 = 100 * 1_024 * 1_024;
50
51/// Core scanner: walks `root`, respects config, and returns all found fuses.
52///
53/// `today` is injected rather than derived internally so that tests can use a
54/// fixed date without depending on the current wall-clock time.
55pub 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    // ----------------------------------------------------------------
60    // Phase 1 (serial): Walk the directory tree and collect the set of
61    // candidate files that pass the cheap exclude/extension/binary
62    // filters.  WalkDir is inherently serial (lazy recursion), so we
63    // keep this phase single-threaded and use it purely to decide *what*
64    // to process.
65    // ----------------------------------------------------------------
66    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        // SECURITY: must remain false to prevent symlink traversal outside the scan root.
76        .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        // Compute a path relative to root for glob matching and display.
93        let rel_path = abs_path
94            .strip_prefix(root)
95            .unwrap_or(&abs_path)
96            .to_path_buf();
97
98        // Skip excluded paths.
99        if config.is_excluded(&rel_path, &globset) {
100            skipped_files += 1;
101            continue;
102        }
103
104        // Skip files whose extension is not in the allowed list.
105        if !config.extension_allowed(&rel_path) {
106            continue;
107        }
108
109        // If --since was given, skip files not in the git-diff set.
110        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    // ----------------------------------------------------------------
120    // Phase 2 (parallel): Scan each candidate file on a rayon thread-
121    // pool worker.  Each worker reads the file once as raw bytes,
122    // performs binary detection inline (no second open), then decodes
123    // and scans.  Binary skips are counted via an atomic so Phase 1
124    // stays free of file I/O.
125    // ----------------------------------------------------------------
126    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            // Reject oversized files to avoid processing giant blobs; check after
135            // read so we only make one syscall instead of stat + read.
136            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            // Binary detection: a null byte means this is not a text file.
147            if bytes.contains(&0u8) {
148                binary_count.fetch_add(1, Ordering::Relaxed);
149                return Ok(vec![]);
150            }
151            // Non-UTF-8 bytes are replaced with U+FFFD — intentional; binary
152            // files are already rejected above by the null-byte check.
153            let content = String::from_utf8_lossy(&bytes);
154            scan_content(&content, &c.rel_path, &regex, config, today)
155        })
156        .collect();
157
158    let binary_skipped = binary_count.load(Ordering::Relaxed);
159    skipped_files += binary_skipped;
160    // swept_files = candidates that passed Phase 1 minus those found binary in Phase 2.
161    let swept_files = candidates.len() - binary_skipped;
162
163    // ----------------------------------------------------------------
164    // Phase 3 (serial): Flatten the per-file fuse lists, then
165    // sort the combined result by date ascending so the most urgent
166    // items appear first.
167    // ----------------------------------------------------------------
168    let mut fuses: Vec<Fuse> = results?.into_iter().flatten().collect();
169    // Unstable sort is faster — NaiveDate is Copy and there is no meaningful
170    // tiebreaker for equal dates, so stability adds cost for free.
171    fuses.sort_unstable_by_key(|a| a.date);
172
173    Ok(ScanResult {
174        fuses,
175        swept_files,
176        skipped_files,
177    })
178}
179
180/// Scan a single file and return all fuses found.
181///
182/// `abs_path` is used for reading; `rel_path` is stored in the `Fuse` for display.
183/// Binary files (detected via null-byte check) return an empty vec.
184/// Non-UTF-8 bytes in text files are replaced with U+FFFD; because binary
185/// files are rejected first, this replacement is a deliberate convenience for
186/// mixed-encoding text rather than a lossy fallback.
187pub 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    // Check size after read — one syscall instead of stat + read.
199    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
214/// Scan a string (file content) for fuses. Exposed separately for unit testing.
215pub 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        // Fast byte pre-filter: every valid fuse contains '['.
226        // Skips the regex engine entirely for the vast majority of lines.
227        if !line.contains('[') {
228            continue;
229        }
230
231        // Language-agnostic inline ignore directive. Works inside any comment
232        // syntax (// # -- /* */). Case-insensitive so TIMEBOMB: IGNORE works too.
233        if line.to_ascii_lowercase().contains("timebomb: ignore") {
234            continue;
235        }
236
237        let line_number = line_idx + 1; // 1-based
238
239        for caps in regex.captures_iter(line) {
240            let date_str = &caps[2];
241
242            // Parse the date before any heap allocation — on an invalid date
243            // the three allocations below are avoided entirely.
244            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
279/// Build the fuse-matching regex from the config's trigger list.
280pub fn build_regex(config: &Config) -> Result<Regex> {
281    let pattern = config.fuse_regex_pattern();
282    Regex::new(&pattern).map_err(Error::RegexCompile)
283}
284
285/// Detect binary files by looking for null bytes in the first 8 KB.
286///
287/// Exposed for benchmarking and testing. Not called in the scan pipeline —
288/// Phase 2 of `scan()` performs inline binary detection as part of the single
289/// `fs::read` call, avoiding a double file open.
290pub 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    // BufReader adds overhead for a single fixed-size read; use File directly.
298    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
305/// Convenience: scan a string with a freshly built regex.
306/// Useful for testing, benchmarking, and one-off scanning without a filesystem walk.
307pub 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, &regex, 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    // -----------------------------------------------------------------------
333    // scan_str helpers
334    // -----------------------------------------------------------------------
335
336    #[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        // Plain TODO without brackets must be ignored
359        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"); // normalised to upper
370    }
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        // 2025-06-10 is 9 days from today (2025-06-01), within the 14-day window
391        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        // Find each by tag
417        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        // Invalid date — should not produce a fuse (warning printed to stderr)
433        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        // Rebuild is implicit via scan_str which calls build_regex
473        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        // Same day as today, no fuse window → Ticking (0 <= 0)
487        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    // -----------------------------------------------------------------------
497    // is_binary
498    // -----------------------------------------------------------------------
499
500    #[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(); // contains null
513        assert!(is_binary(f.path()).unwrap());
514    }
515
516    // -----------------------------------------------------------------------
517    // ScanResult helpers
518    // -----------------------------------------------------------------------
519
520    #[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; // used in test context, suppress warning
554        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    // -----------------------------------------------------------------------
568    // Full filesystem scan (integration-style)
569    // -----------------------------------------------------------------------
570
571    #[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        // Create a .git subdirectory with a Rust-ish file
592        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        // And a normal file
597        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        // Only lib.rs should be scanned; the .git file should be excluded
602        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        // .xyz extension — not in default list
614        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        // scan_str() preserves source order; date-ascending sort is done by scan() only.
625        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    // -----------------------------------------------------------------------
637    // timebomb: ignore directive
638    // -----------------------------------------------------------------------
639
640    #[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}