use grep_regex::RegexMatcher;
use grep_searcher::{Searcher, Sink, SinkMatch};
use ignore::WalkBuilder;
use std::fs::{self, File};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use crate::error::AppError;
use crate::locale::get_weekday_mappings;
use crate::parser::extract_tasks_with_counter;
use crate::types::{ProcessingStats, Task, DEFAULT_MAX_TASKS, MAX_FILE_SIZE};
const READ_BUF_INITIAL_CAP: usize = 64 * 1024;
#[derive(Debug, Clone, Copy)]
pub struct ScanOptions<'a> {
pub glob: &'a str,
pub max_tasks: usize,
pub absolute_paths: bool,
pub locale: &'a str,
}
impl Default for ScanOptions<'_> {
fn default() -> Self {
Self {
glob: "*.md",
max_tasks: DEFAULT_MAX_TASKS,
absolute_paths: false,
locale: "ru,en",
}
}
}
#[derive(Debug)]
pub struct ScanOutcome {
pub tasks: Vec<Task>,
pub stats: ProcessingStats,
}
pub fn scan_directory(
dir: &Path,
options: &ScanOptions<'_>,
interrupt: Option<&AtomicBool>,
) -> Result<ScanOutcome, AppError> {
let dir_canonical = validate_dir(dir)?;
let mappings = get_weekday_mappings(options.locale);
let mut run = Run::new(options);
scan_files(
options,
&dir_canonical,
&mappings,
interrupt,
None,
&mut run,
)?;
Ok(run.finish())
}
pub fn scan_directories(
dirs: &[PathBuf],
options: &ScanOptions<'_>,
interrupt: Option<&AtomicBool>,
) -> Result<ScanOutcome, AppError> {
if dirs.is_empty() {
return Err(AppError::InvalidDirectory(
"no directory to scan".to_string(),
));
}
let mut roots: Vec<PathBuf> = Vec::with_capacity(dirs.len());
for dir in dirs {
let canonical = validate_dir(dir)?;
if !roots.contains(&canonical) {
roots.push(canonical);
}
}
let mappings = get_weekday_mappings(options.locale);
let mut run = Run::new(options);
for root in &roots {
if run.stats.interrupted || run.stats.max_tasks_reached {
break;
}
let label = root.display().to_string();
scan_files(
options,
root,
&mappings,
interrupt,
Some(label.as_str()),
&mut run,
)?;
}
Ok(run.finish())
}
struct Run {
tasks: Vec<Task>,
stats: ProcessingStats,
}
impl Run {
fn new(options: &ScanOptions<'_>) -> Self {
Self {
tasks: Vec::new(),
stats: ProcessingStats {
max_tasks_limit: options.max_tasks,
..ProcessingStats::default()
},
}
}
fn finish(self) -> ScanOutcome {
ScanOutcome {
tasks: self.tasks,
stats: self.stats,
}
}
}
pub fn validate_dir(dir: &Path) -> Result<PathBuf, AppError> {
if !dir.exists() {
return Err(AppError::InvalidDirectory(format!(
"directory does not exist: {}",
dir.display()
)));
}
if !dir.is_dir() {
return Err(AppError::InvalidDirectory(format!(
"path is not a directory: {}",
dir.display()
)));
}
fs::canonicalize(dir).map_err(|e| {
AppError::InvalidDirectory(format!("cannot canonicalize {}: {e}", dir.display()))
})
}
fn scan_files(
options: &ScanOptions<'_>,
dir_canonical: &Path,
mappings: &[(&'static str, &'static str)],
interrupt: Option<&AtomicBool>,
root: Option<&str>,
run: &mut Run,
) -> Result<(), AppError> {
let glob_matcher = compile_glob(options.glob)?;
let Run { tasks, stats } = run;
let matcher = RegexMatcher::new(
r"(?m)(^[#*]+\s+(TODO|DONE)\s|DEADLINE:|SCHEDULED:|CREATED:|CLOSED:|CLOCK:)",
)
.map_err(|e| AppError::Regex(e.to_string()))?;
let walker = WalkBuilder::new(dir_canonical)
.standard_filters(true)
.follow_links(false)
.same_file_system(true)
.build();
let mut searcher = Searcher::new();
let mut buf: Vec<u8> = Vec::with_capacity(READ_BUF_INITIAL_CAP);
for result in walker {
if interrupt.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
stats.interrupted = true;
break;
}
let entry = match result {
Ok(entry) => entry,
Err(err) => {
stats.walk_errors += 1;
let msg = err.to_string();
stats.record_failed_path(&msg);
tracing::warn!(error = %msg, "walker entry failed; skipping");
continue;
}
};
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let path = entry.path();
if !glob_match(&glob_matcher, path, dir_canonical) {
continue;
}
match read_capped_into(path, MAX_FILE_SIZE, &mut buf) {
Ok(true) => {}
Ok(false) => {
stats.files_skipped_size += 1;
continue;
}
Err(e) => {
stats.files_failed_read += 1;
stats.record_failed_path(&path.display().to_string());
tracing::debug!(file = %path.display(), error = %e, "file read failed; skipping");
continue;
}
}
let mut found = false;
if let Err(e) = searcher.search_slice(&matcher, &buf, FoundSink { found: &mut found }) {
stats.files_failed_search += 1;
stats.record_failed_path(&path.display().to_string());
tracing::debug!(file = %path.display(), error = %e, "content search failed; skipping");
continue;
}
if !found {
continue;
}
let content = match std::str::from_utf8(&buf) {
Ok(s) => s,
Err(e) => {
stats.files_not_utf8 += 1;
stats.record_failed_path(&path.display().to_string());
tracing::debug!(file = %path.display(), error = %e, "file is not valid UTF-8; skipping");
continue;
}
};
let display_path = if options.absolute_paths {
path.display().to_string()
} else {
match path.strip_prefix(dir_canonical) {
Ok(rel) => rel.display().to_string(),
Err(_) => path.display().to_string(),
}
};
if path.to_str().is_none() {
stats.note_nonutf8_path(&display_path);
}
let span = tracing::debug_span!("file", file = %display_path);
let extracted = span.in_scope(|| {
extract_tasks_with_counter(
Path::new(&display_path),
content,
mappings,
options.max_tasks,
&mut stats.ts_warnings_emitted,
&mut stats.prop_warnings_emitted,
)
});
tasks.extend(extracted.into_iter().map(|mut task| {
task.root = root.map(str::to_string);
task
}));
stats.files_processed += 1;
if tasks.len() >= options.max_tasks {
tasks.truncate(options.max_tasks);
stats.max_tasks_reached = true;
break;
}
}
Ok(())
}
fn read_capped_into(path: &Path, cap: u64, buf: &mut Vec<u8>) -> io::Result<bool> {
buf.clear();
let file = File::open(path)?;
let probe = cap.saturating_add(1);
file.take(probe).read_to_end(buf)?;
Ok((buf.len() as u64) <= cap)
}
struct FoundSink<'a> {
found: &'a mut bool,
}
impl Sink for FoundSink<'_> {
type Error = std::io::Error;
fn matched(&mut self, _searcher: &Searcher, _mat: &SinkMatch) -> Result<bool, Self::Error> {
*self.found = true;
Ok(false)
}
}
fn compile_glob(pattern: &str) -> Result<globset::GlobMatcher, AppError> {
if pattern.is_empty() {
return Err(AppError::InvalidGlob("empty pattern".to_string()));
}
if pattern == "*." {
return Err(AppError::InvalidGlob(
"pattern '*.': extension cannot be empty".to_string(),
));
}
globset::Glob::new(pattern)
.map(|g| g.compile_matcher())
.map_err(|e| AppError::InvalidGlob(format_error_chain(pattern, &e)))
}
fn format_error_chain(pattern: &str, err: &dyn std::error::Error) -> String {
let mut msg = format!("invalid pattern '{pattern}': {err}");
let mut source = err.source();
while let Some(cause) = source {
msg.push_str(&format!(" (caused by: {cause})"));
source = cause.source();
}
msg
}
fn glob_match(matcher: &globset::GlobMatcher, path: &Path, dir_root: &Path) -> bool {
if let Ok(rel) = path.strip_prefix(dir_root) {
if matcher.is_match(rel) {
return true;
}
}
if let Some(name) = path.file_name() {
return matcher.is_match(Path::new(name));
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn m(pattern: &str, file: &str) -> bool {
let matcher = compile_glob(pattern).unwrap();
glob_match(&matcher, &PathBuf::from(file), Path::new(""))
}
#[test]
fn glob_simple_extension_matches_at_any_depth() {
assert!(m("*.md", "test.md"));
assert!(m("*.md", "src/notes/test.md"));
assert!(!m("*.md", "test.txt"));
}
#[test]
fn glob_exact_name_matches() {
assert!(m("README.md", "README.md"));
assert!(!m("README.md", "OTHER.md"));
}
#[test]
fn glob_double_star_matches_full_path() {
assert!(m("**/*.md", "src/notes/test.md"));
assert!(m("src/*.md", "src/test.md"));
assert!(!m("src/*.md", "other/test.md"));
}
#[test]
fn glob_invalid_patterns_rejected() {
assert!(compile_glob("").is_err());
assert!(compile_glob("*.").is_err());
assert!(compile_glob("{md,").is_err());
}
#[test]
fn compile_glob_message_echoes_offending_pattern() {
let err = compile_glob("{md,").unwrap_err();
let s = err.to_string();
assert!(s.contains("{md,"), "pattern missing in message: {s}");
assert!(s.contains("invalid pattern"), "expected prefix, got: {s}");
}
#[test]
fn format_error_chain_walks_source() {
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct Inner;
impl fmt::Display for Inner {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "inner reason")
}
}
impl Error for Inner {}
#[derive(Debug)]
struct Outer(Inner);
impl fmt::Display for Outer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "outer failure")
}
}
impl Error for Outer {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.0)
}
}
let msg = format_error_chain("pat", &Outer(Inner));
assert!(msg.contains("invalid pattern 'pat'"), "got: {msg}");
assert!(msg.contains("outer failure"), "top-level missing: {msg}");
assert!(
msg.contains("caused by: inner reason"),
"source missing: {msg}"
);
}
#[test]
fn read_capped_into_returns_true_when_file_within_limit() {
let dir = tempdir().unwrap();
let path = dir.path().join("small.md");
fs::write(&path, b"hello world").unwrap();
let mut buf = Vec::new();
assert!(read_capped_into(&path, 1024, &mut buf).unwrap());
assert_eq!(buf, b"hello world");
}
#[test]
fn read_capped_into_returns_true_at_exact_limit() {
let dir = tempdir().unwrap();
let path = dir.path().join("exact.md");
let payload = vec![b'x'; 64];
fs::write(&path, &payload).unwrap();
let mut buf = Vec::new();
assert!(read_capped_into(&path, 64, &mut buf).unwrap());
assert_eq!(buf, payload);
}
#[test]
fn read_capped_into_returns_false_when_file_over_limit() {
let dir = tempdir().unwrap();
let path = dir.path().join("big.md");
let payload = vec![b'x'; 65];
fs::write(&path, &payload).unwrap();
let mut buf = Vec::new();
let ok = read_capped_into(&path, 64, &mut buf).unwrap();
assert!(
!ok,
"expected false for file exceeding cap (read {} bytes)",
buf.len()
);
}
#[test]
fn read_capped_into_returns_err_for_missing_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("missing.md");
let mut buf = Vec::new();
assert!(read_capped_into(&path, 64, &mut buf).is_err());
}
#[test]
fn read_capped_into_clears_previous_contents() {
let dir = tempdir().unwrap();
let path1 = dir.path().join("first.md");
let path2 = dir.path().join("second.md");
fs::write(&path1, b"longer content here").unwrap();
fs::write(&path2, b"short").unwrap();
let mut buf = Vec::new();
read_capped_into(&path1, 1024, &mut buf).unwrap();
assert_eq!(buf, b"longer content here");
read_capped_into(&path2, 1024, &mut buf).unwrap();
assert_eq!(buf, b"short", "buffer must be cleared on each read");
}
}