Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/tool-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <duration>`,
`past <duration>`, `now-<duration>`, `<start> to <end>` 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 <duration>`,
`past <duration>`, `now-<duration>`, `<start> to <end>` (`to` is
case-insensitive; each side is `now`, `now-<duration>`, `now+<duration>` 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`.
Expand Down
104 changes: 91 additions & 13 deletions src/tool_executor.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -256,11 +256,23 @@ pub fn resolve_time_range_at(arguments: &Value, reference: DateTime<Utc>) -> Res
.get("timezone")
.and_then(Value::as_str)
.unwrap_or("UTC");
let timezone = timezone_name
.parse::<Tz>()
.map_err(|_| format!("invalid IANA timezone `{timezone_name}`"))?;
let timezone = if timezone_name.eq_ignore_ascii_case("utc") {
Tz::UTC
} else {
timezone_name
.parse::<Tz>()
.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
Expand All @@ -270,7 +282,7 @@ pub fn resolve_time_range_at(arguments: &Value, reference: DateTime<Utc>) -> 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
Expand All @@ -282,7 +294,7 @@ pub fn resolve_time_range_at(arguments: &Value, reference: DateTime<Utc>) -> 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)
Expand All @@ -309,13 +321,9 @@ pub fn resolve_time_range_at(arguments: &Value, reference: DateTime<Utc>) -> 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,
Expand All @@ -325,6 +333,15 @@ pub fn resolve_time_range_at(arguments: &Value, reference: DateTime<Utc>) -> 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<Utc> {
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()];
Expand Down
2 changes: 1 addition & 1 deletion src/utils/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Utc>,
Expand Down
Loading