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 mut run = Run::new(options);
91    // No root on the tasks: the caller named the one directory and `file` is
92    // relative to it, so repeating it on every task would say nothing new.
93    scan_files(
94        options,
95        &dir_canonical,
96        &mappings,
97        interrupt,
98        None,
99        &mut run,
100    )?;
101
102    Ok(run.finish())
103}
104
105/// Scan several roots in one run and return the tasks of all of them.
106///
107/// Notes are kept in more than one place — a work repository and a private
108/// one, a shared vault and a personal one — and the agenda over them is the
109/// agenda of all of them together. The merge is here rather than in the caller
110/// because the parts that make it a merge belong to a scan: the task cap is a
111/// budget for the run, the statistics are one report over it, and
112/// [`filter_agenda`](crate::filter_agenda) already takes a flat list of tasks
113/// whatever they were read from.
114///
115/// Every task carries [`Task::root`], the canonical path of the directory its
116/// `file` is relative to: the same relative path in two roots is two different
117/// files. The roots are walked in the order they are given, and one named
118/// twice is walked once — the same directory configured as two sources would
119/// otherwise show every task in it twice.
120///
121/// A root nested inside another one is not detected, and the notes under it
122/// are read by both walks. Nesting roots is a choice the caller makes, and
123/// refusing it would rule out a collection that deliberately holds a smaller
124/// one.
125///
126/// Errors:
127/// - `AppError::InvalidDirectory` — the list is empty, or any root is missing,
128///   is not a directory, or cannot be canonicalized. Refused rather than
129///   skipped: a directory that has been unmounted or renamed would otherwise
130///   read as a collection with nothing in it.
131/// - `AppError::InvalidGlob` / `AppError::Regex` — as for [`scan_directory`].
132pub fn scan_directories(
133    dirs: &[PathBuf],
134    options: &ScanOptions<'_>,
135    interrupt: Option<&AtomicBool>,
136) -> Result<ScanOutcome, AppError> {
137    if dirs.is_empty() {
138        return Err(AppError::InvalidDirectory(
139            "no directory to scan".to_string(),
140        ));
141    }
142
143    // Every root is validated before any of them is walked, so a mistyped path
144    // is reported as such rather than after a walk that already spent seconds
145    // on the roots before it.
146    let mut roots: Vec<PathBuf> = Vec::with_capacity(dirs.len());
147    for dir in dirs {
148        let canonical = validate_dir(dir)?;
149        if !roots.contains(&canonical) {
150            roots.push(canonical);
151        }
152    }
153
154    let mappings = get_weekday_mappings(options.locale);
155    let mut run = Run::new(options);
156
157    for root in &roots {
158        // Both stops belong to the run rather than to one root: a cap that has
159        // been reached is not going to un-reach itself, and a signal that
160        // stopped the first walk must not start the second.
161        if run.stats.interrupted || run.stats.max_tasks_reached {
162            break;
163        }
164
165        let label = root.display().to_string();
166        scan_files(
167            options,
168            root,
169            &mappings,
170            interrupt,
171            Some(label.as_str()),
172            &mut run,
173        )?;
174    }
175
176    Ok(run.finish())
177}
178
179/// What a scan accumulates across the roots it walks.
180///
181/// One vector and one `ProcessingStats` for the whole run, so the task cap is
182/// a budget over all the roots and the summary is a single report.
183struct Run {
184    tasks: Vec<Task>,
185    stats: ProcessingStats,
186}
187
188impl Run {
189    fn new(options: &ScanOptions<'_>) -> Self {
190        Self {
191            tasks: Vec::new(),
192            stats: ProcessingStats {
193                max_tasks_limit: options.max_tasks,
194                ..ProcessingStats::default()
195            },
196        }
197    }
198
199    fn finish(self) -> ScanOutcome {
200        ScanOutcome {
201            tasks: self.tasks,
202            stats: self.stats,
203        }
204    }
205}
206
207/// Validate that a scan root points to an existing directory and canonicalize
208/// it. Exposed because the binary validates `--dir` before it opens the run
209/// span, so the error surfaces before any log line mentions the directory.
210pub fn validate_dir(dir: &Path) -> Result<PathBuf, AppError> {
211    if !dir.exists() {
212        return Err(AppError::InvalidDirectory(format!(
213            "directory does not exist: {}",
214            dir.display()
215        )));
216    }
217    if !dir.is_dir() {
218        return Err(AppError::InvalidDirectory(format!(
219            "path is not a directory: {}",
220            dir.display()
221        )));
222    }
223    fs::canonicalize(dir).map_err(|e| {
224        AppError::InvalidDirectory(format!("cannot canonicalize {}: {e}", dir.display()))
225    })
226}
227
228/// Walk `dir_canonical`, apply the glob filter and a keyword pre-filter, then
229/// parse matching files into `Task`s, appending them to `run`.
230///
231/// `root` is what every task found here reports as [`Task::root`]; `None`
232/// leaves the field unset, which is the single-directory case. The tasks and
233/// the statistics are accumulated in `run` rather than returned, so a scan of
234/// several roots shares one task budget and one summary.
235fn scan_files(
236    options: &ScanOptions<'_>,
237    dir_canonical: &Path,
238    mappings: &[(&'static str, &'static str)],
239    interrupt: Option<&AtomicBool>,
240    root: Option<&str>,
241    run: &mut Run,
242) -> Result<(), AppError> {
243    let glob_matcher = compile_glob(options.glob)?;
244
245    let Run { tasks, stats } = run;
246    let matcher = RegexMatcher::new(
247        r"(?m)(^[#*]+\s+(TODO|DONE)\s|DEADLINE:|SCHEDULED:|CREATED:|CLOSED:|CLOCK:)",
248    )
249    .map_err(|e| AppError::Regex(e.to_string()))?;
250
251    // Defense-in-depth: refuse to follow symlinks and stay within the chosen
252    // filesystem. Pass `dir_canonical` (absolute) so every emitted path is an
253    // absolute descendant of the root, which lets `strip_prefix(dir_canonical)`
254    // succeed downstream for both glob matching and display-path computation.
255    // Using the caller's (often relative) path would silently break
256    // multi-segment glob patterns like `notes/*.md`.
257    let walker = WalkBuilder::new(dir_canonical)
258        .standard_filters(true)
259        .follow_links(false)
260        .same_file_system(true)
261        .build();
262
263    // Reuse one Searcher and one read buffer across the entire walk. Both are
264    // designed to be cleared and reused; allocating them per file added a
265    // monotonic cost that scaled with tree size for no gain.
266    let mut searcher = Searcher::new();
267    let mut buf: Vec<u8> = Vec::with_capacity(READ_BUF_INITIAL_CAP);
268
269    for result in walker {
270        // A SIGINT/SIGTERM trips the flag; bail out *before* opening the next
271        // file so the partial summary is consistent with what was actually
272        // processed. `Relaxed` is sufficient — the only writer is the signal
273        // handler, and we re-check on every iteration, so there is no need
274        // for ordering with respect to other reads/writes here.
275        if interrupt.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
276            stats.interrupted = true;
277            break;
278        }
279        // A walker error on one entry (permission denied on a subdir, broken
280        // metadata, etc.) must not abort the whole scan: the rest of the
281        // tree may still contain usable files. Record it in the summary so
282        // the user knows their output is partial. The Display impl of
283        // ignore::Error already includes the failing path, so we forward the
284        // whole message into `failed_paths` for the listing in print_summary.
285        let entry = match result {
286            Ok(entry) => entry,
287            Err(err) => {
288                stats.walk_errors += 1;
289                let msg = err.to_string();
290                stats.record_failed_path(&msg);
291                tracing::warn!(error = %msg, "walker entry failed; skipping");
292                continue;
293            }
294        };
295        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
296            continue;
297        }
298
299        let path = entry.path();
300
301        if !glob_match(&glob_matcher, path, dir_canonical) {
302            continue;
303        }
304
305        // Read once with a hard cap into the reusable buffer. Avoids the
306        // TOCTOU window where a separate metadata() check might say a file is
307        // small but the subsequent read() pulls in a file that has since
308        // grown — read_capped_into probes one byte past the cap and refuses
309        // anything larger.
310        match read_capped_into(path, MAX_FILE_SIZE, &mut buf) {
311            Ok(true) => {}
312            Ok(false) => {
313                stats.files_skipped_size += 1;
314                continue;
315            }
316            Err(e) => {
317                stats.files_failed_read += 1;
318                stats.record_failed_path(&path.display().to_string());
319                // The path is surfaced in the aggregated summary warn
320                // (see ProcessingStats::print_summary). Keep the
321                // underlying cause at debug level so `-vv` can explain
322                // *why* a path failed without re-flooding the default
323                // warn stream that the O5 aggregation deliberately
324                // quietened (2026-05-25 review, m3 / error-handling).
325                tracing::debug!(file = %path.display(), error = %e, "file read failed; skipping");
326                continue;
327            }
328        }
329
330        let mut found = false;
331        if let Err(e) = searcher.search_slice(&matcher, &buf, FoundSink { found: &mut found }) {
332            stats.files_failed_search += 1;
333            stats.record_failed_path(&path.display().to_string());
334            tracing::debug!(file = %path.display(), error = %e, "content search failed; skipping");
335            continue;
336        }
337
338        if !found {
339            continue;
340        }
341
342        let content = match std::str::from_utf8(&buf) {
343            Ok(s) => s,
344            Err(e) => {
345                stats.files_not_utf8 += 1;
346                stats.record_failed_path(&path.display().to_string());
347                tracing::debug!(file = %path.display(), error = %e, "file is not valid UTF-8; skipping");
348                continue;
349            }
350        };
351
352        let display_path = if options.absolute_paths {
353            path.display().to_string()
354        } else {
355            // WalkBuilder traverses `dir_canonical`, so every emitted path is
356            // an absolute descendant of it; strip_prefix cannot fail unless
357            // canonicalize and the walker disagree (a TOCTOU we cannot fix
358            // here). The absolute path is the safest fallback for that case.
359            match path.strip_prefix(dir_canonical) {
360                Ok(rel) => rel.display().to_string(),
361                Err(_) => path.display().to_string(),
362            }
363        };
364
365        // A path that is not valid UTF-8 (arbitrary bytes on Linux, unpaired
366        // surrogates on Windows) was just rendered lossily into `display_path`
367        // via `Path::display`, which substitutes U+FFFD for the invalid bytes.
368        // The file is still processed, but the `file` field cannot round-trip,
369        // so warn once per run and count it (ADR-0019). `to_str().is_none()` is
370        // the precise signal: it distinguishes a genuinely non-UTF-8 path from
371        // a valid path that merely happens to contain a literal U+FFFD.
372        if path.to_str().is_none() {
373            stats.note_nonutf8_path(&display_path);
374        }
375
376        // Wrap parsing in a span so every debug!/trace! emitted by the parser,
377        // timestamp extractor, and clock extractor inherits `file` automatically.
378        // Without this, multi-file runs at `-vv` produce a soup of messages
379        // without any way to tie a warning back to the file it came from. The
380        // key is `file` (not `path`) so the span agrees with the parser events
381        // and the `Task.file` output field — one path, one key (2026-05-25
382        // review, O3).
383        let span = tracing::debug_span!("file", file = %display_path);
384        let extracted = span.in_scope(|| {
385            extract_tasks_with_counter(
386                Path::new(&display_path),
387                content,
388                mappings,
389                options.max_tasks,
390                &mut stats.ts_warnings_emitted,
391                &mut stats.prop_warnings_emitted,
392            )
393        });
394        tasks.extend(extracted.into_iter().map(|mut task| {
395            task.root = root.map(str::to_string);
396            task
397        }));
398        stats.files_processed += 1;
399
400        if tasks.len() >= options.max_tasks {
401            tasks.truncate(options.max_tasks);
402            stats.max_tasks_reached = true;
403            break;
404        }
405    }
406
407    Ok(())
408}
409
410/// Read up to `cap` bytes from `path` into `buf`, clearing `buf` first.
411///
412/// Defense-in-depth against TOCTOU: we cannot trust a prior `fs::metadata`
413/// call because the file may have grown (or been swapped out for a symlink
414/// target on a different filesystem) between the metadata read and the content
415/// read. Reading `cap + 1` bytes lets us detect overruns without first asking
416/// the filesystem how large the file claims to be.
417///
418/// Returns:
419///
420/// - `Ok(true)` -- file content fully read (length <= `cap`).
421/// - `Ok(false)` -- file exceeds `cap`; `buf` holds the first `cap + 1` bytes
422///   (caller should treat as over-cap and discard).
423/// - `Err(_)` -- IO error (open / read failure).
424///
425/// Reusing one buffer across the scan loop lets a tight walker avoid one
426/// allocation per file. The buffer's capacity grows monotonically to the
427/// largest file seen, which is bounded by `MAX_FILE_SIZE` plus the probe byte.
428fn read_capped_into(path: &Path, cap: u64, buf: &mut Vec<u8>) -> io::Result<bool> {
429    buf.clear();
430    let file = File::open(path)?;
431    let probe = cap.saturating_add(1);
432    file.take(probe).read_to_end(buf)?;
433    Ok((buf.len() as u64) <= cap)
434}
435
436struct FoundSink<'a> {
437    found: &'a mut bool,
438}
439
440impl Sink for FoundSink<'_> {
441    type Error = std::io::Error;
442
443    fn matched(&mut self, _searcher: &Searcher, _mat: &SinkMatch) -> Result<bool, Self::Error> {
444        *self.found = true;
445        Ok(false)
446    }
447}
448
449/// Compile a glob pattern into a `globset::GlobMatcher`. Empty patterns and
450/// `*.` (extension-less) are rejected for parity with previous behaviour.
451fn compile_glob(pattern: &str) -> Result<globset::GlobMatcher, AppError> {
452    if pattern.is_empty() {
453        return Err(AppError::InvalidGlob("empty pattern".to_string()));
454    }
455    if pattern == "*." {
456        return Err(AppError::InvalidGlob(
457            "pattern '*.': extension cannot be empty".to_string(),
458        ));
459    }
460    globset::Glob::new(pattern)
461        .map(|g| g.compile_matcher())
462        .map_err(|e| AppError::InvalidGlob(format_error_chain(pattern, &e)))
463}
464
465/// Flatten a `globset::Error` (or any `std::error::Error`) into a single line
466/// that preserves its `source()` chain. Without this the user only sees the
467/// top-level `Display`, which sometimes elides the underlying reason (e.g. the
468/// specific syntax error inside a brace alternative).
469fn format_error_chain(pattern: &str, err: &dyn std::error::Error) -> String {
470    let mut msg = format!("invalid pattern '{pattern}': {err}");
471    let mut source = err.source();
472    while let Some(cause) = source {
473        msg.push_str(&format!(" (caused by: {cause})"));
474        source = cause.source();
475    }
476    msg
477}
478
479/// Match a path against the compiled glob. The matcher is tried against:
480/// (1) the path relative to `dir_root` — supports patterns like `**/*.md`,
481/// (2) the file name — supports patterns like `*.md` regardless of depth.
482fn glob_match(matcher: &globset::GlobMatcher, path: &Path, dir_root: &Path) -> bool {
483    if let Ok(rel) = path.strip_prefix(dir_root) {
484        if matcher.is_match(rel) {
485            return true;
486        }
487    }
488    if let Some(name) = path.file_name() {
489        return matcher.is_match(Path::new(name));
490    }
491    false
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use tempfile::tempdir;
498
499    fn m(pattern: &str, file: &str) -> bool {
500        let matcher = compile_glob(pattern).unwrap();
501        glob_match(&matcher, &PathBuf::from(file), Path::new(""))
502    }
503
504    #[test]
505    fn glob_simple_extension_matches_at_any_depth() {
506        assert!(m("*.md", "test.md"));
507        assert!(m("*.md", "src/notes/test.md"));
508        assert!(!m("*.md", "test.txt"));
509    }
510
511    #[test]
512    fn glob_exact_name_matches() {
513        assert!(m("README.md", "README.md"));
514        assert!(!m("README.md", "OTHER.md"));
515    }
516
517    #[test]
518    fn glob_double_star_matches_full_path() {
519        assert!(m("**/*.md", "src/notes/test.md"));
520        assert!(m("src/*.md", "src/test.md"));
521        assert!(!m("src/*.md", "other/test.md"));
522    }
523
524    #[test]
525    fn glob_invalid_patterns_rejected() {
526        assert!(compile_glob("").is_err());
527        assert!(compile_glob("*.").is_err());
528        // unbalanced brace — globset rejects it
529        assert!(compile_glob("{md,").is_err());
530    }
531
532    #[test]
533    fn compile_glob_message_echoes_offending_pattern() {
534        // The user-facing message must mention the pattern so the user does
535        // not have to guess which invocation produced the error.
536        let err = compile_glob("{md,").unwrap_err();
537        let s = err.to_string();
538        assert!(s.contains("{md,"), "pattern missing in message: {s}");
539        assert!(s.contains("invalid pattern"), "expected prefix, got: {s}");
540    }
541
542    #[test]
543    fn format_error_chain_walks_source() {
544        use std::error::Error;
545        use std::fmt;
546        // Two-link chain: Outer ── source ──> Inner.
547        #[derive(Debug)]
548        struct Inner;
549        impl fmt::Display for Inner {
550            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
551                write!(f, "inner reason")
552            }
553        }
554        impl Error for Inner {}
555
556        #[derive(Debug)]
557        struct Outer(Inner);
558        impl fmt::Display for Outer {
559            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
560                write!(f, "outer failure")
561            }
562        }
563        impl Error for Outer {
564            fn source(&self) -> Option<&(dyn Error + 'static)> {
565                Some(&self.0)
566            }
567        }
568
569        let msg = format_error_chain("pat", &Outer(Inner));
570        assert!(msg.contains("invalid pattern 'pat'"), "got: {msg}");
571        assert!(msg.contains("outer failure"), "top-level missing: {msg}");
572        assert!(
573            msg.contains("caused by: inner reason"),
574            "source missing: {msg}"
575        );
576    }
577
578    #[test]
579    fn read_capped_into_returns_true_when_file_within_limit() {
580        let dir = tempdir().unwrap();
581        let path = dir.path().join("small.md");
582        fs::write(&path, b"hello world").unwrap();
583        let mut buf = Vec::new();
584        assert!(read_capped_into(&path, 1024, &mut buf).unwrap());
585        assert_eq!(buf, b"hello world");
586    }
587
588    #[test]
589    fn read_capped_into_returns_true_at_exact_limit() {
590        let dir = tempdir().unwrap();
591        let path = dir.path().join("exact.md");
592        let payload = vec![b'x'; 64];
593        fs::write(&path, &payload).unwrap();
594        let mut buf = Vec::new();
595        assert!(read_capped_into(&path, 64, &mut buf).unwrap());
596        assert_eq!(buf, payload);
597    }
598
599    #[test]
600    fn read_capped_into_returns_false_when_file_over_limit() {
601        let dir = tempdir().unwrap();
602        let path = dir.path().join("big.md");
603        let payload = vec![b'x'; 65];
604        fs::write(&path, &payload).unwrap();
605        // cap is 64, file is 65 bytes — must be rejected (false), not truncated.
606        let mut buf = Vec::new();
607        let ok = read_capped_into(&path, 64, &mut buf).unwrap();
608        assert!(
609            !ok,
610            "expected false for file exceeding cap (read {} bytes)",
611            buf.len()
612        );
613    }
614
615    #[test]
616    fn read_capped_into_returns_err_for_missing_file() {
617        let dir = tempdir().unwrap();
618        let path = dir.path().join("missing.md");
619        let mut buf = Vec::new();
620        assert!(read_capped_into(&path, 64, &mut buf).is_err());
621    }
622
623    #[test]
624    fn read_capped_into_clears_previous_contents() {
625        // Buffer reuse contract: any leftover content from a previous read
626        // must not bleed into the next file.
627        let dir = tempdir().unwrap();
628        let path1 = dir.path().join("first.md");
629        let path2 = dir.path().join("second.md");
630        fs::write(&path1, b"longer content here").unwrap();
631        fs::write(&path2, b"short").unwrap();
632
633        let mut buf = Vec::new();
634        read_capped_into(&path1, 1024, &mut buf).unwrap();
635        assert_eq!(buf, b"longer content here");
636        read_capped_into(&path2, 1024, &mut buf).unwrap();
637        assert_eq!(buf, b"short", "buffer must be cleared on each read");
638    }
639}