Skip to main content

cortexai_tools/
datetime.rs

1//! DateTime tools for time operations
2
3use async_trait::async_trait;
4use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, TimeZone, Utc};
5use cortexai_core::{errors::ToolError, ExecutionContext, Tool, ToolSchema};
6use serde_json::json;
7
8/// Get current date and time
9pub struct CurrentTimeTool;
10
11impl Default for CurrentTimeTool {
12    fn default() -> Self {
13        Self::new()
14    }
15}
16
17impl CurrentTimeTool {
18    pub fn new() -> Self {
19        Self
20    }
21}
22
23#[async_trait]
24impl Tool for CurrentTimeTool {
25    fn schema(&self) -> ToolSchema {
26        ToolSchema::new("current_time", "Get the current date and time").with_parameters(json!({
27            "type": "object",
28            "properties": {
29                "timezone": {
30                    "type": "string",
31                    "description": "Timezone (e.g., 'UTC', 'local'). Default: 'local'"
32                },
33                "format": {
34                    "type": "string",
35                    "description": "Output format. Options: 'iso', 'rfc2822', 'unix', 'custom'. Default: 'iso'"
36                },
37                "custom_format": {
38                    "type": "string",
39                    "description": "Custom strftime format string (only used when format='custom')"
40                }
41            }
42        }))
43    }
44
45    async fn execute(
46        &self,
47        _context: &ExecutionContext,
48        arguments: serde_json::Value,
49    ) -> Result<serde_json::Value, ToolError> {
50        let timezone = arguments["timezone"].as_str().unwrap_or("local");
51        let format = arguments["format"].as_str().unwrap_or("iso");
52
53        let now_utc = Utc::now();
54        let now_local = Local::now();
55
56        let (datetime_str, timestamp) = match timezone.to_lowercase().as_str() {
57            "utc" => {
58                let formatted =
59                    format_datetime(&now_utc, format, arguments["custom_format"].as_str())?;
60                (formatted, now_utc.timestamp())
61            }
62            "local" | _ => {
63                let formatted =
64                    format_datetime(&now_local, format, arguments["custom_format"].as_str())?;
65                (formatted, now_local.timestamp())
66            }
67        };
68
69        Ok(json!({
70            "datetime": datetime_str,
71            "timestamp": timestamp,
72            "timezone": timezone,
73            "utc": now_utc.to_rfc3339(),
74            "local": now_local.to_rfc3339(),
75            "components": {
76                "year": now_local.format("%Y").to_string(),
77                "month": now_local.format("%m").to_string(),
78                "day": now_local.format("%d").to_string(),
79                "hour": now_local.format("%H").to_string(),
80                "minute": now_local.format("%M").to_string(),
81                "second": now_local.format("%S").to_string(),
82                "weekday": now_local.format("%A").to_string(),
83                "week_number": now_local.format("%W").to_string()
84            }
85        }))
86    }
87}
88
89fn format_datetime<Tz: TimeZone>(
90    dt: &DateTime<Tz>,
91    format: &str,
92    custom_format: Option<&str>,
93) -> Result<String, ToolError>
94where
95    Tz::Offset: std::fmt::Display,
96{
97    match format {
98        "iso" => Ok(dt.to_rfc3339()),
99        "rfc2822" => Ok(dt.to_rfc2822()),
100        "unix" => Ok(dt.timestamp().to_string()),
101        "custom" => {
102            let fmt = custom_format.ok_or_else(|| {
103                ToolError::InvalidArguments(
104                    "custom_format required when format='custom'".to_string(),
105                )
106            })?;
107            Ok(dt.format(fmt).to_string())
108        }
109        _ => Ok(dt.to_rfc3339()),
110    }
111}
112
113/// Parse and format dates
114pub struct DateParserTool;
115
116impl Default for DateParserTool {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122impl DateParserTool {
123    pub fn new() -> Self {
124        Self
125    }
126}
127
128#[async_trait]
129impl Tool for DateParserTool {
130    fn schema(&self) -> ToolSchema {
131        ToolSchema::new("parse_date", "Parse a date string into components")
132            .with_parameters(json!({
133                "type": "object",
134                "properties": {
135                    "date": {
136                        "type": "string",
137                        "description": "Date string to parse (supports ISO 8601, RFC 2822, common formats)"
138                    },
139                    "output_format": {
140                        "type": "string",
141                        "description": "Desired output format (strftime format string)"
142                    }
143                },
144                "required": ["date"]
145            }))
146    }
147
148    async fn execute(
149        &self,
150        _context: &ExecutionContext,
151        arguments: serde_json::Value,
152    ) -> Result<serde_json::Value, ToolError> {
153        let date_str = arguments["date"]
154            .as_str()
155            .ok_or_else(|| ToolError::InvalidArguments("Missing 'date' field".to_string()))?;
156
157        // Try different parsing strategies
158        let parsed = parse_flexible_date(date_str)?;
159
160        let output_format = arguments["output_format"].as_str();
161        let formatted = if let Some(fmt) = output_format {
162            parsed.format(fmt).to_string()
163        } else {
164            parsed.to_rfc3339()
165        };
166
167        Ok(json!({
168            "input": date_str,
169            "parsed": parsed.to_rfc3339(),
170            "formatted": formatted,
171            "timestamp": parsed.timestamp(),
172            "components": {
173                "year": parsed.format("%Y").to_string().parse::<i32>().unwrap_or(0),
174                "month": parsed.format("%m").to_string().parse::<u32>().unwrap_or(0),
175                "day": parsed.format("%d").to_string().parse::<u32>().unwrap_or(0),
176                "hour": parsed.format("%H").to_string().parse::<u32>().unwrap_or(0),
177                "minute": parsed.format("%M").to_string().parse::<u32>().unwrap_or(0),
178                "second": parsed.format("%S").to_string().parse::<u32>().unwrap_or(0),
179                "weekday": parsed.format("%A").to_string(),
180                "day_of_year": parsed.format("%j").to_string().parse::<u32>().unwrap_or(0)
181            }
182        }))
183    }
184}
185
186fn parse_flexible_date(s: &str) -> Result<DateTime<Utc>, ToolError> {
187    // Try RFC 3339 / ISO 8601
188    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
189        return Ok(dt.with_timezone(&Utc));
190    }
191
192    // Try RFC 2822
193    if let Ok(dt) = DateTime::parse_from_rfc2822(s) {
194        return Ok(dt.with_timezone(&Utc));
195    }
196
197    // Try common formats
198    let formats = [
199        "%Y-%m-%d %H:%M:%S",
200        "%Y-%m-%d %H:%M",
201        "%Y-%m-%d",
202        "%d/%m/%Y %H:%M:%S",
203        "%d/%m/%Y %H:%M",
204        "%d/%m/%Y",
205        "%m/%d/%Y %H:%M:%S",
206        "%m/%d/%Y %H:%M",
207        "%m/%d/%Y",
208        "%Y%m%d",
209        "%d-%m-%Y",
210        "%B %d, %Y",
211        "%b %d, %Y",
212    ];
213
214    for fmt in &formats {
215        if let Ok(naive) = NaiveDateTime::parse_from_str(s, fmt) {
216            return Ok(Utc.from_utc_datetime(&naive));
217        }
218        if let Ok(naive) = NaiveDate::parse_from_str(s, fmt) {
219            return Ok(Utc.from_utc_datetime(&naive.and_hms_opt(0, 0, 0).unwrap()));
220        }
221    }
222
223    // Try unix timestamp
224    if let Ok(ts) = s.parse::<i64>() {
225        if let Some(dt) = DateTime::from_timestamp(ts, 0) {
226            return Ok(dt);
227        }
228    }
229
230    Err(ToolError::InvalidArguments(format!(
231        "Could not parse date: '{}'",
232        s
233    )))
234}
235
236/// Calculate date differences and offsets
237pub struct DateCalculatorTool;
238
239impl Default for DateCalculatorTool {
240    fn default() -> Self {
241        Self::new()
242    }
243}
244
245impl DateCalculatorTool {
246    pub fn new() -> Self {
247        Self
248    }
249}
250
251#[async_trait]
252impl Tool for DateCalculatorTool {
253    fn schema(&self) -> ToolSchema {
254        ToolSchema::new(
255            "date_calculator",
256            "Calculate date differences or add/subtract time",
257        )
258        .with_parameters(json!({
259            "type": "object",
260            "properties": {
261                "operation": {
262                    "type": "string",
263                    "enum": ["diff", "add", "subtract"],
264                    "description": "Operation to perform"
265                },
266                "date1": {
267                    "type": "string",
268                    "description": "First date (or base date for add/subtract)"
269                },
270                "date2": {
271                    "type": "string",
272                    "description": "Second date (for diff operation)"
273                },
274                "amount": {
275                    "type": "integer",
276                    "description": "Amount to add/subtract"
277                },
278                "unit": {
279                    "type": "string",
280                    "enum": ["days", "hours", "minutes", "seconds", "weeks"],
281                    "description": "Unit for add/subtract operation"
282                }
283            },
284            "required": ["operation", "date1"]
285        }))
286    }
287
288    async fn execute(
289        &self,
290        _context: &ExecutionContext,
291        arguments: serde_json::Value,
292    ) -> Result<serde_json::Value, ToolError> {
293        let operation = arguments["operation"]
294            .as_str()
295            .ok_or_else(|| ToolError::InvalidArguments("Missing 'operation'".to_string()))?;
296        let date1_str = arguments["date1"]
297            .as_str()
298            .ok_or_else(|| ToolError::InvalidArguments("Missing 'date1'".to_string()))?;
299
300        let date1 = parse_flexible_date(date1_str)?;
301
302        match operation {
303            "diff" => {
304                let date2_str = arguments["date2"].as_str().ok_or_else(|| {
305                    ToolError::InvalidArguments("Missing 'date2' for diff".to_string())
306                })?;
307                let date2 = parse_flexible_date(date2_str)?;
308
309                let diff = date2.signed_duration_since(date1);
310
311                Ok(json!({
312                    "date1": date1.to_rfc3339(),
313                    "date2": date2.to_rfc3339(),
314                    "difference": {
315                        "total_seconds": diff.num_seconds(),
316                        "total_minutes": diff.num_minutes(),
317                        "total_hours": diff.num_hours(),
318                        "total_days": diff.num_days(),
319                        "total_weeks": diff.num_weeks(),
320                        "human_readable": format_duration(diff)
321                    }
322                }))
323            }
324            "add" | "subtract" => {
325                let amount = arguments["amount"]
326                    .as_i64()
327                    .ok_or_else(|| ToolError::InvalidArguments("Missing 'amount'".to_string()))?;
328                let unit = arguments["unit"]
329                    .as_str()
330                    .ok_or_else(|| ToolError::InvalidArguments("Missing 'unit'".to_string()))?;
331
332                let duration = match unit {
333                    "seconds" => Duration::seconds(amount),
334                    "minutes" => Duration::minutes(amount),
335                    "hours" => Duration::hours(amount),
336                    "days" => Duration::days(amount),
337                    "weeks" => Duration::weeks(amount),
338                    _ => {
339                        return Err(ToolError::InvalidArguments(format!(
340                            "Unknown unit: {}",
341                            unit
342                        )))
343                    }
344                };
345
346                let result = if operation == "add" {
347                    date1 + duration
348                } else {
349                    date1 - duration
350                };
351
352                Ok(json!({
353                    "original": date1.to_rfc3339(),
354                    "operation": operation,
355                    "amount": amount,
356                    "unit": unit,
357                    "result": result.to_rfc3339(),
358                    "timestamp": result.timestamp()
359                }))
360            }
361            _ => Err(ToolError::InvalidArguments(format!(
362                "Unknown operation: {}",
363                operation
364            ))),
365        }
366    }
367}
368
369fn format_duration(dur: Duration) -> String {
370    let total_seconds = dur.num_seconds().abs();
371    let days = total_seconds / 86400;
372    let hours = (total_seconds % 86400) / 3600;
373    let minutes = (total_seconds % 3600) / 60;
374    let seconds = total_seconds % 60;
375
376    let sign = if dur.num_seconds() < 0 { "-" } else { "" };
377
378    if days > 0 {
379        format!("{}{}d {}h {}m {}s", sign, days, hours, minutes, seconds)
380    } else if hours > 0 {
381        format!("{}{}h {}m {}s", sign, hours, minutes, seconds)
382    } else if minutes > 0 {
383        format!("{}{}m {}s", sign, minutes, seconds)
384    } else {
385        format!("{}{}s", sign, seconds)
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use cortexai_core::types::AgentId;
393
394    fn test_ctx() -> ExecutionContext {
395        ExecutionContext::new(AgentId::new("test-agent"))
396    }
397
398    #[tokio::test]
399    async fn test_current_time() {
400        let tool = CurrentTimeTool::new();
401        let ctx = test_ctx();
402
403        let result = tool.execute(&ctx, json!({})).await.unwrap();
404        assert!(result["datetime"].as_str().is_some());
405        assert!(result["timestamp"].as_i64().is_some());
406    }
407
408    #[tokio::test]
409    async fn test_parse_date_iso() {
410        let tool = DateParserTool::new();
411        let ctx = test_ctx();
412
413        let result = tool
414            .execute(&ctx, json!({"date": "2024-01-15T10:30:00Z"}))
415            .await
416            .unwrap();
417
418        assert_eq!(result["components"]["year"], 2024);
419        assert_eq!(result["components"]["month"], 1);
420        assert_eq!(result["components"]["day"], 15);
421    }
422
423    #[tokio::test]
424    async fn test_parse_date_formats() {
425        let tool = DateParserTool::new();
426        let ctx = test_ctx();
427
428        // YYYY-MM-DD
429        let result = tool
430            .execute(&ctx, json!({"date": "2024-06-20"}))
431            .await
432            .unwrap();
433        assert_eq!(result["components"]["year"], 2024);
434        assert_eq!(result["components"]["month"], 6);
435
436        // Unix timestamp
437        let result = tool
438            .execute(&ctx, json!({"date": "1700000000"}))
439            .await
440            .unwrap();
441        assert!(result["components"]["year"].as_i64().unwrap() >= 2023);
442    }
443
444    #[tokio::test]
445    async fn test_date_calculator_diff() {
446        let tool = DateCalculatorTool::new();
447        let ctx = test_ctx();
448
449        let result = tool
450            .execute(
451                &ctx,
452                json!({
453                    "operation": "diff",
454                    "date1": "2024-01-01",
455                    "date2": "2024-01-08"
456                }),
457            )
458            .await
459            .unwrap();
460
461        assert_eq!(result["difference"]["total_days"], 7);
462        assert_eq!(result["difference"]["total_weeks"], 1);
463    }
464
465    #[tokio::test]
466    async fn test_date_calculator_add() {
467        let tool = DateCalculatorTool::new();
468        let ctx = test_ctx();
469
470        let result = tool
471            .execute(
472                &ctx,
473                json!({
474                    "operation": "add",
475                    "date1": "2024-01-01T00:00:00Z",
476                    "amount": 7,
477                    "unit": "days"
478                }),
479            )
480            .await
481            .unwrap();
482
483        assert!(result["result"].as_str().unwrap().contains("2024-01-08"));
484    }
485}