diff --git a/Cargo.lock b/Cargo.lock index c1f43a9..0f455f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1135,6 +1135,7 @@ name = "heph" version = "0.0.0" dependencies = [ "anyhow", + "chrono", "clap", "heph-core", "hephd", diff --git a/crates/heph/Cargo.toml b/crates/heph/Cargo.toml index dabed91..97b8aaa 100644 --- a/crates/heph/Cargo.toml +++ b/crates/heph/Cargo.toml @@ -18,6 +18,7 @@ hephd = { path = "../hephd" } clap.workspace = true serde_json.workspace = true anyhow.workspace = true +chrono.workspace = true [dev-dependencies] tempfile = "3" diff --git a/crates/heph/src/datespec.rs b/crates/heph/src/datespec.rs new file mode 100644 index 0000000..cb01493 --- /dev/null +++ b/crates/heph/src/datespec.rs @@ -0,0 +1,372 @@ +//! 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