mod agenda;
mod cli;
mod clock;
mod error;
mod format;
mod holidays;
mod parser;
mod regex_limits;
mod render;
mod timestamp;
mod types;
use clap::Parser;
use grep_regex::RegexMatcher;
use grep_searcher::{Searcher, Sink, SinkMatch};
use ignore::WalkBuilder;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use crate::agenda::filter_agenda;
use crate::cli::{get_weekday_mappings, Cli};
use crate::error::AppError;
use crate::format::OutputFormat;
use crate::parser::extract_tasks;
use crate::render::{render_html, render_markdown};
use crate::types::{ProcessingStats, MAX_FILE_SIZE};
fn main() {
if let Err(e) = run() {
eprintln!("error: {e}");
std::process::exit(1);
}
}
fn run() -> Result<(), AppError> {
let cli = Cli::parse();
cli.init_tracing();
if let Some(year) = cli.holidays {
return handle_holidays(year);
}
if let Some(ref out_path) = cli.output {
if !is_stdout_sigil(out_path) {
validate_output_path(out_path)?;
}
}
let dir_canonical = validate_dir(&cli.dir)?;
let mappings = get_weekday_mappings(&cli.locale);
let (tasks, stats) = scan_files(&cli, &dir_canonical, &mappings)?;
tracing::info!(
files = stats.files_processed,
tasks = tasks.len(),
"scan finished"
);
if stats.has_warnings() {
stats.print_summary();
}
let agenda_output = filter_agenda(
tasks,
cli.agenda_scope(),
cli.date.as_deref(),
cli.from.as_deref(),
cli.to.as_deref(),
&cli.tz,
cli.current_date.as_deref(),
)?;
render_output(&cli, agenda_output)
}
fn handle_holidays(year: i32) -> Result<(), AppError> {
let calendar = holidays::HolidayCalendar::global();
let holidays = calendar.get_holidays_for_year(year);
let dates: Vec<String> = holidays
.iter()
.map(|d| d.format("%Y-%m-%d").to_string())
.collect();
let output = serde_json::to_string_pretty(&dates)?;
io::stdout().write_all(output.as_bytes())?;
Ok(())
}
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(
cli: &Cli,
dir_canonical: &Path,
mappings: &[(&'static str, &'static str)],
) -> Result<(Vec<types::Task>, ProcessingStats), AppError> {
let glob_matcher = compile_glob(&cli.glob)?;
let mut tasks = Vec::new();
let mut stats = ProcessingStats {
max_tasks_limit: cli.max_tasks,
..ProcessingStats::default()
};
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(&cli.dir)
.standard_filters(true)
.follow_links(false)
.same_file_system(true)
.build();
for result in walker {
let entry = result?;
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;
}
let bytes = match read_capped(path, MAX_FILE_SIZE) {
Ok(Some(b)) => b,
Ok(None) => {
stats.files_skipped_size += 1;
continue;
}
Err(_) => {
stats.files_failed_read += 1;
stats.record_failed_path(&path.display().to_string());
continue;
}
};
let mut found = false;
let mut searcher = Searcher::new();
if searcher
.search_slice(&matcher, &bytes, FoundSink { found: &mut found })
.is_err()
{
stats.files_failed_search += 1;
stats.record_failed_path(&path.display().to_string());
continue;
}
if !found {
continue;
}
let content = match String::from_utf8(bytes) {
Ok(s) => s,
Err(_) => {
stats.files_failed_read += 1;
stats.record_failed_path(&path.display().to_string());
continue;
}
};
let display_path = if cli.absolute_paths {
path.display().to_string()
} else {
match path
.strip_prefix(dir_canonical)
.or_else(|_| path.strip_prefix(&cli.dir))
{
Ok(rel) => rel.display().to_string(),
Err(_) => path.display().to_string(),
}
};
let span = tracing::debug_span!("file", path = %display_path);
let extracted = span.in_scope(|| {
extract_tasks(Path::new(&display_path), &content, mappings, cli.max_tasks)
});
tasks.extend(extracted);
stats.files_processed += 1;
if tasks.len() >= cli.max_tasks {
tasks.truncate(cli.max_tasks);
stats.max_tasks_reached = true;
break;
}
}
Ok((tasks, stats))
}
fn render_output(cli: &Cli, agenda_output: agenda::AgendaOutput) -> Result<(), AppError> {
let output = match cli.format {
OutputFormat::Json => match agenda_output {
agenda::AgendaOutput::Days(days) => serde_json::to_string_pretty(&days)?,
agenda::AgendaOutput::Tasks(tasks) => serde_json::to_string_pretty(&tasks)?,
},
OutputFormat::Markdown => match agenda_output {
agenda::AgendaOutput::Days(days) => render::render_days_markdown(&days),
agenda::AgendaOutput::Tasks(tasks) => render_markdown(&tasks),
},
OutputFormat::Html => match agenda_output {
agenda::AgendaOutput::Days(days) => render::render_days_html(&days),
agenda::AgendaOutput::Tasks(tasks) => render_html(&tasks),
},
};
match cli.output.as_deref() {
Some(p) if !is_stdout_sigil(p) => fs::write(p, output)?,
_ => io::stdout().write_all(output.as_bytes())?,
}
Ok(())
}
fn is_stdout_sigil(path: &Path) -> bool {
path.as_os_str() == "-"
}
fn validate_output_path(path: &Path) -> Result<(), AppError> {
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
if !parent.exists() {
return Err(AppError::InvalidOutput(format!(
"parent directory does not exist: {}",
parent.display()
)));
}
if !parent.is_dir() {
return Err(AppError::InvalidOutput(format!(
"parent is not a directory: {}",
parent.display()
)));
}
match fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(AppError::InvalidOutput(format!(
"refusing to overwrite symlink: {}",
path.display()
)));
}
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => {
return Err(AppError::InvalidOutput(format!(
"cannot inspect output path {}: {e}",
path.display()
)));
}
}
Ok(())
}
fn read_capped(path: &Path, cap: u64) -> io::Result<Option<Vec<u8>>> {
let file = File::open(path)?;
let mut buf = Vec::new();
let probe = cap.saturating_add(1);
file.take(probe).read_to_end(&mut buf)?;
if buf.len() as u64 > cap {
return Ok(None);
}
Ok(Some(buf))
}
struct FoundSink<'a> {
found: &'a mut bool,
}
impl<'a> Sink for FoundSink<'a> {
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 std::path::PathBuf;
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 validate_output_rejects_missing_parent() {
let p = PathBuf::from("/nonexistent_definitely_xyz/out.json");
assert!(matches!(
validate_output_path(&p),
Err(AppError::InvalidOutput(_))
));
}
#[test]
fn validate_output_accepts_missing_target_in_existing_dir() {
let dir = tempdir().unwrap();
let target = dir.path().join("fresh.json");
validate_output_path(&target).expect("missing target in existing dir must be OK");
}
#[test]
fn validate_output_accepts_existing_regular_file() {
let dir = tempdir().unwrap();
let target = dir.path().join("regular.json");
fs::write(&target, b"existing").unwrap();
validate_output_path(&target).expect("existing regular file must be OK");
}
#[test]
#[cfg(unix)]
fn validate_output_rejects_existing_symlink_target() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let real = dir.path().join("real.json");
fs::write(&real, b"data").unwrap();
let link = dir.path().join("link.json");
symlink(&real, &link).unwrap();
let err = validate_output_path(&link).expect_err("symlink must be rejected");
assert!(matches!(err, AppError::InvalidOutput(ref m) if m.contains("symlink")));
}
#[test]
fn read_capped_returns_some_when_file_within_limit() {
let dir = tempdir().unwrap();
let path = dir.path().join("small.md");
fs::write(&path, b"hello world").unwrap();
let result = read_capped(&path, 1024).unwrap();
assert_eq!(result.as_deref(), Some(&b"hello world"[..]));
}
#[test]
fn read_capped_returns_some_when_file_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 result = read_capped(&path, 64).unwrap();
assert_eq!(result.as_deref(), Some(payload.as_slice()));
}
#[test]
fn read_capped_returns_none_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 result = read_capped(&path, 64).unwrap();
assert!(
result.is_none(),
"expected None for file exceeding cap, got {:?}",
result.as_ref().map(|v| v.len())
);
}
#[test]
fn read_capped_returns_err_for_missing_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("missing.md");
assert!(read_capped(&path, 64).is_err());
}
}