use crate::error::Result;
use crate::model::{ContentBlock, TextContent};
use crate::tools::{Tool, ToolEffects, ToolOutput, ToolUpdate};
use chrono::{DateTime, FixedOffset, Local, SecondsFormat, Utc};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeSnapshot {
pub utc: String,
pub local: String,
pub offset_seconds: i32,
pub offset: String,
pub unix_seconds: i64,
pub unix_millis: i64,
pub weekday: String,
pub iso_week: u32,
pub timezone: Option<String>,
}
impl TimeSnapshot {
#[must_use]
pub fn from_instants(
utc: DateTime<Utc>,
local: DateTime<FixedOffset>,
timezone: Option<String>,
) -> Self {
let offset_seconds = local.offset().local_minus_utc();
let sign = if offset_seconds < 0 { '-' } else { '+' };
let abs = offset_seconds.unsigned_abs();
Self {
utc: utc.to_rfc3339_opts(SecondsFormat::Secs, true),
local: local.to_rfc3339_opts(SecondsFormat::Secs, false),
offset_seconds,
offset: format!("{sign}{:02}:{:02}", abs / 3600, (abs % 3600) / 60),
unix_seconds: utc.timestamp(),
unix_millis: utc.timestamp_millis(),
weekday: local.format("%A").to_string(),
iso_week: local.format("%V").to_string().parse().unwrap_or(0),
timezone,
}
}
#[must_use]
pub fn now() -> Self {
let utc = Utc::now();
let local: DateTime<FixedOffset> = Local::now().fixed_offset();
let timezone = std::env::var("TZ")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
Self::from_instants(utc, local, timezone)
}
#[must_use]
pub fn render_text(&self) -> String {
let timezone = self
.timezone
.as_deref()
.map_or_else(String::new, |tz| format!(" [{tz}]"));
format!(
"UTC: {}\nLocal: {} (UTC{}){timezone}\nUnix: {}\nWeekday: {}\nISO week: {}",
self.utc, self.local, self.offset, self.unix_seconds, self.weekday, self.iso_week
)
}
#[must_use]
pub fn details(&self) -> serde_json::Value {
serde_json::json!({
"utc": self.utc,
"local": self.local,
"offset": self.offset,
"offsetSeconds": self.offset_seconds,
"unixSeconds": self.unix_seconds,
"unixMillis": self.unix_millis,
"weekday": self.weekday,
"isoWeek": self.iso_week,
"timezone": self.timezone,
})
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct CurrentTimeTool;
impl CurrentTimeTool {
#[must_use]
pub const fn new() -> Self {
Self
}
}
#[async_trait::async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Tool for CurrentTimeTool {
fn name(&self) -> &str {
"current_time"
}
fn label(&self) -> &str {
"current_time"
}
fn description(&self) -> &str {
"Return the host's current wall-clock time: UTC and local ISO-8601 \
timestamps, UTC offset, Unix epoch seconds, weekday, and ISO week. \
Call this before reasoning about today's date, deadlines, \"recent\" \
changes, or timestamped filenames. Takes no arguments."
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
})
}
fn effects(&self) -> ToolEffects {
ToolEffects::read()
}
async fn execute(
&self,
_tool_call_id: &str,
_input: serde_json::Value,
_on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
) -> Result<ToolOutput> {
let snapshot = TimeSnapshot::now();
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(snapshot.render_text()))],
details: Some(snapshot.details()),
is_error: false,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn pinned() -> TimeSnapshot {
let utc = Utc.with_ymd_and_hms(2026, 9, 1, 23, 15, 42).unwrap(); let offset = FixedOffset::west_opt(4 * 3600).unwrap(); TimeSnapshot::from_instants(
utc,
utc.with_timezone(&offset),
Some("America/New_York".to_string()),
)
}
#[test]
fn snapshot_renders_every_field_from_a_pinned_clock() {
let snap = pinned();
assert_eq!(snap.utc, "2026-09-01T23:15:42Z");
assert_eq!(snap.local, "2026-09-01T19:15:42-04:00");
assert_eq!(snap.offset, "-04:00");
assert_eq!(snap.offset_seconds, -4 * 3600);
assert_eq!(snap.unix_seconds, 1_788_304_542);
assert_eq!(snap.unix_millis, 1_788_304_542_000);
assert_eq!(snap.weekday, "Tuesday");
assert_eq!(snap.iso_week, 36);
assert_eq!(
snap.render_text(),
"UTC: 2026-09-01T23:15:42Z\n\
Local: 2026-09-01T19:15:42-04:00 (UTC-04:00) [America/New_York]\n\
Unix: 1788304542\n\
Weekday: Tuesday\n\
ISO week: 36"
);
}
#[test]
fn positive_offsets_and_missing_timezone_render_cleanly() {
let utc = Utc.with_ymd_and_hms(2027, 1, 3, 0, 30, 0).unwrap(); let offset = FixedOffset::east_opt(5 * 3600 + 30 * 60).unwrap(); let snap = TimeSnapshot::from_instants(utc, utc.with_timezone(&offset), None);
assert_eq!(snap.local, "2027-01-03T06:00:00+05:30");
assert_eq!(snap.offset, "+05:30");
assert_eq!(snap.weekday, "Sunday");
assert_eq!(snap.iso_week, 53);
assert!(!snap.render_text().contains('['), "{}", snap.render_text());
assert_eq!(snap.details()["timezone"], serde_json::Value::Null);
}
#[test]
fn details_payload_uses_stable_camel_case_keys() {
let details = pinned().details();
for key in [
"utc",
"local",
"offset",
"offsetSeconds",
"unixSeconds",
"unixMillis",
"weekday",
"isoWeek",
"timezone",
] {
assert!(details.get(key).is_some(), "missing details key {key}");
}
assert_eq!(details["timezone"], "America/New_York");
}
#[test]
fn tool_contract_is_tiny_and_read_only() {
let tool = CurrentTimeTool::new();
assert_eq!(tool.name(), "current_time");
assert_eq!(tool.label(), "current_time");
let params = tool.parameters();
assert_eq!(params["type"], "object");
assert!(params["properties"].as_object().unwrap().is_empty()); assert!(params.get("required").is_none());
assert!(tool.effects().reads());
assert!(!tool.effects().writes());
}
#[test]
fn execute_reads_the_live_clock_and_stays_consistent_with_itself() {
asupersync::test_utils::run_test(|| async {
let tool = CurrentTimeTool::new();
let before = Utc::now().timestamp();
let output = tool
.execute("call-1", serde_json::json!({}), None)
.await
.expect("current_time never fails"); let after = Utc::now().timestamp();
assert!(!output.is_error);
let details = output.details.expect("details present"); let unix = details["unixSeconds"].as_i64().expect("unixSeconds"); assert!(
(before..=after).contains(&unix),
"unixSeconds {unix} outside [{before}, {after}]"
);
let text = match output.content.first() {
Some(ContentBlock::Text(text)) => text.text.clone(),
other => panic!("unexpected content block: {other:?}"), };
let utc = details["utc"].as_str().expect("utc"); assert!(text.starts_with(&format!("UTC: {utc}\n")), "{text}");
assert!(text.contains("\nWeekday: "), "{text}");
let parsed = DateTime::parse_from_rfc3339(utc).expect("utc parses"); assert_eq!(parsed.timestamp(), unix);
});
}
}