Skip to main content

todo_tree/
scanner.rs

1//! Directory walking and file parsing orchestration.
2
3use crate::core::{ScanResult, TodoItem};
4use crate::parser::TodoParser;
5use color_eyre::eyre::{Result, WrapErr};
6use ignore::overrides::{Override, OverrideBuilder};
7use ignore::{WalkBuilder, WalkState};
8use std::path::{Path, PathBuf};
9use std::sync::OnceLock;
10use std::sync::mpsc;
11use std::time::Instant;
12
13/// Options controlling how [`Scanner`] walks a directory tree.
14#[derive(Debug, Clone)]
15pub struct ScanOptions {
16    /// Glob patterns to include; empty means "include everything not
17    /// excluded".
18    pub include: Vec<String>,
19    /// Glob patterns to exclude.
20    pub exclude: Vec<String>,
21    /// Maximum directory depth to descend; `0` means unlimited.
22    pub max_depth: usize,
23    /// Whether to follow symlinks.
24    pub follow_links: bool,
25    /// Whether to include hidden files and directories.
26    pub hidden: bool,
27    /// Number of worker threads to use; `0` lets the walker choose.
28    pub threads: usize,
29    /// Whether to respect `.gitignore`/global/local git ignore rules.
30    pub respect_gitignore: bool,
31}
32
33impl Default for ScanOptions {
34    fn default() -> Self {
35        Self {
36            include: Vec::new(),
37            exclude: Vec::new(),
38            max_depth: 0,
39            follow_links: false,
40            hidden: false,
41            threads: 0,
42            respect_gitignore: true,
43        }
44    }
45}
46
47/// Walks a directory tree, parsing every file with a [`TodoParser`].
48///
49/// A `Scanner` is cheap to reuse across repeated calls to [`Scanner::scan`]
50/// (e.g. `tt watch` re-scanning on every file change): the include/exclude
51/// [`Override`] set is built once, on the first call, and cached for the
52/// life of the `Scanner`.
53pub struct Scanner {
54    parser: TodoParser,
55    options: ScanOptions,
56    overrides: OnceLock<Override>,
57}
58
59impl Scanner {
60    /// Creates a scanner using `parser` and `options`.
61    pub fn new(parser: TodoParser, options: ScanOptions) -> Self {
62        Self {
63            parser,
64            options,
65            overrides: OnceLock::new(),
66        }
67    }
68
69    /// Walks `root`, parsing every matching file and collecting the
70    /// results. Files are walked and parsed in parallel across
71    /// [`ScanOptions::threads`] workers.
72    pub fn scan(&self, root: &Path) -> Result<ScanResult> {
73        let start = Instant::now();
74        let root = root
75            .canonicalize()
76            .wrap_err_with(|| format!("Failed to resolve path: {}", root.display()))?;
77
78        let mut result = ScanResult::new(root.clone());
79        let mut builder = WalkBuilder::new(&root);
80
81        builder
82            .hidden(!self.options.hidden)
83            .follow_links(self.options.follow_links)
84            .git_ignore(self.options.respect_gitignore)
85            .git_global(self.options.respect_gitignore)
86            .git_exclude(self.options.respect_gitignore);
87
88        if self.options.max_depth > 0 {
89            builder.max_depth(Some(self.options.max_depth));
90        }
91
92        if self.options.threads > 0 {
93            builder.threads(self.options.threads);
94        }
95
96        if !self.options.include.is_empty() || !self.options.exclude.is_empty() {
97            let overrides = self.build_overrides(&root)?;
98            builder.overrides(overrides);
99        }
100
101        let (tx, rx) = mpsc::channel::<(PathBuf, Vec<TodoItem>)>();
102        let parser = &self.parser;
103
104        builder.build_parallel().run(|| {
105            let parser = parser.clone();
106            let tx = tx.clone();
107
108            Box::new(move |entry| {
109                let entry = match entry {
110                    Ok(entry) => entry,
111                    Err(_) => return WalkState::Continue,
112                };
113
114                let path = entry.path();
115
116                if path.is_dir() {
117                    return WalkState::Continue;
118                }
119
120                if let Some(file_type) = entry.file_type()
121                    && !file_type.is_file()
122                {
123                    return WalkState::Continue;
124                }
125
126                // A parse error is treated the same as zero matches: the
127                // file still counts as scanned, but nothing is stored.
128                let items = parser.parse_file(path).unwrap_or_default();
129                let _ = tx.send((path.to_path_buf(), items));
130
131                WalkState::Continue
132            })
133        });
134
135        // Drop the original sender so the `rx` iterator below ends once
136        // every worker thread (and its cloned sender) has finished.
137        drop(tx);
138        for (path, items) in rx {
139            result.add_file(path, items);
140        }
141
142        result.summary.duration_ms = start.elapsed().as_millis();
143
144        Ok(result)
145    }
146
147    /// Returns the cached include/exclude [`Override`] set, building and
148    /// caching it on first use.
149    fn build_overrides(&self, root: &Path) -> Result<Override> {
150        if let Some(overrides) = self.overrides.get() {
151            return Ok(overrides.clone());
152        }
153
154        let mut override_builder = OverrideBuilder::new(root);
155        for pattern in &self.options.include {
156            override_builder
157                .add(pattern)
158                .wrap_err_with(|| format!("Invalid include pattern: {}", pattern))?;
159        }
160
161        for pattern in &self.options.exclude {
162            let exclude_pattern = format!("!{}", pattern);
163            override_builder
164                .add(&exclude_pattern)
165                .wrap_err_with(|| format!("Invalid exclude pattern: {}", pattern))?;
166        }
167
168        let overrides = override_builder.build()?;
169        // Best-effort: if another thread raced us to set it, keep theirs.
170        let _ = self.overrides.set(overrides.clone());
171
172        Ok(overrides)
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::parser::TodoParser;
180    use std::fs;
181    use std::time::{SystemTime, UNIX_EPOCH};
182
183    fn temp_dir(name: &str) -> std::path::PathBuf {
184        let unique = SystemTime::now()
185            .duration_since(UNIX_EPOCH)
186            .unwrap()
187            .as_nanos();
188        let dir = std::env::temp_dir().join(format!("todo_tree_scanner_test_{name}_{unique}"));
189        fs::create_dir_all(&dir).unwrap();
190        dir
191    }
192
193    fn parser() -> TodoParser {
194        TodoParser::new(&["TODO".to_string(), "FIXME".to_string()], true)
195    }
196
197    fn scanner(options: ScanOptions) -> Scanner {
198        Scanner::new(parser(), options)
199    }
200
201    #[test]
202    fn default_options_respect_gitignore_and_no_limits() {
203        let options = ScanOptions::default();
204        assert!(options.respect_gitignore);
205        assert_eq!(options.max_depth, 0);
206        assert_eq!(options.threads, 0);
207        assert!(!options.hidden);
208        assert!(!options.follow_links);
209        assert!(options.include.is_empty());
210        assert!(options.exclude.is_empty());
211    }
212
213    #[test]
214    fn scan_finds_todos_and_counts_all_files() {
215        let dir = temp_dir("basic");
216        fs::write(dir.join("a.rs"), "// TODO: fix this\nfn main() {}\n").unwrap();
217        fs::write(dir.join("b.rs"), "fn main() {}\n").unwrap();
218
219        let result = scanner(ScanOptions::default()).scan(&dir).unwrap();
220        let _ = fs::remove_dir_all(&dir);
221
222        assert_eq!(result.summary.total_count, 1);
223        assert_eq!(result.summary.files_with_todos, 1);
224        assert_eq!(result.summary.files_scanned, 2);
225    }
226
227    #[test]
228    fn scan_errors_on_nonexistent_path() {
229        let dir =
230            std::env::temp_dir().join("todo_tree_scanner_test_missing_dir_definitely_not_here");
231        let result = scanner(ScanOptions::default()).scan(&dir);
232        assert!(result.is_err());
233    }
234
235    #[test]
236    #[cfg(unix)]
237    fn scan_skips_broken_symlinks() {
238        let dir = temp_dir("broken_symlink");
239        fs::write(dir.join("real.rs"), "// TODO: real file\n").unwrap();
240        std::os::unix::fs::symlink(dir.join("does_not_exist.rs"), dir.join("dangling.rs")).unwrap();
241
242        let result = scanner(ScanOptions::default()).scan(&dir).unwrap();
243        let _ = fs::remove_dir_all(&dir);
244
245        assert_eq!(result.summary.total_count, 1);
246    }
247
248    #[test]
249    fn scan_respects_include_patterns() {
250        let dir = temp_dir("include");
251        fs::write(dir.join("a.rs"), "// TODO: rust file\n").unwrap();
252        fs::write(dir.join("b.py"), "# TODO: python file\n").unwrap();
253
254        let options = ScanOptions {
255            include: vec!["*.rs".to_string()],
256            ..Default::default()
257        };
258        let result = scanner(options).scan(&dir).unwrap();
259        let _ = fs::remove_dir_all(&dir);
260
261        assert_eq!(result.summary.total_count, 1);
262    }
263
264    #[test]
265    fn scan_respects_exclude_patterns() {
266        let dir = temp_dir("exclude");
267        fs::write(dir.join("a.rs"), "// TODO: keep\n").unwrap();
268        fs::write(dir.join("b.rs"), "// TODO: drop\n").unwrap();
269
270        let options = ScanOptions {
271            exclude: vec!["b.rs".to_string()],
272            ..Default::default()
273        };
274        let result = scanner(options).scan(&dir).unwrap();
275        let _ = fs::remove_dir_all(&dir);
276
277        assert_eq!(result.summary.total_count, 1);
278    }
279
280    #[test]
281    fn scan_errors_on_invalid_include_pattern() {
282        let dir = temp_dir("bad_pattern");
283
284        let options = ScanOptions {
285            include: vec!["[".to_string()],
286            ..Default::default()
287        };
288        let result = scanner(options).scan(&dir);
289        let _ = fs::remove_dir_all(&dir);
290
291        assert!(result.is_err());
292    }
293
294    #[test]
295    fn scan_skips_hidden_files_by_default() {
296        let dir = temp_dir("hidden");
297        fs::write(dir.join(".hidden.rs"), "// TODO: hidden\n").unwrap();
298
299        let result = scanner(ScanOptions::default()).scan(&dir).unwrap();
300        let _ = fs::remove_dir_all(&dir);
301
302        assert_eq!(result.summary.total_count, 0);
303    }
304
305    #[test]
306    fn scan_includes_hidden_files_when_enabled() {
307        let dir = temp_dir("hidden_enabled");
308        fs::write(dir.join(".hidden.rs"), "// TODO: hidden\n").unwrap();
309
310        let options = ScanOptions {
311            hidden: true,
312            ..Default::default()
313        };
314        let result = scanner(options).scan(&dir).unwrap();
315        let _ = fs::remove_dir_all(&dir);
316
317        assert_eq!(result.summary.total_count, 1);
318    }
319
320    #[test]
321    fn scan_respects_max_depth() {
322        let dir = temp_dir("depth");
323        let nested = dir.join("nested");
324        fs::create_dir_all(&nested).unwrap();
325        fs::write(dir.join("top.rs"), "// TODO: top\n").unwrap();
326        fs::write(nested.join("deep.rs"), "// TODO: deep\n").unwrap();
327
328        let options = ScanOptions {
329            max_depth: 1,
330            ..Default::default()
331        };
332        let result = scanner(options).scan(&dir).unwrap();
333        let _ = fs::remove_dir_all(&dir);
334
335        assert_eq!(result.summary.total_count, 1);
336    }
337
338    #[test]
339    fn scan_counts_unparseable_files_as_scanned() {
340        let dir = temp_dir("bad_utf8");
341        fs::write(dir.join("bad.rs"), [0xFF, 0xFE, 0xFD]).unwrap();
342
343        let result = scanner(ScanOptions::default()).scan(&dir).unwrap();
344        let _ = fs::remove_dir_all(&dir);
345
346        assert_eq!(result.summary.files_scanned, 1);
347        assert_eq!(result.summary.total_count, 0);
348    }
349
350    #[test]
351    fn scan_uses_custom_thread_count() {
352        let dir = temp_dir("threads");
353        fs::write(dir.join("a.rs"), "// TODO: threaded\n").unwrap();
354
355        let options = ScanOptions {
356            threads: 2,
357            ..Default::default()
358        };
359        let result = scanner(options).scan(&dir).unwrap();
360        let _ = fs::remove_dir_all(&dir);
361
362        assert_eq!(result.summary.total_count, 1);
363    }
364
365    #[test]
366    fn scan_reuses_cached_overrides_across_repeated_calls() {
367        let dir = temp_dir("reused_overrides");
368        fs::write(dir.join("a.rs"), "// TODO: rust file\n").unwrap();
369        fs::write(dir.join("b.py"), "# TODO: python file\n").unwrap();
370
371        let options = ScanOptions {
372            include: vec!["*.rs".to_string()],
373            ..Default::default()
374        };
375        let s = scanner(options);
376
377        // The `Override` set is built and cached on the first call; a
378        // second call on the same `Scanner` must reuse it and produce the
379        // same result rather than rebuilding (or erroring).
380        let first = s.scan(&dir).unwrap();
381        let second = s.scan(&dir).unwrap();
382        let _ = fs::remove_dir_all(&dir);
383
384        assert_eq!(first.summary.total_count, 1);
385        assert_eq!(second.summary.total_count, 1);
386    }
387
388    #[test]
389    fn scan_finds_todos_across_many_files_in_parallel() {
390        let dir = temp_dir("parallel_many_files");
391        for i in 0..50 {
392            fs::write(dir.join(format!("f{i}.rs")), format!("// TODO: item {i}\n")).unwrap();
393        }
394
395        let options = ScanOptions {
396            threads: 4,
397            ..Default::default()
398        };
399        let result = scanner(options).scan(&dir).unwrap();
400        let _ = fs::remove_dir_all(&dir);
401
402        assert_eq!(result.summary.total_count, 50);
403        assert_eq!(result.summary.files_with_todos, 50);
404        assert_eq!(result.summary.files_scanned, 50);
405    }
406}