working prototype

This commit is contained in:
Hermes Agent
2026-08-16 19:23:59 +00:00
commit ba530a7271
13 changed files with 6787 additions and 0 deletions

179
src/parser.rs Normal file
View File

@@ -0,0 +1,179 @@
use anyhow::{Result, bail};
use chrono::{
Datelike, Duration, Local, Months, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Weekday,
};
use regex::Regex;
/// Extracts the first recognizable date/time phrase and returns local RFC 3339.
/// Deliberately favors useful, predictable planner phrases over pretending to be
/// a complete natural-language parser.
pub fn extract_when(text: &str) -> Option<String> {
extract_when_from(text, Local::now().naive_local())
}
pub fn extract_when_from(text: &str, now: NaiveDateTime) -> 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 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() {
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 {
NaiveTime::from_hms_opt(9, 0, 0)?
};
let local = Local.from_local_datetime(&date.and_time(time)).single()?;
Some(local.to_rfc3339())
}
pub fn pretty_when(value: Option<&str>) -> String {
let Some(value) = value else {
return String::new();
};
chrono::DateTime::parse_from_rfc3339(value)
.map(|d| d.format("%Y-%m-%d %H:%M").to_string())
.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")
);
}
}