//! Human-friendly date and recurrence parsing for the CLI (tech-spec §1, §8). //! //! `heph-core` is clock-pure (no ambient wall-clock reads); the CLI is a client, //! so it may read the local clock. Date parsing is split so the logic is //! deterministically testable: the pure functions take `today` / a year, and the //! thin wrappers supply `Local::now()`. //! //! Recurrence input mirrors how the owner thinks in Todoist (see [[design]] //! §6.2.1): presets, the common natural-language forms, or a raw RRULE. use anyhow::{bail, Context, Result}; use chrono::{Datelike, Days, Local, Months, NaiveDate, TimeZone, Weekday}; // --------------------------------------------------------------------------- // Dates // --------------------------------------------------------------------------- /// Parse a human date spec **relative to `today`** into a `NaiveDate`. Accepts: /// `today`, `tomorrow`/`tom`, `yesterday`; `+Nd`/`+Nw`/`+Nm` (and bare `+N` = /// days); weekday names (`mon`..`sun`, the soonest such day on/after today); /// ISO `YYYY-MM-DD`. pub fn parse_date(input: &str, today: NaiveDate) -> Result { let s = input.trim().to_lowercase(); if s.is_empty() { bail!("empty date"); } match s.as_str() { "today" | "now" => return Ok(today), "tomorrow" | "tom" => return Ok(today + Days::new(1)), "yesterday" => return Ok(today - Days::new(1)), _ => {} } if let Some(wd) = parse_weekday(&s) { return Ok(soonest_weekday(today, wd)); } if let Some(rest) = s.strip_prefix('+') { return parse_offset(rest, today); } NaiveDate::parse_from_str(&s, "%Y-%m-%d").with_context(|| { format!("unrecognized date: {input:?} (try today, tomorrow, +3d, fri, or YYYY-MM-DD)") }) } /// `parse_date` against the local `today`. pub fn parse_date_today(input: &str) -> Result { parse_date(input, Local::now().date_naive()) } /// Parse a human date spec to epoch milliseconds at **local midnight** — the /// form `do_date`/`late_on` are stored in (a date-grained candidacy gate, §7). pub fn parse_date_ms(input: &str) -> Result { Ok(to_epoch_ms(parse_date_today(input)?)) } /// Local-midnight epoch ms for a date. Falls back gracefully across a DST gap. pub fn to_epoch_ms(date: NaiveDate) -> i64 { let midnight = date.and_hms_opt(0, 0, 0).expect("00:00:00 is always valid"); match Local.from_local_datetime(&midnight).earliest() { Some(dt) => dt.timestamp_millis(), None => midnight.and_utc().timestamp_millis(), } } /// Compact display of an epoch-ms date: `MM-DD` within the current year, /// `YYYY-MM-DD` otherwise. pub fn fmt_date(ms: i64) -> String { let date = match Local.timestamp_millis_opt(ms).earliest() { Some(dt) => dt.date_naive(), None => return ms.to_string(), }; fmt_naive(date, Local::now().date_naive().year()) } fn fmt_naive(date: NaiveDate, this_year: i32) -> String { if date.year() == this_year { format!("{:02}-{:02}", date.month(), date.day()) } else { date.format("%Y-%m-%d").to_string() } } fn parse_offset(rest: &str, today: NaiveDate) -> Result { let rest = rest.trim(); let (num, unit) = rest.split_at( rest.find(|c: char| !c.is_ascii_digit()) .unwrap_or(rest.len()), ); let n: u64 = num .parse() .with_context(|| format!("not a relative date offset: +{rest}"))?; match unit.trim() { "" | "d" | "day" | "days" => Ok(today + Days::new(n)), "w" | "wk" | "week" | "weeks" => Ok(today + Days::new(n * 7)), "m" | "mo" | "month" | "months" => Ok(today + Months::new(n as u32)), other => bail!("unknown offset unit {other:?} (use d, w, or m)"), } } /// Map a weekday name (full or common abbreviation) to a `Weekday`. Matches /// only recognized weekday tokens — not arbitrary prefixes (so "months" is not /// "mon"). fn parse_weekday(s: &str) -> Option { match s { "mon" | "monday" => Some(Weekday::Mon), "tue" | "tues" | "tuesday" => Some(Weekday::Tue), "wed" | "weds" | "wednesday" => Some(Weekday::Wed), "thu" | "thur" | "thurs" | "thursday" => Some(Weekday::Thu), "fri" | "friday" => Some(Weekday::Fri), "sat" | "saturday" => Some(Weekday::Sat), "sun" | "sunday" => Some(Weekday::Sun), _ => None, } } /// The soonest date on/after `today` whose weekday is `wd`. fn soonest_weekday(today: NaiveDate, wd: Weekday) -> NaiveDate { let mut d = today; for _ in 0..7 { if d.weekday() == wd { return d; } d = d + Days::new(1); } today // unreachable: a weekday occurs within 7 days } // --------------------------------------------------------------------------- // Recurrence // --------------------------------------------------------------------------- /// Parse a recurrence spec into an RFC-5545 RRULE. Accepts a raw RRULE /// (anything containing `FREQ=`), presets (`daily|weekly|monthly|yearly| /// weekdays`), and the common natural-language forms the owner uses in Todoist /// (§6.2.1): `every N (day|week|month|year)s`, `every `, `every other /// `, `every workday`, `every `. A trailing /// `at