generated from eblume/project-template
All checks were successful
Build / validate (pull_request) Successful in 2m47s
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>
397 lines
14 KiB
Rust
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());
|
|
}
|
|
}
|