Skip to main content

grc_20/util/
datetime.rs

1//! RFC 3339 date/time parsing and formatting utilities.
2//!
3//! Converts between RFC 3339 formatted strings and GRC-20 internal representations:
4//! - Date: days since Unix epoch (1970-01-01) + offset in minutes
5//! - Time: microseconds since midnight (`time_micros`) + offset in minutes
6//! - Datetime: microseconds since Unix epoch (`epoch_micros`) + offset in minutes
7
8
9const MICROSECONDS_PER_SECOND: i64 = 1_000_000;
10const MICROSECONDS_PER_MINUTE: i64 = 60 * MICROSECONDS_PER_SECOND;
11const MICROSECONDS_PER_HOUR: i64 = 60 * MICROSECONDS_PER_MINUTE;
12const MILLISECONDS_PER_DAY: i64 = 24 * 60 * 60 * 1000;
13
14/// Error type for RFC 3339 parsing failures.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct DateTimeParseError {
17    pub message: String,
18}
19
20impl std::fmt::Display for DateTimeParseError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        write!(f, "{}", self.message)
23    }
24}
25
26impl std::error::Error for DateTimeParseError {}
27
28/// Parses a timezone offset string (Z, +HH:MM, -HH:MM) and returns offset in minutes.
29fn parse_timezone_offset(offset: &str) -> Result<i16, DateTimeParseError> {
30    if offset == "Z" || offset == "z" {
31        return Ok(0);
32    }
33
34    if offset.len() != 6 {
35        return Err(DateTimeParseError {
36            message: format!("Invalid timezone offset: {}", offset),
37        });
38    }
39
40    let sign = match offset.chars().next() {
41        Some('+') => 1i16,
42        Some('-') => -1i16,
43        _ => {
44            return Err(DateTimeParseError {
45                message: format!("Invalid timezone offset: {}", offset),
46            })
47        }
48    };
49
50    if offset.chars().nth(3) != Some(':') {
51        return Err(DateTimeParseError {
52            message: format!("Invalid timezone offset: {}", offset),
53        });
54    }
55
56    let hours: i16 = offset[1..3].parse().map_err(|_| DateTimeParseError {
57        message: format!("Invalid timezone offset: {}", offset),
58    })?;
59
60    let minutes: i16 = offset[4..6].parse().map_err(|_| DateTimeParseError {
61        message: format!("Invalid timezone offset: {}", offset),
62    })?;
63
64    // Validate hours and minutes (allow 24:00 as special case for ±24:00)
65    if hours > 24 || (hours == 24 && minutes != 0) || minutes > 59 {
66        return Err(DateTimeParseError {
67            message: format!("Invalid timezone offset: {}", offset),
68        });
69    }
70
71    let total_minutes = sign * (hours * 60 + minutes);
72    if total_minutes < -1440 || total_minutes > 1440 {
73        return Err(DateTimeParseError {
74            message: format!("Timezone offset out of range [-24:00, +24:00]: {}", offset),
75        });
76    }
77
78    Ok(total_minutes)
79}
80
81/// Formats an offset in minutes as a timezone string (Z, +HH:MM, -HH:MM).
82fn format_timezone_offset(offset_min: i16) -> String {
83    if offset_min == 0 {
84        return "Z".to_string();
85    }
86
87    let sign = if offset_min >= 0 { '+' } else { '-' };
88    let abs_offset = offset_min.abs();
89    let hours = abs_offset / 60;
90    let minutes = abs_offset % 60;
91
92    format!("{}{:02}:{:02}", sign, hours, minutes)
93}
94
95/// Parses fractional seconds string and returns microseconds.
96fn parse_fractional_seconds(frac: Option<&str>) -> i64 {
97    match frac {
98        None => 0,
99        Some(s) if s.is_empty() => 0,
100        Some(s) => {
101            // Pad or truncate to 6 digits (microseconds)
102            let mut padded = s.to_string();
103            while padded.len() < 6 {
104                padded.push('0');
105            }
106            padded.truncate(6);
107            padded.parse().unwrap_or(0)
108        }
109    }
110}
111
112/// Formats microseconds as fractional seconds string, omitting if zero.
113fn format_fractional_seconds(us: i64) -> String {
114    if us == 0 {
115        return String::new();
116    }
117
118    // Convert to 6-digit string and trim trailing zeros
119    let str = format!("{:06}", us);
120    let trimmed = str.trim_end_matches('0');
121    format!(".{}", trimmed)
122}
123
124/// Returns true if the given year is a leap year.
125fn is_leap_year(year: i32) -> bool {
126    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
127}
128
129/// Returns the number of days in a given month (1-indexed).
130fn days_in_month(year: i32, month: u32) -> u32 {
131    match month {
132        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
133        4 | 6 | 9 | 11 => 30,
134        2 => {
135            if is_leap_year(year) {
136                29
137            } else {
138                28
139            }
140        }
141        _ => 0,
142    }
143}
144
145/// Calculates days since Unix epoch for a given date.
146fn date_to_days(year: i32, month: u32, day: u32) -> i32 {
147    // Use a well-known algorithm for converting dates to days since epoch
148    // This is based on the algorithm from Howard Hinnant
149    let y = if month <= 2 {
150        year - 1
151    } else {
152        year
153    } as i64;
154
155    let m = if month <= 2 {
156        month as i64 + 9
157    } else {
158        month as i64 - 3
159    };
160
161    let era = if y >= 0 { y } else { y - 399 } / 400;
162    let yoe = (y - era * 400) as u32; // year of era
163    let doy = (153 * m as u32 + 2) / 5 + day - 1; // day of year
164    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // day of era
165
166    (era * 146097 + doe as i64 - 719468) as i32
167}
168
169/// Converts days since Unix epoch to (year, month, day).
170fn days_to_date(days: i32) -> (i32, u32, u32) {
171    // Howard Hinnant's algorithm in reverse
172    let z = days as i64 + 719468;
173    let era = if z >= 0 { z } else { z - 146096 } / 146097;
174    let doe = (z - era * 146097) as u32; // day of era
175    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // year of era
176    let y = yoe as i64 + era * 400;
177    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year
178    let mp = (5 * doy + 2) / 153; // month index
179    let d = doy - (153 * mp + 2) / 5 + 1; // day
180    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // month
181
182    let year = if m <= 2 { y + 1 } else { y } as i32;
183    (year, m, d)
184}
185
186// =====================
187// DATE functions
188// =====================
189
190/// Parses an RFC 3339 date string (YYYY-MM-DD with optional timezone) and returns
191/// days since Unix epoch and offset in minutes.
192pub fn parse_date_rfc3339(date_str: &str) -> Result<(i32, i16), DateTimeParseError> {
193    // Match YYYY-MM-DD with optional timezone offset
194    let (date_part, offset_str) = if date_str.len() >= 10 {
195        let date = &date_str[..10];
196        let rest = &date_str[10..];
197        if rest.is_empty() {
198            (date, None)
199        } else {
200            (date, Some(rest))
201        }
202    } else {
203        return Err(DateTimeParseError {
204            message: format!("Invalid RFC 3339 date: {}", date_str),
205        });
206    };
207
208    // Validate format: YYYY-MM-DD
209    if date_part.len() != 10
210        || date_part.chars().nth(4) != Some('-')
211        || date_part.chars().nth(7) != Some('-')
212    {
213        return Err(DateTimeParseError {
214            message: format!("Invalid RFC 3339 date: {}", date_str),
215        });
216    }
217
218    let year: i32 = date_part[..4].parse().map_err(|_| DateTimeParseError {
219        message: format!("Invalid year in date: {}", date_str),
220    })?;
221
222    let month: u32 = date_part[5..7].parse().map_err(|_| DateTimeParseError {
223        message: format!("Invalid month in date: {}", date_str),
224    })?;
225
226    let day: u32 = date_part[8..10].parse().map_err(|_| DateTimeParseError {
227        message: format!("Invalid day in date: {}", date_str),
228    })?;
229
230    // Validate month and day
231    if month < 1 || month > 12 {
232        return Err(DateTimeParseError {
233            message: format!("Invalid month in date: {}", date_str),
234        });
235    }
236    if day < 1 || day > days_in_month(year, month) {
237        return Err(DateTimeParseError {
238            message: format!("Invalid day in date: {}", date_str),
239        });
240    }
241
242    let days = date_to_days(year, month, day);
243    let offset_min = match offset_str {
244        Some(s) => parse_timezone_offset(s)?,
245        None => 0,
246    };
247
248    Ok((days, offset_min))
249}
250
251/// Formats days since Unix epoch as RFC 3339 date string.
252pub fn format_date_rfc3339(days: i32, offset_min: i16) -> String {
253    let (year, month, day) = days_to_date(days);
254    let offset = format_timezone_offset(offset_min);
255    format!("{:04}-{:02}-{:02}{}", year, month, day, offset)
256}
257
258// =====================
259// TIME functions
260// =====================
261
262/// Parses an RFC 3339 time string (HH:MM:SS[.ssssss][Z|+HH:MM]) and returns
263/// microseconds since midnight and offset in minutes.
264///
265/// Spec: TIME value definition (spec.md "TIME" section) requires offset_min;
266/// reject inputs without explicit timezone (Z or ±HH:MM).
267pub fn parse_time_rfc3339(time_str: &str) -> Result<(i64, i16), DateTimeParseError> {
268    // Minimum length is 8 (HH:MM:SS)
269    if time_str.len() < 8 {
270        return Err(DateTimeParseError {
271            message: format!("Invalid RFC 3339 time: {}", time_str),
272        });
273    }
274
275    // Validate basic format
276    if time_str.chars().nth(2) != Some(':') || time_str.chars().nth(5) != Some(':') {
277        return Err(DateTimeParseError {
278            message: format!("Invalid RFC 3339 time: {}", time_str),
279        });
280    }
281
282    let hours: i64 = time_str[..2].parse().map_err(|_| DateTimeParseError {
283        message: format!("Invalid hours in time: {}", time_str),
284    })?;
285
286    let minutes: i64 = time_str[3..5].parse().map_err(|_| DateTimeParseError {
287        message: format!("Invalid minutes in time: {}", time_str),
288    })?;
289
290    let seconds: i64 = time_str[6..8].parse().map_err(|_| DateTimeParseError {
291        message: format!("Invalid seconds in time: {}", time_str),
292    })?;
293
294    // Validate ranges
295    if hours > 23 {
296        return Err(DateTimeParseError {
297            message: format!("Invalid hours in time: {}", time_str),
298        });
299    }
300    if minutes > 59 {
301        return Err(DateTimeParseError {
302            message: format!("Invalid minutes in time: {}", time_str),
303        });
304    }
305    if seconds > 59 {
306        return Err(DateTimeParseError {
307            message: format!("Invalid seconds in time: {}", time_str),
308        });
309    }
310
311    // Parse optional fractional seconds and timezone
312    let rest = &time_str[8..];
313    let (fractional, offset_str) = if rest.starts_with('.') {
314        // Find where fractional seconds end
315        let frac_end = rest[1..]
316            .find(|c: char| !c.is_ascii_digit())
317            .map(|i| i + 1)
318            .unwrap_or(rest.len());
319
320        let frac = &rest[1..frac_end];
321        let tz = if frac_end < rest.len() {
322            Some(&rest[frac_end..])
323        } else {
324            None
325        };
326        (Some(frac), tz)
327    } else if rest.is_empty() {
328        (None, None)
329    } else {
330        (None, Some(rest))
331    };
332
333    let microseconds = parse_fractional_seconds(fractional);
334    let time_micros = hours * MICROSECONDS_PER_HOUR
335        + minutes * MICROSECONDS_PER_MINUTE
336        + seconds * MICROSECONDS_PER_SECOND
337        + microseconds;
338
339    // Validate total is within day
340    if time_micros > 86_399_999_999 {
341        return Err(DateTimeParseError {
342            message: format!("Time exceeds maximum (23:59:59.999999): {}", time_str),
343        });
344    }
345
346    let offset_min = match offset_str {
347        Some(s) => parse_timezone_offset(s)?,
348        None => {
349            return Err(DateTimeParseError {
350                message: format!("Timezone offset required in time: {}", time_str),
351            });
352        }
353    };
354
355    Ok((time_micros, offset_min))
356}
357
358/// Formats microseconds since midnight as RFC 3339 time string.
359pub fn format_time_rfc3339(time_micros: i64, offset_min: i16) -> String {
360    let hours = time_micros / MICROSECONDS_PER_HOUR;
361    let remaining1 = time_micros % MICROSECONDS_PER_HOUR;
362    let minutes = remaining1 / MICROSECONDS_PER_MINUTE;
363    let remaining2 = remaining1 % MICROSECONDS_PER_MINUTE;
364    let seconds = remaining2 / MICROSECONDS_PER_SECOND;
365    let microseconds = remaining2 % MICROSECONDS_PER_SECOND;
366
367    let frac = format_fractional_seconds(microseconds);
368    let offset = format_timezone_offset(offset_min);
369
370    format!("{:02}:{:02}:{:02}{}{}", hours, minutes, seconds, frac, offset)
371}
372
373// =====================
374// DATETIME functions
375// =====================
376
377/// Parses an RFC 3339 datetime string and returns microseconds since Unix epoch
378/// and offset in minutes.
379///
380/// Spec: DATETIME value definition (spec.md "DATETIME" section) requires offset_min;
381/// reject inputs without explicit timezone (Z or ±HH:MM).
382pub fn parse_datetime_rfc3339(datetime_str: &str) -> Result<(i64, i16), DateTimeParseError> {
383    // Minimum length is 19 (YYYY-MM-DDTHH:MM:SS)
384    if datetime_str.len() < 19 {
385        return Err(DateTimeParseError {
386            message: format!("Invalid RFC 3339 datetime: {}", datetime_str),
387        });
388    }
389
390    // Check for T or space separator
391    let sep = datetime_str.chars().nth(10);
392    if sep != Some('T') && sep != Some(' ') {
393        return Err(DateTimeParseError {
394            message: format!("Invalid RFC 3339 datetime: {}", datetime_str),
395        });
396    }
397
398    // Parse date part
399    let date_part = &datetime_str[..10];
400    if date_part.chars().nth(4) != Some('-') || date_part.chars().nth(7) != Some('-') {
401        return Err(DateTimeParseError {
402            message: format!("Invalid RFC 3339 datetime: {}", datetime_str),
403        });
404    }
405
406    let year: i32 = date_part[..4].parse().map_err(|_| DateTimeParseError {
407        message: format!("Invalid year in datetime: {}", datetime_str),
408    })?;
409
410    let month: u32 = date_part[5..7].parse().map_err(|_| DateTimeParseError {
411        message: format!("Invalid month in datetime: {}", datetime_str),
412    })?;
413
414    let day: u32 = date_part[8..10].parse().map_err(|_| DateTimeParseError {
415        message: format!("Invalid day in datetime: {}", datetime_str),
416    })?;
417
418    // Validate month and day
419    if month < 1 || month > 12 {
420        return Err(DateTimeParseError {
421            message: format!("Invalid month in datetime: {}", datetime_str),
422        });
423    }
424    if day < 1 || day > days_in_month(year, month) {
425        return Err(DateTimeParseError {
426            message: format!("Invalid day in datetime: {}", datetime_str),
427        });
428    }
429
430    // Parse time part
431    let time_part = &datetime_str[11..];
432    if time_part.len() < 8
433        || time_part.chars().nth(2) != Some(':')
434        || time_part.chars().nth(5) != Some(':')
435    {
436        return Err(DateTimeParseError {
437            message: format!("Invalid RFC 3339 datetime: {}", datetime_str),
438        });
439    }
440
441    let hours: i64 = time_part[..2].parse().map_err(|_| DateTimeParseError {
442        message: format!("Invalid hours in datetime: {}", datetime_str),
443    })?;
444
445    let minutes: i64 = time_part[3..5].parse().map_err(|_| DateTimeParseError {
446        message: format!("Invalid minutes in datetime: {}", datetime_str),
447    })?;
448
449    let seconds: i64 = time_part[6..8].parse().map_err(|_| DateTimeParseError {
450        message: format!("Invalid seconds in datetime: {}", datetime_str),
451    })?;
452
453    // Validate ranges
454    if hours > 23 {
455        return Err(DateTimeParseError {
456            message: format!("Invalid hours in datetime: {}", datetime_str),
457        });
458    }
459    if minutes > 59 {
460        return Err(DateTimeParseError {
461            message: format!("Invalid minutes in datetime: {}", datetime_str),
462        });
463    }
464    if seconds > 59 {
465        return Err(DateTimeParseError {
466            message: format!("Invalid seconds in datetime: {}", datetime_str),
467        });
468    }
469
470    // Parse optional fractional seconds and timezone
471    let rest = &time_part[8..];
472    let (fractional, offset_str) = if rest.starts_with('.') {
473        // Find where fractional seconds end
474        let frac_end = rest[1..]
475            .find(|c: char| !c.is_ascii_digit())
476            .map(|i| i + 1)
477            .unwrap_or(rest.len());
478
479        let frac = &rest[1..frac_end];
480        let tz = if frac_end < rest.len() {
481            Some(&rest[frac_end..])
482        } else {
483            None
484        };
485        (Some(frac), tz)
486    } else if rest.is_empty() {
487        (None, None)
488    } else {
489        (None, Some(rest))
490    };
491
492    let offset_min = match offset_str {
493        Some(s) => parse_timezone_offset(s)?,
494        None => {
495            return Err(DateTimeParseError {
496                message: format!("Timezone offset required in datetime: {}", datetime_str),
497            });
498        }
499    };
500
501    let microseconds = parse_fractional_seconds(fractional);
502
503    // Calculate epoch microseconds
504    // First, get days since epoch for the date
505    let days = date_to_days(year, month, day) as i64;
506
507    // Calculate epoch_micros for the local time components
508    let epoch_micros_utc = days * MILLISECONDS_PER_DAY * 1000
509        + hours * MICROSECONDS_PER_HOUR
510        + minutes * MICROSECONDS_PER_MINUTE
511        + seconds * MICROSECONDS_PER_SECOND
512        + microseconds;
513
514    // Adjust for timezone offset: local time = UTC + offset, so UTC = local - offset
515    let offset_us = offset_min as i64 * MICROSECONDS_PER_MINUTE;
516    let epoch_micros = epoch_micros_utc - offset_us;
517
518    Ok((epoch_micros, offset_min))
519}
520
521/// Formats microseconds since Unix epoch as RFC 3339 datetime string.
522pub fn format_datetime_rfc3339(epoch_micros: i64, offset_min: i16) -> String {
523    // Adjust for timezone offset: local time = UTC + offset
524    let offset_us = offset_min as i64 * MICROSECONDS_PER_MINUTE;
525    let local_us = epoch_micros + offset_us;
526
527    // Convert to days and time-of-day
528    let us_per_day = MILLISECONDS_PER_DAY * 1000;
529
530    // Handle negative microseconds (before epoch)
531    let (days, time_micros) = if local_us >= 0 {
532        let days = (local_us / us_per_day) as i32;
533        let time_micros = local_us % us_per_day;
534        (days, time_micros)
535    } else {
536        // For negative values, we need to adjust
537        let days = ((local_us + 1) / us_per_day - 1) as i32;
538        let time_micros = ((local_us % us_per_day) + us_per_day) % us_per_day;
539        (days, time_micros)
540    };
541
542    let (year, month, day) = days_to_date(days);
543
544    let hours = time_micros / MICROSECONDS_PER_HOUR;
545    let remaining1 = time_micros % MICROSECONDS_PER_HOUR;
546    let minutes = remaining1 / MICROSECONDS_PER_MINUTE;
547    let remaining2 = remaining1 % MICROSECONDS_PER_MINUTE;
548    let seconds = remaining2 / MICROSECONDS_PER_SECOND;
549    let microseconds = remaining2 % MICROSECONDS_PER_SECOND;
550
551    let frac = format_fractional_seconds(microseconds);
552    let offset = format_timezone_offset(offset_min);
553
554    format!(
555        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{}",
556        year, month, day, hours, minutes, seconds, frac, offset
557    )
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    #[test]
565    fn test_parse_date_basic() {
566        let (days, offset) = parse_date_rfc3339("1970-01-01").unwrap();
567        assert_eq!(days, 0);
568        assert_eq!(offset, 0);
569
570        let (days, offset) = parse_date_rfc3339("1970-01-01Z").unwrap();
571        assert_eq!(days, 0);
572        assert_eq!(offset, 0);
573
574        let (days, offset) = parse_date_rfc3339("2024-03-15").unwrap();
575        assert_eq!(days, 19797);
576        assert_eq!(offset, 0);
577
578        let (days, offset) = parse_date_rfc3339("2024-03-15+05:30").unwrap();
579        assert_eq!(days, 19797);
580        assert_eq!(offset, 330);
581    }
582
583    #[test]
584    fn test_format_date() {
585        assert_eq!(format_date_rfc3339(0, 0), "1970-01-01Z");
586        assert_eq!(format_date_rfc3339(19797, 0), "2024-03-15Z");
587        assert_eq!(format_date_rfc3339(19797, 330), "2024-03-15+05:30");
588        assert_eq!(format_date_rfc3339(19797, -300), "2024-03-15-05:00");
589    }
590
591    #[test]
592    fn test_date_roundtrip() {
593        let dates = [
594            "1970-01-01Z",
595            "2024-03-15Z",
596            "2024-03-15+05:30",
597            "2024-12-31-08:00",
598            "2000-02-29Z", // leap year
599        ];
600
601        for date in dates {
602            let (days, offset) = parse_date_rfc3339(date).unwrap();
603            let formatted = format_date_rfc3339(days, offset);
604            assert_eq!(date, formatted, "Roundtrip failed for {}", date);
605        }
606    }
607
608    #[test]
609    fn test_parse_time_basic() {
610        let (time_micros, offset) = parse_time_rfc3339("14:30:00Z").unwrap();
611        assert_eq!(time_micros, 52_200_000_000);
612        assert_eq!(offset, 0);
613
614        let (time_micros, offset) = parse_time_rfc3339("14:30:00.5Z").unwrap();
615        assert_eq!(time_micros, 52_200_500_000);
616        assert_eq!(offset, 0);
617
618        let (time_micros, offset) = parse_time_rfc3339("14:30:00.123456+05:30").unwrap();
619        assert_eq!(time_micros, 52_200_123_456);
620        assert_eq!(offset, 330);
621    }
622
623    #[test]
624    fn test_format_time() {
625        assert_eq!(format_time_rfc3339(0, 0), "00:00:00Z");
626        assert_eq!(format_time_rfc3339(52_200_000_000, 0), "14:30:00Z");
627        assert_eq!(format_time_rfc3339(52_200_500_000, 0), "14:30:00.5Z");
628        assert_eq!(format_time_rfc3339(52_200_123_456, 330), "14:30:00.123456+05:30");
629    }
630
631    #[test]
632    fn test_time_roundtrip() {
633        let times = [
634            "00:00:00Z",
635            "14:30:00Z",
636            "14:30:00.5Z",
637            "14:30:00.123456Z",
638            "23:59:59.999999Z",
639            "14:30:00+05:30",
640            "14:30:00-08:00",
641        ];
642
643        for time in times {
644            let (time_micros, offset) = parse_time_rfc3339(time).unwrap();
645            let formatted = format_time_rfc3339(time_micros, offset);
646            assert_eq!(time, formatted, "Roundtrip failed for {}", time);
647        }
648    }
649
650    #[test]
651    fn test_parse_datetime_basic() {
652        let (epoch_micros, offset) = parse_datetime_rfc3339("1970-01-01T00:00:00Z").unwrap();
653        assert_eq!(epoch_micros, 0);
654        assert_eq!(offset, 0);
655
656        let (epoch_micros, offset) = parse_datetime_rfc3339("2024-03-15T14:30:00Z").unwrap();
657        assert_eq!(epoch_micros, 1710513000000000);
658        assert_eq!(offset, 0);
659
660        let (epoch_micros, offset) = parse_datetime_rfc3339("2024-03-15T14:30:00.123456Z").unwrap();
661        assert_eq!(epoch_micros, 1710513000123456);
662        assert_eq!(offset, 0);
663    }
664
665    #[test]
666    fn test_format_datetime() {
667        assert_eq!(format_datetime_rfc3339(0, 0), "1970-01-01T00:00:00Z");
668        assert_eq!(
669            format_datetime_rfc3339(1710513000000000, 0),
670            "2024-03-15T14:30:00Z"
671        );
672        assert_eq!(
673            format_datetime_rfc3339(1710513000123456, 0),
674            "2024-03-15T14:30:00.123456Z"
675        );
676    }
677
678    #[test]
679    fn test_datetime_roundtrip() {
680        let datetimes = [
681            "1970-01-01T00:00:00Z",
682            "2024-03-15T14:30:00Z",
683            "2024-03-15T14:30:00.5Z",
684            "2024-03-15T14:30:00.123456Z",
685            "2024-12-31T23:59:59.999999Z",
686        ];
687
688        for datetime in datetimes {
689            let (epoch_micros, offset) = parse_datetime_rfc3339(datetime).unwrap();
690            let formatted = format_datetime_rfc3339(epoch_micros, offset);
691            assert_eq!(datetime, formatted, "Roundtrip failed for {}", datetime);
692        }
693    }
694
695    #[test]
696    fn test_datetime_with_offset() {
697        // 2024-03-15T14:30:00+05:30 should be 2024-03-15T09:00:00Z
698        let (epoch_micros, offset) = parse_datetime_rfc3339("2024-03-15T14:30:00+05:30").unwrap();
699        assert_eq!(offset, 330);
700        // The epoch_micros should be 5.5 hours less than 2024-03-15T14:30:00Z
701        let (utc_epoch_micros, _) = parse_datetime_rfc3339("2024-03-15T09:00:00Z").unwrap();
702        assert_eq!(epoch_micros, utc_epoch_micros);
703
704        // Formatting should preserve the offset
705        let formatted = format_datetime_rfc3339(epoch_micros, offset);
706        assert_eq!(formatted, "2024-03-15T14:30:00+05:30");
707    }
708
709    #[test]
710    fn test_negative_epoch() {
711        // Before Unix epoch
712        let (epoch_micros, offset) = parse_datetime_rfc3339("1969-12-31T23:59:59Z").unwrap();
713        assert_eq!(epoch_micros, -1_000_000);
714        assert_eq!(offset, 0);
715
716        let formatted = format_datetime_rfc3339(epoch_micros, offset);
717        assert_eq!(formatted, "1969-12-31T23:59:59Z");
718    }
719
720    #[test]
721    fn test_invalid_dates() {
722        assert!(parse_date_rfc3339("2024-13-01").is_err()); // invalid month
723        assert!(parse_date_rfc3339("2024-00-01").is_err()); // invalid month
724        assert!(parse_date_rfc3339("2024-02-30").is_err()); // invalid day
725        assert!(parse_date_rfc3339("2023-02-29").is_err()); // not a leap year
726        assert!(parse_date_rfc3339("not-a-date").is_err());
727    }
728
729    #[test]
730    fn test_invalid_times() {
731        assert!(parse_time_rfc3339("00:00:00").is_err()); // missing timezone
732        assert!(parse_time_rfc3339("24:00:00").is_err()); // invalid hour
733        assert!(parse_time_rfc3339("14:60:00").is_err()); // invalid minute
734        assert!(parse_time_rfc3339("14:30:60").is_err()); // invalid second
735        assert!(parse_time_rfc3339("not:a:time").is_err());
736    }
737
738    #[test]
739    fn test_invalid_datetimes() {
740        assert!(parse_datetime_rfc3339("2024-03-15T14:30:00").is_err()); // missing timezone
741        assert!(parse_datetime_rfc3339("not-a-datetime").is_err());
742    }
743
744    #[test]
745    fn test_timezone_offset_edge_cases() {
746        assert!(parse_timezone_offset("+24:00").is_ok());
747        assert!(parse_timezone_offset("-24:00").is_ok());
748        assert!(parse_timezone_offset("+24:01").is_err()); // out of range
749        assert!(parse_timezone_offset("-24:01").is_err()); // out of range
750    }
751}