diff --git a/docs/tool-api.md b/docs/tool-api.md index f7d4623bb..9df789c45 100644 --- a/docs/tool-api.md +++ b/docs/tool-api.md @@ -191,10 +191,14 @@ with `selectedDatasets`, `activeDatasetCount`, `truncated` and up to 20 `unavailableDatasets`. If no dataset is active and at least one could not be counted, the result has `status: "error"` and no datasets. -`resolve_time_range` accepts any IANA `timezone` (default `UTC`), and uses it -for `today` and `yesterday` day boundaries. It also accepts `last `, -`past `, `now-`, ` to ` and RFC3339 start -times. An unknown timezone is an error. +`resolve_time_range` accepts any IANA `timezone` (default `UTC`; `UTC` itself +is matched case-insensitively), and uses it for `today` and `yesterday` day +boundaries. A day whose local midnight is skipped by a DST change starts at +the first instant after the gap. It also accepts `last `, +`past `, `now-`, ` to ` (`to` is +case-insensitive; each side is `now`, `now-`, `now+` or +RFC3339, and the start may also be a bare duration such as `1h`) and RFC3339 +start times. An unknown timezone is an error. `sample_events` accepts an explicit `startTime` and `endTime`, which must be provided together. They take precedence over `minutes`. diff --git a/src/tool_executor.rs b/src/tool_executor.rs index 21891447d..17ed3e416 100644 --- a/src/tool_executor.rs +++ b/src/tool_executor.rs @@ -1,7 +1,7 @@ use std::{collections::HashMap, ops::ControlFlow}; use actix_web::{HttpRequest, HttpResponse, body::to_bytes_limited, http::header::HeaderMap, web}; -use chrono::{DateTime, Duration, LocalResult, TimeZone, Utc}; +use chrono::{DateTime, Duration, LocalResult, NaiveDateTime, Offset, TimeZone, Utc}; use chrono_tz::Tz; use futures::{StreamExt, stream}; use serde_json::{Value, json}; @@ -256,11 +256,23 @@ pub fn resolve_time_range_at(arguments: &Value, reference: DateTime) -> Res .get("timezone") .and_then(Value::as_str) .unwrap_or("UTC"); - let timezone = timezone_name - .parse::() - .map_err(|_| format!("invalid IANA timezone `{timezone_name}`"))?; + let timezone = if timezone_name.eq_ignore_ascii_case("utc") { + Tz::UTC + } else { + timezone_name + .parse::() + .map_err(|_| format!("invalid IANA timezone `{timezone_name}`"))? + }; let normalized = expression.trim().to_ascii_lowercase(); - let range = if normalized == "today" || normalized == "yesterday" { + // ASCII lowercasing preserves byte offsets, so the split index applies to `expression`. + let explicit_range = expression + .to_ascii_lowercase() + .find(" to ") + .map(|index| (&expression[..index], &expression[index + 4..])); + let range = if let Some((start, end)) = explicit_range { + TimeRange::parse_human_time_at(start.trim(), end.trim(), reference) + .map_err(|error| error.to_string())? + } else if normalized == "today" || normalized == "yesterday" { let date = reference.with_timezone(&timezone).date_naive() - Duration::days(i64::from(normalized == "yesterday")); let local_start = date @@ -270,7 +282,7 @@ pub fn resolve_time_range_at(arguments: &Value, reference: DateTime) -> Res LocalResult::Single(start) | LocalResult::Ambiguous(start, _) => { start.with_timezone(&Utc) } - LocalResult::None => return Err("local day boundary does not exist".to_owned()), + LocalResult::None => first_instant_after_gap(&timezone, local_start), }; let end = if normalized == "today" { reference @@ -282,7 +294,7 @@ pub fn resolve_time_range_at(arguments: &Value, reference: DateTime) -> Res LocalResult::Single(end) | LocalResult::Ambiguous(end, _) => { end.with_timezone(&Utc) } - LocalResult::None => return Err("local day boundary does not exist".to_owned()), + LocalResult::None => first_instant_after_gap(&timezone, next_local_start), } }; TimeRange::new(start, end) @@ -309,13 +321,9 @@ pub fn resolve_time_range_at(arguments: &Value, reference: DateTime) -> Res .checked_sub_signed(duration) .ok_or_else(|| "time duration is too large".to_owned())?; TimeRange::new(start, reference) - } else if let Some((start, end)) = expression.split_once(" to ") { - TimeRange::parse_human_time(start.trim(), end.trim()).map_err(|error| error.to_string())? - } else if DateTime::parse_from_rfc3339(expression).is_ok() { - TimeRange::parse_human_time(expression, &reference.to_rfc3339()) - .map_err(|error| error.to_string())? } else { - TimeRange::parse_human_time(expression, "now").map_err(|error| error.to_string())? + TimeRange::parse_human_time_at(expression, "now", reference) + .map_err(|error| error.to_string())? }; Ok(json!({ "expression": expression, @@ -325,6 +333,15 @@ pub fn resolve_time_range_at(arguments: &Value, reference: DateTime) -> Res })) } +/// Resolves a local time that falls in a DST gap to the first instant after the gap: the +/// local time read with the offset in effect before the transition. +fn first_instant_after_gap(timezone: &Tz, local: NaiveDateTime) -> DateTime { + let offset_before = timezone + .offset_from_utc_datetime(&(local - Duration::days(1))) + .fix(); + (local - offset_before).and_utc() +} + fn nested_string<'a>(value: &'a Value, name: &str) -> Option<&'a str> { match value { Value::Object(object) => object @@ -3079,6 +3096,37 @@ mod tests { ); } + #[actix_web::test] + async fn time_resolution_parses_now_relative_explicit_ranges_against_reference() { + let reference = DateTime::parse_from_rfc3339("2020-01-01T12:00:00Z") + .unwrap() + .to_utc(); + for expression in ["now-1h to now", "now-1h TO now"] { + let resolved = + resolve_time_range_at(&json!({ "expression": expression }), reference).unwrap(); + assert_eq!(resolved["startTime"], "2020-01-01T11:00:00+00:00"); + assert_eq!(resolved["endTime"], "2020-01-01T12:00:00+00:00"); + } + let fallback = resolve_time_range_at(&json!({ "expression": "1h" }), reference).unwrap(); + assert_eq!(fallback["startTime"], "2020-01-01T11:00:00+00:00"); + assert_eq!(fallback["endTime"], "2020-01-01T12:00:00+00:00"); + } + + #[actix_web::test] + async fn time_resolution_accepts_utc_case_insensitively() { + let reference = DateTime::parse_from_rfc3339("2026-10-01T12:00:00Z") + .unwrap() + .to_utc(); + for timezone in ["utc", "Utc", "UTC"] { + let today = resolve_time_range_at( + &json!({ "expression": "today", "timezone": timezone }), + reference, + ) + .unwrap(); + assert_eq!(today["startTime"], "2026-10-01T00:00:00+00:00"); + } + } + #[actix_web::test] async fn sample_events_selects_at_most_ten_columns() { let fields = (0..15) @@ -3129,6 +3177,36 @@ mod tests { } } + #[actix_web::test] + async fn time_resolution_starts_days_after_a_dst_gap_at_midnight() { + // America/Santiago skipped 2026-09-06T00:00 local (-04:00 -> -03:00 at 04:00Z). + let today = resolve_time_range_at( + &json!({ "expression": "today", "timezone": "America/Santiago" }), + DateTime::parse_from_rfc3339("2026-09-06T15:00:00Z") + .unwrap() + .to_utc(), + ) + .unwrap(); + assert_eq!(today["startTime"], "2026-09-06T04:00:00+00:00"); + let yesterday = resolve_time_range_at( + &json!({ "expression": "yesterday", "timezone": "America/Santiago" }), + DateTime::parse_from_rfc3339("2026-09-07T15:00:00Z") + .unwrap() + .to_utc(), + ) + .unwrap(); + assert_eq!(yesterday["startTime"], "2026-09-06T04:00:00+00:00"); + assert_eq!(yesterday["endTime"], "2026-09-07T03:00:00+00:00"); + let day_before = resolve_time_range_at( + &json!({ "expression": "yesterday", "timezone": "America/Santiago" }), + DateTime::parse_from_rfc3339("2026-09-06T15:00:00Z") + .unwrap() + .to_utc(), + ) + .unwrap(); + assert_eq!(day_before["endTime"], "2026-09-06T04:00:00+00:00"); + } + #[actix_web::test] async fn sample_events_requires_both_explicit_range_bounds() { let fields = ["p_timestamp".to_owned()]; diff --git a/src/utils/time.rs b/src/utils/time.rs index e4bd4ac49..618295185 100644 --- a/src/utils/time.rs +++ b/src/utils/time.rs @@ -207,7 +207,7 @@ impl TimeRange { Self::parse_human_time_at(start_time, end_time, Utc::now()) } - fn parse_human_time_at( + pub(crate) fn parse_human_time_at( start_time: &str, end_time: &str, now: DateTime,