Skip to main content

lc_tools/
datetime.rs

1// lc-tools/src/datetime.rs
2//! Date and time tool for agents.
3//!
4//! Provides date/time query and calculation functionality.
5
6use async_trait::async_trait;
7use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, NaiveDateTime, TimeZone, Weekday};
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use lc_core::tools::{BaseTool, Tool, ToolError};
12
13/// DateTime tool input parameters.
14#[derive(Debug, Deserialize, JsonSchema)]
15pub struct DateTimeInput {
16    /// Operation type: "now", "format", "add", "subtract", "weekday", "diff".
17    pub operation: String,
18
19    /// Date/time string (optional, format: YYYY-MM-DD or YYYY-MM-DD HH:MM:SS).
20    pub datetime: Option<String>,
21
22    /// Time unit: "days", "hours", "minutes", "weeks", "months", "years".
23    pub unit: Option<String>,
24
25    /// Value for add/subtract operations.
26    pub value: Option<i64>,
27
28    /// Target date/time for diff operation.
29    pub target: Option<String>,
30}
31
32/// DateTime tool output result.
33#[derive(Debug, Serialize)]
34pub struct DateTimeOutput {
35    /// Operation result.
36    pub result: String,
37
38    /// Operation type.
39    pub operation: String,
40
41    /// Additional details.
42    pub details: Option<String>,
43}
44
45/// DateTime tool for querying and manipulating dates and times.
46pub struct DateTimeTool;
47
48impl DateTimeTool {
49    /// Creates a new DateTimeTool instance.
50    pub fn new() -> Self {
51        Self
52    }
53
54    fn parse_datetime(&self, dt_str: &str) -> Result<DateTime<Local>, ToolError> {
55        if let Ok(dt) = NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%d %H:%M:%S") {
56            return Local
57                .from_local_datetime(&dt)
58                .single()
59                .ok_or_else(|| ToolError::ExecutionFailed("invalid datetime".to_string()));
60        }
61
62        if let Ok(date) = NaiveDate::parse_from_str(dt_str, "%Y-%m-%d") {
63            let dt = date
64                .and_hms_opt(0, 0, 0)
65                .ok_or_else(|| ToolError::ExecutionFailed("invalid date".to_string()))?;
66            return Local
67                .from_local_datetime(&dt)
68                .single()
69                .ok_or_else(|| ToolError::ExecutionFailed("invalid datetime".to_string()));
70        }
71
72        Err(ToolError::ExecutionFailed(format!(
73            "failed to parse datetime: {}, use format YYYY-MM-DD or YYYY-MM-DD HH:MM:SS",
74            dt_str
75        )))
76    }
77
78    fn get_now(&self) -> DateTimeOutput {
79        let now = Local::now();
80        DateTimeOutput {
81            result: now.format("%Y-%m-%d %H:%M:%S").to_string(),
82            operation: "now".to_string(),
83            details: Some(format!(
84                "星期{},第{}周",
85                match now.weekday() {
86                    Weekday::Mon => "一",
87                    Weekday::Tue => "二",
88                    Weekday::Wed => "三",
89                    Weekday::Thu => "四",
90                    Weekday::Fri => "五",
91                    Weekday::Sat => "六",
92                    Weekday::Sun => "日",
93                },
94                now.iso_week().week()
95            )),
96        }
97    }
98
99    fn format_datetime(&self, dt_str: &str) -> Result<DateTimeOutput, ToolError> {
100        let dt = self.parse_datetime(dt_str)?;
101
102        Ok(DateTimeOutput {
103            result: dt.format("%Y年%m月%d日 %H时%M分%S秒").to_string(),
104            operation: "format".to_string(),
105            details: Some(format!(
106                "星期{},{}年{}周",
107                match dt.weekday() {
108                    Weekday::Mon => "一",
109                    Weekday::Tue => "二",
110                    Weekday::Wed => "三",
111                    Weekday::Thu => "四",
112                    Weekday::Fri => "五",
113                    Weekday::Sat => "六",
114                    Weekday::Sun => "日",
115                },
116                dt.year(),
117                dt.iso_week().week()
118            )),
119        })
120    }
121
122    fn add_time(&self, dt_str: &str, value: i64, unit: &str) -> Result<DateTimeOutput, ToolError> {
123        let dt = self.parse_datetime(dt_str)?;
124
125        let new_dt = match unit {
126            "seconds" => dt + Duration::seconds(value),
127            "minutes" => dt + Duration::minutes(value),
128            "hours" => dt + Duration::hours(value),
129            "days" => dt + Duration::days(value),
130            "weeks" => dt + Duration::weeks(value),
131            "months" => {
132                let months = value as i32;
133                // M-27: `value` may be negative (subtract). Rust `/` and `%` truncate toward
134                // zero, so `(new_month - 1) / 12` and `% 12` produced a wrong year offset and a
135                // negative month that wrapped on `as u32` → "month calculation failed" whenever
136                // the delta crossed a year boundary backwards (e.g. 2024-03-15 minus 13 months).
137                // `div_euclid`/`rem_euclid` floor to the mathematical quotient, correct for both
138                // positive and negative deltas while leaving positive behavior unchanged.
139                let total_zero_based = dt.month() as i32 - 1 + months;
140                let year = dt.year() + total_zero_based.div_euclid(12);
141                let month = (total_zero_based.rem_euclid(12) + 1) as u32;
142
143                dt.with_year(year)
144                    .and_then(|d| d.with_month(month))
145                    .ok_or_else(|| ToolError::ExecutionFailed("month calculation failed".to_string()))?
146            }
147            "years" => dt
148                .with_year(dt.year() + value as i32)
149                .ok_or_else(|| ToolError::ExecutionFailed("year calculation failed".to_string()))?,
150            _ => {
151                return Err(ToolError::ExecutionFailed(format!(
152                "unsupported time unit: {}, use: seconds, minutes, hours, days, weeks, months, years",
153                unit
154            )))
155            }
156        };
157
158        Ok(DateTimeOutput {
159            result: new_dt.format("%Y-%m-%d %H:%M:%S").to_string(),
160            operation: "add".to_string(),
161            details: Some(format!("{} {} 后", value, self.unit_to_chinese(unit))),
162        })
163    }
164
165    fn subtract_time(
166        &self,
167        dt_str: &str,
168        value: i64,
169        unit: &str,
170    ) -> Result<DateTimeOutput, ToolError> {
171        self.add_time(dt_str, -value, unit)
172    }
173
174    fn get_weekday(&self, dt_str: &str) -> Result<DateTimeOutput, ToolError> {
175        let dt = self.parse_datetime(dt_str)?;
176
177        let weekday = match dt.weekday() {
178            Weekday::Mon => "星期一",
179            Weekday::Tue => "星期二",
180            Weekday::Wed => "星期三",
181            Weekday::Thu => "星期四",
182            Weekday::Fri => "星期五",
183            Weekday::Sat => "星期六",
184            Weekday::Sun => "星期日",
185        };
186
187        Ok(DateTimeOutput {
188            result: weekday.to_string(),
189            operation: "weekday".to_string(),
190            details: Some(format!("{} 是 {}", dt.format("%Y-%m-%d"), weekday)),
191        })
192    }
193
194    fn diff_time(&self, dt1_str: &str, dt2_str: &str) -> Result<DateTimeOutput, ToolError> {
195        let dt1 = self.parse_datetime(dt1_str)?;
196        let dt2 = self.parse_datetime(dt2_str)?;
197
198        let diff = dt2.signed_duration_since(dt1);
199
200        // M-27: decompose the total duration once over its absolute seconds instead of mixing
201        // `num_days()` (floored whole days) with `num_hours() % 24` / `num_minutes() % 60`
202        // (truncation toward zero). For a negative diff the floored days already consumed the
203        // remainder the mod parts tried to contribute, double-counting hours/minutes. Working on
204        // `abs_seconds` keeps one authoritative, sign-correct decomposition for every magnitude.
205        let total_seconds = diff.num_seconds();
206        let direction = total_seconds.signum();
207        let abs_seconds = total_seconds.unsigned_abs();
208        let days = abs_seconds / 86_400;
209        let hours = (abs_seconds % 86_400) / 3_600;
210        let minutes = (abs_seconds % 3_600) / 60;
211
212        Ok(DateTimeOutput {
213            result: format!("{}天 {}小时 {}分钟", days, hours, minutes),
214            operation: "diff".to_string(),
215            details: Some(if direction >= 0 {
216                format!(
217                    "从 {} 到 {} 相隔 {}天 {}小时 {}分钟",
218                    dt1.format("%Y-%m-%d"),
219                    dt2.format("%Y-%m-%d"),
220                    days,
221                    hours,
222                    minutes
223                )
224            } else {
225                format!(
226                    "从 {} 到 {} 相隔 {}天 {}小时 {}分钟",
227                    dt2.format("%Y-%m-%d"),
228                    dt1.format("%Y-%m-%d"),
229                    days,
230                    hours,
231                    minutes
232                )
233            }),
234        })
235    }
236
237    fn unit_to_chinese(&self, unit: &str) -> String {
238        match unit {
239            "seconds" => "秒",
240            "minutes" => "分钟",
241            "hours" => "小时",
242            "days" => "天",
243            "weeks" => "周",
244            "months" => "月",
245            "years" => "年",
246            _ => unit,
247        }
248        .to_string()
249    }
250}
251
252impl Default for DateTimeTool {
253    fn default() -> Self {
254        Self::new()
255    }
256}
257
258#[async_trait]
259impl Tool for DateTimeTool {
260    type Input = DateTimeInput;
261    type Output = DateTimeOutput;
262
263    async fn invoke(&self, input: Self::Input) -> Result<Self::Output, ToolError> {
264        match input.operation.as_str() {
265            "now" => Ok(self.get_now()),
266            "format" => {
267                let dt = input.datetime.ok_or_else(|| {
268                    ToolError::InvalidInput(
269                        "format operation requires a datetime parameter".to_string(),
270                    )
271                })?;
272                self.format_datetime(&dt)
273            }
274            "add" => {
275                let dt = input.datetime.ok_or_else(|| {
276                    ToolError::InvalidInput(
277                        "add operation requires a datetime parameter".to_string(),
278                    )
279                })?;
280                let value = input.value.ok_or_else(|| {
281                    ToolError::InvalidInput("add operation requires a value parameter".to_string())
282                })?;
283                let unit = input.unit.ok_or_else(|| {
284                    ToolError::InvalidInput("add operation requires a unit parameter".to_string())
285                })?;
286                self.add_time(&dt, value, &unit)
287            }
288            "subtract" => {
289                let dt = input.datetime.ok_or_else(|| {
290                    ToolError::InvalidInput(
291                        "subtract operation requires a datetime parameter".to_string(),
292                    )
293                })?;
294                let value = input.value.ok_or_else(|| {
295                    ToolError::InvalidInput(
296                        "subtract operation requires a value parameter".to_string(),
297                    )
298                })?;
299                let unit = input.unit.ok_or_else(|| {
300                    ToolError::InvalidInput(
301                        "subtract operation requires a unit parameter".to_string(),
302                    )
303                })?;
304                self.subtract_time(&dt, value, &unit)
305            }
306            "weekday" => {
307                let dt = input.datetime.ok_or_else(|| {
308                    ToolError::InvalidInput(
309                        "weekday operation requires a datetime parameter".to_string(),
310                    )
311                })?;
312                self.get_weekday(&dt)
313            }
314            "diff" => {
315                let dt1 = input.datetime.ok_or_else(|| {
316                    ToolError::InvalidInput(
317                        "diff operation requires a datetime parameter".to_string(),
318                    )
319                })?;
320                let dt2 = input.target.ok_or_else(|| {
321                    ToolError::InvalidInput(
322                        "diff operation requires a target parameter".to_string(),
323                    )
324                })?;
325                self.diff_time(&dt1, &dt2)
326            }
327            _ => Err(ToolError::InvalidInput(format!(
328                "unsupported operation: {}, use: now, format, add, subtract, weekday, diff",
329                input.operation
330            ))),
331        }
332    }
333}
334
335#[async_trait]
336impl BaseTool for DateTimeTool {
337    fn name(&self) -> &str {
338        "datetime"
339    }
340
341    fn description(&self) -> &str {
342        "日期时间工具。支持多种操作:
343
344操作类型:
345- now: 获取当前时间
346- format: 格式化日期时间
347- add: 添加时间
348- subtract: 减去时间
349- weekday: 获取星期几
350- diff: 计算时间差
351
352示例:
353- 获取当前时间: {\"operation\": \"now\"}
354- 格式化日期: {\"operation\": \"format\", \"datetime\": \"2024-01-15\"}
355- 添加3天: {\"operation\": \"add\", \"datetime\": \"2024-01-15\", \"value\": 3, \"unit\": \"days\"}
356- 计算差值: {\"operation\": \"diff\", \"datetime\": \"2024-01-01\", \"target\": \"2024-01-15\"}"
357    }
358
359    async fn run(&self, input: String) -> Result<String, ToolError> {
360        let parsed: DateTimeInput = serde_json::from_str(&input)
361            .map_err(|e| ToolError::InvalidInput(format!("JSON parse failed: {}", e)))?;
362
363        let output = self.invoke(parsed).await?;
364
365        Ok(format!(
366            "{}\n详细信息: {}",
367            output.result,
368            output.details.unwrap_or_default()
369        ))
370    }
371
372    fn args_schema(&self) -> Option<serde_json::Value> {
373        use schemars::schema_for;
374        serde_json::to_value(schema_for!(DateTimeInput)).ok()
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[tokio::test]
383    async fn test_datetime_now() {
384        let tool = DateTimeTool::new();
385
386        let input = DateTimeInput {
387            operation: "now".to_string(),
388            datetime: None,
389            unit: None,
390            value: None,
391            target: None,
392        };
393
394        let result = tool.invoke(input).await.unwrap();
395        assert!(!result.result.is_empty());
396        assert!(result.details.is_some());
397    }
398
399    #[tokio::test]
400    async fn test_datetime_format() {
401        let tool = DateTimeTool::new();
402
403        let input = DateTimeInput {
404            operation: "format".to_string(),
405            datetime: Some("2024-01-15".to_string()),
406            unit: None,
407            value: None,
408            target: None,
409        };
410
411        let result = tool.invoke(input).await.unwrap();
412        assert!(result.result.contains("2024年"));
413        assert!(result.result.contains("01月"));
414        assert!(result.result.contains("15日"));
415    }
416
417    #[tokio::test]
418    async fn test_datetime_add_days() {
419        let tool = DateTimeTool::new();
420
421        let input = DateTimeInput {
422            operation: "add".to_string(),
423            datetime: Some("2024-01-15".to_string()),
424            unit: Some("days".to_string()),
425            value: Some(3),
426            target: None,
427        };
428
429        let result = tool.invoke(input).await.unwrap();
430        assert!(result.result.contains("2024-01-18"));
431    }
432
433    #[tokio::test]
434    async fn test_datetime_weekday() {
435        let tool = DateTimeTool::new();
436
437        // 2024-01-15 is a Monday
438        let input = DateTimeInput {
439            operation: "weekday".to_string(),
440            datetime: Some("2024-01-15".to_string()),
441            unit: None,
442            value: None,
443            target: None,
444        };
445
446        let result = tool.invoke(input).await.unwrap();
447        assert_eq!(result.result, "星期一");
448    }
449
450    #[tokio::test]
451    async fn test_datetime_diff() {
452        let tool = DateTimeTool::new();
453
454        let input = DateTimeInput {
455            operation: "diff".to_string(),
456            datetime: Some("2024-01-01".to_string()),
457            unit: None,
458            value: None,
459            target: Some("2024-01-15".to_string()),
460        };
461
462        let result = tool.invoke(input).await.unwrap();
463        assert!(result.result.contains("14天"));
464    }
465
466    #[tokio::test]
467    async fn test_datetime_subtract_months_cross_year() {
468        let tool = DateTimeTool::new();
469        // M-27: subtracting 13 months from 2024-03-15 crosses a year backward.
470        // The old `/` `%` (truncate toward zero) arithmetic failed here with "month calculation failed".
471        let input = DateTimeInput {
472            operation: "subtract".to_string(),
473            datetime: Some("2024-03-15".to_string()),
474            unit: Some("months".to_string()),
475            value: Some(13),
476            target: None,
477        };
478        let result = tool.invoke(input).await.unwrap();
479        assert!(
480            result.result.contains("2023-02-15"),
481            "expected 2023-02-15, got: {}",
482            result.result
483        );
484    }
485
486    #[tokio::test]
487    async fn test_datetime_diff_negative_decomposition() {
488        let tool = DateTimeTool::new();
489        // M-27: -25h ≡ 1 day 1 hour ago. Mixing `num_days()` (floored) with `num_hours() % 24`
490        // (truncated) double-counted negative diffs; absolute-seconds decomposition must not.
491        let input = DateTimeInput {
492            operation: "diff".to_string(),
493            datetime: Some("2024-01-02 01:00:00".to_string()),
494            unit: None,
495            value: None,
496            target: Some("2024-01-01 00:00:00".to_string()),
497        };
498        let result = tool.invoke(input).await.unwrap();
499        assert!(
500            result.result.contains("1天 1小时 0分钟"),
501            "expected 1天 1小时 0分钟, got: {}",
502            result.result
503        );
504    }
505}