#![warn(missing_docs)]
mod cli;
mod format;
use clap::Parser;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use markdown_org_extract::agenda::{self, filter_agenda, AgendaDates};
use markdown_org_extract::scan::{scan_directory, validate_dir, ScanOptions};
use markdown_org_extract::{render, AppError, HolidayCalendar};
use crate::cli::Cli;
use crate::format::OutputFormat;
const EXIT_INTERRUPTED: i32 = 130;
fn main() {
let interrupt = Arc::new(AtomicBool::new(false));
if let Err(e) = install_signal_handlers(&interrupt) {
eprintln!("error: failed to install signal handlers: {e}");
std::process::exit(74);
}
if let Err(e) = run(&interrupt) {
if is_broken_pipe(&e) {
std::process::exit(0);
}
eprintln!("error: {e}");
std::process::exit(e.exit_code());
}
}
fn install_signal_handlers(interrupt: &Arc<AtomicBool>) -> io::Result<()> {
signal_hook::flag::register(signal_hook::consts::SIGINT, Arc::clone(interrupt))?;
#[cfg(unix)]
signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(interrupt))?;
Ok(())
}
fn is_broken_pipe(e: &AppError) -> bool {
if let AppError::Io { source, .. } = e {
return source.kind() == io::ErrorKind::BrokenPipe;
}
false
}
fn run(interrupt: &AtomicBool) -> Result<(), AppError> {
let cli = Cli::parse();
cli.init_tracing();
if cli.verbose_saturated() {
tracing::warn!(
verbose = cli.verbose,
"--verbose saturated at -vvv (TRACE); additional v's have no effect"
);
}
if let Some(shell) = cli.completions {
return handle_completions(shell);
}
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 run_span = tracing::info_span!("run", dir = %dir_canonical.display());
let _run = run_span.enter();
let options = ScanOptions {
glob: &cli.glob,
max_tasks: cli.max_tasks,
absolute_paths: cli.absolute_paths,
locale: &cli.locale,
};
let outcome = scan_directory(&dir_canonical, &options, Some(interrupt))?;
let stats = outcome.stats;
tracing::info!(
files = stats.files_processed,
tasks = outcome.tasks.len(),
interrupted = stats.interrupted,
"scan finished"
);
if stats.interrupted {
stats.print_summary();
std::process::exit(EXIT_INTERRUPTED);
}
if stats.has_warnings() {
stats.print_summary();
}
let agenda_output = filter_agenda(
outcome.tasks,
cli.agenda_scope(),
AgendaDates {
date: cli.date.as_deref(),
from: cli.from.as_deref(),
to: cli.to.as_deref(),
current_date: cli.current_date.as_deref(),
},
&cli.tz,
cli.tasks_include_done,
cli.tasks_include_cancelled,
matches!(cli.format, OutputFormat::Json),
)?;
render_output(&cli, agenda_output)
}
fn handle_holidays(year: i32) -> Result<(), AppError> {
let calendar = 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 mut output = serde_json::to_string_pretty(&dates)?;
ensure_trailing_newline(&mut output);
io::stdout()
.write_all(output.as_bytes())
.map_err(|e| AppError::io("<stdout>", e))?;
Ok(())
}
fn ensure_trailing_newline(s: &mut String) {
if !s.ends_with('\n') {
s.push('\n');
}
}
fn handle_completions(shell: clap_complete::Shell) -> Result<(), AppError> {
let mut cmd = <Cli as clap::CommandFactory>::command();
let name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, name, &mut io::stdout());
Ok(())
}
fn render_output(cli: &Cli, agenda_output: agenda::AgendaOutput) -> Result<(), AppError> {
let mut 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::render_markdown(&tasks),
},
OutputFormat::Html => match agenda_output {
agenda::AgendaOutput::Days(days) => render::render_days_html(&days),
agenda::AgendaOutput::Tasks(tasks) => render::render_html(&tasks),
},
};
ensure_trailing_newline(&mut output);
match cli.output.as_deref() {
Some(p) if !is_stdout_sigil(p) => {
fs::write(p, output).map_err(|e| AppError::io(p.display().to_string(), e))?
}
_ => io::stdout()
.write_all(output.as_bytes())
.map_err(|e| AppError::io("<stdout>", e))?,
}
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(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[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 ensure_trailing_newline_adds_one_when_missing() {
let mut s = String::from("payload");
ensure_trailing_newline(&mut s);
assert_eq!(s, "payload\n");
}
#[test]
fn ensure_trailing_newline_leaves_an_existing_one_alone() {
let mut s = String::from("payload\n");
ensure_trailing_newline(&mut s);
assert_eq!(s, "payload\n");
}
#[test]
fn stdout_sigil_is_only_the_bare_dash() {
assert!(is_stdout_sigil(Path::new("-")));
assert!(!is_stdout_sigil(Path::new("./-")));
assert!(!is_stdout_sigil(Path::new("out.json")));
}
}