#[derive(Debug, Clone, PartialEq, Eq)]
pub struct When {
pub due: String,
pub text: String,
pub start: usize,
pub end: usize,
}
const RELATIVE_DAYS: &[(&str, u32)] = &[("tomorrow", 1), ("tmrw", 1), ("overmorrow", 2)];
pub fn find_when(input: &str) -> Option<When> {
let lower = input.to_lowercase();
let mut best: Option<When> = None;
let mut consider = |due: String, start: usize, end: usize| {
let cand = When {
due,
text: input[start..end].to_string(),
start,
end,
};
let better = match &best {
None => true,
Some(b) if cand.start < b.start => true,
Some(b) if cand.start == b.start => (cand.end - cand.start) > (b.end - b.start),
Some(_) => false,
};
if better {
best = Some(cand);
}
};
if let Some(m) = find_word(&lower, "the day after tomorrow") {
consider("+2d".into(), m.0, m.1);
}
if let Some(m) = find_word(&lower, "today") {
consider("today".into(), m.0, m.1);
}
if let Some(m) = find_word(&lower, "tonight") {
consider("today".into(), m.0, m.1);
}
for (word, days) in RELATIVE_DAYS {
if let Some(m) = find_word(&lower, word) {
let due = if *days == 1 {
"tomorrow".to_string()
} else {
format!("+{days}d")
};
consider(due, m.0, m.1);
}
}
if let Some(m) = find_in_n_days(&lower) {
consider(m.2, m.0, m.1);
}
if let Some(m) = find_iso_date(&lower) {
consider(input[m.0..m.1].to_string(), m.0, m.1);
}
best
}
fn find_word(haystack: &str, needle: &str) -> Option<(usize, usize)> {
let mut from = 0;
while let Some(i) = haystack[from..].find(needle) {
let start = from + i;
let end = start + needle.len();
let before_ok = start == 0
|| !haystack[..start]
.chars()
.next_back()
.is_some_and(|c| c.is_alphanumeric());
let after_ok = end == haystack.len()
|| !haystack[end..]
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric());
if before_ok && after_ok {
return Some((start, end));
}
from = end;
}
None
}
fn find_in_n_days(haystack: &str) -> Option<(usize, usize, String)> {
let bytes = haystack.as_bytes();
let mut from = 0;
while let Some(i) = haystack[from..].find("in ") {
let start = from + i;
let before_ok = start == 0 || !(bytes[start - 1] as char).is_alphanumeric();
let rest = &haystack[start + 3..];
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
if before_ok && !digits.is_empty() {
let after = rest[digits.len()..].trim_start();
let gap = rest.len() - digits.len() - after.len();
for (unit, mult) in [("days", 1u32), ("day", 1), ("weeks", 7), ("week", 7)] {
if let Some(m) = after.strip_prefix(unit) {
if m.is_empty() || !m.starts_with(|c: char| c.is_alphanumeric()) {
let n: u32 = digits.parse().ok()?;
let end = start + 3 + digits.len() + gap + unit.len();
return Some((start, end, format!("+{}d", n * mult)));
}
}
}
}
from = start + 3;
}
None
}
fn find_iso_date(haystack: &str) -> Option<(usize, usize)> {
let b = haystack.as_bytes();
for start in 0..b.len().saturating_sub(9) {
let w = &haystack[start..start + 10];
let ok = w.as_bytes().iter().enumerate().all(|(i, c)| match i {
4 | 7 => *c == b'-',
_ => c.is_ascii_digit(),
});
if !ok {
continue;
}
let before_ok = start == 0 || !(b[start - 1] as char).is_alphanumeric();
let end = start + 10;
let after_ok = end == b.len() || !(b[end] as char).is_alphanumeric();
if before_ok && after_ok {
return Some((start, end));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn due(input: &str) -> Option<String> {
find_when(input).map(|w| w.due)
}
#[test]
fn the_owners_words_come_back_exactly_as_typed() {
let input = "Call Bob tomorrow about the grant";
let w = find_when(input).unwrap();
assert_eq!(w.due, "tomorrow");
assert_eq!(
&input[w.start..w.end],
"tomorrow",
"the span points at the real bytes"
);
assert_eq!(w.text, "tomorrow");
}
#[test]
fn a_time_of_day_is_left_in_the_name_because_the_board_cannot_hold_one() {
let input = "call Bob tomorrow at 3";
let w = find_when(input).unwrap();
assert_eq!(w.due, "tomorrow");
assert_eq!(w.text, "tomorrow", "the chip does not claim the time");
assert!(
input[w.end..].contains("at 3"),
"and the time survives in the name"
);
}
#[test]
fn the_day_after_tomorrow_is_not_tomorrow() {
assert_eq!(due("ship it the day after tomorrow"), Some("+2d".into()));
assert_eq!(due("ship it tomorrow"), Some("tomorrow".into()));
}
#[test]
fn a_word_that_merely_contains_one_is_not_one() {
assert_eq!(due("visit tomorrowland"), None);
assert_eq!(due("read the todays paper archive"), None);
assert_eq!(due("book a stay in 3 daysworth of rooms"), None);
}
#[test]
fn a_sentence_that_ends_in_punctuation_still_parses() {
assert_eq!(due("call Bob tomorrow."), Some("tomorrow".into()));
assert_eq!(due("today: sort the inbox"), Some("today".into()));
}
#[test]
fn everything_emitted_is_something_the_graph_already_understands() {
for (input, expect) in [
("do it today", "today"),
("do it tonight", "today"),
("do it tomorrow", "tomorrow"),
("do it in 3 days", "+3d"),
("do it in 1 day", "+1d"),
("do it in 2 weeks", "+14d"),
("do it 2026-09-05", "2026-09-05"),
] {
let got = due(input).unwrap_or_else(|| panic!("no when in {input:?}"));
assert_eq!(got, expect, "for {input:?}");
assert!(
got == "today"
|| got == "tomorrow"
|| (got.starts_with('+') && got.ends_with('d'))
|| got.len() == 10,
"{got:?} is not a spelling parse_due accepts"
);
}
}
#[test]
fn a_weekday_is_not_detected_because_the_store_could_not_take_it() {
assert_eq!(due("call Bob on friday"), None);
assert_eq!(due("call Bob next week"), None);
}
#[test]
fn the_first_when_wins() {
let w = find_when("tomorrow tell Bob about today").unwrap();
assert_eq!(w.due, "tomorrow");
assert_eq!(w.start, 0);
}
#[test]
fn a_capture_with_no_date_says_so() {
assert_eq!(due("call Bob about the grant"), None);
assert_eq!(due(""), None);
}
}