use chrono::NaiveDate;
use clap::{Parser, ValueEnum};
use std::path::PathBuf;
use crate::format::OutputFormat;
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
#[clap(rename_all = "lower")]
pub enum ColorMode {
Auto,
Always,
Never,
}
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
#[clap(rename_all = "lower")]
pub enum AgendaMode {
Day,
Week,
Month,
Tasks,
}
const CLI_LONG_ABOUT: &str = "\
Extract Emacs Org-mode tasks (timestamps, SCHEDULED/DEADLINE/CLOSED, CLOCK)
from markdown files. Output is JSON by default; HTML and Markdown are also
available via --format. See <https://github.com/VitalyOstanin/markdown-org-extract>.
Examples:
Scan the current directory as JSON (default):
markdown-org-extract
Today's agenda for a specific vault:
markdown-org-extract --dir ~/notes --agenda day
Week containing a date, as Markdown:
markdown-org-extract --agenda week --date 2026-05-25 --format markdown
Two-week window, anchored at today:
markdown-org-extract --agenda week --from 2026-05-21 --to 2026-06-04
Flat task list, absolute paths, no progress noise:
markdown-org-extract --tasks --absolute-paths --quiet
Public RF holidays for a year:
markdown-org-extract --holidays 2026
Install bash completion for the current user:
markdown-org-extract --completions bash > ~/.local/share/bash-completion/completions/markdown-org-extract
Environment:
RUST_LOG Diagnostic log filter (tracing syntax). Takes precedence
over --verbose / --quiet (e.g. RUST_LOG=error mutes -vv).
NO_COLOR Any value disables ANSI colour in diagnostics.
CLICOLOR_FORCE Non-zero value forces colour even when stderr is not a TTY.
CLICOLOR CLICOLOR=0 disables colour in --color auto mode.
Exit status:
0 success (also --holidays, --completions, and a broken output pipe)
2 usage or input-validation error
70 internal software error (EX_SOFTWARE: regex/serializer)
74 IO error (EX_IOERR: unreadable input, walker, --output write)
130 aborted by SIGINT/SIGTERM (128 + signal)
";
#[derive(Parser)]
#[command(name = "markdown-org-extract")]
#[command(
about = "Extract tasks from markdown files with org-mode timestamps; emits JSON by default"
)]
#[command(long_about = CLI_LONG_ABOUT)]
#[command(version)]
pub struct Cli {
#[arg(long, default_value = ".", help_heading = "Input")]
pub dir: PathBuf,
#[arg(long, default_value = "*.md", help_heading = "Input")]
pub glob: String,
#[arg(long, default_value = "json", value_enum, help_heading = "Output")]
pub format: OutputFormat,
#[arg(long, help_heading = "Output")]
pub output: Option<PathBuf>,
#[arg(long, help_heading = "Output")]
pub absolute_paths: bool,
#[arg(long, default_value = "ru,en", value_parser = validate_locale, help_heading = "Agenda")]
pub locale: String,
#[arg(
long,
default_value = "day",
value_enum,
conflicts_with = "tasks",
help_heading = "Agenda"
)]
pub agenda: AgendaMode,
#[arg(long, help_heading = "Agenda")]
pub tasks: bool,
#[arg(long, value_parser = validate_date, help_heading = "Agenda")]
pub date: Option<String>,
#[arg(long, value_parser = validate_date, conflicts_with = "tasks", help_heading = "Agenda")]
pub from: Option<String>,
#[arg(long, value_parser = validate_date, conflicts_with = "tasks", help_heading = "Agenda")]
pub to: Option<String>,
#[arg(long, default_value = "Europe/Moscow", value_parser = validate_timezone, help_heading = "Agenda")]
pub tz: String,
#[arg(long, value_parser = validate_date, help_heading = "Agenda")]
pub current_date: Option<String>,
#[arg(long, default_value_t = crate::types::DEFAULT_MAX_TASKS, value_parser = validate_max_tasks, help_heading = "Limits")]
pub max_tasks: usize,
#[arg(long, short = 'v', action = clap::ArgAction::Count, conflicts_with = "quiet", help_heading = "Diagnostics")]
pub verbose: u8,
#[arg(
long,
short = 'q',
conflicts_with = "verbose",
help_heading = "Diagnostics"
)]
pub quiet: bool,
#[arg(long, conflicts_with = "color", help_heading = "Diagnostics")]
pub no_color: bool,
#[arg(long, value_enum, default_value = "auto", help_heading = "Diagnostics")]
pub color: ColorMode,
#[arg(
long,
value_parser = validate_year,
conflicts_with_all = ["dir", "glob", "format", "output", "tasks", "agenda", "date", "from", "to", "absolute_paths", "max_tasks", "completions"],
help_heading = "Actions"
)]
pub holidays: Option<i32>,
#[arg(
long,
value_enum,
value_name = "SHELL",
conflicts_with_all = ["dir", "glob", "format", "output", "tasks", "agenda", "date", "from", "to", "absolute_paths", "max_tasks", "holidays"],
help_heading = "Actions"
)]
pub completions: Option<clap_complete::Shell>,
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct ColorEnv {
pub no_color: bool,
pub clicolor_force: bool,
pub clicolor_zero: bool,
}
impl ColorEnv {
fn from_process_env() -> Self {
Self {
no_color: std::env::var_os("NO_COLOR").is_some(),
clicolor_force: clicolor_force_active(std::env::var("CLICOLOR_FORCE").ok().as_deref()),
clicolor_zero: matches!(std::env::var("CLICOLOR").ok().as_deref(), Some("0")),
}
}
}
fn clicolor_force_active(value: Option<&str>) -> bool {
match value {
Some(v) => !v.is_empty() && v != "0",
None => false,
}
}
pub(crate) fn decide_use_color(
mode: ColorMode,
no_color_flag: bool,
env: ColorEnv,
is_tty: bool,
) -> bool {
match mode {
ColorMode::Always => return true,
ColorMode::Never => return false,
ColorMode::Auto => {}
}
if no_color_flag {
return false;
}
if env.no_color {
return false;
}
if env.clicolor_force {
return true;
}
if env.clicolor_zero {
return false;
}
is_tty
}
impl Cli {
pub fn log_level(&self) -> tracing::Level {
if self.quiet {
tracing::Level::ERROR
} else {
match self.verbose {
0 => tracing::Level::WARN,
1 => tracing::Level::INFO,
2 => tracing::Level::DEBUG,
_ => tracing::Level::TRACE,
}
}
}
pub fn verbose_saturated(&self) -> bool {
self.verbose > 3
}
pub fn use_color(&self) -> bool {
use std::io::IsTerminal;
let is_tty = std::io::stderr().is_terminal();
decide_use_color(
self.color,
self.no_color,
ColorEnv::from_process_env(),
is_tty,
)
}
pub fn agenda_scope(&self) -> crate::agenda::AgendaScope {
use crate::agenda::AgendaScope;
if self.tasks {
return AgendaScope::Tasks;
}
match self.agenda {
AgendaMode::Day => AgendaScope::Day,
AgendaMode::Week => AgendaScope::Week,
AgendaMode::Month => AgendaScope::Month,
AgendaMode::Tasks => AgendaScope::Tasks,
}
}
pub fn init_tracing(&self) {
use tracing_subscriber::EnvFilter;
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(self.log_level().to_string().to_lowercase()));
let _ = tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_target(false)
.without_time()
.with_ansi(self.use_color())
.with_env_filter(env_filter)
.try_init();
}
}
const DATE_YEAR_MIN: i32 = 1900;
const DATE_YEAR_MAX: i32 = 2100;
fn validate_date(s: &str) -> Result<String, String> {
use chrono::Datelike;
let parsed = NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map_err(|e| format!("{e}; use YYYY-MM-DD format"))?;
let year = parsed.year();
if !(DATE_YEAR_MIN..=DATE_YEAR_MAX).contains(&year) {
return Err(format!(
"year must be between {DATE_YEAR_MIN} and {DATE_YEAR_MAX}"
));
}
Ok(s.to_string())
}
fn validate_year(s: &str) -> Result<i32, String> {
let year: i32 = s.parse().map_err(|_| "must be a number".to_string())?;
if !(DATE_YEAR_MIN..=DATE_YEAR_MAX).contains(&year) {
return Err(format!(
"must be between {DATE_YEAR_MIN} and {DATE_YEAR_MAX}"
));
}
Ok(year)
}
const MAX_TASKS_ALLOWED: usize = 10_000_000;
const MAX_TASKS_ALLOWED_DISPLAY: &str = "10_000_000";
fn validate_max_tasks(s: &str) -> Result<usize, String> {
use std::num::IntErrorKind;
let n: usize = match s.parse() {
Ok(n) => n,
Err(e) => {
return Err(match e.kind() {
IntErrorKind::PosOverflow => {
format!("out of range, must be at most {MAX_TASKS_ALLOWED_DISPLAY}")
}
_ => format!("must be a positive integer up to {MAX_TASKS_ALLOWED_DISPLAY}"),
});
}
};
if n == 0 {
return Err("must be at least 1".to_string());
}
if n > MAX_TASKS_ALLOWED {
return Err(format!("must be at most {MAX_TASKS_ALLOWED_DISPLAY}"));
}
Ok(n)
}
fn validate_timezone(s: &str) -> Result<String, String> {
s.parse::<chrono_tz::Tz>()
.map(|_| s.to_string())
.map_err(|e| format!("{e}; use IANA timezone names (e.g. 'Europe/Moscow', 'UTC')"))
}
fn validate_locale(s: &str) -> Result<String, String> {
for seg in s.split(',') {
let entry = seg.trim();
if entry.is_empty() {
continue;
}
if !SUPPORTED_LOCALES.contains(&entry) {
return Err(format!(
"unknown locale '{entry}'; supported: {SUPPORTED_LOCALES:?}"
));
}
}
Ok(s.to_string())
}
pub(crate) const RU_WEEKDAY_MAPPINGS: &[(&str, &str)] = &[
("Понедельник", "Monday"),
("Вторник", "Tuesday"),
("Среда", "Wednesday"),
("Четверг", "Thursday"),
("Пятница", "Friday"),
("Суббота", "Saturday"),
("Воскресенье", "Sunday"),
("Пн", "Mon"),
("Вт", "Tue"),
("Ср", "Wed"),
("Чт", "Thu"),
("Пт", "Fri"),
("Сб", "Sat"),
("Вс", "Sun"),
];
pub(crate) const SUPPORTED_LOCALES: &[&str] = &["ru", "en"];
pub fn get_weekday_mappings(locale: &str) -> Vec<(&'static str, &'static str)> {
let mut mappings = Vec::new();
for loc in locale.split(',') {
if loc.trim() == "ru" {
mappings.extend_from_slice(RU_WEEKDAY_MAPPINGS);
}
}
mappings
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_weekday_mappings_ru() {
let mappings = get_weekday_mappings("ru");
assert!(mappings.contains(&("Понедельник", "Monday")));
assert!(mappings.contains(&("Пн", "Mon")));
}
#[test]
fn test_get_weekday_mappings_multiple() {
let mappings = get_weekday_mappings("ru,en");
assert!(mappings.contains(&("Понедельник", "Monday")));
}
#[test]
fn get_weekday_mappings_ru_matches_static_table() {
let mappings = get_weekday_mappings("ru");
assert_eq!(mappings.as_slice(), RU_WEEKDAY_MAPPINGS);
}
#[test]
fn test_get_weekday_mappings_empty() {
let mappings = get_weekday_mappings("en");
assert!(mappings.is_empty());
}
#[test]
fn validate_max_tasks_accepts_valid() {
assert_eq!(validate_max_tasks("1"), Ok(1));
assert_eq!(validate_max_tasks("10000000"), Ok(10_000_000));
}
#[test]
fn validate_max_tasks_rejects_zero() {
let err = validate_max_tasks("0").unwrap_err();
assert!(err.contains("at least 1"), "got: {err}");
}
#[test]
fn validate_max_tasks_rejects_above_cap_with_cap_message() {
let err = validate_max_tasks("20000000").unwrap_err();
assert!(err.contains("at most 10_000_000"), "got: {err}");
}
#[test]
fn validate_max_tasks_rejects_non_number_with_explicit_cap_hint() {
let err = validate_max_tasks("abc").unwrap_err();
assert!(err.contains("positive integer"), "got: {err}");
assert!(
err.contains("10_000_000"),
"expected cap in message, got: {err}"
);
}
#[test]
fn max_tasks_allowed_display_matches_value() {
let parsed: usize = MAX_TASKS_ALLOWED_DISPLAY
.replace('_', "")
.parse()
.expect("display must be a number once underscores are stripped");
assert_eq!(
parsed, MAX_TASKS_ALLOWED,
"MAX_TASKS_ALLOWED_DISPLAY must match MAX_TASKS_ALLOWED"
);
}
#[test]
fn validate_date_accepts_year_at_lower_bound() {
assert!(validate_date("1900-01-01").is_ok());
}
#[test]
fn validate_date_accepts_year_at_upper_bound() {
assert!(validate_date("2100-12-31").is_ok());
}
#[test]
fn validate_date_rejects_year_below_lower_bound() {
let err = validate_date("1899-12-31").unwrap_err();
assert!(err.contains("1900"), "got: {err}");
assert!(err.contains("2100"), "got: {err}");
}
#[test]
fn validate_date_rejects_year_above_upper_bound() {
let err = validate_date("2101-01-01").unwrap_err();
assert!(err.contains("1900"), "got: {err}");
assert!(err.contains("2100"), "got: {err}");
}
#[test]
fn validate_timezone_accepts_iana() {
assert!(validate_timezone("Europe/Moscow").is_ok());
assert!(validate_timezone("UTC").is_ok());
}
#[test]
fn validate_timezone_propagates_underlying_error_and_hint() {
let err = validate_timezone("Not/A_Zone").unwrap_err();
assert!(
err.contains("failed to parse timezone"),
"expected chrono-tz reason, got: {err}"
);
assert!(err.contains("IANA"), "expected IANA hint, got: {err}");
}
#[test]
fn validate_date_still_rejects_malformed() {
let err = validate_date("not-a-date").unwrap_err();
assert!(err.contains("YYYY-MM-DD"), "got: {err}");
}
#[test]
fn validate_max_tasks_distinguishes_overflow_from_garbage() {
let huge = "99999999999999999999999999999999999";
let err = validate_max_tasks(huge).unwrap_err();
assert!(
err.contains("out of range"),
"expected 'out of range' wording for overflow, got: {err}"
);
assert!(
err.contains("10_000_000"),
"expected cap in message, got: {err}"
);
}
fn env_with(no_color: bool, clicolor_force: bool, clicolor_zero: bool) -> ColorEnv {
ColorEnv {
no_color,
clicolor_force,
clicolor_zero,
}
}
#[test]
fn color_always_overrides_everything() {
assert!(decide_use_color(
ColorMode::Always,
true,
env_with(true, false, true),
false,
));
}
#[test]
fn color_never_overrides_everything() {
assert!(!decide_use_color(
ColorMode::Never,
false,
env_with(false, true, false),
true,
));
}
#[test]
fn no_color_flag_beats_clicolor_force() {
assert!(!decide_use_color(
ColorMode::Auto,
true,
env_with(false, true, false),
true,
));
}
#[test]
fn no_color_env_beats_clicolor_force() {
assert!(!decide_use_color(
ColorMode::Auto,
false,
env_with(true, true, false),
true,
));
}
#[test]
fn clicolor_force_overrides_no_tty() {
assert!(decide_use_color(
ColorMode::Auto,
false,
env_with(false, true, false),
false,
));
}
#[test]
fn clicolor_force_beats_clicolor_zero() {
assert!(decide_use_color(
ColorMode::Auto,
false,
env_with(false, true, true),
false,
));
}
#[test]
fn clicolor_zero_disables_in_auto() {
assert!(!decide_use_color(
ColorMode::Auto,
false,
env_with(false, false, true),
true,
));
}
#[test]
fn auto_with_no_env_follows_tty() {
assert!(decide_use_color(
ColorMode::Auto,
false,
env_with(false, false, false),
true,
));
assert!(!decide_use_color(
ColorMode::Auto,
false,
env_with(false, false, false),
false,
));
}
#[test]
fn validate_locale_accepts_supported() {
assert!(validate_locale("ru").is_ok());
assert!(validate_locale("en").is_ok());
assert!(validate_locale("ru,en").is_ok());
}
#[test]
fn validate_locale_tolerates_empty_segments() {
assert!(validate_locale("ru,").is_ok());
assert!(validate_locale(",en").is_ok());
assert!(validate_locale("ru,,en").is_ok());
assert!(validate_locale("").is_ok());
}
#[test]
fn validate_locale_rejects_unknown_entry() {
let err = validate_locale("ru,de").unwrap_err();
assert!(err.contains("unknown locale 'de'"), "got: {err}");
assert!(err.contains("ru"), "expected supported list, got: {err}");
assert!(err.contains("en"), "expected supported list, got: {err}");
}
#[test]
fn validate_locale_rejects_unknown_with_whitespace_padding() {
let err = validate_locale("ru, de").unwrap_err();
assert!(err.contains("unknown locale 'de'"), "got: {err}");
}
#[test]
fn clicolor_force_active_treats_zero_and_empty_as_inactive() {
assert!(!clicolor_force_active(None));
assert!(!clicolor_force_active(Some("0")));
assert!(!clicolor_force_active(Some("")));
assert!(clicolor_force_active(Some("1")));
assert!(clicolor_force_active(Some("yes")));
assert!(clicolor_force_active(Some(" ")));
}
}