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}