Skip to main content

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}