1use 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#[derive(Debug, Deserialize, Clone, Copy, VariantNames)]
34#[serde(rename_all = "kebab-case")]
35#[strum(serialize_all = "kebab-case")]
36pub enum TimeUnit {
37 Years,
39 Months,
41 Weeks,
43 Days,
45 Hours,
47 Minutes,
49 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#[derive(Debug, Deserialize, Clone, Copy, VariantNames)]
81#[serde(rename_all = "kebab-case")]
82#[strum(serialize_all = "kebab-case")]
83pub enum DatePart {
84 Year,
86 Month,
88 Week,
90 Day,
92 DayOfWeek,
94 DayOfYear,
96 Hour,
98 Minute,
100 Second,
102 Millisecond,
104 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#[derive(Debug, Deserialize, Clone, Copy, VariantNames)]
122#[serde(rename_all = "kebab-case")]
123#[strum(serialize_all = "kebab-case")]
124pub enum RoundMode {
125 Floor,
127 Ceil,
129 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#[derive(Debug, Deserialize, Clone, Copy, VariantNames)]
147#[serde(rename_all = "kebab-case")]
148#[strum(serialize_all = "kebab-case")]
149pub enum DateFormat {
150 Iso8601,
152 Rfc2822,
154 DateOnly,
156 TimeOnly,
158 UsShortDate,
160 EuShortDate,
162 LongDate,
164 DateTime,
166 Unix,
168 UnixMs,
170 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#[derive(Debug, Deserialize, CapabilityInput)]
192#[capability_input(display_name = "Get Current Date Input")]
193pub struct GetCurrentDateInput {
194 #[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 #[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#[derive(Debug, Deserialize, Default, CapabilityInput)]
226#[capability_input(display_name = "Format Date Input")]
227pub struct FormatDateInput {
228 #[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 #[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 #[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 #[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#[derive(Debug, Deserialize, Default, CapabilityInput)]
270#[capability_input(display_name = "Add to Date Input")]
271pub struct AddToDateInput {
272 #[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 #[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 #[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 #[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#[derive(Debug, Deserialize, Default, CapabilityInput)]
313#[capability_input(display_name = "Subtract from Date Input")]
314pub struct SubtractFromDateInput {
315 #[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 #[field(
326 display_name = "Amount",
327 description = "Amount to subtract",
328 example = "3"
329 )]
330 #[serde(default)]
331 pub amount: i64,
332
333 #[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 #[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#[derive(Debug, Deserialize, Default, CapabilityInput)]
356#[capability_input(display_name = "Get Time Between Input")]
357pub struct GetTimeBetweenInput {
358 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize, CapabilityOutput)]
392#[capability_output(display_name = "Time Between Result")]
393#[serde(rename_all = "camelCase")]
394pub struct TimeBetweenResult {
395 #[field(
397 display_name = "Difference",
398 description = "The difference in the specified unit",
399 example = "14"
400 )]
401 pub difference: i64,
402
403 #[field(
405 display_name = "Unit",
406 description = "The unit of the difference",
407 example = "days"
408 )]
409 pub unit: String,
410
411 #[field(
413 display_name = "Exact Milliseconds",
414 description = "Exact difference in milliseconds",
415 example = "1209600000"
416 )]
417 pub exact_ms: i64,
418}
419
420#[derive(Debug, Deserialize, Default, CapabilityInput)]
422#[capability_input(display_name = "Extract Date Part Input")]
423pub struct ExtractDatePartInput {
424 #[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 #[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 #[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#[derive(Debug, Deserialize, Default, CapabilityInput)]
456#[capability_input(display_name = "Date to Unix Input")]
457pub struct DateToUnixInput {
458 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize, CapabilityOutput)]
480#[capability_output(display_name = "Unix Timestamp Result")]
481#[serde(rename_all = "camelCase")]
482pub struct UnixTimestampResult {
483 #[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 #[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#[derive(Debug, Deserialize, Default, CapabilityInput)]
502#[capability_input(display_name = "Unix to Date Input")]
503pub struct UnixToDateInput {
504 #[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 #[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 #[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#[derive(Debug, Deserialize, Default, CapabilityInput)]
535#[capability_input(display_name = "Round Date Input")]
536pub struct RoundDateInput {
537 #[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 #[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 #[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 #[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
578fn default_true() -> bool {
583 true
584}
585
586fn 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 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
615fn try_match_token(s: &str) -> Option<(usize, &'static str)> {
618 static TOKENS: &[(&str, &str)] = &[
620 ("yyyy", "%Y"), ("MMMM", "%B"), ("EEEE", "%A"), ("MMM", "%b"), ("EEE", "%a"), ("SSS", "%3f"), ("ZZZ", "%:z"), ("yy", "%y"), ("MM", "%m"), ("dd", "%d"), ("HH", "%H"), ("hh", "%I"), ("mm", "%M"), ("ss", "%S"), ("ZZ", "%z"), ("a", "%p"), ("W", "%W"), ("o", "%j"), ("Z", "%Z"), ("E", "%a"), ];
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
655const PARSE_FORMATS: &[&str] = &[
661 "%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 "%m/%d/%y %H:%M:%S",
673 "%m/%d/%y",
674 "%m/%d/%Y %H:%M:%S",
676 "%m/%d/%Y",
677 "%d/%m/%y %H:%M:%S",
679 "%d/%m/%y",
680 "%d.%m.%y %H:%M:%S",
681 "%d.%m.%y",
682 "%d/%m/%Y %H:%M:%S",
684 "%d/%m/%Y",
685 "%d.%m.%Y %H:%M:%S",
686 "%d.%m.%Y",
687];
688
689fn parse_flexible_date(
691 date_str: &str,
692 timezone: Option<&str>,
693) -> Result<DateTime<FixedOffset>, String> {
694 let trimmed = date_str.trim();
695
696 if let Ok(ts) = trimmed.parse::<i64>() {
698 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 if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
712 return apply_timezone(dt.with_timezone(&Utc), timezone);
713 }
714
715 if let Ok(dt) = DateTime::parse_from_rfc2822(trimmed) {
717 return apply_timezone(dt.with_timezone(&Utc), timezone);
718 }
719
720 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 const DATE_ONLY_FORMATS: &[&str] = &[
730 "%Y-%m-%d", "%m/%d/%y", "%d/%m/%y", "%d.%m.%y", "%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
750fn parse_timezone(tz_str: &str) -> Result<FixedOffset, String> {
756 let trimmed = tz_str.trim();
757
758 if trimmed.eq_ignore_ascii_case("utc") || trimmed == "Z" {
760 return Ok(FixedOffset::east_opt(0).unwrap());
761 }
762
763 if trimmed.starts_with('+') || trimmed.starts_with('-') {
765 return parse_offset(trimmed);
766 }
767
768 if let Ok(tz) = trimmed.parse::<Tz>() {
770 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
782fn 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
821fn 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
835fn 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
844fn 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; let day = naive.day();
854
855 let total_months = month - 1 + months; let years_to_add = total_months.div_euclid(12);
859 let new_month = (total_months.rem_euclid(12) + 1) as u32; let new_year = year + years_to_add;
861
862 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
873fn 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
889fn is_leap_year(year: i32) -> bool {
891 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
892}
893
894#[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#[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#[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#[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 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#[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 duration.num_days() / 365
1014 }
1015 TimeUnit::Months => {
1016 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#[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, 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#[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 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 naive
1249 }
1250 };
1251
1252 let result = dt.offset().from_local_datetime(&rounded_naive).unwrap();
1253 Ok(format_iso8601(&result))
1254}
1255
1256#[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#[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 let is_ms = input.is_milliseconds.unwrap_or_else(|| {
1290 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#[cfg(test)]
1313mod tests {
1314 use super::*;
1315
1316 #[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 #[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 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 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 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 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 #[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 #[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 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 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 assert!(result.unwrap().contains("2024-01-16"));
1630 }
1631
1632 #[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 #[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, 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, 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 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 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}