//! 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 { 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> { 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> { // 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> { 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) -> 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); } } }