1use 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#[derive(Debug, Clone)]
15pub struct ScanOptions {
16 pub include: Vec<String>,
19 pub exclude: Vec<String>,
21 pub max_depth: usize,
23 pub follow_links: bool,
25 pub hidden: bool,
27 pub threads: usize,
29 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
47pub struct Scanner {
54 parser: TodoParser,
55 options: ScanOptions,
56 overrides: OnceLock<Override>,
57}
58
59impl Scanner {
60 pub fn new(parser: TodoParser, options: ScanOptions) -> Self {
62 Self {
63 parser,
64 options,
65 overrides: OnceLock::new(),
66 }
67 }
68
69 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 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(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 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 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 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}