mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
fix: add timezone conversion support to time tool (#687)
This commit is contained in:
Generated
+144
-23
@@ -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
@@ -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<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(¶ms, "timestamp")?;
|
||||
|
||||
let ts2 = require_str(¶ms, "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(¶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<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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user