hephaestus/crates/heph/src/datespec.rs
Erich Blume 2d4e4ae4d7
All checks were successful
Build / validate (pull_request) Successful in 2m47s
feat(cli): parse "every Nth" recurrence → monthly by day-of-month
Todoist uses "every 5th" for monthly-on-the-5th; map it to
FREQ=MONTHLY;BYMONTHDAY=N (1..=31). Surfaced by the Todoist import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:02:22 -07:00

397 lines
14 KiB
Rust

//! 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<NaiveDate> {
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<NaiveDate> {
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<i64> {
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<NaiveDate> {
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<Weekday> {
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 <weekday>`, `every other
/// <weekday|unit>`, `every workday`, `every <Month> <day>`. A trailing
/// `at <time>` is ignored (heph's do-date is date-grained).
pub fn parse_recurrence(spec: &str) -> Result<String> {
let raw = spec.trim();
if raw.to_uppercase().contains("FREQ=") {
return Ok(raw.to_string());
}
// Normalize: lowercase, drop a trailing "at <time>" clause.
let mut s = raw.to_lowercase();
if let Some(at) = s.find(" at ") {
s.truncate(at);
}
let s = s.trim();
// Presets.
match s {
"daily" | "day" => return Ok("FREQ=DAILY".into()),
"weekly" | "week" => return Ok("FREQ=WEEKLY".into()),
"monthly" | "month" => return Ok("FREQ=MONTHLY".into()),
"yearly" | "annually" | "year" => return Ok("FREQ=YEARLY".into()),
"weekdays" | "workdays" | "workday" | "weekday" => {
return Ok("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR".into())
}
_ => {}
}
let body = s.strip_prefix("every ").unwrap_or(s).trim();
// "every other <...>" → INTERVAL=2.
if let Some(rest) = body.strip_prefix("other ") {
return interval_form(2, rest.trim());
}
// "every workday/weekday".
if body == "workday" || body == "weekday" || body == "workdays" || body == "weekdays" {
return Ok("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR".into());
}
// "every <weekday>".
if let Some(wd) = parse_weekday(body) {
return Ok(format!("FREQ=WEEKLY;BYDAY={}", byday(wd)));
}
// "every <Month> <day>".
if let Some((m, d)) = parse_month_day(body) {
return Ok(format!("FREQ=YEARLY;BYMONTH={m};BYMONTHDAY={d}"));
}
// "every <Nth>" → monthly on that day of the month ("every 5th").
if let Some(d) = parse_monthday_ordinal(body) {
return Ok(format!("FREQ=MONTHLY;BYMONTHDAY={d}"));
}
// "every <unit>" / "every N <unit>s".
let mut it = body.split_whitespace();
let first = it.next().unwrap_or("");
if let Ok(n) = first.parse::<u32>() {
return interval_form(n, it.next().unwrap_or(""));
}
interval_form(1, first)
}
/// `FREQ=...[;INTERVAL=n]` for a unit word, or a weekday with an interval.
fn interval_form(n: u32, unit: &str) -> Result<String> {
if let Some(wd) = parse_weekday(unit) {
let mut r = format!("FREQ=WEEKLY;BYDAY={}", byday(wd));
if n != 1 {
r = format!("FREQ=WEEKLY;INTERVAL={n};BYDAY={}", byday(wd));
}
return Ok(r);
}
let freq = match unit {
"day" | "days" => "DAILY",
"week" | "weeks" => "WEEKLY",
"month" | "months" => "MONTHLY",
"year" | "years" => "YEARLY",
other => bail!(
"unrecognized recurrence {other:?} (try daily/weekly/monthly/yearly, \
'every 3 days', 'every fri', or a raw RRULE)"
),
};
Ok(if n == 1 {
format!("FREQ={freq}")
} else {
format!("FREQ={freq};INTERVAL={n}")
})
}
fn byday(wd: Weekday) -> &'static str {
match wd {
Weekday::Mon => "MO",
Weekday::Tue => "TU",
Weekday::Wed => "WE",
Weekday::Thu => "TH",
Weekday::Fri => "FR",
Weekday::Sat => "SA",
Weekday::Sun => "SU",
}
}
/// Parse a day-of-month ordinal like "5th", "1st", "22nd", "3rd" → 1..=31.
fn parse_monthday_ordinal(s: &str) -> Option<u32> {
let digits = s.trim_end_matches(|c: char| c.is_ascii_alphabetic());
let suffix = &s[digits.len()..];
if !matches!(suffix, "st" | "nd" | "rd" | "th") {
return None;
}
match digits.parse::<u32>() {
Ok(d @ 1..=31) => Some(d),
_ => None,
}
}
/// Parse "april 15" / "apr 15" (and the reversed "15 april") → (month, day).
fn parse_month_day(s: &str) -> Option<(u32, u32)> {
let toks: Vec<&str> = s.split_whitespace().collect();
if toks.len() != 2 {
return None;
}
let month = |t: &str| -> Option<u32> {
match &t[..t.len().min(3)] {
"jan" => Some(1),
"feb" => Some(2),
"mar" => Some(3),
"apr" => Some(4),
"may" => Some(5),
"jun" => Some(6),
"jul" => Some(7),
"aug" => Some(8),
"sep" => Some(9),
"oct" => Some(10),
"nov" => Some(11),
"dec" => Some(12),
_ => None,
}
};
let day = |t: &str| {
t.trim_end_matches(|c: char| !c.is_ascii_digit())
.parse::<u32>()
.ok()
};
if let (Some(m), Some(d)) = (month(toks[0]), day(toks[1])) {
return Some((m, d));
}
if let (Some(d), Some(m)) = (day(toks[0]), month(toks[1])) {
return Some((m, d));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
// 2026-06-02 is a Tuesday.
fn today() -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 6, 2).unwrap()
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn parse_date_keywords_and_offsets() {
let t = today();
assert_eq!(parse_date("today", t).unwrap(), d(2026, 6, 2));
assert_eq!(parse_date("tomorrow", t).unwrap(), d(2026, 6, 3));
assert_eq!(parse_date("yesterday", t).unwrap(), d(2026, 6, 1));
assert_eq!(parse_date("+3d", t).unwrap(), d(2026, 6, 5));
assert_eq!(parse_date("+2w", t).unwrap(), d(2026, 6, 16));
assert_eq!(parse_date("+1m", t).unwrap(), d(2026, 7, 2));
assert_eq!(parse_date("+5", t).unwrap(), d(2026, 6, 7));
}
#[test]
fn parse_date_weekdays_are_soonest_on_or_after_today() {
let t = today(); // Tuesday
assert_eq!(
parse_date("tue", t).unwrap(),
d(2026, 6, 2),
"today if it matches"
);
assert_eq!(parse_date("fri", t).unwrap(), d(2026, 6, 5));
assert_eq!(
parse_date("mon", t).unwrap(),
d(2026, 6, 8),
"wraps to next week"
);
}
#[test]
fn parse_date_iso_and_errors() {
assert_eq!(parse_date("2026-12-25", today()).unwrap(), d(2026, 12, 25));
assert!(parse_date("someday", today()).is_err());
assert!(parse_date("", today()).is_err());
}
#[test]
fn fmt_naive_elides_the_current_year() {
assert_eq!(fmt_naive(d(2026, 6, 5), 2026), "06-05");
assert_eq!(fmt_naive(d(2027, 1, 9), 2026), "2027-01-09");
}
#[test]
fn recurrence_presets_and_raw() {
assert_eq!(parse_recurrence("daily").unwrap(), "FREQ=DAILY");
assert_eq!(parse_recurrence("weekly").unwrap(), "FREQ=WEEKLY");
assert_eq!(parse_recurrence("monthly").unwrap(), "FREQ=MONTHLY");
assert_eq!(parse_recurrence("yearly").unwrap(), "FREQ=YEARLY");
assert_eq!(
parse_recurrence("weekdays").unwrap(),
"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
);
// Raw RRULE passes through untouched.
assert_eq!(
parse_recurrence("FREQ=DAILY;INTERVAL=2").unwrap(),
"FREQ=DAILY;INTERVAL=2"
);
}
#[test]
fn recurrence_natural_language() {
assert_eq!(parse_recurrence("every day").unwrap(), "FREQ=DAILY");
assert_eq!(
parse_recurrence("every 3 days").unwrap(),
"FREQ=DAILY;INTERVAL=3"
);
assert_eq!(
parse_recurrence("every 2 weeks").unwrap(),
"FREQ=WEEKLY;INTERVAL=2"
);
assert_eq!(
parse_recurrence("every 6 months").unwrap(),
"FREQ=MONTHLY;INTERVAL=6"
);
assert_eq!(
parse_recurrence("every fri").unwrap(),
"FREQ=WEEKLY;BYDAY=FR"
);
assert_eq!(
parse_recurrence("every other wed").unwrap(),
"FREQ=WEEKLY;INTERVAL=2;BYDAY=WE"
);
assert_eq!(
parse_recurrence("every other day").unwrap(),
"FREQ=DAILY;INTERVAL=2"
);
assert_eq!(
parse_recurrence("every workday at 08:00").unwrap(),
"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
);
assert_eq!(
parse_recurrence("every April 15").unwrap(),
"FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15"
);
assert_eq!(
parse_recurrence("every 5th").unwrap(),
"FREQ=MONTHLY;BYMONTHDAY=5"
);
assert_eq!(
parse_recurrence("every 22nd").unwrap(),
"FREQ=MONTHLY;BYMONTHDAY=22"
);
assert!(parse_recurrence("every blue moon").is_err());
}
}