use regex::Regex;
use std::sync::OnceLock;
const DAY_MS: i64 = 86_400_000;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct TemporalRewrite {
pub residual_query: String,
pub after_ms: Option<i64>,
pub before_ms: Option<i64>,
pub matched_phrase: String,
}
#[derive(Clone, Copy)]
enum Rule {
Between,
Before,
After,
LastNDays,
Relative,
BareDate,
}
fn rules() -> &'static [(Rule, Regex)] {
static RULES: OnceLock<Vec<(Rule, Regex)>> = OnceLock::new();
RULES.get_or_init(|| {
let date = r"(19[7-9]\d|20\d{2})(?:-(\d{2})(?:-(\d{2}))?)?";
let re = |p: &str| Regex::new(p).expect("static temporal pattern");
vec![
(
Rule::Between,
re(&format!(r"(?i)\bbetween\s+{date}\s+and\s+{date}\b")),
),
(
Rule::Before,
re(&format!(r"(?i)\b(?:before|until)\s+{date}\b")),
),
(
Rule::After,
re(&format!(r"(?i)\b(?:after|since)\s+{date}\b")),
),
(Rule::LastNDays, re(r"(?i)\blast\s+(\d{1,4})\s+days?\b")),
(
Rule::Relative,
re(r"(?i)\b(yesterday|today|last\s+week|last\s+month)\b"),
),
(
Rule::BareDate,
re(&format!(r"(?i)\b((?:in|on|during)\s+)?{date}\b")),
),
]
})
}
#[derive(Clone, Copy)]
enum DateSpec {
Day { y: i64, m: i64, d: i64 },
Month { y: i64, m: i64 },
Year { y: i64 },
}
impl DateSpec {
fn read(caps: ®ex::Captures<'_>, i: usize) -> Option<Self> {
let y: i64 = caps.get(i)?.as_str().parse().ok()?;
let m: i64 = match caps.get(i + 1) {
Some(m) => m.as_str().parse().ok()?,
None => return Some(DateSpec::Year { y }),
};
if !(1..=12).contains(&m) {
return None;
}
let d: i64 = match caps.get(i + 2) {
Some(d) => d.as_str().parse().ok()?,
None => return Some(DateSpec::Month { y, m }),
};
(1..=days_in_month(y, m))
.contains(&d)
.then_some(DateSpec::Day { y, m, d })
}
fn start_ms(self) -> i64 {
match self {
DateSpec::Day { y, m, d } => days_from_civil(y, m, d) * DAY_MS,
DateSpec::Month { y, m } => days_from_civil(y, m, 1) * DAY_MS,
DateSpec::Year { y } => days_from_civil(y, 1, 1) * DAY_MS,
}
}
fn end_ms(self) -> i64 {
match self {
DateSpec::Day { y, m, d } => (days_from_civil(y, m, d) + 1) * DAY_MS,
DateSpec::Month { y, m: 12 } => days_from_civil(y + 1, 1, 1) * DAY_MS,
DateSpec::Month { y, m } => days_from_civil(y, m + 1, 1) * DAY_MS,
DateSpec::Year { y } => days_from_civil(y + 1, 1, 1) * DAY_MS,
}
}
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y / 400;
let yoe = y - era * 400;
let mp = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
fn days_in_month(y: i64, m: i64) -> i64 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ => {
if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 {
29
} else {
28
}
}
}
}
pub fn parse_temporal_query(query: &str, now_ms: i64) -> Option<TemporalRewrite> {
let today = now_ms.div_euclid(DAY_MS) * DAY_MS;
for (rule, re) in rules() {
let caps = match re.captures(query) {
Some(c) => c,
None => continue,
};
let (after_ms, before_ms) = match rule {
Rule::Between => {
let a = DateSpec::read(&caps, 1)?;
let b = DateSpec::read(&caps, 4)?;
if a.start_ms() >= b.end_ms() {
return None;
}
(Some(a.start_ms()), Some(b.end_ms()))
}
Rule::Before => (None, Some(DateSpec::read(&caps, 1)?.start_ms())),
Rule::After => (Some(DateSpec::read(&caps, 1)?.start_ms()), None),
Rule::LastNDays => {
let n: i64 = caps[1].parse().ok()?;
if n == 0 {
return None;
}
(Some(today - n * DAY_MS), None)
}
Rule::Relative => {
let key = caps[1].to_ascii_lowercase();
match key
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.as_str()
{
"yesterday" => (Some(today - DAY_MS), Some(today)),
"today" => (Some(today), Some(today + DAY_MS)),
"last week" => (Some(today - 7 * DAY_MS), None),
"last month" => (Some(today - 30 * DAY_MS), None),
_ => return None,
}
}
Rule::BareDate => {
let d = DateSpec::read(&caps, 2)?;
if matches!(d, DateSpec::Year { .. }) && caps.get(1).is_none() {
return None;
}
(Some(d.start_ms()), Some(d.end_ms()))
}
};
let whole = caps.get(0)?;
let residual = format!("{} {}", &query[..whole.start()], &query[whole.end()..])
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if residual.is_empty() {
return None;
}
if !residual.contains(|c: char| c.is_ascii_alphanumeric()) {
return None;
}
return Some(TemporalRewrite {
residual_query: residual,
after_ms,
before_ms,
matched_phrase: whole.as_str().to_string(),
});
}
None
}
pub fn parse_iso_instant(s: &str) -> Option<i64> {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| {
Regex::new(
r"^\s*(19[7-9]\d|20\d{2})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?Z?)?)?)?\s*$",
)
.expect("static temporal pattern")
});
let caps = re.captures(s)?;
let date_ms = DateSpec::read(&caps, 1).map(DateSpec::start_ms)?;
match (caps.get(4), caps.get(5)) {
(Some(h), Some(m)) => {
let (h, m): (i64, i64) = (h.as_str().parse().ok()?, m.as_str().parse().ok()?);
let sec: i64 = caps.get(6).map_or(Some(0), |x| x.as_str().parse().ok())?;
if h > 23 || m > 59 || sec > 59 {
return None;
}
Some(date_ms + (h * 3600 + m * 60 + sec) * 1000)
}
_ => Some(date_ms),
}
}
#[cfg(test)]
mod tests {
use super::*;
type TestCase<'a> = (&'a str, &'a str, Option<i64>, Option<i64>, &'a str);
const NOW_MS: i64 = 1_786_276_800_000;
const TODAY: i64 = 1_786_233_600_000;
const DAY: i64 = 86_400_000;
#[track_caller]
fn parse(q: &str) -> TemporalRewrite {
parse_temporal_query(q, NOW_MS).unwrap_or_else(|| panic!("expected a rewrite for {q:?}"))
}
fn assert_cases(cases: &[TestCase]) {
for (query, residual, after, before, phrase) in cases {
let got = parse(query);
assert_eq!(got.residual_query, *residual, "residual for {query:?}");
assert_eq!(got.after_ms, *after, "after_ms for {query:?}");
assert_eq!(got.before_ms, *before, "before_ms for {query:?}");
assert_eq!(got.matched_phrase, *phrase, "phrase for {query:?}");
}
}
#[test]
fn absolute_forms_resolve_to_utc_boundaries() {
assert_cases(&[
(
"decisions before 2026-08-01",
"decisions",
None,
Some(1_785_542_400_000),
"before 2026-08-01",
),
(
"decisions until 2026-08",
"decisions",
None,
Some(1_785_542_400_000),
"until 2026-08",
),
(
"hnsw work since 2026-01-01",
"hnsw work",
Some(1_767_225_600_000),
None,
"since 2026-01-01",
),
(
"hnsw work after 2026",
"hnsw work",
Some(1_767_225_600_000),
None,
"after 2026",
),
(
"releases between 2026-01-01 and 2026-03-01",
"releases",
Some(1_767_225_600_000),
Some(1_772_409_600_000),
"between 2026-01-01 and 2026-03-01",
),
(
"ci failures in 2026-08",
"ci failures",
Some(1_785_542_400_000),
Some(1_788_220_800_000),
"in 2026-08",
),
(
"standup on 2026-08-01",
"standup",
Some(1_785_542_400_000),
Some(1_785_628_800_000),
"on 2026-08-01",
),
]);
}
#[test]
fn relative_forms_anchor_on_the_injected_now() {
assert_cases(&[
(
"what shipped yesterday",
"what shipped",
Some(TODAY - DAY),
Some(TODAY),
"yesterday",
),
(
"standup notes today",
"standup notes",
Some(TODAY),
Some(TODAY + DAY),
"today",
),
(
"bugs last week",
"bugs",
Some(TODAY - 7 * DAY),
None,
"last week",
),
(
"merges Last Month",
"merges",
Some(TODAY - 30 * DAY),
None,
"Last Month",
),
(
"deploys last 3 days",
"deploys",
Some(TODAY - 3 * DAY),
None,
"last 3 days",
),
]);
}
#[test]
fn unparseable_or_pure_temporal_queries_return_none() {
for query in [
"before the v8 migration", "last week", "yesterday", "port 8080 config", "releases between 2026-03-01 and 2026-01-01", "notes before 2026-13-01", "kind-aware recency prior", ] {
assert_eq!(parse_temporal_query(query, NOW_MS), None, "for {query:?}");
}
}
#[test]
fn residual_strips_the_phrase_without_doubling_spaces() {
let got = parse("topodb decisions before 2026-08-01 about hnsw");
assert_eq!(got.residual_query, "topodb decisions about hnsw");
assert_eq!(got.matched_phrase, "before 2026-08-01");
}
#[test]
fn deterministic_and_pure_under_a_shifted_now() {
let base = parse_temporal_query("bugs last week", NOW_MS).unwrap();
let shifted = parse_temporal_query("bugs last week", NOW_MS + 3 * DAY).unwrap();
assert_eq!(shifted.after_ms, base.after_ms.map(|a| a + 3 * DAY));
let abs = parse_temporal_query("bugs since 2026-01-01", NOW_MS).unwrap();
let abs2 = parse_temporal_query("bugs since 2026-01-01", NOW_MS + 3 * DAY).unwrap();
assert_eq!(abs, abs2);
}
#[test]
fn parse_iso_instant_resolves_start_of_period() {
assert_eq!(parse_iso_instant("2026-08-01"), Some(1_785_542_400_000));
assert_eq!(parse_iso_instant("2026-08"), Some(1_785_542_400_000));
assert_eq!(parse_iso_instant("2026"), Some(1_767_225_600_000));
for bad in ["not-a-date", "08/01/2026", "", "2026-13-01", "8080"] {
assert_eq!(parse_iso_instant(bad), None, "for {bad:?}");
}
}
#[test]
fn parse_iso_instant_accepts_utc_datetimes() {
assert_eq!(
parse_iso_instant("2026-08-01T00:00:00Z"),
parse_iso_instant("2026-08-01"),
);
assert_eq!(
parse_iso_instant("2026-08-01T15:30:00Z"),
Some(1_785_542_400_000 + 55_800_000),
);
assert_eq!(
parse_iso_instant("2026-08-01T15:30"),
Some(1_785_542_400_000 + 55_800_000),
);
for bad in [
"2026-08-01T15:30:00+02:00",
"2026-08-01T15:30:00.5Z",
"2026-08-01T24:00",
"2026-08-01T15:61",
"2026-08T15:30",
] {
assert_eq!(parse_iso_instant(bad), None, "for {bad:?}");
}
}
#[test]
fn unanalyzable_residual_passes_through_unrewritten() {
assert_eq!(parse_temporal_query("!!! since 2026-01-01", NOW_MS), None);
}
#[test]
fn bare_year_requires_preposition() {
assert_eq!(
parse_temporal_query("the 2026 roadmap", NOW_MS),
None,
"bare year without preposition should not match"
);
assert_cases(&[
(
"decisions in 2026",
"decisions",
Some(1_767_225_600_000),
Some(1_798_761_600_000),
"in 2026",
),
(
"shipped during 2026",
"shipped",
Some(1_767_225_600_000),
Some(1_798_761_600_000),
"during 2026",
),
]);
assert_cases(&[
(
"incidents 2026-08",
"incidents",
Some(1_785_542_400_000),
Some(1_788_220_800_000),
"2026-08",
),
(
"notes 2026-08-01",
"notes",
Some(1_785_542_400_000),
Some(1_785_628_800_000),
"2026-08-01",
),
]);
}
}