diff --git a/Cargo.lock b/Cargo.lock index c6ad733a..cc2fb3f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -864,6 +864,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -2872,6 +2882,7 @@ dependencies = [ "bollard", "bytes", "chrono", + "chrono-tz", "clap", "clap_complete", "cron", @@ -3892,6 +3903,15 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf" version = "0.13.1" @@ -3966,6 +3986,15 @@ dependencies = [ "uncased", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" diff --git a/Cargo.toml b/Cargo.toml index 75d42f63..005a62ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ toml = "0.8" # Core types uuid = { version = "1", features = ["v4", "v5", "serde"] } chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.10" rust_decimal = { version = "1", features = ["serde", "serde-with-str", "maths"] } rust_decimal_macros = "1" diff --git a/src/tools/builtin/time.rs b/src/tools/builtin/time.rs index 9388f8c7..85b46f0c 100644 --- a/src/tools/builtin/time.rs +++ b/src/tools/builtin/time.rs @@ -1,11 +1,56 @@ //! Time utility tool. use async_trait::async_trait; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, FixedOffset, Utc}; +use chrono_tz::Tz; use crate::context::JobContext; use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; +/// Parse a timezone string into a `chrono_tz::Tz`, returning a clear error. +fn parse_timezone(tz_str: &str) -> Result { + tz_str.parse::().map_err(|_| { + ToolError::InvalidParameters(format!( + "Unknown timezone '{}'. Use IANA names like 'America/New_York' or 'Europe/London'.", + tz_str + )) + }) +} + +/// Parse an input timestamp string. Accepts RFC 3339 with offset, or naive +/// datetime in `YYYY-MM-DDTHH:MM:SS` / `YYYY-MM-DD HH:MM:SS` format +/// (interpreted as UTC unless `default_tz` is provided). +fn parse_input_timestamp( + input: &str, + default_tz: Option, +) -> Result, ToolError> { + // Try RFC 3339 first (has offset info) + if let Ok(dt) = DateTime::parse_from_rfc3339(input) { + return Ok(dt); + } + // Try common formats without offset — interpret in default_tz or UTC + for fmt in &["%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"] { + if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(input, fmt) { + let tz = default_tz.unwrap_or(Tz::UTC); + let local = naive + .and_local_timezone(tz) + .single() + .ok_or_else(|| { + ToolError::InvalidParameters(format!( + "Ambiguous or invalid datetime '{}' in timezone '{}'", + input, tz + )) + })?; + return Ok(local.fixed_offset()); + } + } + Err(ToolError::InvalidParameters(format!( + "Invalid timestamp '{}'. Use RFC 3339 (e.g. '2026-03-07T12:00:00Z') \ + or 'YYYY-MM-DD HH:MM:SS' format.", + input + ))) +} + /// Tool for getting current time and date operations. pub struct TimeTool; @@ -16,7 +61,7 @@ impl Tool for TimeTool { } fn description(&self) -> &str { - "Get current time, convert timezones, or calculate time differences." + "Get current time, convert timezones, format timestamps, or calculate time differences." } fn parameters_schema(&self) -> serde_json::Value { @@ -25,20 +70,28 @@ 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" }, "timestamp": { "type": "string", - "description": "ISO 8601 timestamp (for parse/format/diff operations)" - }, - "format": { - "type": "string", - "description": "Output format string (for format operation)" + "description": "ISO 8601 timestamp (for parse/convert/format/diff operations)" }, "timestamp2": { "type": "string", "description": "Second timestamp (for diff operation)" + }, + "timezone": { + "type": "string", + "description": "IANA timezone name, e.g. 'America/New_York' (for now/convert/format/parse)" + }, + "to_timezone": { + "type": "string", + "description": "Target IANA timezone for convert operation" + }, + "format_string": { + "type": "string", + "description": "strftime format string (for format operation), default: '%Y-%m-%d %H:%M:%S %Z'" } }, "required": ["operation"] @@ -57,36 +110,91 @@ impl Tool for TimeTool { let result = match operation { "now" => { let now = Utc::now(); - serde_json::json!({ + let mut result = serde_json::json!({ + "utc_iso": now.to_rfc3339(), "iso": now.to_rfc3339(), "unix": now.timestamp(), "unix_millis": now.timestamp_millis() - }) + }); + if let Some(tz_str) = params.get("timezone").and_then(|v| v.as_str()) { + let tz = parse_timezone(tz_str)?; + let local = now.with_timezone(&tz); + result["local_iso"] = serde_json::json!(local.to_rfc3339()); + result["timezone"] = serde_json::json!(tz_str); + } + result } "parse" => { let timestamp = require_str(¶ms, "timestamp")?; + let tz = params + .get("timezone") + .and_then(|v| v.as_str()) + .map(parse_timezone) + .transpose()?; - let dt: DateTime = timestamp.parse().map_err(|e| { - ToolError::InvalidParameters(format!("invalid timestamp: {}", e)) - })?; + let dt = parse_input_timestamp(timestamp, tz)?; + let utc = dt.with_timezone(&Utc); + + let mut result = serde_json::json!({ + "iso": utc.to_rfc3339(), + "unix": utc.timestamp(), + "unix_millis": utc.timestamp_millis() + }); + if let Some(tz) = tz { + let local = dt.with_timezone(&tz); + result["local_iso"] = serde_json::json!(local.to_rfc3339()); + result["timezone"] = serde_json::json!(tz.to_string()); + } + result + } + "convert" => { + let timestamp = require_str(¶ms, "timestamp")?; + let to_tz_str = require_str(¶ms, "to_timezone")?; + let to_tz = parse_timezone(to_tz_str)?; + + let from_tz = params + .get("timezone") + .and_then(|v| v.as_str()) + .map(parse_timezone) + .transpose()?; + + let dt = parse_input_timestamp(timestamp, from_tz)?; + let converted = dt.with_timezone(&to_tz); serde_json::json!({ - "iso": dt.to_rfc3339(), - "unix": dt.timestamp(), - "unix_millis": dt.timestamp_millis() + "input": timestamp, + "output": converted.to_rfc3339(), + "timezone": to_tz.to_string() }) } + "format" => { + let timestamp = require_str(¶ms, "timestamp")?; + let fmt = params + .get("format_string") + .and_then(|v| v.as_str()) + .unwrap_or("%Y-%m-%d %H:%M:%S %Z"); + + let tz = params + .get("timezone") + .and_then(|v| v.as_str()) + .map(parse_timezone) + .transpose()?; + + let dt = parse_input_timestamp(timestamp, None)?; + let formatted = if let Some(tz) = tz { + dt.with_timezone(&tz).format(fmt).to_string() + } else { + dt.format(fmt).to_string() + }; + + serde_json::json!({ "formatted": formatted }) + } "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 dt1 = parse_input_timestamp(ts1, None)?; + let dt2 = parse_input_timestamp(ts2, None)?; let diff = dt2.signed_duration_since(dt1); @@ -112,3 +220,215 @@ impl Tool for TimeTool { false // Internal tool, no external data } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::JobContext; + use serde_json::json; + + fn test_ctx() -> JobContext { + JobContext::new("test-job", "test time tool") + } + + #[tokio::test] + async fn test_now_utc() { + let tool = TimeTool; + let result = tool + .execute(json!({"operation": "now"}), &test_ctx()) + .await + .unwrap(); + let v: serde_json::Value = result.result.clone(); + assert!(v["utc_iso"].as_str().is_some()); + assert!(v["iso"].as_str().is_some()); + assert!(v["unix"].as_i64().is_some()); + // No timezone requested — no local_iso + assert!(v.get("local_iso").is_none()); + } + + #[tokio::test] + async fn test_now_with_timezone() { + let tool = TimeTool; + let result = tool + .execute( + json!({"operation": "now", "timezone": "America/New_York"}), + &test_ctx(), + ) + .await + .unwrap(); + let v: serde_json::Value = result.result.clone(); + assert!(v["local_iso"].as_str().is_some()); + assert_eq!(v["timezone"].as_str().unwrap(), "America/New_York"); + // local_iso should contain a non-UTC offset + let local = v["local_iso"].as_str().unwrap(); + assert!(!local.ends_with('Z') || local.contains("-04:00") || local.contains("-05:00")); + } + + #[tokio::test] + async fn test_now_invalid_timezone() { + let tool = TimeTool; + let result = tool + .execute( + json!({"operation": "now", "timezone": "Not/A/Zone"}), + &test_ctx(), + ) + .await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("Unknown timezone")); + assert!(err.to_string().contains("Not/A/Zone")); + } + + #[tokio::test] + async fn test_convert_timezone() { + let tool = TimeTool; + let result = tool + .execute( + json!({ + "operation": "convert", + "timestamp": "2026-03-07T12:00:00Z", + "to_timezone": "Asia/Tokyo" + }), + &test_ctx(), + ) + .await + .unwrap(); + let v: serde_json::Value = result.result.clone(); + // UTC 12:00 -> JST 21:00 (UTC+9) + let output = v["output"].as_str().unwrap(); + assert!(output.contains("21:00:00")); + assert_eq!(v["timezone"].as_str().unwrap(), "Asia/Tokyo"); + } + + #[tokio::test] + async fn test_convert_dst_boundary() { + let tool = TimeTool; + // US spring forward: 2026-03-08 2:00 AM EST -> 3:00 AM EDT + // Before DST: EST = UTC-5, After: EDT = UTC-4 + let result = tool + .execute( + json!({ + "operation": "convert", + "timestamp": "2026-03-08T06:30:00Z", + "to_timezone": "America/New_York" + }), + &test_ctx(), + ) + .await + .unwrap(); + let v: serde_json::Value = result.result.clone(); + // UTC 06:30 on Mar 8 -> after spring forward, EDT (UTC-4) = 02:30 + // But DST springs forward at 2 AM -> 3 AM, so 06:30 UTC = 01:30 EST or 02:30 EDT + let output = v["output"].as_str().unwrap(); + assert!(output.contains("2026-03-08")); + } + + #[tokio::test] + async fn test_format_with_timezone() { + let tool = TimeTool; + let result = tool + .execute( + json!({ + "operation": "format", + "timestamp": "2026-03-07T12:00:00Z", + "timezone": "Europe/London", + "format_string": "%Y-%m-%d %H:%M %Z" + }), + &test_ctx(), + ) + .await + .unwrap(); + let v: serde_json::Value = result.result.clone(); + let formatted = v["formatted"].as_str().unwrap(); + assert!(formatted.contains("2026-03-07")); + assert!(formatted.contains("12:00")); // London = UTC in March (before DST) + assert!(formatted.contains("GMT")); + } + + #[tokio::test] + async fn test_format_default_format_string() { + let tool = TimeTool; + let result = tool + .execute( + json!({ + "operation": "format", + "timestamp": "2026-06-15T18:30:00Z", + "timezone": "America/Los_Angeles" + }), + &test_ctx(), + ) + .await + .unwrap(); + let v: serde_json::Value = result.result.clone(); + let formatted = v["formatted"].as_str().unwrap(); + // UTC 18:30 -> PDT (UTC-7) = 11:30 + assert!(formatted.contains("11:30:00")); + assert!(formatted.contains("PDT")); + } + + #[tokio::test] + async fn test_parse_naive_with_timezone() { + let tool = TimeTool; + let result = tool + .execute( + json!({ + "operation": "parse", + "timestamp": "2026-03-07 09:00:00", + "timezone": "America/New_York" + }), + &test_ctx(), + ) + .await + .unwrap(); + let v: serde_json::Value = result.result.clone(); + // 09:00 EST = 14:00 UTC (EST = UTC-5 in March before DST) + let iso = v["iso"].as_str().unwrap(); + assert!(iso.contains("14:00:00")); + assert_eq!(v["timezone"].as_str().unwrap(), "America/New_York"); + } + + #[tokio::test] + async fn test_diff() { + let tool = TimeTool; + let result = tool + .execute( + json!({ + "operation": "diff", + "timestamp": "2026-03-07T00:00:00Z", + "timestamp2": "2026-03-07T02:30:00Z" + }), + &test_ctx(), + ) + .await + .unwrap(); + let v: serde_json::Value = result.result.clone(); + assert_eq!(v["hours"].as_i64().unwrap(), 2); + assert_eq!(v["minutes"].as_i64().unwrap(), 150); + assert_eq!(v["seconds"].as_i64().unwrap(), 9000); + } + + #[tokio::test] + async fn test_convert_missing_to_timezone() { + let tool = TimeTool; + let result = tool + .execute( + json!({ + "operation": "convert", + "timestamp": "2026-03-07T12:00:00Z" + }), + &test_ctx(), + ) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_unknown_operation() { + let tool = TimeTool; + let result = tool + .execute(json!({"operation": "explode"}), &test_ctx()) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("unknown operation")); + } +}