Files
ottyl/src/filter_datetime.rs
T
Hugh KaznowskiandClaude Sonnet 5 0311eeb712 Add filter_datetime module for FilterBar timestamp parsing
Standalone module (no engine/index/filter-grammar deps) that normalizes
relative humantime (5h, -3m), absolute time-of-day (12:00, resolved to
today in UTC), and full date/RFC3339 literals (2024-01-15,
2024-01-15T12:00:00Z) into a single Timestamp, given a passed-in `now`
for testability. Adds humantime and chrono dependencies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 12:42:51 +01:00

325 lines
12 KiB
Rust

//! Datetime parsing for the filter query language's timestamp comparisons.
//!
//! This module is intentionally standalone: it has no dependency on the engine/index
//! types or the filter grammar module, so it can be developed, tested, and reused
//! independently of the rest of the filter machinery.
//!
//! Three input forms are supported, all normalized to a single [`Timestamp`]:
//!
//! 1. Relative humantime, e.g. `5h`, `-3m` — resolved against a supplied "now" at
//! parse/eval time (parsed with the `humantime` crate).
//! 2. Absolute time-of-day, e.g. `12:00` — resolves to that time on the current day.
//! **This module treats "current day" as the UTC calendar day of `now`, and resolves
//! the time-of-day in UTC.** (We use `chrono` for date/time parsing since no other
//! datetime crate was already a dependency.)
//! 3. Full date/datetime literals, e.g. `2024-01-15` or `2024-01-15T12:00:00Z`
//! (RFC3339-ish). A bare date is interpreted as midnight UTC on that date.
// Not yet wired into the filter grammar/engine (a separate integration ticket); allow
// dead code so this standalone module builds warning-free until that lands.
#![allow(dead_code)]
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
use crate::timestamp::Timestamp;
/// Error returned when a datetime filter literal can't be resolved to a [`Timestamp`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DateTimeParseError {
/// The input matched the shape of a supported format (relative duration,
/// time-of-day, or date/datetime) but its value was invalid or ambiguous,
/// e.g. an out-of-range time like `25:99`.
Invalid { input: String, reason: String },
/// The input didn't match any supported format at all.
Unparseable(String),
}
impl std::fmt::Display for DateTimeParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DateTimeParseError::Invalid { input, reason } => {
write!(f, "invalid datetime literal {input:?}: {reason}")
}
DateTimeParseError::Unparseable(input) => write!(
f,
"unparseable datetime literal {input:?}: expected a relative duration \
(e.g. \"5h\", \"-3m\"), a time-of-day (e.g. \"12:00\"), or a date/datetime \
(e.g. \"2024-01-15\" or \"2024-01-15T12:00:00Z\")"
),
}
}
}
impl std::error::Error for DateTimeParseError {}
/// Parses a filter datetime literal into an absolute [`Timestamp`], resolving any
/// relative or day-relative forms against `now`.
///
/// `now` is taken as a parameter (rather than this function calling `Timestamp::now()`
/// internally) so callers can pass a fixed value in tests.
pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTimeParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(DateTimeParseError::Unparseable(input.to_string()));
}
if let Some(result) = try_relative(trimmed, now) {
return result;
}
if let Some(result) = try_full_datetime(trimmed) {
return result;
}
if let Some(result) = try_time_of_day(trimmed, now) {
return result;
}
Err(DateTimeParseError::Unparseable(input.to_string()))
}
/// Tries to interpret `input` as a signed relative humantime duration (`5h`, `-3m`).
///
/// Returns `None` if `input` doesn't look like a relative duration at all (so the
/// caller can try the other formats); returns `Some(Err(..))` if it looks like one but
/// the duration itself is malformed.
fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> {
let (sign, rest) = match input.strip_prefix('-') {
Some(rest) => (-1i128, rest),
None => match input.strip_prefix('+') {
Some(rest) => (1i128, rest),
None => (1i128, input),
},
};
// Only treat this as an attempted relative duration if it starts with a digit
// followed by (eventually) an alphabetic unit -- this keeps dates like
// "2024-01-15" (which start with digits but aren't durations) from being
// mis-attempted here and reported as "invalid duration" instead of falling
// through to the date parser.
let looks_like_duration = rest
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit())
&& rest.chars().any(|c| c.is_ascii_alphabetic())
&& !rest.contains('-')
&& !rest.contains(':');
if !looks_like_duration {
return None;
}
match humantime::parse_duration(rest) {
Ok(duration) => {
let nanos = duration.as_nanos() as i128 * sign;
let now_nanos = now.as_millis() as i128 * 1_000_000;
let result_millis = ((now_nanos + nanos) / 1_000_000) as i64;
Some(Ok(Timestamp::from_millis(result_millis)))
}
Err(err) => Some(Err(DateTimeParseError::Invalid {
input: input.to_string(),
reason: format!("not a valid relative duration: {err}"),
})),
}
}
/// Tries to interpret `input` as a full date or RFC3339-ish datetime literal.
fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError>> {
// Only attempt this path for inputs shaped like a date (contain a '-' separating
// digits, e.g. "2024-01-15"); otherwise let other parsers have a shot.
let looks_like_date = input.len() >= 8 && input.as_bytes()[4] == b'-';
if !looks_like_date {
return None;
}
if let Ok(dt) = DateTime::parse_from_rfc3339(input) {
return Some(Ok(timestamp_from_datetime(dt.with_timezone(&Utc))));
}
match NaiveDate::parse_from_str(input, "%Y-%m-%d") {
Ok(date) => {
let dt = date.and_hms_opt(0, 0, 0).expect("midnight is always valid");
Some(Ok(timestamp_from_datetime(Utc.from_utc_datetime(&dt))))
}
Err(_) => Some(Err(DateTimeParseError::Invalid {
input: input.to_string(),
reason: "not a valid date or RFC3339 datetime (expected e.g. \"2024-01-15\" or \
\"2024-01-15T12:00:00Z\")"
.to_string(),
})),
}
}
/// Tries to interpret `input` as a bare time-of-day (`12:00`, `00:00`, `23:59:59`),
/// resolved against the UTC calendar day of `now`.
fn try_time_of_day(
input: &str,
now: Timestamp,
) -> Option<Result<Timestamp, DateTimeParseError>> {
if !input.contains(':') {
return None;
}
let time = NaiveTime::parse_from_str(input, "%H:%M:%S")
.or_else(|_| NaiveTime::parse_from_str(input, "%H:%M"));
let time = match time {
Ok(time) => time,
Err(err) => {
return Some(Err(DateTimeParseError::Invalid {
input: input.to_string(),
reason: format!("not a valid time-of-day (expected e.g. \"12:00\"): {err}"),
}));
}
};
let now_millis = now.as_millis();
let now_dt = Utc.timestamp_millis_opt(now_millis).single().unwrap_or_else(|| {
// Fall back to the epoch date if `now` is somehow out of range; this should
// not happen in practice since `Timestamp` always comes from a valid instant.
Utc.timestamp_millis_opt(0).single().expect("epoch is valid")
});
let today = now_dt.date_naive();
let dt = Utc.from_utc_datetime(&today.and_time(time));
Some(Ok(timestamp_from_datetime(dt)))
}
fn timestamp_from_datetime(dt: DateTime<Utc>) -> Timestamp {
Timestamp::from_millis(dt.timestamp_millis())
}
#[cfg(test)]
mod tests {
use super::*;
/// A fixed "now": 2024-06-15T12:34:56Z.
fn fixed_now() -> Timestamp {
let dt = Utc.with_ymd_and_hms(2024, 6, 15, 12, 34, 56).unwrap();
Timestamp::from_millis(dt.timestamp_millis())
}
#[test]
fn relative_positive_duration() {
let now = fixed_now();
let got = parse_datetime("5h", now).expect("should parse");
let expected = Utc.with_ymd_and_hms(2024, 6, 15, 17, 34, 56).unwrap();
assert_eq!(got.as_millis(), expected.timestamp_millis());
}
#[test]
fn relative_negative_duration() {
let now = fixed_now();
let got = parse_datetime("-3m", now).expect("should parse");
let expected = Utc.with_ymd_and_hms(2024, 6, 15, 12, 31, 56).unwrap();
assert_eq!(got.as_millis(), expected.timestamp_millis());
}
#[test]
fn relative_explicit_plus() {
let now = fixed_now();
let got = parse_datetime("+10s", now).expect("should parse");
let expected = Utc.with_ymd_and_hms(2024, 6, 15, 12, 35, 6).unwrap();
assert_eq!(got.as_millis(), expected.timestamp_millis());
}
#[test]
fn time_of_day_resolves_to_today() {
let now = fixed_now();
let got = parse_datetime("12:00", now).expect("should parse");
let expected = Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
assert_eq!(got.as_millis(), expected.timestamp_millis());
}
#[test]
fn time_of_day_midnight() {
let now = fixed_now();
let got = parse_datetime("00:00", now).expect("should parse");
let expected = Utc.with_ymd_and_hms(2024, 6, 15, 0, 0, 0).unwrap();
assert_eq!(got.as_millis(), expected.timestamp_millis());
}
#[test]
fn time_of_day_end_of_day() {
let now = fixed_now();
let got = parse_datetime("23:59", now).expect("should parse");
let expected = Utc.with_ymd_and_hms(2024, 6, 15, 23, 59, 0).unwrap();
assert_eq!(got.as_millis(), expected.timestamp_millis());
}
#[test]
fn time_of_day_with_seconds() {
let now = fixed_now();
let got = parse_datetime("23:59:59", now).expect("should parse");
let expected = Utc.with_ymd_and_hms(2024, 6, 15, 23, 59, 59).unwrap();
assert_eq!(got.as_millis(), expected.timestamp_millis());
}
#[test]
fn full_date_only() {
let now = fixed_now();
let got = parse_datetime("2024-01-15", now).expect("should parse");
let expected = Utc.with_ymd_and_hms(2024, 1, 15, 0, 0, 0).unwrap();
assert_eq!(got.as_millis(), expected.timestamp_millis());
}
#[test]
fn full_datetime_rfc3339() {
let now = fixed_now();
let got = parse_datetime("2024-01-15T12:00:00Z", now).expect("should parse");
let expected = Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap();
assert_eq!(got.as_millis(), expected.timestamp_millis());
}
#[test]
fn full_datetime_rfc3339_with_offset() {
let now = fixed_now();
let got = parse_datetime("2024-01-15T12:00:00+02:00", now).expect("should parse");
let expected = Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap();
assert_eq!(got.as_millis(), expected.timestamp_millis());
}
#[test]
fn invalid_time_of_day_is_an_error() {
let now = fixed_now();
let err = parse_datetime("25:99", now).expect_err("should not parse");
match err {
DateTimeParseError::Invalid { .. } => {}
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn invalid_date_is_an_error() {
let now = fixed_now();
let err = parse_datetime("2024-13-40", now).expect_err("should not parse");
match err {
DateTimeParseError::Invalid { .. } => {}
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn totally_unparseable_input_is_an_error() {
let now = fixed_now();
let err = parse_datetime("not a date at all", now).expect_err("should not parse");
match err {
DateTimeParseError::Unparseable(_) => {}
other => panic!("expected Unparseable, got {other:?}"),
}
}
#[test]
fn empty_input_is_an_error() {
let now = fixed_now();
let err = parse_datetime("", now).expect_err("should not parse");
assert!(matches!(err, DateTimeParseError::Unparseable(_)));
}
#[test]
fn does_not_panic_on_garbage() {
let now = fixed_now();
for input in ["-", ":", "--", "::", "5", "h5", "T", "-T:", "🎉"] {
let _ = parse_datetime(input, now);
}
}
}