runtara_agents/agents/
datetime.rs

1// Copyright (C) 2025 SyncMyOrders Sp. z o.o.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! DateTime agents for workflow execution
4//!
5//! This module provides date and time manipulation operations:
6//! - Get current date/time (get-current-date)
7//! - Format date (format-date)
8//! - Add to date (add-to-date)
9//! - Subtract from date (subtract-from-date)
10//! - Get time between dates (get-time-between)
11//! - Extract date part (extract-date-part)
12//! - Round date (round-date)
13//! - Date to Unix timestamp (date-to-unix)
14//! - Unix timestamp to date (unix-to-date)
15//!
16//! All dates are handled in UTC by default with optional timezone support.
17//! Format strings use Luxon-style tokens (yyyy, MM, dd, HH, mm, ss).
18
19use chrono::{
20    DateTime, Datelike, Duration, FixedOffset, NaiveDateTime, Offset, TimeZone, Timelike, Utc,
21};
22use chrono_tz::Tz;
23use runtara_agent_macro::{CapabilityInput, CapabilityOutput, capability};
24use runtara_dsl::agent_meta::EnumVariants;
25use serde::{Deserialize, Serialize};
26use strum::VariantNames;
27
28// ============================================================================
29// Enums
30// ============================================================================
31
32/// Time unit for date arithmetic and rounding operations
33#[derive(Debug, Deserialize, Clone, Copy, VariantNames)]
34#[serde(rename_all = "kebab-case")]
35#[strum(serialize_all = "kebab-case")]
36pub enum TimeUnit {
37    /// Years
38    Years,
39    /// Months
40    Months,
41    /// Weeks
42    Weeks,
43    /// Days
44    Days,
45    /// Hours
46    Hours,
47    /// Minutes
48    Minutes,
49    /// Seconds
50    Seconds,
51}
52
53impl EnumVariants for TimeUnit {
54    fn variant_names() -> &'static [&'static str] {
55        Self::VARIANTS
56    }
57}
58
59impl Default for TimeUnit {
60    fn default() -> Self {
61        Self::Days
62    }
63}
64
65impl std::fmt::Display for TimeUnit {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            TimeUnit::Years => write!(f, "years"),
69            TimeUnit::Months => write!(f, "months"),
70            TimeUnit::Weeks => write!(f, "weeks"),
71            TimeUnit::Days => write!(f, "days"),
72            TimeUnit::Hours => write!(f, "hours"),
73            TimeUnit::Minutes => write!(f, "minutes"),
74            TimeUnit::Seconds => write!(f, "seconds"),
75        }
76    }
77}
78
79/// Date component to extract
80#[derive(Debug, Deserialize, Clone, Copy, VariantNames)]
81#[serde(rename_all = "kebab-case")]
82#[strum(serialize_all = "kebab-case")]
83pub enum DatePart {
84    /// Year (e.g., 2024)
85    Year,
86    /// Month (1-12)
87    Month,
88    /// ISO week number (1-53)
89    Week,
90    /// Day of month (1-31)
91    Day,
92    /// Day of week (1=Monday, 7=Sunday)
93    DayOfWeek,
94    /// Day of year (1-366)
95    DayOfYear,
96    /// Hour (0-23)
97    Hour,
98    /// Minute (0-59)
99    Minute,
100    /// Second (0-59)
101    Second,
102    /// Millisecond (0-999)
103    Millisecond,
104    /// Quarter (1-4)
105    Quarter,
106}
107
108impl EnumVariants for DatePart {
109    fn variant_names() -> &'static [&'static str] {
110        Self::VARIANTS
111    }
112}
113
114impl Default for DatePart {
115    fn default() -> Self {
116        Self::Day
117    }
118}
119
120/// Rounding mode for round-date operation
121#[derive(Debug, Deserialize, Clone, Copy, VariantNames)]
122#[serde(rename_all = "kebab-case")]
123#[strum(serialize_all = "kebab-case")]
124pub enum RoundMode {
125    /// Round down (floor)
126    Floor,
127    /// Round up (ceil)
128    Ceil,
129    /// Round to nearest
130    Round,
131}
132
133impl EnumVariants for RoundMode {
134    fn variant_names() -> &'static [&'static str] {
135        Self::VARIANTS
136    }
137}
138
139impl Default for RoundMode {
140    fn default() -> Self {
141        Self::Round
142    }
143}
144
145/// Preset date formats
146#[derive(Debug, Deserialize, Clone, Copy, VariantNames)]
147#[serde(rename_all = "kebab-case")]
148#[strum(serialize_all = "kebab-case")]
149pub enum DateFormat {
150    /// ISO 8601: 2024-01-15T14:30:00Z
151    Iso8601,
152    /// RFC 2822: Mon, 15 Jan 2024 14:30:00 +0000
153    Rfc2822,
154    /// Date only: 2024-01-15
155    DateOnly,
156    /// Time only: 14:30:00
157    TimeOnly,
158    /// US short date: 01/15/2024
159    UsShortDate,
160    /// EU short date: 15/01/2024
161    EuShortDate,
162    /// Long date: January 15, 2024
163    LongDate,
164    /// Date and time: 2024-01-15 14:30:00
165    DateTime,
166    /// Unix timestamp (seconds)
167    Unix,
168    /// Unix timestamp (milliseconds)
169    UnixMs,
170    /// Custom format (uses customFormat field)
171    Custom,
172}
173
174impl EnumVariants for DateFormat {
175    fn variant_names() -> &'static [&'static str] {
176        Self::VARIANTS
177    }
178}
179
180impl Default for DateFormat {
181    fn default() -> Self {
182        Self::Iso8601
183    }
184}
185
186// ============================================================================
187// Input/Output Types
188// ============================================================================
189
190/// Input for getting current date/time
191#[derive(Debug, Deserialize, CapabilityInput)]
192#[capability_input(display_name = "Get Current Date Input")]
193pub struct GetCurrentDateInput {
194    /// Whether to include time component (default: true)
195    #[field(
196        display_name = "Include Time",
197        description = "Whether to include time in the output (default: true)",
198        example = "true",
199        default = "true"
200    )]
201    #[serde(default = "default_true")]
202    #[serde(rename = "includeTime")]
203    pub include_time: bool,
204
205    /// Timezone for the output (IANA name or offset)
206    #[field(
207        display_name = "Timezone",
208        description = "Timezone (e.g., 'America/New_York', '+05:30', 'UTC'). Default: UTC",
209        example = "America/New_York"
210    )]
211    #[serde(default)]
212    pub timezone: Option<String>,
213}
214
215impl Default for GetCurrentDateInput {
216    fn default() -> Self {
217        Self {
218            include_time: true,
219            timezone: None,
220        }
221    }
222}
223
224/// Input for formatting a date
225#[derive(Debug, Deserialize, Default, CapabilityInput)]
226#[capability_input(display_name = "Format Date Input")]
227pub struct FormatDateInput {
228    /// The date to format (ISO 8601 string or Unix timestamp)
229    #[field(
230        display_name = "Date",
231        description = "Date to format (ISO 8601, Unix timestamp, or common formats)",
232        example = "2024-01-15T14:30:00Z"
233    )]
234    #[serde(default)]
235    pub date: Option<String>,
236
237    /// Preset format or custom
238    #[field(
239        display_name = "Format",
240        description = "Preset format type",
241        example = "iso8601",
242        default = "iso8601",
243        enum_type = "DateFormat"
244    )]
245    #[serde(default)]
246    pub format: DateFormat,
247
248    /// Custom format string using Luxon-style tokens
249    #[field(
250        display_name = "Custom Format",
251        description = "Custom format using Luxon tokens (yyyy, MM, dd, HH, mm, ss)",
252        example = "yyyy-MM-dd HH:mm:ss"
253    )]
254    #[serde(default)]
255    #[serde(rename = "customFormat")]
256    pub custom_format: Option<String>,
257
258    /// Timezone for output
259    #[field(
260        display_name = "Timezone",
261        description = "Output timezone (e.g., 'America/New_York', '+05:30'). Default: UTC",
262        example = "Europe/London"
263    )]
264    #[serde(default)]
265    pub timezone: Option<String>,
266}
267
268/// Input for adding duration to a date
269#[derive(Debug, Deserialize, Default, CapabilityInput)]
270#[capability_input(display_name = "Add to Date Input")]
271pub struct AddToDateInput {
272    /// The base date
273    #[field(
274        display_name = "Date",
275        description = "The date to add to (ISO 8601, Unix timestamp, or common formats)",
276        example = "2024-01-15T14:30:00Z"
277    )]
278    #[serde(default)]
279    pub date: Option<String>,
280
281    /// Amount to add (can be negative)
282    #[field(
283        display_name = "Amount",
284        description = "Amount to add (positive) or subtract (negative)",
285        example = "7"
286    )]
287    #[serde(default)]
288    pub amount: i64,
289
290    /// Unit of time to add
291    #[field(
292        display_name = "Unit",
293        description = "Time unit (years, months, weeks, days, hours, minutes, seconds)",
294        example = "days",
295        default = "days",
296        enum_type = "TimeUnit"
297    )]
298    #[serde(default)]
299    pub unit: TimeUnit,
300
301    /// Timezone for the operation
302    #[field(
303        display_name = "Timezone",
304        description = "Timezone for the operation. Default: UTC",
305        example = "UTC"
306    )]
307    #[serde(default)]
308    pub timezone: Option<String>,
309}
310
311/// Input for subtracting duration from a date
312#[derive(Debug, Deserialize, Default, CapabilityInput)]
313#[capability_input(display_name = "Subtract from Date Input")]
314pub struct SubtractFromDateInput {
315    /// The base date
316    #[field(
317        display_name = "Date",
318        description = "The date to subtract from (ISO 8601, Unix timestamp, or common formats)",
319        example = "2024-01-15T14:30:00Z"
320    )]
321    #[serde(default)]
322    pub date: Option<String>,
323
324    /// Amount to subtract
325    #[field(
326        display_name = "Amount",
327        description = "Amount to subtract",
328        example = "3"
329    )]
330    #[serde(default)]
331    pub amount: i64,
332
333    /// Unit of time to subtract
334    #[field(
335        display_name = "Unit",
336        description = "Time unit (years, months, weeks, days, hours, minutes, seconds)",
337        example = "months",
338        default = "days",
339        enum_type = "TimeUnit"
340    )]
341    #[serde(default)]
342    pub unit: TimeUnit,
343
344    /// Timezone for the operation
345    #[field(
346        display_name = "Timezone",
347        description = "Timezone for the operation. Default: UTC",
348        example = "UTC"
349    )]
350    #[serde(default)]
351    pub timezone: Option<String>,
352}
353
354/// Input for calculating time between two dates
355#[derive(Debug, Deserialize, Default, CapabilityInput)]
356#[capability_input(display_name = "Get Time Between Input")]
357pub struct GetTimeBetweenInput {
358    /// Start date
359    #[field(
360        display_name = "Start Date",
361        description = "The start date (ISO 8601, Unix timestamp, or common formats)",
362        example = "2024-01-01T00:00:00Z"
363    )]
364    #[serde(default)]
365    #[serde(rename = "startDate")]
366    pub start_date: Option<String>,
367
368    /// End date
369    #[field(
370        display_name = "End Date",
371        description = "The end date (ISO 8601, Unix timestamp, or common formats)",
372        example = "2024-01-15T00:00:00Z"
373    )]
374    #[serde(default)]
375    #[serde(rename = "endDate")]
376    pub end_date: Option<String>,
377
378    /// Unit to return the difference in
379    #[field(
380        display_name = "Unit",
381        description = "Unit for the result (years, months, weeks, days, hours, minutes, seconds)",
382        example = "days",
383        default = "days",
384        enum_type = "TimeUnit"
385    )]
386    #[serde(default)]
387    pub unit: TimeUnit,
388}
389
390/// Output for time between calculation
391#[derive(Debug, Clone, Serialize, Deserialize, CapabilityOutput)]
392#[capability_output(display_name = "Time Between Result")]
393#[serde(rename_all = "camelCase")]
394pub struct TimeBetweenResult {
395    /// The numeric difference in the specified unit
396    #[field(
397        display_name = "Difference",
398        description = "The difference in the specified unit",
399        example = "14"
400    )]
401    pub difference: i64,
402
403    /// The unit of the difference
404    #[field(
405        display_name = "Unit",
406        description = "The unit of the difference",
407        example = "days"
408    )]
409    pub unit: String,
410
411    /// Exact difference in milliseconds for precision
412    #[field(
413        display_name = "Exact Milliseconds",
414        description = "Exact difference in milliseconds",
415        example = "1209600000"
416    )]
417    pub exact_ms: i64,
418}
419
420/// Input for extracting a part of a date
421#[derive(Debug, Deserialize, Default, CapabilityInput)]
422#[capability_input(display_name = "Extract Date Part Input")]
423pub struct ExtractDatePartInput {
424    /// The date to extract from
425    #[field(
426        display_name = "Date",
427        description = "The date to extract from (ISO 8601, Unix timestamp, or common formats)",
428        example = "2024-01-15T14:30:00Z"
429    )]
430    #[serde(default)]
431    pub date: Option<String>,
432
433    /// Part to extract
434    #[field(
435        display_name = "Part",
436        description = "Date part to extract (year, month, week, day, hour, minute, second, etc.)",
437        example = "year",
438        default = "day",
439        enum_type = "DatePart"
440    )]
441    #[serde(default)]
442    pub part: DatePart,
443
444    /// Timezone for the extraction
445    #[field(
446        display_name = "Timezone",
447        description = "Timezone for extraction. Default: UTC",
448        example = "America/New_York"
449    )]
450    #[serde(default)]
451    pub timezone: Option<String>,
452}
453
454/// Input for converting date to Unix timestamp
455#[derive(Debug, Deserialize, Default, CapabilityInput)]
456#[capability_input(display_name = "Date to Unix Input")]
457pub struct DateToUnixInput {
458    /// The date to convert
459    #[field(
460        display_name = "Date",
461        description = "The date to convert (ISO 8601, Unix timestamp, or common formats)",
462        example = "2024-01-15T14:30:00Z"
463    )]
464    #[serde(default)]
465    pub date: Option<String>,
466
467    /// Whether to output milliseconds instead of seconds
468    #[field(
469        display_name = "Milliseconds",
470        description = "If true, returns Unix timestamp in milliseconds instead of seconds (default: false)",
471        example = "false",
472        default = "false"
473    )]
474    #[serde(default)]
475    pub milliseconds: bool,
476}
477
478/// Output for Unix timestamp conversion
479#[derive(Debug, Clone, Serialize, Deserialize, CapabilityOutput)]
480#[capability_output(display_name = "Unix Timestamp Result")]
481#[serde(rename_all = "camelCase")]
482pub struct UnixTimestampResult {
483    /// The Unix timestamp
484    #[field(
485        display_name = "Timestamp",
486        description = "Unix timestamp (seconds or milliseconds based on input)",
487        example = "1705329000"
488    )]
489    pub timestamp: i64,
490
491    /// Whether this is in milliseconds
492    #[field(
493        display_name = "Is Milliseconds",
494        description = "True if timestamp is in milliseconds, false if seconds",
495        example = "false"
496    )]
497    pub is_milliseconds: bool,
498}
499
500/// Input for converting Unix timestamp to date
501#[derive(Debug, Deserialize, Default, CapabilityInput)]
502#[capability_input(display_name = "Unix to Date Input")]
503pub struct UnixToDateInput {
504    /// The Unix timestamp
505    #[field(
506        display_name = "Timestamp",
507        description = "Unix timestamp in seconds or milliseconds",
508        example = "1705329000"
509    )]
510    #[serde(default)]
511    pub timestamp: Option<i64>,
512
513    /// Whether the input is in milliseconds
514    #[field(
515        display_name = "Is Milliseconds",
516        description = "If true, timestamp is in milliseconds; if false, in seconds (default: auto-detect)",
517        example = "false"
518    )]
519    #[serde(default)]
520    #[serde(rename = "isMilliseconds")]
521    pub is_milliseconds: Option<bool>,
522
523    /// Timezone for output
524    #[field(
525        display_name = "Timezone",
526        description = "Output timezone (e.g., 'America/New_York', '+05:30'). Default: UTC",
527        example = "UTC"
528    )]
529    #[serde(default)]
530    pub timezone: Option<String>,
531}
532
533/// Input for rounding a date
534#[derive(Debug, Deserialize, Default, CapabilityInput)]
535#[capability_input(display_name = "Round Date Input")]
536pub struct RoundDateInput {
537    /// The date to round
538    #[field(
539        display_name = "Date",
540        description = "The date to round (ISO 8601, Unix timestamp, or common formats)",
541        example = "2024-01-15T14:37:42Z"
542    )]
543    #[serde(default)]
544    pub date: Option<String>,
545
546    /// Unit to round to
547    #[field(
548        display_name = "Unit",
549        description = "Time unit to round to (years, months, weeks, days, hours, minutes, seconds)",
550        example = "hours",
551        default = "days",
552        enum_type = "TimeUnit"
553    )]
554    #[serde(default)]
555    pub unit: TimeUnit,
556
557    /// Rounding mode
558    #[field(
559        display_name = "Mode",
560        description = "Rounding mode (floor, ceil, round)",
561        example = "floor",
562        default = "round",
563        enum_type = "RoundMode"
564    )]
565    #[serde(default)]
566    pub mode: RoundMode,
567
568    /// Timezone for the operation
569    #[field(
570        display_name = "Timezone",
571        description = "Timezone for rounding. Default: UTC",
572        example = "UTC"
573    )]
574    #[serde(default)]
575    pub timezone: Option<String>,
576}
577
578// ============================================================================
579// Default Value Helpers
580// ============================================================================
581
582fn default_true() -> bool {
583    true
584}
585
586// ============================================================================
587// Luxon to Chrono Format Conversion
588// ============================================================================
589
590/// Converts a Luxon-style format string to chrono strftime format
591/// Uses a character-by-character approach to avoid partial replacement issues
592fn luxon_to_chrono_format(luxon_format: &str) -> String {
593    let mut result = String::with_capacity(luxon_format.len() * 2);
594    let chars: Vec<char> = luxon_format.chars().collect();
595    let mut i = 0;
596
597    while i < chars.len() {
598        let remaining = &luxon_format[luxon_format.char_indices().nth(i).unwrap().0..];
599
600        // Try to match tokens (longest first)
601        let matched = try_match_token(remaining);
602
603        if let Some((luxon_len, chrono_token)) = matched {
604            result.push_str(chrono_token);
605            i += luxon_len;
606        } else {
607            result.push(chars[i]);
608            i += 1;
609        }
610    }
611
612    result
613}
614
615/// Try to match a Luxon token at the start of the string
616/// Returns (token_char_length, chrono_format) if matched
617fn try_match_token(s: &str) -> Option<(usize, &'static str)> {
618    // Tokens ordered by length (longest first) and by specificity
619    static TOKENS: &[(&str, &str)] = &[
620        // 4-char tokens
621        ("yyyy", "%Y"), // 4-digit year: 2024
622        ("MMMM", "%B"), // Full month name: January
623        ("EEEE", "%A"), // Full weekday: Monday
624        // 3-char tokens
625        ("MMM", "%b"),  // Abbreviated month: Jan
626        ("EEE", "%a"),  // Abbreviated weekday: Mon
627        ("SSS", "%3f"), // Milliseconds: 123
628        ("ZZZ", "%:z"), // Timezone offset: +05:30
629        // 2-char tokens
630        ("yy", "%y"), // 2-digit year: 24
631        ("MM", "%m"), // 2-digit month: 01
632        ("dd", "%d"), // 2-digit day: 01
633        ("HH", "%H"), // 24-hour with padding: 09
634        ("hh", "%I"), // 12-hour with padding: 09
635        ("mm", "%M"), // Minutes with padding: 05
636        ("ss", "%S"), // Seconds with padding: 05
637        ("ZZ", "%z"), // Timezone offset: +0530
638        // 1-char tokens (only match specific single chars that are unambiguous)
639        ("a", "%p"), // AM/PM
640        ("W", "%W"), // Week of year
641        ("o", "%j"), // Day of year: 001-366
642        ("Z", "%Z"), // Timezone abbreviation: EST
643        ("E", "%a"), // Same as EEE
644    ];
645
646    for (luxon, chrono) in TOKENS {
647        if s.starts_with(luxon) {
648            return Some((luxon.chars().count(), *chrono));
649        }
650    }
651
652    None
653}
654
655// ============================================================================
656// Date Parsing Helpers
657// ============================================================================
658
659/// Common date formats to try when parsing
660const PARSE_FORMATS: &[&str] = &[
661    // ISO 8601 variants (always 4-digit year, safe to try first)
662    "%Y-%m-%dT%H:%M:%S%.fZ",
663    "%Y-%m-%dT%H:%M:%SZ",
664    "%Y-%m-%dT%H:%M:%S%.f%:z",
665    "%Y-%m-%dT%H:%M:%S%:z",
666    "%Y-%m-%dT%H:%M:%S%.f",
667    "%Y-%m-%dT%H:%M:%S",
668    "%Y-%m-%d %H:%M:%S%.f",
669    "%Y-%m-%d %H:%M:%S",
670    "%Y-%m-%d",
671    // US formats (2-digit year FIRST - chrono's %Y is lenient and would match 2-digit years as year 25 AD)
672    "%m/%d/%y %H:%M:%S",
673    "%m/%d/%y",
674    // US formats (4-digit year)
675    "%m/%d/%Y %H:%M:%S",
676    "%m/%d/%Y",
677    // European formats (2-digit year FIRST)
678    "%d/%m/%y %H:%M:%S",
679    "%d/%m/%y",
680    "%d.%m.%y %H:%M:%S",
681    "%d.%m.%y",
682    // European formats (4-digit year)
683    "%d/%m/%Y %H:%M:%S",
684    "%d/%m/%Y",
685    "%d.%m.%Y %H:%M:%S",
686    "%d.%m.%Y",
687];
688
689/// Parse a date string with flexible format detection
690fn parse_flexible_date(
691    date_str: &str,
692    timezone: Option<&str>,
693) -> Result<DateTime<FixedOffset>, String> {
694    let trimmed = date_str.trim();
695
696    // Try Unix timestamp first (seconds)
697    if let Ok(ts) = trimmed.parse::<i64>() {
698        // Check if it looks like milliseconds (> year 2001 in seconds)
699        let (secs, nanos) = if ts > 1_000_000_000_000 {
700            (ts / 1000, ((ts % 1000) * 1_000_000) as u32)
701        } else {
702            (ts, 0)
703        };
704
705        let utc = DateTime::from_timestamp(secs, nanos)
706            .ok_or_else(|| format!("Invalid Unix timestamp: {}", ts))?;
707        return apply_timezone(utc, timezone);
708    }
709
710    // Try ISO 8601 parsing with chrono's built-in parser (RFC 3339)
711    if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
712        return apply_timezone(dt.with_timezone(&Utc), timezone);
713    }
714
715    // Try RFC 2822
716    if let Ok(dt) = DateTime::parse_from_rfc2822(trimmed) {
717        return apply_timezone(dt.with_timezone(&Utc), timezone);
718    }
719
720    // Try each format as datetime
721    for fmt in PARSE_FORMATS {
722        if let Ok(naive) = NaiveDateTime::parse_from_str(trimmed, fmt) {
723            let utc = Utc.from_utc_datetime(&naive);
724            return apply_timezone(utc, timezone);
725        }
726    }
727
728    // Try date-only formats (2-digit year FIRST - chrono's %Y is lenient)
729    const DATE_ONLY_FORMATS: &[&str] = &[
730        "%Y-%m-%d", // ISO format is safe (always 4-digit year with dashes)
731        // 2-digit year variants first
732        "%m/%d/%y", "%d/%m/%y", "%d.%m.%y", // 4-digit year variants
733        "%m/%d/%Y", "%d/%m/%Y", "%d.%m.%Y",
734    ];
735
736    for fmt in DATE_ONLY_FORMATS {
737        if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(trimmed, fmt) {
738            let naive = naive_date.and_hms_opt(0, 0, 0).unwrap();
739            let utc = Utc.from_utc_datetime(&naive);
740            return apply_timezone(utc, timezone);
741        }
742    }
743
744    Err(format!(
745        "Unable to parse date: '{}'. Supported formats: ISO 8601, RFC 2822, Unix timestamp, or common date formats",
746        date_str
747    ))
748}
749
750// ============================================================================
751// Timezone Helpers
752// ============================================================================
753
754/// Parse a timezone string (IANA name or offset)
755fn parse_timezone(tz_str: &str) -> Result<FixedOffset, String> {
756    let trimmed = tz_str.trim();
757
758    // Handle UTC explicitly
759    if trimmed.eq_ignore_ascii_case("utc") || trimmed == "Z" {
760        return Ok(FixedOffset::east_opt(0).unwrap());
761    }
762
763    // Try parsing as offset (+05:30, -08:00, +0530)
764    if trimmed.starts_with('+') || trimmed.starts_with('-') {
765        return parse_offset(trimmed);
766    }
767
768    // Try parsing as IANA timezone name
769    if let Ok(tz) = trimmed.parse::<Tz>() {
770        // Get current offset for this timezone
771        let now = Utc::now().with_timezone(&tz);
772        let fixed = now.offset().fix();
773        return Ok(fixed);
774    }
775
776    Err(format!(
777        "Unknown timezone: '{}'. Use IANA names (e.g., 'America/New_York') or offsets (e.g., '+05:30')",
778        trimmed
779    ))
780}
781
782/// Parse timezone offset string (+05:30, -08:00, +0530)
783fn parse_offset(offset_str: &str) -> Result<FixedOffset, String> {
784    let sign = if offset_str.starts_with('-') { -1 } else { 1 };
785    let without_sign = offset_str.trim_start_matches(['+', '-']);
786
787    let (hours, minutes) = if without_sign.contains(':') {
788        let parts: Vec<&str> = without_sign.split(':').collect();
789        if parts.len() != 2 {
790            return Err(format!("Invalid offset format: {}", offset_str));
791        }
792        (
793            parts[0]
794                .parse::<i32>()
795                .map_err(|_| format!("Invalid hours in offset: {}", offset_str))?,
796            parts[1]
797                .parse::<i32>()
798                .map_err(|_| format!("Invalid minutes in offset: {}", offset_str))?,
799        )
800    } else if without_sign.len() == 4 {
801        (
802            without_sign[0..2]
803                .parse::<i32>()
804                .map_err(|_| format!("Invalid offset: {}", offset_str))?,
805            without_sign[2..4]
806                .parse::<i32>()
807                .map_err(|_| format!("Invalid offset: {}", offset_str))?,
808        )
809    } else {
810        return Err(format!(
811            "Invalid offset format: {}. Use +HH:MM or +HHMM",
812            offset_str
813        ));
814    };
815
816    let total_seconds = sign * (hours * 3600 + minutes * 60);
817    FixedOffset::east_opt(total_seconds)
818        .ok_or_else(|| format!("Offset out of range: {}", offset_str))
819}
820
821/// Apply timezone to a UTC datetime
822fn apply_timezone(
823    utc: DateTime<Utc>,
824    timezone: Option<&str>,
825) -> Result<DateTime<FixedOffset>, String> {
826    match timezone {
827        Some(tz) if !tz.is_empty() => {
828            let offset = parse_timezone(tz)?;
829            Ok(utc.with_timezone(&offset))
830        }
831        _ => Ok(utc.with_timezone(&FixedOffset::east_opt(0).unwrap())),
832    }
833}
834
835/// Format a datetime to ISO 8601 string
836fn format_iso8601(dt: &DateTime<FixedOffset>) -> String {
837    if dt.offset().local_minus_utc() == 0 {
838        dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()
839    } else {
840        dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string()
841    }
842}
843
844// ============================================================================
845// Date Arithmetic Helpers
846// ============================================================================
847
848/// Add months to a date, handling month-end edge cases
849fn add_months(dt: DateTime<FixedOffset>, months: i32) -> DateTime<FixedOffset> {
850    let naive = dt.naive_local();
851    let year = naive.year();
852    let month = naive.month() as i32; // 1-12
853    let day = naive.day();
854
855    // Calculate new year and month
856    // month is 1-based, so we subtract 1 to make it 0-based for arithmetic
857    let total_months = month - 1 + months; // 0-based month + months to add
858    let years_to_add = total_months.div_euclid(12);
859    let new_month = (total_months.rem_euclid(12) + 1) as u32; // back to 1-based
860    let new_year = year + years_to_add;
861
862    // Cap day to max days in target month
863    let max_day = days_in_month(new_year, new_month);
864    let new_day = day.min(max_day);
865
866    let new_naive = chrono::NaiveDate::from_ymd_opt(new_year, new_month, new_day)
867        .and_then(|d| d.and_hms_opt(naive.hour(), naive.minute(), naive.second()))
868        .unwrap_or(naive);
869
870    dt.offset().from_local_datetime(&new_naive).unwrap()
871}
872
873/// Get the number of days in a month
874fn days_in_month(year: i32, month: u32) -> u32 {
875    match month {
876        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
877        4 | 6 | 9 | 11 => 30,
878        2 => {
879            if is_leap_year(year) {
880                29
881            } else {
882                28
883            }
884        }
885        _ => 30,
886    }
887}
888
889/// Check if a year is a leap year
890fn is_leap_year(year: i32) -> bool {
891    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
892}
893
894// ============================================================================
895// Capabilities
896// ============================================================================
897
898/// Get the current date and optionally time
899#[capability(
900    module = "datetime",
901    display_name = "Get Current Date",
902    description = "Get the current date and optionally time in the specified timezone",
903    module_display_name = "DateTime",
904    module_description = "Date and time capabilities for parsing, formatting, calculating, and manipulating dates"
905)]
906pub fn get_current_date(input: GetCurrentDateInput) -> Result<String, String> {
907    let now = Utc::now();
908    let dt = apply_timezone(now, input.timezone.as_deref())?;
909
910    if input.include_time {
911        Ok(format_iso8601(&dt))
912    } else {
913        Ok(dt.format("%Y-%m-%d").to_string())
914    }
915}
916
917/// Format a date using preset formats or custom Luxon-style tokens
918#[capability(
919    module = "datetime",
920    display_name = "Format Date",
921    description = "Format a date using preset formats or custom Luxon-style tokens (yyyy, MM, dd, HH, mm, ss)"
922)]
923pub fn format_date(input: FormatDateInput) -> Result<String, String> {
924    let date_str = input.date.as_ref().ok_or("Date is required")?;
925
926    let dt = parse_flexible_date(date_str, input.timezone.as_deref())?;
927
928    match input.format {
929        DateFormat::Iso8601 => Ok(format_iso8601(&dt)),
930        DateFormat::Rfc2822 => Ok(dt.format("%a, %d %b %Y %H:%M:%S %z").to_string()),
931        DateFormat::DateOnly => Ok(dt.format("%Y-%m-%d").to_string()),
932        DateFormat::TimeOnly => Ok(dt.format("%H:%M:%S").to_string()),
933        DateFormat::UsShortDate => Ok(dt.format("%m/%d/%Y").to_string()),
934        DateFormat::EuShortDate => Ok(dt.format("%d/%m/%Y").to_string()),
935        DateFormat::LongDate => Ok(dt.format("%B %d, %Y").to_string()),
936        DateFormat::DateTime => Ok(dt.format("%Y-%m-%d %H:%M:%S").to_string()),
937        DateFormat::Unix => Ok(dt.timestamp().to_string()),
938        DateFormat::UnixMs => {
939            Ok((dt.timestamp() * 1000 + dt.timestamp_subsec_millis() as i64).to_string())
940        }
941        DateFormat::Custom => {
942            let custom = input
943                .custom_format
944                .as_ref()
945                .ok_or("Custom format is required when format is 'custom'")?;
946            let chrono_fmt = luxon_to_chrono_format(custom);
947            Ok(dt.format(&chrono_fmt).to_string())
948        }
949    }
950}
951
952/// Add a duration to a date
953#[capability(
954    module = "datetime",
955    display_name = "Add to Date",
956    description = "Add a duration (years, months, weeks, days, hours, minutes, seconds) to a date"
957)]
958pub fn add_to_date(input: AddToDateInput) -> Result<String, String> {
959    let date_str = input.date.as_ref().ok_or("Date is required")?;
960
961    let dt = parse_flexible_date(date_str, input.timezone.as_deref())?;
962    let amount = input.amount;
963
964    let result = match input.unit {
965        TimeUnit::Years => add_months(dt, (amount * 12) as i32),
966        TimeUnit::Months => add_months(dt, amount as i32),
967        TimeUnit::Weeks => dt + Duration::weeks(amount),
968        TimeUnit::Days => dt + Duration::days(amount),
969        TimeUnit::Hours => dt + Duration::hours(amount),
970        TimeUnit::Minutes => dt + Duration::minutes(amount),
971        TimeUnit::Seconds => dt + Duration::seconds(amount),
972    };
973
974    Ok(format_iso8601(&result))
975}
976
977/// Subtract a duration from a date
978#[capability(
979    module = "datetime",
980    display_name = "Subtract from Date",
981    description = "Subtract a duration (years, months, weeks, days, hours, minutes, seconds) from a date"
982)]
983pub fn subtract_from_date(input: SubtractFromDateInput) -> Result<String, String> {
984    // Reuse add_to_date with negated amount
985    let add_input = AddToDateInput {
986        date: input.date,
987        amount: -input.amount,
988        unit: input.unit,
989        timezone: input.timezone,
990    };
991    add_to_date(add_input)
992}
993
994/// Calculate the difference between two dates
995#[capability(
996    module = "datetime",
997    display_name = "Get Time Between Dates",
998    description = "Calculate the difference between two dates in the specified unit"
999)]
1000pub fn get_time_between(input: GetTimeBetweenInput) -> Result<TimeBetweenResult, String> {
1001    let start_str = input.start_date.as_ref().ok_or("Start date is required")?;
1002    let end_str = input.end_date.as_ref().ok_or("End date is required")?;
1003
1004    let start = parse_flexible_date(start_str, None)?;
1005    let end = parse_flexible_date(end_str, None)?;
1006
1007    let duration = end.signed_duration_since(start);
1008    let exact_ms = duration.num_milliseconds();
1009
1010    let difference = match input.unit {
1011        TimeUnit::Years => {
1012            // Approximate years (365.25 days)
1013            duration.num_days() / 365
1014        }
1015        TimeUnit::Months => {
1016            // Approximate months (30.44 days)
1017            duration.num_days() / 30
1018        }
1019        TimeUnit::Weeks => duration.num_weeks(),
1020        TimeUnit::Days => duration.num_days(),
1021        TimeUnit::Hours => duration.num_hours(),
1022        TimeUnit::Minutes => duration.num_minutes(),
1023        TimeUnit::Seconds => duration.num_seconds(),
1024    };
1025
1026    Ok(TimeBetweenResult {
1027        difference,
1028        unit: input.unit.to_string(),
1029        exact_ms,
1030    })
1031}
1032
1033/// Extract a specific component from a date
1034#[capability(
1035    module = "datetime",
1036    display_name = "Extract Part of Date",
1037    description = "Extract a specific component (year, month, day, hour, etc.) from a date"
1038)]
1039pub fn extract_date_part(input: ExtractDatePartInput) -> Result<i32, String> {
1040    let date_str = input.date.as_ref().ok_or("Date is required")?;
1041
1042    let dt = parse_flexible_date(date_str, input.timezone.as_deref())?;
1043
1044    let value = match input.part {
1045        DatePart::Year => dt.year(),
1046        DatePart::Month => dt.month() as i32,
1047        DatePart::Week => dt.iso_week().week() as i32,
1048        DatePart::Day => dt.day() as i32,
1049        DatePart::DayOfWeek => dt.weekday().num_days_from_monday() as i32 + 1, // 1=Monday, 7=Sunday
1050        DatePart::DayOfYear => dt.ordinal() as i32,
1051        DatePart::Hour => dt.hour() as i32,
1052        DatePart::Minute => dt.minute() as i32,
1053        DatePart::Second => dt.second() as i32,
1054        DatePart::Millisecond => (dt.nanosecond() / 1_000_000) as i32,
1055        DatePart::Quarter => ((dt.month() - 1) / 3 + 1) as i32,
1056    };
1057
1058    Ok(value)
1059}
1060
1061/// Round a date to the nearest unit
1062#[capability(
1063    module = "datetime",
1064    display_name = "Round Date",
1065    description = "Round a date to the nearest unit (floor, ceil, or round)"
1066)]
1067pub fn round_date(input: RoundDateInput) -> Result<String, String> {
1068    let date_str = input.date.as_ref().ok_or("Date is required")?;
1069
1070    let dt = parse_flexible_date(date_str, input.timezone.as_deref())?;
1071    let naive = dt.naive_local();
1072
1073    let rounded_naive = match input.unit {
1074        TimeUnit::Years => {
1075            let year = match input.mode {
1076                RoundMode::Floor => naive.year(),
1077                RoundMode::Ceil => {
1078                    if naive.month() > 1
1079                        || naive.day() > 1
1080                        || naive.hour() > 0
1081                        || naive.minute() > 0
1082                        || naive.second() > 0
1083                    {
1084                        naive.year() + 1
1085                    } else {
1086                        naive.year()
1087                    }
1088                }
1089                RoundMode::Round => {
1090                    if naive.month() >= 7 {
1091                        naive.year() + 1
1092                    } else {
1093                        naive.year()
1094                    }
1095                }
1096            };
1097            NaiveDateTime::new(
1098                chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap(),
1099                chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
1100            )
1101        }
1102        TimeUnit::Months => {
1103            let (year, month) = match input.mode {
1104                RoundMode::Floor => (naive.year(), naive.month()),
1105                RoundMode::Ceil => {
1106                    if naive.day() > 1
1107                        || naive.hour() > 0
1108                        || naive.minute() > 0
1109                        || naive.second() > 0
1110                    {
1111                        if naive.month() == 12 {
1112                            (naive.year() + 1, 1)
1113                        } else {
1114                            (naive.year(), naive.month() + 1)
1115                        }
1116                    } else {
1117                        (naive.year(), naive.month())
1118                    }
1119                }
1120                RoundMode::Round => {
1121                    if naive.day() >= 16 {
1122                        if naive.month() == 12 {
1123                            (naive.year() + 1, 1)
1124                        } else {
1125                            (naive.year(), naive.month() + 1)
1126                        }
1127                    } else {
1128                        (naive.year(), naive.month())
1129                    }
1130                }
1131            };
1132            NaiveDateTime::new(
1133                chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap(),
1134                chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
1135            )
1136        }
1137        TimeUnit::Weeks => {
1138            // Get the Monday of the current week
1139            let weekday = naive.weekday().num_days_from_monday();
1140            let monday = naive.date() - Duration::days(weekday as i64);
1141            let next_monday = monday + Duration::days(7);
1142
1143            let target_date = match input.mode {
1144                RoundMode::Floor => monday,
1145                RoundMode::Ceil => {
1146                    if weekday > 0 || naive.hour() > 0 || naive.minute() > 0 || naive.second() > 0 {
1147                        next_monday
1148                    } else {
1149                        monday
1150                    }
1151                }
1152                RoundMode::Round => {
1153                    if weekday >= 4 {
1154                        next_monday
1155                    } else {
1156                        monday
1157                    }
1158                }
1159            };
1160            NaiveDateTime::new(
1161                target_date,
1162                chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
1163            )
1164        }
1165        TimeUnit::Days => {
1166            let date = match input.mode {
1167                RoundMode::Floor => naive.date(),
1168                RoundMode::Ceil => {
1169                    if naive.hour() > 0 || naive.minute() > 0 || naive.second() > 0 {
1170                        naive.date() + Duration::days(1)
1171                    } else {
1172                        naive.date()
1173                    }
1174                }
1175                RoundMode::Round => {
1176                    if naive.hour() >= 12 {
1177                        naive.date() + Duration::days(1)
1178                    } else {
1179                        naive.date()
1180                    }
1181                }
1182            };
1183            NaiveDateTime::new(date, chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap())
1184        }
1185        TimeUnit::Hours => {
1186            let (date, hour) = match input.mode {
1187                RoundMode::Floor => (naive.date(), naive.hour()),
1188                RoundMode::Ceil => {
1189                    if naive.minute() > 0 || naive.second() > 0 {
1190                        if naive.hour() == 23 {
1191                            (naive.date() + Duration::days(1), 0)
1192                        } else {
1193                            (naive.date(), naive.hour() + 1)
1194                        }
1195                    } else {
1196                        (naive.date(), naive.hour())
1197                    }
1198                }
1199                RoundMode::Round => {
1200                    if naive.minute() >= 30 {
1201                        if naive.hour() == 23 {
1202                            (naive.date() + Duration::days(1), 0)
1203                        } else {
1204                            (naive.date(), naive.hour() + 1)
1205                        }
1206                    } else {
1207                        (naive.date(), naive.hour())
1208                    }
1209                }
1210            };
1211            NaiveDateTime::new(date, chrono::NaiveTime::from_hms_opt(hour, 0, 0).unwrap())
1212        }
1213        TimeUnit::Minutes => {
1214            let total_mins = naive.hour() * 60 + naive.minute();
1215            let new_mins = match input.mode {
1216                RoundMode::Floor => total_mins,
1217                RoundMode::Ceil => {
1218                    if naive.second() > 0 {
1219                        total_mins + 1
1220                    } else {
1221                        total_mins
1222                    }
1223                }
1224                RoundMode::Round => {
1225                    if naive.second() >= 30 {
1226                        total_mins + 1
1227                    } else {
1228                        total_mins
1229                    }
1230                }
1231            };
1232
1233            let (extra_days, final_mins) = if new_mins >= 24 * 60 {
1234                (1, new_mins - 24 * 60)
1235            } else {
1236                (0, new_mins)
1237            };
1238
1239            let new_hour = final_mins / 60;
1240            let new_min = final_mins % 60;
1241            NaiveDateTime::new(
1242                naive.date() + Duration::days(extra_days),
1243                chrono::NaiveTime::from_hms_opt(new_hour, new_min, 0).unwrap(),
1244            )
1245        }
1246        TimeUnit::Seconds => {
1247            // Seconds are already the smallest unit we handle
1248            naive
1249        }
1250    };
1251
1252    let result = dt.offset().from_local_datetime(&rounded_naive).unwrap();
1253    Ok(format_iso8601(&result))
1254}
1255
1256/// Convert a date to Unix timestamp
1257#[capability(
1258    module = "datetime",
1259    display_name = "Date to Unix Timestamp",
1260    description = "Convert a date to Unix timestamp (seconds or milliseconds)"
1261)]
1262pub fn date_to_unix(input: DateToUnixInput) -> Result<UnixTimestampResult, String> {
1263    let date_str = input.date.as_ref().ok_or("Date is required")?;
1264
1265    let dt = parse_flexible_date(date_str, None)?;
1266
1267    let timestamp = if input.milliseconds {
1268        dt.timestamp() * 1000 + dt.timestamp_subsec_millis() as i64
1269    } else {
1270        dt.timestamp()
1271    };
1272
1273    Ok(UnixTimestampResult {
1274        timestamp,
1275        is_milliseconds: input.milliseconds,
1276    })
1277}
1278
1279/// Convert a Unix timestamp to a date string
1280#[capability(
1281    module = "datetime",
1282    display_name = "Unix Timestamp to Date",
1283    description = "Convert a Unix timestamp (seconds or milliseconds) to an ISO 8601 date string"
1284)]
1285pub fn unix_to_date(input: UnixToDateInput) -> Result<String, String> {
1286    let ts = input.timestamp.ok_or("Timestamp is required")?;
1287
1288    // Auto-detect if milliseconds based on magnitude, or use explicit flag
1289    let is_ms = input.is_milliseconds.unwrap_or_else(|| {
1290        // If timestamp is larger than year 2001 in seconds (~1 billion),
1291        // and looks like milliseconds (> 10 billion), assume milliseconds
1292        ts > 1_000_000_000_000
1293    });
1294
1295    let (secs, nanos) = if is_ms {
1296        (ts / 1000, ((ts % 1000) * 1_000_000) as u32)
1297    } else {
1298        (ts, 0)
1299    };
1300
1301    let utc = DateTime::from_timestamp(secs, nanos)
1302        .ok_or_else(|| format!("Invalid Unix timestamp: {}", ts))?;
1303
1304    let dt = apply_timezone(utc, input.timezone.as_deref())?;
1305    Ok(format_iso8601(&dt))
1306}
1307
1308// ============================================================================
1309// Tests
1310// ============================================================================
1311
1312#[cfg(test)]
1313mod tests {
1314    use super::*;
1315
1316    // Format conversion tests
1317    #[test]
1318    fn test_luxon_format_year() {
1319        assert_eq!(luxon_to_chrono_format("yyyy"), "%Y");
1320        assert_eq!(luxon_to_chrono_format("yy"), "%y");
1321    }
1322
1323    #[test]
1324    fn test_luxon_format_complex() {
1325        assert_eq!(
1326            luxon_to_chrono_format("yyyy-MM-dd HH:mm:ss"),
1327            "%Y-%m-%d %H:%M:%S"
1328        );
1329    }
1330
1331    #[test]
1332    fn test_luxon_format_12hour() {
1333        assert_eq!(luxon_to_chrono_format("hh:mm:ss a"), "%I:%M:%S %p");
1334    }
1335
1336    // Date parsing tests
1337    #[test]
1338    fn test_parse_iso8601() {
1339        let result = parse_flexible_date("2024-01-15T14:30:00Z", None);
1340        assert!(result.is_ok());
1341        let dt = result.unwrap();
1342        assert_eq!(dt.year(), 2024);
1343        assert_eq!(dt.month(), 1);
1344        assert_eq!(dt.day(), 15);
1345    }
1346
1347    #[test]
1348    fn test_parse_unix_timestamp() {
1349        let result = parse_flexible_date("1705329000", None);
1350        assert!(result.is_ok());
1351    }
1352
1353    #[test]
1354    fn test_parse_unix_ms_timestamp() {
1355        let result = parse_flexible_date("1705329000000", None);
1356        assert!(result.is_ok());
1357    }
1358
1359    #[test]
1360    fn test_parse_date_only() {
1361        let result = parse_flexible_date("2024-01-15", None);
1362        assert!(result.is_ok());
1363    }
1364
1365    #[test]
1366    fn test_parse_two_digit_year() {
1367        // US format MM/DD/YY - year 25 should be 2025, not 0025
1368        let result = parse_flexible_date("10/22/25", None);
1369        assert!(result.is_ok());
1370        let dt = result.unwrap();
1371        assert_eq!(dt.year(), 2025);
1372        assert_eq!(dt.month(), 10);
1373        assert_eq!(dt.day(), 22);
1374
1375        // European format DD.MM.YY
1376        let result = parse_flexible_date("22.10.25", None);
1377        assert!(result.is_ok());
1378        let dt = result.unwrap();
1379        assert_eq!(dt.year(), 2025);
1380
1381        // Year 99 should be 1999 (chrono pivot: 70-99 -> 1970-1999)
1382        let result = parse_flexible_date("01/15/99", None);
1383        assert!(result.is_ok());
1384        let dt = result.unwrap();
1385        assert_eq!(dt.year(), 1999);
1386
1387        // Year 00 should be 2000 (chrono pivot: 00-69 -> 2000-2069)
1388        let result = parse_flexible_date("01/15/00", None);
1389        assert!(result.is_ok());
1390        let dt = result.unwrap();
1391        assert_eq!(dt.year(), 2000);
1392    }
1393
1394    // Timezone tests
1395    #[test]
1396    fn test_timezone_utc() {
1397        let offset = parse_timezone("UTC");
1398        assert!(offset.is_ok());
1399        assert_eq!(offset.unwrap().local_minus_utc(), 0);
1400    }
1401
1402    #[test]
1403    fn test_timezone_offset() {
1404        let offset = parse_timezone("+05:30");
1405        assert!(offset.is_ok());
1406        assert_eq!(offset.unwrap().local_minus_utc(), 5 * 3600 + 30 * 60);
1407    }
1408
1409    #[test]
1410    fn test_timezone_negative_offset() {
1411        let offset = parse_timezone("-08:00");
1412        assert!(offset.is_ok());
1413        assert_eq!(offset.unwrap().local_minus_utc(), -8 * 3600);
1414    }
1415
1416    #[test]
1417    fn test_timezone_iana() {
1418        let offset = parse_timezone("America/New_York");
1419        assert!(offset.is_ok());
1420    }
1421
1422    // Capability tests
1423    #[test]
1424    fn test_get_current_date_utc() {
1425        let input = GetCurrentDateInput::default();
1426        let result = get_current_date(input);
1427        assert!(result.is_ok());
1428        let date = result.unwrap();
1429        assert!(date.ends_with('Z'));
1430    }
1431
1432    #[test]
1433    fn test_get_current_date_no_time() {
1434        let input = GetCurrentDateInput {
1435            include_time: false,
1436            timezone: None,
1437        };
1438        let result = get_current_date(input);
1439        assert!(result.is_ok());
1440        let date = result.unwrap();
1441        assert!(!date.contains('T'));
1442        assert!(date.contains('-'));
1443    }
1444
1445    #[test]
1446    fn test_format_date_iso8601() {
1447        let input = FormatDateInput {
1448            date: Some("2024-01-15T14:30:00Z".to_string()),
1449            format: DateFormat::Iso8601,
1450            ..Default::default()
1451        };
1452        let result = format_date(input);
1453        assert!(result.is_ok());
1454        assert_eq!(result.unwrap(), "2024-01-15T14:30:00Z");
1455    }
1456
1457    #[test]
1458    fn test_format_date_custom() {
1459        let input = FormatDateInput {
1460            date: Some("2024-01-15T14:30:00Z".to_string()),
1461            format: DateFormat::Custom,
1462            custom_format: Some("yyyy/MM/dd".to_string()),
1463            ..Default::default()
1464        };
1465        let result = format_date(input);
1466        assert!(result.is_ok());
1467        assert_eq!(result.unwrap(), "2024/01/15");
1468    }
1469
1470    #[test]
1471    fn test_format_date_unix() {
1472        let input = FormatDateInput {
1473            date: Some("2024-01-15T14:30:00Z".to_string()),
1474            format: DateFormat::Unix,
1475            ..Default::default()
1476        };
1477        let result = format_date(input);
1478        assert!(result.is_ok());
1479        // Just verify it's a valid number
1480        assert!(result.unwrap().parse::<i64>().is_ok());
1481    }
1482
1483    #[test]
1484    fn test_add_days() {
1485        let input = AddToDateInput {
1486            date: Some("2024-01-15T00:00:00Z".to_string()),
1487            amount: 7,
1488            unit: TimeUnit::Days,
1489            ..Default::default()
1490        };
1491        let result = add_to_date(input);
1492        assert!(result.is_ok());
1493        assert!(result.unwrap().contains("2024-01-22"));
1494    }
1495
1496    #[test]
1497    fn test_add_months() {
1498        let input = AddToDateInput {
1499            date: Some("2024-01-31T00:00:00Z".to_string()),
1500            amount: 1,
1501            unit: TimeUnit::Months,
1502            ..Default::default()
1503        };
1504        let result = add_to_date(input);
1505        assert!(result.is_ok());
1506        // January 31 + 1 month = February 29 (2024 is a leap year)
1507        assert!(result.unwrap().contains("2024-02-29"));
1508    }
1509
1510    #[test]
1511    fn test_add_negative() {
1512        let input = AddToDateInput {
1513            date: Some("2024-01-15T00:00:00Z".to_string()),
1514            amount: -5,
1515            unit: TimeUnit::Days,
1516            ..Default::default()
1517        };
1518        let result = add_to_date(input);
1519        assert!(result.is_ok());
1520        assert!(result.unwrap().contains("2024-01-10"));
1521    }
1522
1523    #[test]
1524    fn test_subtract_days() {
1525        let input = SubtractFromDateInput {
1526            date: Some("2024-01-15T00:00:00Z".to_string()),
1527            amount: 5,
1528            unit: TimeUnit::Days,
1529            ..Default::default()
1530        };
1531        let result = subtract_from_date(input);
1532        assert!(result.is_ok());
1533        assert!(result.unwrap().contains("2024-01-10"));
1534    }
1535
1536    #[test]
1537    fn test_time_between_days() {
1538        let input = GetTimeBetweenInput {
1539            start_date: Some("2024-01-01T00:00:00Z".to_string()),
1540            end_date: Some("2024-01-15T00:00:00Z".to_string()),
1541            unit: TimeUnit::Days,
1542        };
1543        let result = get_time_between(input).unwrap();
1544        assert_eq!(result.difference, 14);
1545        assert_eq!(result.unit, "days");
1546    }
1547
1548    #[test]
1549    fn test_time_between_hours() {
1550        let input = GetTimeBetweenInput {
1551            start_date: Some("2024-01-01T00:00:00Z".to_string()),
1552            end_date: Some("2024-01-01T12:00:00Z".to_string()),
1553            unit: TimeUnit::Hours,
1554        };
1555        let result = get_time_between(input).unwrap();
1556        assert_eq!(result.difference, 12);
1557    }
1558
1559    #[test]
1560    fn test_extract_year() {
1561        let input = ExtractDatePartInput {
1562            date: Some("2024-01-15T14:30:00Z".to_string()),
1563            part: DatePart::Year,
1564            ..Default::default()
1565        };
1566        let result = extract_date_part(input);
1567        assert_eq!(result.unwrap(), 2024);
1568    }
1569
1570    #[test]
1571    fn test_extract_month() {
1572        let input = ExtractDatePartInput {
1573            date: Some("2024-01-15T14:30:00Z".to_string()),
1574            part: DatePart::Month,
1575            ..Default::default()
1576        };
1577        let result = extract_date_part(input);
1578        assert_eq!(result.unwrap(), 1);
1579    }
1580
1581    #[test]
1582    fn test_extract_quarter() {
1583        let input = ExtractDatePartInput {
1584            date: Some("2024-07-15T14:30:00Z".to_string()),
1585            part: DatePart::Quarter,
1586            ..Default::default()
1587        };
1588        let result = extract_date_part(input);
1589        assert_eq!(result.unwrap(), 3);
1590    }
1591
1592    #[test]
1593    fn test_round_to_hour_floor() {
1594        let input = RoundDateInput {
1595            date: Some("2024-01-15T14:37:42Z".to_string()),
1596            unit: TimeUnit::Hours,
1597            mode: RoundMode::Floor,
1598            ..Default::default()
1599        };
1600        let result = round_date(input);
1601        assert!(result.is_ok());
1602        assert!(result.unwrap().contains("14:00:00"));
1603    }
1604
1605    #[test]
1606    fn test_round_to_hour_ceil() {
1607        let input = RoundDateInput {
1608            date: Some("2024-01-15T14:37:42Z".to_string()),
1609            unit: TimeUnit::Hours,
1610            mode: RoundMode::Ceil,
1611            ..Default::default()
1612        };
1613        let result = round_date(input);
1614        assert!(result.is_ok());
1615        assert!(result.unwrap().contains("15:00:00"));
1616    }
1617
1618    #[test]
1619    fn test_round_to_day() {
1620        let input = RoundDateInput {
1621            date: Some("2024-01-15T14:37:42Z".to_string()),
1622            unit: TimeUnit::Days,
1623            mode: RoundMode::Round,
1624            ..Default::default()
1625        };
1626        let result = round_date(input);
1627        assert!(result.is_ok());
1628        // 14:37 is past noon, so should round up to the 16th
1629        assert!(result.unwrap().contains("2024-01-16"));
1630    }
1631
1632    // Edge case tests
1633    #[test]
1634    fn test_leap_year() {
1635        let input = AddToDateInput {
1636            date: Some("2024-02-28T00:00:00Z".to_string()),
1637            amount: 1,
1638            unit: TimeUnit::Days,
1639            ..Default::default()
1640        };
1641        let result = add_to_date(input);
1642        assert!(result.is_ok());
1643        assert!(result.unwrap().contains("2024-02-29"));
1644    }
1645
1646    #[test]
1647    fn test_non_leap_year() {
1648        let input = AddToDateInput {
1649            date: Some("2023-02-28T00:00:00Z".to_string()),
1650            amount: 1,
1651            unit: TimeUnit::Days,
1652            ..Default::default()
1653        };
1654        let result = add_to_date(input);
1655        assert!(result.is_ok());
1656        assert!(result.unwrap().contains("2023-03-01"));
1657    }
1658
1659    #[test]
1660    fn test_empty_date_error() {
1661        let input = FormatDateInput::default();
1662        let result = format_date(input);
1663        assert!(result.is_err());
1664        assert!(result.unwrap_err().contains("Date is required"));
1665    }
1666
1667    #[test]
1668    fn test_invalid_date_error() {
1669        let input = FormatDateInput {
1670            date: Some("not-a-date".to_string()),
1671            ..Default::default()
1672        };
1673        let result = format_date(input);
1674        assert!(result.is_err());
1675    }
1676
1677    // Unix timestamp capability tests
1678    #[test]
1679    fn test_date_to_unix_seconds() {
1680        let input = DateToUnixInput {
1681            date: Some("2024-01-15T14:30:00Z".to_string()),
1682            milliseconds: false,
1683        };
1684        let result = date_to_unix(input).unwrap();
1685        assert_eq!(result.timestamp, 1705329000);
1686        assert!(!result.is_milliseconds);
1687    }
1688
1689    #[test]
1690    fn test_date_to_unix_milliseconds() {
1691        let input = DateToUnixInput {
1692            date: Some("2024-01-15T14:30:00Z".to_string()),
1693            milliseconds: true,
1694        };
1695        let result = date_to_unix(input).unwrap();
1696        assert_eq!(result.timestamp, 1705329000000);
1697        assert!(result.is_milliseconds);
1698    }
1699
1700    #[test]
1701    fn test_unix_to_date_seconds() {
1702        let input = UnixToDateInput {
1703            timestamp: Some(1705329000),
1704            is_milliseconds: Some(false),
1705            timezone: None,
1706        };
1707        let result = unix_to_date(input).unwrap();
1708        assert_eq!(result, "2024-01-15T14:30:00Z");
1709    }
1710
1711    #[test]
1712    fn test_unix_to_date_milliseconds() {
1713        let input = UnixToDateInput {
1714            timestamp: Some(1705329000000),
1715            is_milliseconds: Some(true),
1716            timezone: None,
1717        };
1718        let result = unix_to_date(input).unwrap();
1719        assert_eq!(result, "2024-01-15T14:30:00Z");
1720    }
1721
1722    #[test]
1723    fn test_unix_to_date_auto_detect_seconds() {
1724        let input = UnixToDateInput {
1725            timestamp: Some(1705329000),
1726            is_milliseconds: None, // auto-detect
1727            timezone: None,
1728        };
1729        let result = unix_to_date(input).unwrap();
1730        assert_eq!(result, "2024-01-15T14:30:00Z");
1731    }
1732
1733    #[test]
1734    fn test_unix_to_date_auto_detect_milliseconds() {
1735        let input = UnixToDateInput {
1736            timestamp: Some(1705329000000),
1737            is_milliseconds: None, // auto-detect
1738            timezone: None,
1739        };
1740        let result = unix_to_date(input).unwrap();
1741        assert_eq!(result, "2024-01-15T14:30:00Z");
1742    }
1743
1744    #[test]
1745    fn test_unix_to_date_with_timezone() {
1746        let input = UnixToDateInput {
1747            timestamp: Some(1705329000),
1748            is_milliseconds: Some(false),
1749            timezone: Some("+05:30".to_string()),
1750        };
1751        let result = unix_to_date(input).unwrap();
1752        assert!(result.contains("20:00:00"));
1753        assert!(result.contains("+05:30"));
1754    }
1755
1756    #[test]
1757    fn test_date_to_unix_roundtrip() {
1758        // Convert date to unix
1759        let to_unix = DateToUnixInput {
1760            date: Some("2024-01-15T14:30:00Z".to_string()),
1761            milliseconds: false,
1762        };
1763        let unix_result = date_to_unix(to_unix).unwrap();
1764
1765        // Convert back to date
1766        let to_date = UnixToDateInput {
1767            timestamp: Some(unix_result.timestamp),
1768            is_milliseconds: Some(false),
1769            timezone: None,
1770        };
1771        let date_result = unix_to_date(to_date).unwrap();
1772        assert_eq!(date_result, "2024-01-15T14:30:00Z");
1773    }
1774}