markdown_org_extract/scan.rs
1//! Directory scanning: walk a tree, pre-filter by glob and by content, then
2//! parse matching files into [`Task`]s.
3//!
4//! This is the half of the old `main.rs` that has nothing to do with being a
5//! command-line program. It takes [`ScanOptions`] rather than the parsed CLI
6//! so embedders — notably an Android build, where no process can be spawned —
7//! drive the same code path the binary does.
8
9use grep_regex::RegexMatcher;
10use grep_searcher::{Searcher, Sink, SinkMatch};
11use ignore::WalkBuilder;
12use std::fs::{self, File};
13use std::io::{self, Read};
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicBool, Ordering};
16
17use crate::error::AppError;
18use crate::locale::get_weekday_mappings;
19use crate::parser::extract_tasks_with_counter;
20use crate::types::{ProcessingStats, Task, DEFAULT_MAX_TASKS, MAX_FILE_SIZE};
21
22/// Initial capacity of the read buffer reused across the walk. Sized at 64 KiB
23/// to cover most source files in a single allocation while still amortising to
24/// one buffer for the whole tree; the buffer grows on demand for larger files.
25const READ_BUF_INITIAL_CAP: usize = 64 * 1024;
26
27/// Inputs to [`scan_directory`]. The defaults match the CLI's own defaults, so
28/// an embedder that only wants "scan this directory the way the tool does"
29/// writes `ScanOptions::default()`.
30#[derive(Debug, Clone, Copy)]
31pub struct ScanOptions<'a> {
32 /// File-name / relative-path glob, as `--glob`. Defaults to `*.md`.
33 pub glob: &'a str,
34 /// Upper bound on collected tasks, as `--max-tasks`. Reaching it stops the
35 /// walk and sets `ProcessingStats::max_tasks_reached`.
36 pub max_tasks: usize,
37 /// Emit absolute paths in `Task::file` instead of paths relative to the
38 /// scanned root, as `--absolute-paths`.
39 pub absolute_paths: bool,
40 /// Comma-separated locale list for weekday-name normalization, as
41 /// `--locale`. Defaults to `ru,en`; see [`crate::locale`].
42 pub locale: &'a str,
43}
44
45impl Default for ScanOptions<'_> {
46 fn default() -> Self {
47 Self {
48 glob: "*.md",
49 max_tasks: DEFAULT_MAX_TASKS,
50 absolute_paths: false,
51 locale: "ru,en",
52 }
53 }
54}
55
56/// What a scan produced: the tasks themselves plus the diagnostics the CLI
57/// prints as its per-run summary. Embedders can ignore `stats`, but it is the
58/// only channel that reports skipped and failed files, so dropping it silently
59/// would hide a partial result.
60#[derive(Debug)]
61pub struct ScanOutcome {
62 /// Tasks extracted from every matching file, in walk order.
63 pub tasks: Vec<Task>,
64 /// Per-run counters: processed / skipped / failed files, warnings, and
65 /// whether the walk was cut short by the task cap or by an interrupt.
66 pub stats: ProcessingStats,
67}
68
69/// Scan `dir` and return the tasks found in it.
70///
71/// `interrupt`, when supplied, is polled between walker iterations: flipping it
72/// to `true` stops the walk at the next file boundary and reports
73/// `stats.interrupted`. The binary wires it to its SIGINT/SIGTERM handler;
74/// in-process callers usually pass `None`.
75///
76/// Errors:
77/// - `AppError::InvalidDirectory` — `dir` is missing, is not a directory, or
78/// cannot be canonicalized.
79/// - `AppError::InvalidGlob` — `options.glob` is empty, is `*.`, or fails to
80/// compile.
81/// - `AppError::Regex` — the built-in keyword pre-filter failed to compile,
82/// which is a bug rather than a user error.
83pub fn scan_directory(
84 dir: &Path,
85 options: &ScanOptions<'_>,
86 interrupt: Option<&AtomicBool>,
87) -> Result<ScanOutcome, AppError> {
88 let dir_canonical = validate_dir(dir)?;
89 let mappings = get_weekday_mappings(options.locale);
90 let (tasks, stats) = scan_files(options, &dir_canonical, &mappings, interrupt)?;
91
92 Ok(ScanOutcome { tasks, stats })
93}
94
95/// Validate that a scan root points to an existing directory and canonicalize
96/// it. Exposed because the binary validates `--dir` before it opens the run
97/// span, so the error surfaces before any log line mentions the directory.
98pub fn validate_dir(dir: &Path) -> Result<PathBuf, AppError> {
99 if !dir.exists() {
100 return Err(AppError::InvalidDirectory(format!(
101 "directory does not exist: {}",
102 dir.display()
103 )));
104 }
105 if !dir.is_dir() {
106 return Err(AppError::InvalidDirectory(format!(
107 "path is not a directory: {}",
108 dir.display()
109 )));
110 }
111 fs::canonicalize(dir).map_err(|e| {
112 AppError::InvalidDirectory(format!("cannot canonicalize {}: {e}", dir.display()))
113 })
114}
115
116/// Walk `dir_canonical`, apply the glob filter and a keyword pre-filter, then
117/// parse matching files into `Task`s. Returns the accumulated tasks and a
118/// `ProcessingStats` recording skipped/failed files.
119fn scan_files(
120 options: &ScanOptions<'_>,
121 dir_canonical: &Path,
122 mappings: &[(&'static str, &'static str)],
123 interrupt: Option<&AtomicBool>,
124) -> Result<(Vec<Task>, ProcessingStats), AppError> {
125 let glob_matcher = compile_glob(options.glob)?;
126
127 let mut tasks = Vec::new();
128 let mut stats = ProcessingStats {
129 max_tasks_limit: options.max_tasks,
130 ..ProcessingStats::default()
131 };
132 let matcher = RegexMatcher::new(
133 r"(?m)(^[#*]+\s+(TODO|DONE)\s|DEADLINE:|SCHEDULED:|CREATED:|CLOSED:|CLOCK:)",
134 )
135 .map_err(|e| AppError::Regex(e.to_string()))?;
136
137 // Defense-in-depth: refuse to follow symlinks and stay within the chosen
138 // filesystem. Pass `dir_canonical` (absolute) so every emitted path is an
139 // absolute descendant of the root, which lets `strip_prefix(dir_canonical)`
140 // succeed downstream for both glob matching and display-path computation.
141 // Using the caller's (often relative) path would silently break
142 // multi-segment glob patterns like `notes/*.md`.
143 let walker = WalkBuilder::new(dir_canonical)
144 .standard_filters(true)
145 .follow_links(false)
146 .same_file_system(true)
147 .build();
148
149 // Reuse one Searcher and one read buffer across the entire walk. Both are
150 // designed to be cleared and reused; allocating them per file added a
151 // monotonic cost that scaled with tree size for no gain.
152 let mut searcher = Searcher::new();
153 let mut buf: Vec<u8> = Vec::with_capacity(READ_BUF_INITIAL_CAP);
154
155 for result in walker {
156 // A SIGINT/SIGTERM trips the flag; bail out *before* opening the next
157 // file so the partial summary is consistent with what was actually
158 // processed. `Relaxed` is sufficient — the only writer is the signal
159 // handler, and we re-check on every iteration, so there is no need
160 // for ordering with respect to other reads/writes here.
161 if interrupt.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
162 stats.interrupted = true;
163 break;
164 }
165 // A walker error on one entry (permission denied on a subdir, broken
166 // metadata, etc.) must not abort the whole scan: the rest of the
167 // tree may still contain usable files. Record it in the summary so
168 // the user knows their output is partial. The Display impl of
169 // ignore::Error already includes the failing path, so we forward the
170 // whole message into `failed_paths` for the listing in print_summary.
171 let entry = match result {
172 Ok(entry) => entry,
173 Err(err) => {
174 stats.walk_errors += 1;
175 let msg = err.to_string();
176 stats.record_failed_path(&msg);
177 tracing::warn!(error = %msg, "walker entry failed; skipping");
178 continue;
179 }
180 };
181 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
182 continue;
183 }
184
185 let path = entry.path();
186
187 if !glob_match(&glob_matcher, path, dir_canonical) {
188 continue;
189 }
190
191 // Read once with a hard cap into the reusable buffer. Avoids the
192 // TOCTOU window where a separate metadata() check might say a file is
193 // small but the subsequent read() pulls in a file that has since
194 // grown — read_capped_into probes one byte past the cap and refuses
195 // anything larger.
196 match read_capped_into(path, MAX_FILE_SIZE, &mut buf) {
197 Ok(true) => {}
198 Ok(false) => {
199 stats.files_skipped_size += 1;
200 continue;
201 }
202 Err(e) => {
203 stats.files_failed_read += 1;
204 stats.record_failed_path(&path.display().to_string());
205 // The path is surfaced in the aggregated summary warn
206 // (see ProcessingStats::print_summary). Keep the
207 // underlying cause at debug level so `-vv` can explain
208 // *why* a path failed without re-flooding the default
209 // warn stream that the O5 aggregation deliberately
210 // quietened (2026-05-25 review, m3 / error-handling).
211 tracing::debug!(file = %path.display(), error = %e, "file read failed; skipping");
212 continue;
213 }
214 }
215
216 let mut found = false;
217 if let Err(e) = searcher.search_slice(&matcher, &buf, FoundSink { found: &mut found }) {
218 stats.files_failed_search += 1;
219 stats.record_failed_path(&path.display().to_string());
220 tracing::debug!(file = %path.display(), error = %e, "content search failed; skipping");
221 continue;
222 }
223
224 if !found {
225 continue;
226 }
227
228 let content = match std::str::from_utf8(&buf) {
229 Ok(s) => s,
230 Err(e) => {
231 stats.files_not_utf8 += 1;
232 stats.record_failed_path(&path.display().to_string());
233 tracing::debug!(file = %path.display(), error = %e, "file is not valid UTF-8; skipping");
234 continue;
235 }
236 };
237
238 let display_path = if options.absolute_paths {
239 path.display().to_string()
240 } else {
241 // WalkBuilder traverses `dir_canonical`, so every emitted path is
242 // an absolute descendant of it; strip_prefix cannot fail unless
243 // canonicalize and the walker disagree (a TOCTOU we cannot fix
244 // here). The absolute path is the safest fallback for that case.
245 match path.strip_prefix(dir_canonical) {
246 Ok(rel) => rel.display().to_string(),
247 Err(_) => path.display().to_string(),
248 }
249 };
250
251 // A path that is not valid UTF-8 (arbitrary bytes on Linux, unpaired
252 // surrogates on Windows) was just rendered lossily into `display_path`
253 // via `Path::display`, which substitutes U+FFFD for the invalid bytes.
254 // The file is still processed, but the `file` field cannot round-trip,
255 // so warn once per run and count it (ADR-0019). `to_str().is_none()` is
256 // the precise signal: it distinguishes a genuinely non-UTF-8 path from
257 // a valid path that merely happens to contain a literal U+FFFD.
258 if path.to_str().is_none() {
259 stats.note_nonutf8_path(&display_path);
260 }
261
262 // Wrap parsing in a span so every debug!/trace! emitted by the parser,
263 // timestamp extractor, and clock extractor inherits `file` automatically.
264 // Without this, multi-file runs at `-vv` produce a soup of messages
265 // without any way to tie a warning back to the file it came from. The
266 // key is `file` (not `path`) so the span agrees with the parser events
267 // and the `Task.file` output field — one path, one key (2026-05-25
268 // review, O3).
269 let span = tracing::debug_span!("file", file = %display_path);
270 let extracted = span.in_scope(|| {
271 extract_tasks_with_counter(
272 Path::new(&display_path),
273 content,
274 mappings,
275 options.max_tasks,
276 &mut stats.ts_warnings_emitted,
277 &mut stats.prop_warnings_emitted,
278 )
279 });
280 tasks.extend(extracted);
281 stats.files_processed += 1;
282
283 if tasks.len() >= options.max_tasks {
284 tasks.truncate(options.max_tasks);
285 stats.max_tasks_reached = true;
286 break;
287 }
288 }
289
290 Ok((tasks, stats))
291}
292
293/// Read up to `cap` bytes from `path` into `buf`, clearing `buf` first.
294///
295/// Defense-in-depth against TOCTOU: we cannot trust a prior `fs::metadata`
296/// call because the file may have grown (or been swapped out for a symlink
297/// target on a different filesystem) between the metadata read and the content
298/// read. Reading `cap + 1` bytes lets us detect overruns without first asking
299/// the filesystem how large the file claims to be.
300///
301/// Returns:
302///
303/// - `Ok(true)` -- file content fully read (length <= `cap`).
304/// - `Ok(false)` -- file exceeds `cap`; `buf` holds the first `cap + 1` bytes
305/// (caller should treat as over-cap and discard).
306/// - `Err(_)` -- IO error (open / read failure).
307///
308/// Reusing one buffer across the scan loop lets a tight walker avoid one
309/// allocation per file. The buffer's capacity grows monotonically to the
310/// largest file seen, which is bounded by `MAX_FILE_SIZE` plus the probe byte.
311fn read_capped_into(path: &Path, cap: u64, buf: &mut Vec<u8>) -> io::Result<bool> {
312 buf.clear();
313 let file = File::open(path)?;
314 let probe = cap.saturating_add(1);
315 file.take(probe).read_to_end(buf)?;
316 Ok((buf.len() as u64) <= cap)
317}
318
319struct FoundSink<'a> {
320 found: &'a mut bool,
321}
322
323impl Sink for FoundSink<'_> {
324 type Error = std::io::Error;
325
326 fn matched(&mut self, _searcher: &Searcher, _mat: &SinkMatch) -> Result<bool, Self::Error> {
327 *self.found = true;
328 Ok(false)
329 }
330}
331
332/// Compile a glob pattern into a `globset::GlobMatcher`. Empty patterns and
333/// `*.` (extension-less) are rejected for parity with previous behaviour.
334fn compile_glob(pattern: &str) -> Result<globset::GlobMatcher, AppError> {
335 if pattern.is_empty() {
336 return Err(AppError::InvalidGlob("empty pattern".to_string()));
337 }
338 if pattern == "*." {
339 return Err(AppError::InvalidGlob(
340 "pattern '*.': extension cannot be empty".to_string(),
341 ));
342 }
343 globset::Glob::new(pattern)
344 .map(|g| g.compile_matcher())
345 .map_err(|e| AppError::InvalidGlob(format_error_chain(pattern, &e)))
346}
347
348/// Flatten a `globset::Error` (or any `std::error::Error`) into a single line
349/// that preserves its `source()` chain. Without this the user only sees the
350/// top-level `Display`, which sometimes elides the underlying reason (e.g. the
351/// specific syntax error inside a brace alternative).
352fn format_error_chain(pattern: &str, err: &dyn std::error::Error) -> String {
353 let mut msg = format!("invalid pattern '{pattern}': {err}");
354 let mut source = err.source();
355 while let Some(cause) = source {
356 msg.push_str(&format!(" (caused by: {cause})"));
357 source = cause.source();
358 }
359 msg
360}
361
362/// Match a path against the compiled glob. The matcher is tried against:
363/// (1) the path relative to `dir_root` — supports patterns like `**/*.md`,
364/// (2) the file name — supports patterns like `*.md` regardless of depth.
365fn glob_match(matcher: &globset::GlobMatcher, path: &Path, dir_root: &Path) -> bool {
366 if let Ok(rel) = path.strip_prefix(dir_root) {
367 if matcher.is_match(rel) {
368 return true;
369 }
370 }
371 if let Some(name) = path.file_name() {
372 return matcher.is_match(Path::new(name));
373 }
374 false
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use tempfile::tempdir;
381
382 fn m(pattern: &str, file: &str) -> bool {
383 let matcher = compile_glob(pattern).unwrap();
384 glob_match(&matcher, &PathBuf::from(file), Path::new(""))
385 }
386
387 #[test]
388 fn glob_simple_extension_matches_at_any_depth() {
389 assert!(m("*.md", "test.md"));
390 assert!(m("*.md", "src/notes/test.md"));
391 assert!(!m("*.md", "test.txt"));
392 }
393
394 #[test]
395 fn glob_exact_name_matches() {
396 assert!(m("README.md", "README.md"));
397 assert!(!m("README.md", "OTHER.md"));
398 }
399
400 #[test]
401 fn glob_double_star_matches_full_path() {
402 assert!(m("**/*.md", "src/notes/test.md"));
403 assert!(m("src/*.md", "src/test.md"));
404 assert!(!m("src/*.md", "other/test.md"));
405 }
406
407 #[test]
408 fn glob_invalid_patterns_rejected() {
409 assert!(compile_glob("").is_err());
410 assert!(compile_glob("*.").is_err());
411 // unbalanced brace — globset rejects it
412 assert!(compile_glob("{md,").is_err());
413 }
414
415 #[test]
416 fn compile_glob_message_echoes_offending_pattern() {
417 // The user-facing message must mention the pattern so the user does
418 // not have to guess which invocation produced the error.
419 let err = compile_glob("{md,").unwrap_err();
420 let s = err.to_string();
421 assert!(s.contains("{md,"), "pattern missing in message: {s}");
422 assert!(s.contains("invalid pattern"), "expected prefix, got: {s}");
423 }
424
425 #[test]
426 fn format_error_chain_walks_source() {
427 use std::error::Error;
428 use std::fmt;
429 // Two-link chain: Outer ── source ──> Inner.
430 #[derive(Debug)]
431 struct Inner;
432 impl fmt::Display for Inner {
433 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
434 write!(f, "inner reason")
435 }
436 }
437 impl Error for Inner {}
438
439 #[derive(Debug)]
440 struct Outer(Inner);
441 impl fmt::Display for Outer {
442 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
443 write!(f, "outer failure")
444 }
445 }
446 impl Error for Outer {
447 fn source(&self) -> Option<&(dyn Error + 'static)> {
448 Some(&self.0)
449 }
450 }
451
452 let msg = format_error_chain("pat", &Outer(Inner));
453 assert!(msg.contains("invalid pattern 'pat'"), "got: {msg}");
454 assert!(msg.contains("outer failure"), "top-level missing: {msg}");
455 assert!(
456 msg.contains("caused by: inner reason"),
457 "source missing: {msg}"
458 );
459 }
460
461 #[test]
462 fn read_capped_into_returns_true_when_file_within_limit() {
463 let dir = tempdir().unwrap();
464 let path = dir.path().join("small.md");
465 fs::write(&path, b"hello world").unwrap();
466 let mut buf = Vec::new();
467 assert!(read_capped_into(&path, 1024, &mut buf).unwrap());
468 assert_eq!(buf, b"hello world");
469 }
470
471 #[test]
472 fn read_capped_into_returns_true_at_exact_limit() {
473 let dir = tempdir().unwrap();
474 let path = dir.path().join("exact.md");
475 let payload = vec![b'x'; 64];
476 fs::write(&path, &payload).unwrap();
477 let mut buf = Vec::new();
478 assert!(read_capped_into(&path, 64, &mut buf).unwrap());
479 assert_eq!(buf, payload);
480 }
481
482 #[test]
483 fn read_capped_into_returns_false_when_file_over_limit() {
484 let dir = tempdir().unwrap();
485 let path = dir.path().join("big.md");
486 let payload = vec![b'x'; 65];
487 fs::write(&path, &payload).unwrap();
488 // cap is 64, file is 65 bytes — must be rejected (false), not truncated.
489 let mut buf = Vec::new();
490 let ok = read_capped_into(&path, 64, &mut buf).unwrap();
491 assert!(
492 !ok,
493 "expected false for file exceeding cap (read {} bytes)",
494 buf.len()
495 );
496 }
497
498 #[test]
499 fn read_capped_into_returns_err_for_missing_file() {
500 let dir = tempdir().unwrap();
501 let path = dir.path().join("missing.md");
502 let mut buf = Vec::new();
503 assert!(read_capped_into(&path, 64, &mut buf).is_err());
504 }
505
506 #[test]
507 fn read_capped_into_clears_previous_contents() {
508 // Buffer reuse contract: any leftover content from a previous read
509 // must not bleed into the next file.
510 let dir = tempdir().unwrap();
511 let path1 = dir.path().join("first.md");
512 let path2 = dir.path().join("second.md");
513 fs::write(&path1, b"longer content here").unwrap();
514 fs::write(&path2, b"short").unwrap();
515
516 let mut buf = Vec::new();
517 read_capped_into(&path1, 1024, &mut buf).unwrap();
518 assert_eq!(buf, b"longer content here");
519 read_capped_into(&path2, 1024, &mut buf).unwrap();
520 assert_eq!(buf, b"short", "buffer must be cleared on each read");
521 }
522}