Skip to main content

microclaw_engine/tools/
time_math.rs

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}