diff --git a/Cargo.lock b/Cargo.lock index 7fb22c4..5259cac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -58,6 +67,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.8.9" @@ -116,18 +131,47 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.6" @@ -159,7 +203,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -174,6 +218,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "errno" version = "0.3.14" @@ -184,6 +234,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -289,6 +345,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + [[package]] name = "hyper" version = "1.11.1" @@ -324,6 +386,30 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -336,6 +422,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.189" @@ -396,6 +493,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -413,7 +519,9 @@ name = "ottyl" version = "0.1.0" dependencies = [ "axum", + "chrono", "clap", + "humantime", "serde", "serde_json", "tokio", @@ -482,6 +590,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.23" @@ -521,7 +635,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -560,6 +674,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -598,6 +718,17 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.5" @@ -640,7 +771,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -753,12 +884,110 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 24df485..0ffc609 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,5 @@ tower-http = { version = "0.6", features = ["fs", "cors"] } serde = { version = "1", features = ["derive"] } serde_json = "1" clap = { version = "4", features = ["derive"] } +chrono = "0.4" +humantime = "2" diff --git a/src/filter_datetime.rs b/src/filter_datetime.rs new file mode 100644 index 0000000..aa5cbae --- /dev/null +++ b/src/filter_datetime.rs @@ -0,0 +1,324 @@ +//! 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); + } + } +} diff --git a/src/main.rs b/src/main.rs index 4b50470..49fb441 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod api; mod data_value; mod dto; mod engine; +mod filter_datetime; mod index_key; mod ingest; mod metric_primitive;