fix: add timezone conversion support to time tool (#687)

This commit is contained in:
Frank
2026-03-08 21:17:07 +00:00
committed by GitHub
parent 7d1461fc74
commit 9401ab0d58
2 changed files with 580 additions and 80 deletions
Generated
+144 -23
View File
@@ -628,13 +628,13 @@ dependencies = [
"http-body-util",
"hyper 1.8.1",
"hyper-named-pipe",
"hyper-rustls",
"hyper-rustls 0.27.7",
"hyper-util",
"hyperlocal",
"log",
"pin-project-lite",
"rustls",
"rustls-native-certs",
"rustls 0.23.37",
"rustls-native-certs 0.8.3",
"rustls-pemfile",
"rustls-pki-types",
"serde",
@@ -2568,6 +2568,24 @@ dependencies = [
"winapi",
]
[[package]]
name = "hyper-rustls"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "399c78f9338483cb7e630c8474b07268983c6bd5acee012e4211f9f7bb21b070"
dependencies = [
"futures-util",
"http 0.2.12",
"hyper 0.14.32",
"log",
"rustls 0.22.4",
"rustls-native-certs 0.7.3",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.25.0",
"webpki-roots 0.26.11",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
@@ -2577,11 +2595,11 @@ dependencies = [
"http 1.4.0",
"hyper 1.8.1",
"hyper-util",
"rustls",
"rustls-native-certs",
"rustls 0.23.37",
"rustls-native-certs 0.8.3",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tokio-rustls 0.26.4",
"tower-service",
]
@@ -2919,12 +2937,12 @@ dependencies = [
"rig-core",
"rust_decimal",
"rust_decimal_macros",
"rustls",
"rustls-native-certs",
"rustls 0.23.37",
"rustls-native-certs 0.8.3",
"rustyline",
"secrecy",
"secret-service",
"security-framework",
"security-framework 3.7.0",
"semver",
"serde",
"serde_json",
@@ -3142,6 +3160,7 @@ dependencies = [
"anyhow",
"async-stream",
"async-trait",
"base64 0.21.7",
"bincode",
"bitflags 2.11.0",
"bytes",
@@ -3149,14 +3168,18 @@ dependencies = [
"futures",
"http 0.2.12",
"hyper 0.14.32",
"hyper-rustls 0.25.0",
"libsql-hrana",
"libsql-sqlite3-parser",
"libsql-sys",
"libsql_replication",
"parking_lot",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
"tokio-util",
"tonic",
"tonic-web",
"tower 0.4.13",
@@ -3176,6 +3199,18 @@ dependencies = [
"cc",
]
[[package]]
name = "libsql-hrana"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeaf5d19e365465e1c23d687a28c805d7462531b3f619f0ba49d3cf369890a3e"
dependencies = [
"base64 0.21.7",
"bytes",
"prost",
"serde",
]
[[package]]
name = "libsql-rusqlite"
version = "0.33.0"
@@ -3499,10 +3534,10 @@ dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-probe 0.2.1",
"openssl-sys",
"schannel",
"security-framework",
"security-framework 3.7.0",
"security-framework-sys",
"tempfile",
]
@@ -3749,6 +3784,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -4291,7 +4332,7 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.1",
"rustls",
"rustls 0.23.37",
"socket2 0.6.2",
"thiserror 2.0.18",
"tokio",
@@ -4311,7 +4352,7 @@ dependencies = [
"rand 0.9.2",
"ring",
"rustc-hash 2.1.1",
"rustls",
"rustls 0.23.37",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
@@ -4650,7 +4691,7 @@ dependencies = [
"http-body 1.0.1",
"http-body-util",
"hyper 1.8.1",
"hyper-rustls",
"hyper-rustls 0.27.7",
"hyper-tls",
"hyper-util",
"js-sys",
@@ -4661,8 +4702,8 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-native-certs",
"rustls 0.23.37",
"rustls-native-certs 0.8.3",
"rustls-pki-types",
"serde",
"serde_json",
@@ -4670,7 +4711,7 @@ dependencies = [
"sync_wrapper 1.0.2",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-rustls 0.26.4",
"tokio-util",
"tower 0.5.3",
"tower-http 0.6.8",
@@ -4847,6 +4888,20 @@ dependencies = [
"rustix 1.1.4",
]
[[package]]
name = "rustls"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
dependencies = [
"log",
"ring",
"rustls-pki-types",
"rustls-webpki 0.102.8",
"subtle",
"zeroize",
]
[[package]]
name = "rustls"
version = "0.23.37"
@@ -4856,21 +4911,34 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"rustls-webpki 0.103.9",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
dependencies = [
"openssl-probe 0.1.6",
"rustls-pemfile",
"rustls-pki-types",
"schannel",
"security-framework 2.11.1",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"openssl-probe 0.2.1",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 3.7.0",
]
[[package]]
@@ -4892,6 +4960,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.102.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
@@ -5060,6 +5139,19 @@ dependencies = [
"zbus",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -5961,20 +6053,31 @@ checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144"
dependencies = [
"const-oid",
"ring",
"rustls",
"rustls 0.23.37",
"tokio",
"tokio-postgres",
"tokio-rustls",
"tokio-rustls 0.26.4",
"x509-cert",
]
[[package]]
name = "tokio-rustls"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f"
dependencies = [
"rustls 0.22.4",
"rustls-pki-types",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"rustls 0.23.37",
"tokio",
]
@@ -7166,6 +7269,24 @@ dependencies = [
"string_cache_codegen",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.6",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
+436 -57
View File
@@ -1,7 +1,8 @@
//! Time utility tool.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use chrono::{DateTime, LocalResult, NaiveDate, NaiveDateTime, TimeZone, Utc};
use chrono_tz::Tz;
use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
@@ -16,7 +17,7 @@ impl Tool for TimeTool {
}
fn description(&self) -> &str {
"Get current time, convert timezones, or calculate time differences."
"Get current time, parse or format timestamps, convert timezones, or calculate time differences."
}
fn parameters_schema(&self) -> serde_json::Value {
@@ -25,20 +26,40 @@ impl Tool for TimeTool {
"properties": {
"operation": {
"type": "string",
"enum": ["now", "parse", "format", "diff"],
"enum": ["now", "parse", "convert", "format", "diff"],
"description": "The time operation to perform"
},
"input": {
"type": "string",
"description": "Input timestamp. Accepts RFC 3339, or a naive timestamp when timezone/from_timezone is provided."
},
"timestamp": {
"type": "string",
"description": "ISO 8601 timestamp (for parse/format/diff operations)"
"description": "Alias for input (kept for backward compatibility)."
},
"timezone": {
"type": "string",
"description": "IANA timezone name (e.g. 'America/New_York'). Used by now/format, and can also interpret naive timestamps."
},
"from_timezone": {
"type": "string",
"description": "Source IANA timezone for naive input timestamps during convert/format/diff."
},
"to_timezone": {
"type": "string",
"description": "Target IANA timezone for convert."
},
"format": {
"type": "string",
"description": "Output format string (for format operation)"
"description": "strftime format string for format (kept for backward compatibility)."
},
"format_string": {
"type": "string",
"description": "strftime format string for format."
},
"timestamp2": {
"type": "string",
"description": "Second timestamp (for diff operation)"
"description": "Second timestamp for diff."
}
},
"required": ["operation"]
@@ -55,53 +76,11 @@ impl Tool for TimeTool {
let operation = require_str(&params, "operation")?;
let result = match operation {
"now" => {
let now = Utc::now();
let tz =
crate::timezone::parse_timezone(&ctx.user_timezone).unwrap_or(chrono_tz::UTC);
let local = now.with_timezone(&tz);
serde_json::json!({
"iso": now.to_rfc3339(),
"unix": now.timestamp(),
"unix_millis": now.timestamp_millis(),
"local_iso": local.to_rfc3339(),
"timezone": tz.name()
})
}
"parse" => {
let timestamp = require_str(&params, "timestamp")?;
let dt: DateTime<Utc> = timestamp.parse().map_err(|e| {
ToolError::InvalidParameters(format!("invalid timestamp: {}", e))
})?;
serde_json::json!({
"iso": dt.to_rfc3339(),
"unix": dt.timestamp(),
"unix_millis": dt.timestamp_millis()
})
}
"diff" => {
let ts1 = require_str(&params, "timestamp")?;
let ts2 = require_str(&params, "timestamp2")?;
let dt1: DateTime<Utc> = ts1.parse().map_err(|e| {
ToolError::InvalidParameters(format!("invalid timestamp: {}", e))
})?;
let dt2: DateTime<Utc> = ts2.parse().map_err(|e| {
ToolError::InvalidParameters(format!("invalid timestamp2: {}", e))
})?;
let diff = dt2.signed_duration_since(dt1);
serde_json::json!({
"seconds": diff.num_seconds(),
"minutes": diff.num_minutes(),
"hours": diff.num_hours(),
"days": diff.num_days()
})
}
"now" => execute_now(&params, ctx)?,
"parse" => execute_parse(&params, ctx)?,
"convert" => execute_convert(&params, ctx)?,
"format" => execute_format(&params, ctx)?,
"diff" => execute_diff(&params, ctx)?,
_ => {
return Err(ToolError::InvalidParameters(format!(
"unknown operation: {}",
@@ -118,12 +97,308 @@ impl Tool for TimeTool {
}
}
fn execute_now(
params: &serde_json::Value,
ctx: &JobContext,
) -> Result<serde_json::Value, ToolError> {
let now = Utc::now();
let mut result = serde_json::json!({
"iso": now.to_rfc3339(),
"utc_iso": now.to_rfc3339(),
"unix": now.timestamp(),
"unix_millis": now.timestamp_millis()
});
if let Some((tz, tz_name)) = resolve_timezone_for_output(params, ctx)? {
let local = now.with_timezone(&tz);
result["local_iso"] = serde_json::Value::String(local.to_rfc3339());
result["timezone"] = serde_json::Value::String(tz_name);
}
Ok(result)
}
fn execute_parse(
params: &serde_json::Value,
ctx: &JobContext,
) -> Result<serde_json::Value, ToolError> {
let input = require_input(params)?;
let parse_tz = resolve_parse_timezone(params, ctx)?;
let dt = parse_timestamp(input, parse_tz.as_ref())?;
Ok(serde_json::json!({
"iso": dt.to_rfc3339(),
"unix": dt.timestamp(),
"unix_millis": dt.timestamp_millis()
}))
}
fn execute_convert(
params: &serde_json::Value,
ctx: &JobContext,
) -> Result<serde_json::Value, ToolError> {
let input = require_input(params)?;
let source_tz = optional_timezone(params, &["from_timezone", "timezone"])?;
let dt = parse_timestamp(input, source_tz.as_ref())?;
let target_name = params
.get("to_timezone")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("convert operation requires 'to_timezone'".to_string())
})?;
let target_tz = parse_timezone(target_name)?;
let converted = dt.with_timezone(&target_tz);
let mut result = serde_json::json!({
"input": input,
"utc_iso": dt.to_rfc3339(),
"output": converted.to_rfc3339(),
"timezone": target_tz.to_string()
});
if let Some((ctx_tz, ctx_tz_name)) = context_timezone(ctx)? {
result["context_timezone"] = serde_json::Value::String(ctx_tz_name);
result["context_iso"] = serde_json::Value::String(dt.with_timezone(&ctx_tz).to_rfc3339());
}
Ok(result)
}
fn execute_format(
params: &serde_json::Value,
ctx: &JobContext,
) -> Result<serde_json::Value, ToolError> {
let input = require_input(params)?;
let output_tz = resolve_timezone_for_output(params, ctx)?;
let source_tz = optional_timezone(params, &["from_timezone"])?
.or_else(|| output_tz.as_ref().map(|(tz, _)| *tz));
let dt = parse_timestamp(input, source_tz.as_ref())?;
let format_string = params
.get("format_string")
.and_then(|v| v.as_str())
.or_else(|| params.get("format").and_then(|v| v.as_str()))
.unwrap_or("%Y-%m-%d %H:%M:%S %Z");
let mut result = if let Some((tz, tz_name)) = output_tz {
serde_json::json!({
"formatted": dt.with_timezone(&tz).format(format_string).to_string(),
"timezone": tz_name
})
} else {
serde_json::json!({
"formatted": dt.format(format_string).to_string()
})
};
result["utc_iso"] = serde_json::Value::String(dt.to_rfc3339());
Ok(result)
}
fn execute_diff(
params: &serde_json::Value,
ctx: &JobContext,
) -> Result<serde_json::Value, ToolError> {
let parse_tz = resolve_parse_timezone(params, ctx)?;
let ts1 = require_input(params)?;
let ts2 = params
.get("timestamp2")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("diff operation requires 'timestamp2'".to_string())
})?;
let dt1 = parse_timestamp(ts1, parse_tz.as_ref())?;
let dt2 = parse_timestamp(ts2, parse_tz.as_ref())?;
let diff = dt2.signed_duration_since(dt1);
Ok(serde_json::json!({
"seconds": diff.num_seconds(),
"minutes": diff.num_minutes(),
"hours": diff.num_hours(),
"days": diff.num_days()
}))
}
fn require_input(params: &serde_json::Value) -> Result<&str, ToolError> {
params
.get("input")
.and_then(|v| v.as_str())
.or_else(|| params.get("timestamp").and_then(|v| v.as_str()))
.ok_or_else(|| {
ToolError::InvalidParameters(
"missing 'input' (or legacy 'timestamp') parameter".to_string(),
)
})
}
fn resolve_parse_timezone(
params: &serde_json::Value,
ctx: &JobContext,
) -> Result<Option<Tz>, ToolError> {
if let Some(tz) = optional_timezone(params, &["from_timezone", "timezone"])? {
return Ok(Some(tz));
}
Ok(context_timezone(ctx)?.map(|(tz, _)| tz))
}
fn resolve_timezone_for_output(
params: &serde_json::Value,
ctx: &JobContext,
) -> Result<Option<(Tz, String)>, ToolError> {
if let Some(name) = params.get("timezone").and_then(|v| v.as_str()) {
let tz = parse_timezone(name)?;
return Ok(Some((tz, tz.to_string())));
}
context_timezone(ctx)
}
/// Resolve the user's timezone from the JobContext.
///
/// Uses `ctx.user_timezone` (set from main's timezone resolution) as the
/// primary source. Falls back to metadata fields for backward compatibility.
fn context_timezone(ctx: &JobContext) -> Result<Option<(Tz, String)>, ToolError> {
// Primary: use the dedicated user_timezone field from JobContext
if ctx.user_timezone != "UTC"
&& !ctx.user_timezone.is_empty()
&& let Some(tz) = crate::timezone::parse_timezone(&ctx.user_timezone)
{
return Ok(Some((tz, tz.to_string())));
}
// Fallback: check metadata for backward compatibility
let tz_name = ctx
.metadata
.get("user_timezone")
.and_then(|v| v.as_str())
.or_else(|| ctx.metadata.get("timezone").and_then(|v| v.as_str()));
match tz_name {
Some(name) => {
let tz = parse_timezone(name)?;
Ok(Some((tz, tz.to_string())))
}
None => Ok(None),
}
}
fn optional_timezone(params: &serde_json::Value, keys: &[&str]) -> Result<Option<Tz>, ToolError> {
for key in keys {
if let Some(value) = params.get(*key).and_then(|v| v.as_str()) {
return parse_timezone(value).map(Some);
}
}
Ok(None)
}
fn parse_timezone(value: &str) -> Result<Tz, ToolError> {
value.parse::<Tz>().map_err(|_| {
ToolError::InvalidParameters(format!(
"Unknown timezone '{}'. Use IANA names like 'America/New_York' or 'Europe/London'.",
value
))
})
}
fn parse_timestamp(input: &str, fallback_tz: Option<&Tz>) -> Result<DateTime<Utc>, ToolError> {
if let Ok(dt) = DateTime::parse_from_rfc3339(input) {
return Ok(dt.with_timezone(&Utc));
}
if let Some(naive) = parse_naive_datetime(input) {
return localize_naive_datetime(naive, fallback_tz, input);
}
Err(ToolError::InvalidParameters(format!(
"invalid timestamp '{}': expected RFC 3339 or a naive timestamp with timezone/from_timezone",
input
)))
}
fn parse_naive_datetime(input: &str) -> Option<NaiveDateTime> {
const DATETIME_FORMATS: &[&str] = &[
"%Y-%m-%d %H:%M:%S%.f",
"%Y-%m-%dT%H:%M:%S%.f",
"%Y-%m-%d %H:%M",
"%Y-%m-%dT%H:%M",
];
const DATE_FORMATS: &[&str] = &["%Y-%m-%d"];
for format in DATETIME_FORMATS {
if let Ok(value) = NaiveDateTime::parse_from_str(input, format) {
return Some(value);
}
}
for format in DATE_FORMATS {
if let Ok(date) = NaiveDate::parse_from_str(input, format) {
return date.and_hms_opt(0, 0, 0);
}
}
None
}
fn localize_naive_datetime(
naive: NaiveDateTime,
fallback_tz: Option<&Tz>,
original_input: &str,
) -> Result<DateTime<Utc>, ToolError> {
let tz = fallback_tz.ok_or_else(|| {
ToolError::InvalidParameters(format!(
"timestamp '{}' has no UTC offset; provide 'timezone' or 'from_timezone'",
original_input
))
})?;
match tz.from_local_datetime(&naive) {
LocalResult::Single(dt) => Ok(dt.with_timezone(&Utc)),
LocalResult::Ambiguous(_, _) => Err(ToolError::InvalidParameters(format!(
"timestamp '{}' is ambiguous in timezone '{}'; include an explicit UTC offset instead",
original_input, tz
))),
LocalResult::None => Err(ToolError::InvalidParameters(format!(
"timestamp '{}' does not exist in timezone '{}'",
original_input, tz
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_now_includes_local_time_when_timezone_set() {
async fn test_now_accepts_explicit_timezone() {
let tool = TimeTool;
let ctx = JobContext::with_user("test", "chat", "test");
let output = tool
.execute(
serde_json::json!({
"operation": "now",
"timezone": "America/New_York"
}),
&ctx,
)
.await
.expect("execute");
assert_eq!(output.result["timezone"].as_str(), Some("America/New_York"));
assert!(
output.result.get("utc_iso").is_some(),
"should have utc_iso"
);
assert!(
output.result.get("local_iso").is_some(),
"should have local_iso"
);
}
#[tokio::test]
async fn test_now_includes_local_time_when_user_timezone_set() {
let tool = TimeTool;
let mut ctx = JobContext::with_user("test", "chat", "test");
ctx.user_timezone = "America/New_York".to_string();
@@ -144,15 +419,119 @@ mod tests {
}
#[tokio::test]
async fn test_now_includes_utc_timezone_by_default() {
async fn test_now_uses_context_metadata_timezone_fallback() {
let tool = TimeTool;
let mut ctx = JobContext::with_user("test", "chat", "test");
ctx.metadata = serde_json::json!({
"user_timezone": "America/Los_Angeles"
});
let output = tool
.execute(serde_json::json!({"operation": "now"}), &ctx)
.await
.expect("execute");
assert_eq!(
output.result["timezone"].as_str(),
Some("America/Los_Angeles")
);
assert!(
output.result.get("local_iso").is_some(),
"should have local_iso"
);
}
#[tokio::test]
async fn test_now_returns_utc_by_default() {
let tool = TimeTool;
let ctx = JobContext::with_user("test", "chat", "test");
// Default user_timezone is "UTC" which is a valid IANA timezone
// Default user_timezone is "UTC" -- context_timezone skips UTC so no
// local_iso is added, but iso and utc_iso are always present.
let output = tool
.execute(serde_json::json!({"operation": "now"}), &ctx)
.await
.expect("execute");
assert!(output.result.get("iso").is_some(), "should have iso");
assert_eq!(output.result["timezone"].as_str(), Some("UTC"));
}
#[tokio::test]
async fn test_convert_across_dst_boundary() {
let tool = TimeTool;
let ctx = JobContext::with_user("test", "chat", "test");
let output = tool
.execute(
serde_json::json!({
"operation": "convert",
"input": "2026-03-08T07:30:00Z",
"to_timezone": "America/New_York"
}),
&ctx,
)
.await
.expect("execute");
assert_eq!(output.result["timezone"].as_str(), Some("America/New_York"));
assert_eq!(
output.result["output"].as_str(),
Some("2026-03-08T03:30:00-04:00")
);
}
#[tokio::test]
async fn test_format_with_timezone() {
let tool = TimeTool;
let ctx = JobContext::with_user("test", "chat", "test");
let output = tool
.execute(
serde_json::json!({
"operation": "format",
"input": "2026-03-08T07:30:00Z",
"timezone": "America/New_York",
"format_string": "%Y-%m-%d %H:%M:%S %Z"
}),
&ctx,
)
.await
.expect("execute");
assert_eq!(output.result["timezone"].as_str(), Some("America/New_York"));
assert_eq!(
output.result["formatted"].as_str(),
Some("2026-03-08 03:30:00 EDT")
);
}
#[tokio::test]
async fn test_invalid_timezone_returns_clear_error() {
let tool = TimeTool;
let ctx = JobContext::with_user("test", "chat", "test");
let err = tool
.execute(
serde_json::json!({
"operation": "now",
"timezone": "Mars/Olympus"
}),
&ctx,
)
.await
.expect_err("expected invalid timezone error");
match err {
ToolError::InvalidParameters(message) => {
assert!(message.contains("Unknown timezone 'Mars/Olympus'"));
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn test_parse_naive_timestamp_with_timezone() {
let dt = parse_timestamp("2026-03-08 03:30:00", Some(&chrono_tz::America::New_York))
.expect("parse timestamp");
assert_eq!(dt.to_rfc3339(), "2026-03-08T07:30:00+00:00");
}
}