feat(cli): parse "every Nth" recurrence → monthly by day-of-month
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>
This commit is contained in:
Erich Blume 2026-06-02 20:02:22 -07:00
commit 2d4e4ae4d7

View file

@ -177,6 +177,10 @@ pub fn parse_recurrence(spec: &str) -> Result<String> {
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("");
@ -224,6 +228,19 @@ fn byday(wd: Weekday) -> &'static str {
}
}
/// 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();
@ -367,6 +384,14 @@ mod tests {
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());
}
}