From 9401ab0d5886e07f452cae4aee32a343ed54b7fd Mon Sep 17 00:00:00 2001 From: Frank <97429702+tsubasakong@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:17:07 -0700 Subject: [PATCH] fix: add timezone conversion support to time tool (#687) --- Cargo.lock | 167 +++++++++++-- src/tools/builtin/time.rs | 493 +++++++++++++++++++++++++++++++++----- 2 files changed, 580 insertions(+), 80 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 31998df0..85adb05a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/src/tools/builtin/time.rs b/src/tools/builtin/time.rs index d93c09e4..bafbd4d7 100644 --- a/src/tools/builtin/time.rs +++ b/src/tools/builtin/time.rs @@ -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(¶ms, "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(¶ms, "timestamp")?; - - let dt: DateTime = 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(¶ms, "timestamp")?; - - let ts2 = require_str(¶ms, "timestamp2")?; - - let dt1: DateTime = ts1.parse().map_err(|e| { - ToolError::InvalidParameters(format!("invalid timestamp: {}", e)) - })?; - let dt2: DateTime = 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(¶ms, ctx)?, + "parse" => execute_parse(¶ms, ctx)?, + "convert" => execute_convert(¶ms, ctx)?, + "format" => execute_format(¶ms, ctx)?, + "diff" => execute_diff(¶ms, 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 { + 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 { + 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 { + 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 { + 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 { + 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, 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, 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, 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, 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 { + value.parse::().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, 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 { + 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, 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"); } }