1use async_trait::async_trait;
2use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc};
3use serde_json::json;
4
5use microclaw_core::llm_types::ToolDefinition;
6
7use super::{schema_object, Tool, ToolResult};
8
9pub struct GetCurrentTimeTool {
10 default_timezone: String,
11}
12
13impl GetCurrentTimeTool {
14 pub fn new(default_timezone: String) -> Self {
15 Self { default_timezone }
16 }
17}
18
19pub struct CompareTimeTool {
20 default_timezone: String,
21}
22
23impl CompareTimeTool {
24 pub fn new(default_timezone: String) -> Self {
25 Self { default_timezone }
26 }
27}
28
29pub struct CalculateTool;
30
31impl CalculateTool {
32 pub fn new() -> Self {
33 Self
34 }
35}
36
37impl Default for CalculateTool {
38 fn default() -> Self {
39 Self::new()
40 }
41}
42
43fn parse_timezone(tz_name: &str) -> Result<chrono_tz::Tz, String> {
44 tz_name
45 .parse::<chrono_tz::Tz>()
46 .map_err(|_| format!("Invalid timezone: {tz_name}"))
47}
48
49fn parse_timestamp(value: &str, tz: chrono_tz::Tz) -> Result<DateTime<Utc>, String> {
50 if let Ok(dt) = DateTime::parse_from_rfc3339(value) {
51 return Ok(dt.with_timezone(&Utc));
52 }
53
54 let naive_formats = [
55 "%Y-%m-%d %H:%M:%S",
56 "%Y-%m-%d %H:%M",
57 "%Y-%m-%dT%H:%M:%S",
58 "%Y-%m-%dT%H:%M",
59 ];
60 for fmt in naive_formats {
61 if let Ok(naive) = NaiveDateTime::parse_from_str(value, fmt) {
62 let local = tz.from_local_datetime(&naive).single().ok_or_else(|| {
63 format!("Ambiguous or invalid local datetime in timezone {tz}: {value}")
64 })?;
65 return Ok(local.with_timezone(&Utc));
66 }
67 }
68
69 if let Ok(date_only) = NaiveDate::parse_from_str(value, "%Y-%m-%d") {
70 let naive = date_only
71 .and_hms_opt(0, 0, 0)
72 .ok_or_else(|| "Invalid date value".to_string())?;
73 let local = tz.from_local_datetime(&naive).single().ok_or_else(|| {
74 format!("Ambiguous or invalid local datetime in timezone {tz}: {value}")
75 })?;
76 return Ok(local.with_timezone(&Utc));
77 }
78
79 Err("Invalid timestamp. Use RFC3339 or local format like `YYYY-MM-DD HH:MM[:SS]`.".to_string())
80}
81
82#[async_trait]
83impl Tool for GetCurrentTimeTool {
84 fn name(&self) -> &str {
85 "get_current_time"
86 }
87
88 fn definition(&self) -> ToolDefinition {
89 ToolDefinition {
90 name: self.name().into(),
91 description: "Get current time in UTC and a target timezone.".into(),
92 input_schema: schema_object(
93 json!({
94 "timezone": {
95 "type": "string",
96 "description": "Optional IANA timezone, e.g. Asia/Shanghai. Defaults to configured timezone."
97 }
98 }),
99 &[],
100 ),
101 }
102 }
103
104 async fn execute(&self, input: serde_json::Value) -> ToolResult {
105 let tz_name = input
106 .get("timezone")
107 .and_then(|v| v.as_str())
108 .unwrap_or(&self.default_timezone);
109 let tz = match parse_timezone(tz_name) {
110 Ok(tz) => tz,
111 Err(e) => return ToolResult::error(e),
112 };
113
114 let now_utc = Utc::now();
115 let now_local = now_utc.with_timezone(&tz);
116 ToolResult::success(format!(
117 "timezone: {tz}\nlocal_time: {}\nutc_time: {}\nunix_seconds: {}\nunix_millis: {}",
118 now_local.to_rfc3339(),
119 now_utc.to_rfc3339(),
120 now_utc.timestamp(),
121 now_utc.timestamp_millis()
122 ))
123 }
124}
125
126#[async_trait]
127impl Tool for CompareTimeTool {
128 fn name(&self) -> &str {
129 "compare_time"
130 }
131
132 fn definition(&self) -> ToolDefinition {
133 ToolDefinition {
134 name: self.name().into(),
135 description:
136 "Compare two timestamps and return which one is earlier/later plus the difference."
137 .into(),
138 input_schema: schema_object(
139 json!({
140 "left": {
141 "type": "string",
142 "description": "Left timestamp (RFC3339 or local datetime)"
143 },
144 "right": {
145 "type": "string",
146 "description": "Right timestamp (RFC3339 or local datetime)"
147 },
148 "timezone": {
149 "type": "string",
150 "description": "Timezone used when timestamps are provided without offset."
151 }
152 }),
153 &["left", "right"],
154 ),
155 }
156 }
157
158 async fn execute(&self, input: serde_json::Value) -> ToolResult {
159 let left_raw = match input.get("left").and_then(|v| v.as_str()) {
160 Some(v) => v,
161 None => return ToolResult::error("Missing required parameter: left".into()),
162 };
163 let right_raw = match input.get("right").and_then(|v| v.as_str()) {
164 Some(v) => v,
165 None => return ToolResult::error("Missing required parameter: right".into()),
166 };
167 let tz_name = input
168 .get("timezone")
169 .and_then(|v| v.as_str())
170 .unwrap_or(&self.default_timezone);
171 let tz = match parse_timezone(tz_name) {
172 Ok(tz) => tz,
173 Err(e) => return ToolResult::error(e),
174 };
175
176 let left = match parse_timestamp(left_raw, tz) {
177 Ok(v) => v,
178 Err(e) => return ToolResult::error(format!("Invalid left timestamp: {e}")),
179 };
180 let right = match parse_timestamp(right_raw, tz) {
181 Ok(v) => v,
182 Err(e) => return ToolResult::error(format!("Invalid right timestamp: {e}")),
183 };
184
185 let delta = right - left;
186 let delta_secs = delta.num_seconds();
187 let abs_secs = delta_secs.unsigned_abs();
188 let hours = abs_secs / 3600;
189 let minutes = (abs_secs % 3600) / 60;
190 let seconds = abs_secs % 60;
191 let relation = if delta_secs > 0 {
192 "left_is_earlier"
193 } else if delta_secs < 0 {
194 "left_is_later"
195 } else {
196 "equal"
197 };
198
199 ToolResult::success(format!(
200 "timezone_for_naive_input: {tz}\nleft_utc: {}\nright_utc: {}\nrelation: {relation}\ndelta_seconds_right_minus_left: {delta_secs}\ndelta_hms_abs: {:02}:{:02}:{:02}",
201 left.to_rfc3339(),
202 right.to_rfc3339(),
203 hours,
204 minutes,
205 seconds
206 ))
207 }
208}
209
210#[derive(Debug, Clone, PartialEq)]
211enum Token {
212 Number(f64),
213 Plus,
214 Minus,
215 Star,
216 Slash,
217 Percent,
218 LParen,
219 RParen,
220}
221
222fn tokenize(expr: &str) -> Result<Vec<Token>, String> {
223 let mut out = Vec::new();
224 let mut i = 0usize;
225 let bytes = expr.as_bytes();
226
227 while i < bytes.len() {
228 match bytes[i] as char {
229 ' ' | '\t' | '\n' | '\r' => i += 1,
230 '+' => {
231 out.push(Token::Plus);
232 i += 1;
233 }
234 '-' => {
235 out.push(Token::Minus);
236 i += 1;
237 }
238 '*' => {
239 out.push(Token::Star);
240 i += 1;
241 }
242 '/' => {
243 out.push(Token::Slash);
244 i += 1;
245 }
246 '%' => {
247 out.push(Token::Percent);
248 i += 1;
249 }
250 '(' => {
251 out.push(Token::LParen);
252 i += 1;
253 }
254 ')' => {
255 out.push(Token::RParen);
256 i += 1;
257 }
258 c if c.is_ascii_digit() || c == '.' => {
259 let start = i;
260 i += 1;
261 while i < bytes.len() {
262 let ch = bytes[i] as char;
263 if ch.is_ascii_digit() || ch == '.' {
264 i += 1;
265 } else {
266 break;
267 }
268 }
269 let number = expr[start..i]
270 .parse::<f64>()
271 .map_err(|_| format!("Invalid number: {}", &expr[start..i]))?;
272 if !number.is_finite() {
273 return Err("Non-finite number is not allowed".into());
274 }
275 out.push(Token::Number(number));
276 }
277 _ => {
278 return Err(format!(
279 "Unsupported character in expression: {}",
280 bytes[i] as char
281 ))
282 }
283 }
284 }
285
286 if out.is_empty() {
287 return Err("Expression is empty".into());
288 }
289 Ok(out)
290}
291
292struct Parser {
293 tokens: Vec<Token>,
294 pos: usize,
295}
296
297impl Parser {
298 fn new(tokens: Vec<Token>) -> Self {
299 Self { tokens, pos: 0 }
300 }
301
302 fn peek(&self) -> Option<&Token> {
303 self.tokens.get(self.pos)
304 }
305
306 fn consume(&mut self) {
307 self.pos += 1;
308 }
309
310 fn parse_expression(&mut self) -> Result<f64, String> {
311 let mut value = self.parse_term()?;
312 loop {
313 match self.peek() {
314 Some(Token::Plus) => {
315 self.consume();
316 value += self.parse_term()?;
317 }
318 Some(Token::Minus) => {
319 self.consume();
320 value -= self.parse_term()?;
321 }
322 _ => break,
323 }
324 }
325 Ok(value)
326 }
327
328 fn parse_term(&mut self) -> Result<f64, String> {
329 let mut value = self.parse_unary()?;
330 loop {
331 match self.peek() {
332 Some(Token::Star) => {
333 self.consume();
334 value *= self.parse_unary()?;
335 }
336 Some(Token::Slash) => {
337 self.consume();
338 let rhs = self.parse_unary()?;
339 if rhs == 0.0 {
340 return Err("Division by zero".into());
341 }
342 value /= rhs;
343 }
344 Some(Token::Percent) => {
345 self.consume();
346 let rhs = self.parse_unary()?;
347 if rhs == 0.0 {
348 return Err("Modulo by zero".into());
349 }
350 value %= rhs;
351 }
352 _ => break,
353 }
354 }
355 Ok(value)
356 }
357
358 fn parse_unary(&mut self) -> Result<f64, String> {
359 match self.peek() {
360 Some(Token::Plus) => {
361 self.consume();
362 self.parse_unary()
363 }
364 Some(Token::Minus) => {
365 self.consume();
366 Ok(-self.parse_unary()?)
367 }
368 _ => self.parse_primary(),
369 }
370 }
371
372 fn parse_primary(&mut self) -> Result<f64, String> {
373 match self.peek().cloned() {
374 Some(Token::Number(n)) => {
375 self.consume();
376 Ok(n)
377 }
378 Some(Token::LParen) => {
379 self.consume();
380 let value = self.parse_expression()?;
381 match self.peek() {
382 Some(Token::RParen) => {
383 self.consume();
384 Ok(value)
385 }
386 _ => Err("Missing closing ')'".into()),
387 }
388 }
389 _ => Err("Expected a number or '('".into()),
390 }
391 }
392}
393
394fn eval_expression(expr: &str) -> Result<f64, String> {
395 let tokens = tokenize(expr)?;
396 let mut parser = Parser::new(tokens);
397 let value = parser.parse_expression()?;
398 if parser.peek().is_some() {
399 return Err("Unexpected trailing tokens".into());
400 }
401 if !value.is_finite() {
402 return Err("Result is non-finite".into());
403 }
404 Ok(value)
405}
406
407#[async_trait]
408impl Tool for CalculateTool {
409 fn name(&self) -> &str {
410 "calculate"
411 }
412
413 fn definition(&self) -> ToolDefinition {
414 ToolDefinition {
415 name: self.name().into(),
416 description: "Evaluate a basic arithmetic expression. Supports +, -, *, /, %, parentheses, unary +/-.".into(),
417 input_schema: schema_object(
418 json!({
419 "expression": {
420 "type": "string",
421 "description": "Arithmetic expression, e.g. (2+3)*4-1"
422 }
423 }),
424 &["expression"],
425 ),
426 }
427 }
428
429 async fn execute(&self, input: serde_json::Value) -> ToolResult {
430 let expr = match input.get("expression").and_then(|v| v.as_str()) {
431 Some(v) => v,
432 None => return ToolResult::error("Missing required parameter: expression".into()),
433 };
434
435 match eval_expression(expr) {
436 Ok(v) => ToolResult::success(format!("{v}")),
437 Err(e) => ToolResult::error(format!("Invalid expression: {e}")),
438 }
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use serde_json::json;
446
447 #[tokio::test]
448 async fn test_get_current_time_tool() {
449 let tool = GetCurrentTimeTool::new("UTC".into());
450 let out = tool.execute(json!({ "timezone": "Asia/Shanghai" })).await;
451 assert!(!out.is_error);
452 assert!(out.content.contains("timezone: Asia/Shanghai"));
453 assert!(out.content.contains("utc_time:"));
454 }
455
456 #[tokio::test]
457 async fn test_compare_time_tool() {
458 let tool = CompareTimeTool::new("UTC".into());
459 let out = tool
460 .execute(json!({
461 "left": "2026-02-28T00:00:00Z",
462 "right": "2026-02-28T01:00:00Z"
463 }))
464 .await;
465 assert!(!out.is_error);
466 assert!(out.content.contains("relation: left_is_earlier"));
467 assert!(out.content.contains("delta_seconds_right_minus_left: 3600"));
468 }
469
470 #[tokio::test]
471 async fn test_calculate_tool() {
472 let tool = CalculateTool::new();
473 let out = tool
474 .execute(json!({ "expression": "(2 + 3) * 4 - 1"}))
475 .await;
476 assert!(!out.is_error);
477 assert_eq!(out.content, "19");
478 }
479
480 #[test]
481 fn test_eval_expression_negative_and_mod() {
482 let v = eval_expression("-10 % 3 + 2").unwrap();
483 assert_eq!(v, 1.0);
484 }
485
486 #[test]
487 fn test_eval_expression_rejects_div_zero() {
488 let err = eval_expression("1/0").unwrap_err();
489 assert!(err.contains("Division by zero"));
490 }
491}