1use regex::Regex;
32use std::sync::OnceLock;
33
34const DAY_MS: i64 = 86_400_000;
35
36#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
38pub struct TemporalRewrite {
39 pub residual_query: String,
41 pub after_ms: Option<i64>,
43 pub before_ms: Option<i64>,
45 pub matched_phrase: String,
48}
49
50#[derive(Clone, Copy)]
54enum Rule {
55 Between,
56 Before,
57 After,
58 LastNDays,
59 Relative,
60 BareDate,
61}
62
63fn rules() -> &'static [(Rule, Regex)] {
64 static RULES: OnceLock<Vec<(Rule, Regex)>> = OnceLock::new();
65 RULES.get_or_init(|| {
66 let date = r"(19[7-9]\d|20\d{2})(?:-(\d{2})(?:-(\d{2}))?)?";
69 let re = |p: &str| Regex::new(p).expect("static temporal pattern");
73 vec![
74 (
75 Rule::Between,
76 re(&format!(r"(?i)\bbetween\s+{date}\s+and\s+{date}\b")),
77 ),
78 (
79 Rule::Before,
80 re(&format!(r"(?i)\b(?:before|until)\s+{date}\b")),
81 ),
82 (
83 Rule::After,
84 re(&format!(r"(?i)\b(?:after|since)\s+{date}\b")),
85 ),
86 (Rule::LastNDays, re(r"(?i)\blast\s+(\d{1,4})\s+days?\b")),
87 (
88 Rule::Relative,
89 re(r"(?i)\b(yesterday|today|last\s+week|last\s+month)\b"),
90 ),
91 (
92 Rule::BareDate,
93 re(&format!(r"(?i)\b((?:in|on|during)\s+)?{date}\b")),
94 ),
95 ]
96 })
97}
98
99#[derive(Clone, Copy)]
101enum DateSpec {
102 Day { y: i64, m: i64, d: i64 },
103 Month { y: i64, m: i64 },
104 Year { y: i64 },
105}
106
107impl DateSpec {
108 fn read(caps: ®ex::Captures<'_>, i: usize) -> Option<Self> {
111 let y: i64 = caps.get(i)?.as_str().parse().ok()?;
112 let m: i64 = match caps.get(i + 1) {
113 Some(m) => m.as_str().parse().ok()?,
114 None => return Some(DateSpec::Year { y }),
115 };
116 if !(1..=12).contains(&m) {
117 return None;
118 }
119 let d: i64 = match caps.get(i + 2) {
120 Some(d) => d.as_str().parse().ok()?,
121 None => return Some(DateSpec::Month { y, m }),
122 };
123 (1..=days_in_month(y, m))
124 .contains(&d)
125 .then_some(DateSpec::Day { y, m, d })
126 }
127
128 fn start_ms(self) -> i64 {
130 match self {
131 DateSpec::Day { y, m, d } => days_from_civil(y, m, d) * DAY_MS,
132 DateSpec::Month { y, m } => days_from_civil(y, m, 1) * DAY_MS,
133 DateSpec::Year { y } => days_from_civil(y, 1, 1) * DAY_MS,
134 }
135 }
136
137 fn end_ms(self) -> i64 {
139 match self {
140 DateSpec::Day { y, m, d } => (days_from_civil(y, m, d) + 1) * DAY_MS,
141 DateSpec::Month { y, m: 12 } => days_from_civil(y + 1, 1, 1) * DAY_MS,
142 DateSpec::Month { y, m } => days_from_civil(y, m + 1, 1) * DAY_MS,
143 DateSpec::Year { y } => days_from_civil(y + 1, 1, 1) * DAY_MS,
144 }
145 }
146}
147
148fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
152 let y = if m <= 2 { y - 1 } else { y };
153 let era = y / 400;
154 let yoe = y - era * 400;
155 let mp = if m > 2 { m - 3 } else { m + 9 };
156 let doy = (153 * mp + 2) / 5 + d - 1;
157 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
158 era * 146_097 + doe - 719_468
159}
160
161fn days_in_month(y: i64, m: i64) -> i64 {
163 match m {
164 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
165 4 | 6 | 9 | 11 => 30,
166 _ => {
167 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 {
168 29
169 } else {
170 28
171 }
172 }
173 }
174}
175
176pub fn parse_temporal_query(query: &str, now_ms: i64) -> Option<TemporalRewrite> {
182 let today = now_ms.div_euclid(DAY_MS) * DAY_MS;
183 for (rule, re) in rules() {
184 let caps = match re.captures(query) {
185 Some(c) => c,
186 None => continue,
187 };
188 let (after_ms, before_ms) = match rule {
192 Rule::Between => {
193 let a = DateSpec::read(&caps, 1)?;
194 let b = DateSpec::read(&caps, 4)?;
195 if a.start_ms() >= b.end_ms() {
196 return None;
197 }
198 (Some(a.start_ms()), Some(b.end_ms()))
199 }
200 Rule::Before => (None, Some(DateSpec::read(&caps, 1)?.start_ms())),
201 Rule::After => (Some(DateSpec::read(&caps, 1)?.start_ms()), None),
202 Rule::LastNDays => {
203 let n: i64 = caps[1].parse().ok()?;
204 if n == 0 {
205 return None;
206 }
207 (Some(today - n * DAY_MS), None)
208 }
209 Rule::Relative => {
210 let key = caps[1].to_ascii_lowercase();
212 match key
213 .split_whitespace()
214 .collect::<Vec<_>>()
215 .join(" ")
216 .as_str()
217 {
218 "yesterday" => (Some(today - DAY_MS), Some(today)),
219 "today" => (Some(today), Some(today + DAY_MS)),
220 "last week" => (Some(today - 7 * DAY_MS), None),
221 "last month" => (Some(today - 30 * DAY_MS), None),
222 _ => return None,
225 }
226 }
227 Rule::BareDate => {
228 let d = DateSpec::read(&caps, 2)?;
229 if matches!(d, DateSpec::Year { .. }) && caps.get(1).is_none() {
231 return None;
232 }
233 (Some(d.start_ms()), Some(d.end_ms()))
234 }
235 };
236 let whole = caps.get(0)?;
237 let residual = format!("{} {}", &query[..whole.start()], &query[whole.end()..])
238 .split_whitespace()
239 .collect::<Vec<_>>()
240 .join(" ");
241 if residual.is_empty() {
242 return None;
244 }
245 if !residual.contains(|c: char| c.is_ascii_alphanumeric()) {
246 return None;
252 }
253 return Some(TemporalRewrite {
254 residual_query: residual,
255 after_ms,
256 before_ms,
257 matched_phrase: whole.as_str().to_string(),
258 });
259 }
260 None
261}
262
263pub fn parse_iso_instant(s: &str) -> Option<i64> {
274 static RE: OnceLock<Regex> = OnceLock::new();
275 let re = RE.get_or_init(|| {
276 Regex::new(
277 r"^\s*(19[7-9]\d|20\d{2})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?Z?)?)?)?\s*$",
278 )
279 .expect("static temporal pattern")
280 });
281 let caps = re.captures(s)?;
282 let date_ms = DateSpec::read(&caps, 1).map(DateSpec::start_ms)?;
283 match (caps.get(4), caps.get(5)) {
284 (Some(h), Some(m)) => {
285 let (h, m): (i64, i64) = (h.as_str().parse().ok()?, m.as_str().parse().ok()?);
286 let sec: i64 = caps.get(6).map_or(Some(0), |x| x.as_str().parse().ok())?;
287 if h > 23 || m > 59 || sec > 59 {
288 return None;
289 }
290 Some(date_ms + (h * 3600 + m * 60 + sec) * 1000)
291 }
292 _ => Some(date_ms),
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 type TestCase<'a> = (&'a str, &'a str, Option<i64>, Option<i64>, &'a str);
301
302 const NOW_MS: i64 = 1_786_276_800_000;
307 const TODAY: i64 = 1_786_233_600_000;
308 const DAY: i64 = 86_400_000;
309
310 #[track_caller]
311 fn parse(q: &str) -> TemporalRewrite {
312 parse_temporal_query(q, NOW_MS).unwrap_or_else(|| panic!("expected a rewrite for {q:?}"))
313 }
314
315 fn assert_cases(cases: &[TestCase]) {
316 for (query, residual, after, before, phrase) in cases {
317 let got = parse(query);
318 assert_eq!(got.residual_query, *residual, "residual for {query:?}");
319 assert_eq!(got.after_ms, *after, "after_ms for {query:?}");
320 assert_eq!(got.before_ms, *before, "before_ms for {query:?}");
321 assert_eq!(got.matched_phrase, *phrase, "phrase for {query:?}");
322 }
323 }
324
325 #[test]
326 fn absolute_forms_resolve_to_utc_boundaries() {
327 assert_cases(&[
332 (
333 "decisions before 2026-08-01",
334 "decisions",
335 None,
336 Some(1_785_542_400_000),
337 "before 2026-08-01",
338 ),
339 (
340 "decisions until 2026-08",
341 "decisions",
342 None,
343 Some(1_785_542_400_000),
344 "until 2026-08",
345 ),
346 (
347 "hnsw work since 2026-01-01",
348 "hnsw work",
349 Some(1_767_225_600_000),
350 None,
351 "since 2026-01-01",
352 ),
353 (
354 "hnsw work after 2026",
355 "hnsw work",
356 Some(1_767_225_600_000),
357 None,
358 "after 2026",
359 ),
360 (
361 "releases between 2026-01-01 and 2026-03-01",
362 "releases",
363 Some(1_767_225_600_000),
364 Some(1_772_409_600_000),
365 "between 2026-01-01 and 2026-03-01",
366 ),
367 (
368 "ci failures in 2026-08",
369 "ci failures",
370 Some(1_785_542_400_000),
371 Some(1_788_220_800_000),
372 "in 2026-08",
373 ),
374 (
375 "standup on 2026-08-01",
376 "standup",
377 Some(1_785_542_400_000),
378 Some(1_785_628_800_000),
379 "on 2026-08-01",
380 ),
381 ]);
382 }
383
384 #[test]
385 fn relative_forms_anchor_on_the_injected_now() {
386 assert_cases(&[
387 (
388 "what shipped yesterday",
389 "what shipped",
390 Some(TODAY - DAY),
391 Some(TODAY),
392 "yesterday",
393 ),
394 (
395 "standup notes today",
396 "standup notes",
397 Some(TODAY),
398 Some(TODAY + DAY),
399 "today",
400 ),
401 (
402 "bugs last week",
403 "bugs",
404 Some(TODAY - 7 * DAY),
405 None,
406 "last week",
407 ),
408 (
410 "merges Last Month",
411 "merges",
412 Some(TODAY - 30 * DAY),
413 None,
414 "Last Month",
415 ),
416 (
417 "deploys last 3 days",
418 "deploys",
419 Some(TODAY - 3 * DAY),
420 None,
421 "last 3 days",
422 ),
423 ]);
424 }
425
426 #[test]
427 fn unparseable_or_pure_temporal_queries_return_none() {
428 for query in [
429 "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", ] {
437 assert_eq!(parse_temporal_query(query, NOW_MS), None, "for {query:?}");
438 }
439 }
440
441 #[test]
442 fn residual_strips_the_phrase_without_doubling_spaces() {
443 let got = parse("topodb decisions before 2026-08-01 about hnsw");
444 assert_eq!(got.residual_query, "topodb decisions about hnsw");
445 assert_eq!(got.matched_phrase, "before 2026-08-01");
446 }
447
448 #[test]
449 fn deterministic_and_pure_under_a_shifted_now() {
450 let base = parse_temporal_query("bugs last week", NOW_MS).unwrap();
453 let shifted = parse_temporal_query("bugs last week", NOW_MS + 3 * DAY).unwrap();
454 assert_eq!(shifted.after_ms, base.after_ms.map(|a| a + 3 * DAY));
455 let abs = parse_temporal_query("bugs since 2026-01-01", NOW_MS).unwrap();
456 let abs2 = parse_temporal_query("bugs since 2026-01-01", NOW_MS + 3 * DAY).unwrap();
457 assert_eq!(abs, abs2);
458 }
459
460 #[test]
461 fn parse_iso_instant_resolves_start_of_period() {
462 assert_eq!(parse_iso_instant("2026-08-01"), Some(1_785_542_400_000));
463 assert_eq!(parse_iso_instant("2026-08"), Some(1_785_542_400_000));
464 assert_eq!(parse_iso_instant("2026"), Some(1_767_225_600_000));
465 for bad in ["not-a-date", "08/01/2026", "", "2026-13-01", "8080"] {
466 assert_eq!(parse_iso_instant(bad), None, "for {bad:?}");
467 }
468 }
469
470 #[test]
471 fn parse_iso_instant_accepts_utc_datetimes() {
472 assert_eq!(
474 parse_iso_instant("2026-08-01T00:00:00Z"),
475 parse_iso_instant("2026-08-01"),
476 );
477 assert_eq!(
479 parse_iso_instant("2026-08-01T15:30:00Z"),
480 Some(1_785_542_400_000 + 55_800_000),
481 );
482 assert_eq!(
483 parse_iso_instant("2026-08-01T15:30"),
484 Some(1_785_542_400_000 + 55_800_000),
485 );
486 for bad in [
489 "2026-08-01T15:30:00+02:00",
490 "2026-08-01T15:30:00.5Z",
491 "2026-08-01T24:00",
492 "2026-08-01T15:61",
493 "2026-08T15:30",
494 ] {
495 assert_eq!(parse_iso_instant(bad), None, "for {bad:?}");
496 }
497 }
498
499 #[test]
500 fn unanalyzable_residual_passes_through_unrewritten() {
501 assert_eq!(parse_temporal_query("!!! since 2026-01-01", NOW_MS), None);
506 }
507
508 #[test]
509 fn bare_year_requires_preposition() {
510 assert_eq!(
513 parse_temporal_query("the 2026 roadmap", NOW_MS),
514 None,
515 "bare year without preposition should not match"
516 );
517
518 assert_cases(&[
521 (
522 "decisions in 2026",
523 "decisions",
524 Some(1_767_225_600_000),
525 Some(1_798_761_600_000),
526 "in 2026",
527 ),
528 (
529 "shipped during 2026",
530 "shipped",
531 Some(1_767_225_600_000),
532 Some(1_798_761_600_000),
533 "during 2026",
534 ),
535 ]);
536
537 assert_cases(&[
539 (
540 "incidents 2026-08",
541 "incidents",
542 Some(1_785_542_400_000),
543 Some(1_788_220_800_000),
544 "2026-08",
545 ),
546 (
547 "notes 2026-08-01",
548 "notes",
549 Some(1_785_542_400_000),
550 Some(1_785_628_800_000),
551 "2026-08-01",
552 ),
553 ]);
554 }
555}