use anyhow::{Result, bail}; use chrono::{ Datelike, Duration, Local, Months, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Weekday, }; use regex::Regex; #[cfg(test)] pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option { extract_when_from_configured( text, now, "ymd", "monday", "09:00", "09:00", "13:00", "18:00", ) } #[allow(clippy::too_many_arguments)] pub fn extract_when_configured( text: &str, date_order: &str, week_start: &str, default_time: &str, morning_time: &str, afternoon_time: &str, evening_time: &str, ) -> Option { extract_when_from_configured( text, Local::now().naive_local(), date_order, week_start, default_time, morning_time, afternoon_time, evening_time, ) } #[allow(clippy::too_many_arguments)] fn extract_when_from_configured( text: &str, now: NaiveDateTime, date_order: &str, week_start: &str, default_time: &str, morning_time: &str, afternoon_time: &str, evening_time: &str, ) -> Option { let lower = text.to_lowercase(); let mut date = None; let iso = Regex::new(r"\b(\d{4})-(\d{2})-(\d{2})\b").unwrap(); if let Some(c) = iso.captures(&lower) { date = NaiveDate::from_ymd_opt(c[1].parse().ok()?, c[2].parse().ok()?, c[3].parse().ok()?); } if date.is_none() { let numeric = Regex::new(r"\b(\d{1,4})[./-](\d{1,2})[./-](\d{1,4})\b").unwrap(); if let Some(c) = numeric.captures(&lower) { let values = [ c[1].parse::().ok()?, c[2].parse::().ok()?, c[3].parse::().ok()?, ]; let (year, month, day) = match date_order { "mdy" => (values[2], values[0], values[1]), "dmy" => (values[2], values[1], values[0]), _ => (values[0], values[1], values[2]), }; let year = if year < 100 { year + 2000 } else { year }; date = NaiveDate::from_ymd_opt(year, month as u32, day as u32); } } if date.is_none() { let in_days = Regex::new(r"\bin\s+(\d+)\s+days?\b").unwrap(); if let Some(c) = in_days.captures(&lower) { date = Some(now.date() + Duration::days(c[1].parse().ok()?)); } else if lower.contains("day after tomorrow") { date = Some(now.date() + Duration::days(2)); } else if lower.contains("tomorrow") { date = Some(now.date() + Duration::days(1)); } else if lower.contains("today") || lower.contains("tonight") { date = Some(now.date()); } } if date.is_none() && (lower.contains("this week") || lower.contains("next week")) { let day_from_start = if week_start == "sunday" { now.weekday().num_days_from_sunday() as i64 } else { now.weekday().num_days_from_monday() as i64 }; let current_start = now.date() - Duration::days(day_from_start); date = Some(if lower.contains("next week") { current_start + Duration::weeks(1) } else { current_start }); } if date.is_none() { let weekdays = [ ("monday", Weekday::Mon), ("tuesday", Weekday::Tue), ("wednesday", Weekday::Wed), ("thursday", Weekday::Thu), ("friday", Weekday::Fri), ("saturday", Weekday::Sat), ("sunday", Weekday::Sun), ]; for (word, weekday) in weekdays { if lower.contains(word) { let mut delta = (weekday.num_days_from_monday() as i64 - now.weekday().num_days_from_monday() as i64 + 7) % 7; // Treat both "Friday" and "next Friday" as the next occurrence. // This mirrors planner expectations and avoids surprising dates // almost two weeks away when the weekday is still ahead. if delta == 0 { delta += 7; } date = Some(now.date() + Duration::days(delta)); break; } } } let date = date?; let time_re = Regex::new(r"\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b").unwrap(); let time24_re = Regex::new(r"\b(?:at\s+)([01]?\d|2[0-3]):([0-5]\d)\b").unwrap(); let time = if let Some(c) = time_re.captures(&lower) { let mut hour: u32 = c[1].parse().ok()?; let minute: u32 = c.get(2).map_or("0", |v| v.as_str()).parse().ok()?; if hour == 12 { hour = 0; } if &c[3] == "pm" { hour += 12; } NaiveTime::from_hms_opt(hour, minute, 0)? } else if let Some(c) = time24_re.captures(&lower) { NaiveTime::from_hms_opt(c[1].parse().ok()?, c[2].parse().ok()?, 0)? } else if lower.contains("morning") { parse_clock(morning_time)? } else if lower.contains("afternoon") { parse_clock(afternoon_time)? } else if lower.contains("evening") || lower.contains("tonight") { parse_clock(evening_time)? } else { parse_clock(default_time)? }; let local = Local.from_local_datetime(&date.and_time(time)).single()?; Some(local.to_rfc3339()) } fn parse_clock(value: &str) -> Option { NaiveTime::parse_from_str(value, "%H:%M").ok() } pub fn format_when(value: Option<&str>, date_format: &str, clock_24h: bool) -> String { let Some(value) = value else { return String::new(); }; chrono::DateTime::parse_from_rfc3339(value) .map(|d| { let date = match date_format { "us" => d.format("%m/%d/%Y").to_string(), "european" => d.format("%d/%m/%Y").to_string(), "long" => d.format("%b %-d, %Y").to_string(), _ => d.format("%Y-%m-%d").to_string(), }; let time = if clock_24h { d.format("%H:%M").to_string() } else { d.format("%-I:%M%P").to_string() }; format!("{date} {time}") }) .unwrap_or_else(|_| value.to_owned()) } pub fn next_occurrence(current: &str, recurrence: &str) -> Result { let current = chrono::DateTime::parse_from_rfc3339(current)?.with_timezone(&Local); let rule = recurrence.trim().to_lowercase(); let next = match rule.as_str() { "daily" => current + Duration::days(1), "weekdays" => { let mut next = current + Duration::days(1); while matches!(next.weekday(), Weekday::Sat | Weekday::Sun) { next += Duration::days(1) } next } "weekly" => current + Duration::weeks(1), "monthly" => current .checked_add_months(Months::new(1)) .ok_or_else(|| anyhow::anyhow!("monthly date is out of range"))?, "yearly" | "annually" => current .checked_add_months(Months::new(12)) .ok_or_else(|| anyhow::anyhow!("yearly date is out of range"))?, _ => { let re = Regex::new(r"^every\s+(\d+)\s+(days?|weeks?|months?|years?)$").unwrap(); let Some(c) = re.captures(&rule) else { bail!( "recurrence must be daily, weekdays, weekly, monthly, yearly, or 'every N days/weeks/months/years'" ) }; let n: u32 = c[1].parse()?; match &c[2] { "day" | "days" => current + Duration::days(n as i64), "week" | "weeks" => current + Duration::weeks(n as i64), "month" | "months" => current .checked_add_months(Months::new(n)) .ok_or_else(|| anyhow::anyhow!("monthly date is out of range"))?, _ => current .checked_add_months(Months::new(n.saturating_mul(12))) .ok_or_else(|| anyhow::anyhow!("yearly date is out of range"))?, } } }; Ok(next.to_rfc3339()) } #[cfg(test)] mod tests { use super::*; fn base() -> NaiveDateTime { NaiveDate::from_ymd_opt(2026, 8, 16) .unwrap() .and_hms_opt(12, 0, 0) .unwrap() } #[test] fn parses_relative_dates_and_time() { let got = extract_when_from("Call Ada tomorrow at 3:30pm", base()).unwrap(); assert!(got.contains("2026-08-17T15:30:00")); } #[test] fn parses_next_weekday() { let got = extract_when_from("Review next Friday", base()).unwrap(); assert!(got.contains("2026-08-21T09:00:00")); } #[test] fn ignores_undated_text() { assert_eq!(extract_when_from("An evergreen idea", base()), None); } #[test] fn advances_recurring_dates() { assert!( next_occurrence("2026-08-14T09:00:00+00:00", "weekdays") .unwrap() .contains("2026-08-17T09:00:00") ); assert!( next_occurrence("2026-08-16T09:00:00+00:00", "every 2 weeks") .unwrap() .contains("2026-08-30T09:00:00") ); } #[test] fn honors_document_date_and_named_time_settings() { let got = extract_when_from_configured( "Planning on 18/08/2026 in the evening", base(), "dmy", "monday", "08:30", "08:00", "14:00", "19:15", ) .unwrap(); assert!(got.contains("2026-08-18T19:15:00")); assert_eq!( format_when(Some(&got), "european", false), "18/08/2026 7:15pm" ); let next_week = extract_when_from_configured( "Review next week", base(), "ymd", "sunday", "09:00", "09:00", "13:00", "18:00", ) .unwrap(); assert!(next_week.contains("2026-08-23T09:00:00")); } }