//! Time utility tool. use async_trait::async_trait; 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}; /// Tool for getting current time and date operations. pub struct TimeTool; #[async_trait] impl Tool for TimeTool { fn name(&self) -> &str { "time" } fn description(&self) -> &str { "Get current time, parse or format timestamps, convert timezones, or calculate time differences." } fn parameters_schema(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "operation": { "type": "string", "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": "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": "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." } }, "required": ["operation"] }) } async fn execute( &self, params: serde_json::Value, ctx: &JobContext, ) -> Result { let start = std::time::Instant::now(); let operation = require_str(¶ms, "operation")?; let result = match operation { "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: {}", operation ))); } }; Ok(ToolOutput::success(result, start.elapsed())) } fn requires_sanitization(&self) -> bool { false // Internal tool, no external data } } 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()) .filter(|s| !s.is_empty()) { 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()) .filter(|s| !s.is_empty()) { 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_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(); let output = tool .execute(serde_json::json!({"operation": "now"}), &ctx) .await .expect("execute"); assert!( output.result.get("local_iso").is_some(), "should have local_iso" ); assert_eq!( output.result["timezone"].as_str(), Some("America/New_York"), "should report timezone" ); } #[tokio::test] 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" -- 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"); } #[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"); } #[tokio::test] async fn test_now_with_empty_timezone_string_does_not_error() { // LLMs sometimes pass "" for optional fields instead of omitting them. // Empty timezone should be treated as absent and fall back to UTC. let tool = TimeTool; let ctx = JobContext::with_user("test", "chat", "test"); let output = tool .execute( serde_json::json!({ "operation": "now", "timezone": "" }), &ctx, ) .await .expect("empty timezone string should not error"); assert!(output.result.get("iso").is_some(), "should have iso"); } #[tokio::test] async fn test_convert_with_empty_from_timezone_string_does_not_error() { // LLMs sometimes pass "" for optional fields instead of omitting them. // Empty from_timezone should be treated as absent. let tool = TimeTool; let ctx = JobContext::with_user("test", "chat", "test"); let output = tool .execute( serde_json::json!({ "operation": "convert", "timestamp": "2026-03-08T12:00:00Z", "to_timezone": "America/New_York", "from_timezone": "" }), &ctx, ) .await .expect("empty from_timezone string should not error"); assert!(output.result.get("output").is_some(), "should have output"); } }