#[derive(Debug, Clone, PartialEq)]
pub struct EventDate {
pub epoch: f64,
pub iso: String,
}
const MONTHS: [(&str, u32); 12] = [
("january", 1),
("february", 2),
("march", 3),
("april", 4),
("may", 5),
("june", 6),
("july", 7),
("august", 8),
("september", 9),
("october", 10),
("november", 11),
("december", 12),
];
fn month_from(word: &str) -> Option<u32> {
let w = word.trim_end_matches('.').to_ascii_lowercase();
if w.len() < 3 {
return None;
}
MONTHS
.iter()
.find(|(full, _)| full.starts_with(&w) && w.len() >= 3)
.map(|(_, n)| *n)
}
fn is_leap(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
fn days_in_month(y: i64, m: u32) -> u32 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(y) => 29,
2 => 28,
_ => 0,
}
}
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = ((m + 9) % 12) as i64;
let doy = (153 * mp + 2) / 5 + d as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
fn to_event(y: i64, m: u32, d: u32) -> Option<EventDate> {
if !(1900..=2200).contains(&y) || m == 0 || m > 12 || d == 0 || d > days_in_month(y, m) {
return None;
}
Some(EventDate {
epoch: (days_from_civil(y, m, d) * 86_400) as f64,
iso: format!("{y:04}-{m:02}-{d:02}"),
})
}
fn parse_u(s: &str) -> Option<i64> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
pub fn extract_event_dates(text: &str) -> Vec<EventDate> {
let mut found: Vec<EventDate> = Vec::new();
let bytes = text.as_bytes();
let d = |k: usize| -> i64 { (bytes[k] - b'0') as i64 };
let mut i = 0usize;
while i + 10 <= bytes.len() {
let ok = bytes[i + 4] == b'-'
&& bytes[i + 7] == b'-'
&& bytes[i..i + 4].iter().all(u8::is_ascii_digit)
&& bytes[i + 5..i + 7].iter().all(u8::is_ascii_digit)
&& bytes[i + 8..i + 10].iter().all(u8::is_ascii_digit)
&& (i == 0 || !bytes[i - 1].is_ascii_digit())
&& (i + 10 >= bytes.len() || !bytes[i + 10].is_ascii_digit());
if ok {
let y = d(i) * 1000 + d(i + 1) * 100 + d(i + 2) * 10 + d(i + 3);
let m = d(i + 5) * 10 + d(i + 6);
let day = d(i + 8) * 10 + d(i + 9);
if let Some(e) = to_event(y, m as u32, day as u32) {
found.push(e);
}
}
i += 1;
}
let toks: Vec<&str> = text
.split(|c: char| c.is_whitespace() || c == '(' || c == ')' || c == '[' || c == ']')
.filter(|t| !t.is_empty())
.collect();
for w in toks.windows(3) {
let (a, b, c) = (
w[0].trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '-'),
w[1].trim_matches(|ch: char| !ch.is_alphanumeric()),
w[2].trim_matches(|ch: char| !ch.is_alphanumeric()),
);
if let (Some(m), Some(d), Some(y)) = (month_from(a), parse_u(b), parse_u(c)) {
if let Some(e) = to_event(y, m, d as u32) {
found.push(e);
continue;
}
}
if let (Some(d), Some(m), Some(y)) = (parse_u(a), month_from(b), parse_u(c)) {
if let Some(e) = to_event(y, m, d as u32) {
found.push(e);
}
}
}
for tok in text.split(|c: char| c.is_whitespace() || c == '[' || c == ']' || c == '|') {
let parts: Vec<&str> = tok
.trim_matches(|c: char| !c.is_alphanumeric())
.split('-')
.collect();
if parts.len() == 3 {
if let (Some(m), Some(d), Some(y)) =
(month_from(parts[0]), parse_u(parts[1]), parse_u(parts[2]))
{
if let Some(e) = to_event(y, m, d as u32) {
found.push(e);
}
}
}
}
found.sort_by(|x, y| x.epoch.total_cmp(&y.epoch).then_with(|| x.iso.cmp(&y.iso)));
found.dedup_by(|x, y| x.iso == y.iso);
found
}
pub fn merge_event_dates(metadata: &serde_json::Value, text: &str) -> serde_json::Value {
let dates = extract_event_dates(text);
if dates.is_empty() {
return metadata.clone();
}
let mut m = metadata.clone();
if !m.is_object() {
m = serde_json::Value::Object(Default::default());
}
let Some(obj) = m.as_object_mut() else {
return m;
};
const KEYS: [&str; 3] = ["event_dates", "event_time_min", "event_time_max"];
if KEYS.iter().any(|k| obj.contains_key(*k)) {
return m;
}
obj.insert(
"event_dates".to_string(),
serde_json::Value::Array(
dates
.iter()
.map(|d| serde_json::Value::String(d.iso.clone()))
.collect(),
),
);
obj.insert("event_time_min".to_string(), dates[0].epoch.into());
obj.insert(
"event_time_max".to_string(),
dates[dates.len() - 1].epoch.into(),
);
m
}
#[cfg(test)]
mod tests {
use super::*;
fn isos(t: &str) -> Vec<String> {
extract_event_dates(t).into_iter().map(|e| e.iso).collect()
}
#[test]
fn parses_the_common_written_forms() {
assert_eq!(isos("due March 15, 2024 sharp"), vec!["2024-03-15"]);
assert_eq!(isos("due Mar 15 2024"), vec!["2024-03-15"]);
assert_eq!(isos("due 15 March 2024"), vec!["2024-03-15"]);
assert_eq!(isos("due 2024-03-15."), vec!["2024-03-15"]);
assert_eq!(
isos("[March-15-2024 | Turn 0] User: hi"),
vec!["2024-03-15"]
);
}
#[test]
fn recovers_a_whole_range_from_one_record() {
let t = "Started December 15, 2023, shipped January 15, 2024, \
reviewed February 15, 2024, deadline March 15, 2024.";
assert_eq!(
isos(t),
vec!["2023-12-15", "2024-01-15", "2024-02-15", "2024-03-15"]
);
}
#[test]
fn refuses_what_it_cannot_know() {
assert!(isos("let's meet next Friday").is_empty());
assert!(isos("in three weeks from now").is_empty());
assert!(
isos("shipped 03/04/2024").is_empty(),
"DD/MM vs MM/DD is a guess"
);
assert!(isos("version 2024-1 of the spec").is_empty());
}
#[test]
fn rejects_impossible_and_non_dates() {
assert!(isos("February 30, 2024").is_empty());
assert!(isos("March 0, 2024").is_empty());
assert!(isos("the year 1200 BC").is_empty());
assert!(
isos("id 20240315123456").is_empty(),
"digit run, not a date"
);
assert_eq!(
isos("February 29, 2024"),
vec!["2024-02-29"],
"leap year is real"
);
assert!(isos("February 29, 2023").is_empty(), "not a leap year");
}
#[test]
fn deduplicates_and_sorts() {
let t = "March 15, 2024 and again March 15, 2024, plus January 2, 2024";
assert_eq!(isos(t), vec!["2024-01-02", "2024-03-15"]);
}
#[test]
fn partial_caller_keys_never_produce_contradictory_metadata() {
let text = "deadline is March 15, 2024";
for supplied in [
serde_json::json!({"event_dates": []}),
serde_json::json!({"event_time_min": 0.0}),
serde_json::json!({"event_time_max": 0.0}),
serde_json::json!({"event_dates": ["1999-01-01"]}),
] {
let out = merge_event_dates(&supplied, text);
let o = out.as_object().unwrap();
let n = ["event_dates", "event_time_min", "event_time_max"]
.iter()
.filter(|k| o.contains_key(**k))
.count();
assert_eq!(
n, 1,
"caller supplied one key and owns all three; got {n} in {out:?}"
);
}
}
#[test]
fn absent_caller_keys_produce_a_consistent_triple() {
let out = merge_event_dates(
&serde_json::json!({}),
"start January 15, 2024 and deadline March 15, 2024",
);
assert_eq!(
out["event_dates"],
serde_json::json!(["2024-01-15", "2024-03-15"])
);
let lo = out["event_time_min"].as_f64().unwrap();
let hi = out["event_time_max"].as_f64().unwrap();
assert!(lo < hi, "min must precede max");
assert_eq!(lo, extract_event_dates("January 15, 2024")[0].epoch);
}
#[test]
fn never_panics_on_multibyte_text() {
let cases = [
"he said \u{201c}the deadline is 2024-03-15\u{201d} and left",
"caf\u{e9} meeting \u{2014} 2024-03-15 \u{2014} confirmed",
"\u{1f389} shipping 2024-03-15",
"\u{4e2d}\u{6587} 2024-03-15 \u{7ed3}",
"\u{201c}\u{201d}\u{2014}\u{1f600}",
];
for c in cases {
let got = extract_event_dates(c);
if c.contains("2024-03-15") {
assert_eq!(got.len(), 1, "should still find the date in {c:?}");
assert_eq!(got[0].iso, "2024-03-15");
}
}
}
#[test]
fn multibyte_at_every_offset_is_safe() {
for pad in 0..12 {
let lead = format!("{}{}", "\u{201c}".repeat(pad), "2024-03-15");
assert_eq!(extract_event_dates(&lead).len(), 1, "leading pad={pad}");
let trail = format!("{}{}", "2024-03-15", "\u{2014}".repeat(pad));
assert_eq!(extract_event_dates(&trail).len(), 1, "trailing pad={pad}");
}
}
#[test]
fn epoch_matches_utc_midnight() {
let e = &extract_event_dates("2024-03-15")[0];
assert_eq!(e.epoch, 1_710_460_800.0);
let e = &extract_event_dates("1970-01-01")[0];
assert_eq!(e.epoch, 0.0);
}
}