Files
RogueAgenda/src/parser.rs
2026-08-16 20:08:27 +00:00

299 lines
9.9 KiB
Rust

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<String> {
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<String> {
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<String> {
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::<i32>().ok()?,
c[2].parse::<i32>().ok()?,
c[3].parse::<i32>().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> {
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<String> {
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"));
}
}