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,
}
#[derive(Parser)]
#[command(name = "markdown-org-extract")]
#[command(about = "Extract tasks from markdown files with org-mode timestamps", long_about = None)]
#[command(version)]
pub struct Cli {
#[arg(long, default_value = ".")]
pub dir: PathBuf,
#[arg(long, default_value = "*.md")]
pub glob: String,
#[arg(long, default_value = "json", value_enum)]
pub format: OutputFormat,
#[arg(long)]
pub output: Option<PathBuf>,
#[arg(long, default_value = "ru,en")]
pub locale: String,
#[arg(long, default_value = "day", value_enum, conflicts_with = "tasks")]
pub agenda: AgendaMode,
#[arg(long)]
pub tasks: bool,
#[arg(long, value_parser = validate_date)]
pub date: Option<String>,
#[arg(long, value_parser = validate_date, conflicts_with = "tasks")]
pub from: Option<String>,
#[arg(long, value_parser = validate_date, conflicts_with = "tasks")]
pub to: Option<String>,
#[arg(long, default_value = "Europe/Moscow", value_parser = validate_timezone)]
pub tz: String,
#[arg(long, value_parser = validate_date)]
pub current_date: Option<String>,
#[arg(
long,
value_parser = validate_year,
conflicts_with_all = ["dir", "glob", "format", "output", "tasks", "agenda", "date", "from", "to", "absolute_paths", "max_tasks"]
)]
pub holidays: Option<i32>,
#[arg(long)]
pub absolute_paths: bool,
#[arg(long, default_value_t = crate::types::DEFAULT_MAX_TASKS, value_parser = validate_max_tasks)]
pub max_tasks: usize,
#[arg(long, short = 'v', action = clap::ArgAction::Count, conflicts_with = "quiet")]
pub verbose: u8,
#[arg(long, short = 'q', conflicts_with = "verbose")]
pub quiet: bool,
#[arg(long, conflicts_with = "color")]
pub no_color: bool,
#[arg(long, value_enum, default_value = "auto")]
pub color: ColorMode,
}
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 use_color(&self) -> bool {
match self.color {
ColorMode::Always => return true,
ColorMode::Never => return false,
ColorMode::Auto => {}
}
if self.no_color {
return false;
}
if std::env::var_os("NO_COLOR").is_some() {
return false;
}
use std::io::IsTerminal;
std::io::stderr().is_terminal()
}
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;
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}")
}
_ => format!("must be a positive integer up to {MAX_TASKS_ALLOWED}"),
});
}
};
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}"));
}
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')"))
}
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 locales: Vec<&str> = locale.split(',').map(|s| s.trim()).collect();
let mut mappings = Vec::new();
for loc in locales {
match loc {
"ru" => mappings.extend_from_slice(RU_WEEKDAY_MAPPINGS),
"en" => {} "" => {} other => {
tracing::warn!(
locale = %other,
supported = ?SUPPORTED_LOCALES,
"unknown --locale entry ignored; no weekday mappings will be added for it"
);
}
}
}
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 10000000"), "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("10000000"),
"expected cap in message, got: {err}"
);
}
#[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("10000000"),
"expected cap in message, got: {err}"
);
}
}