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.
264pub fn parse_time_rfc3339(time_str: &str) -> Result<(i64, i16), DateTimeParseError> {
265    // Minimum length is 8 (HH:MM:SS)
266    if time_str.len() < 8 {
267        return Err(DateTimeParseError {
268            message: format!("Invalid RFC 3339 time: {}", time_str),
269        });
270    }
271
272    // Validate basic format
273    if time_str.chars().nth(2) != Some(':') || time_str.chars().nth(5) != Some(':') {
274        return Err(DateTimeParseError {
275            message: format!("Invalid RFC 3339 time: {}", time_str),
276        });
277    }
278
279    let hours: i64 = time_str[..2].parse().map_err(|_| DateTimeParseError {
280        message: format!("Invalid hours in time: {}", time_str),
281    })?;
282
283    let minutes: i64 = time_str[3..5].parse().map_err(|_| DateTimeParseError {
284        message: format!("Invalid minutes in time: {}", time_str),
285    })?;
286
287    let seconds: i64 = time_str[6..8].parse().map_err(|_| DateTimeParseError {
288        message: format!("Invalid seconds in time: {}", time_str),
289    })?;
290
291    // Validate ranges
292    if hours > 23 {
293        return Err(DateTimeParseError {
294            message: format!("Invalid hours in time: {}", time_str),
295        });
296    }
297    if minutes > 59 {
298        return Err(DateTimeParseError {
299            message: format!("Invalid minutes in time: {}", time_str),
300        });
301    }
302    if seconds > 59 {
303        return Err(DateTimeParseError {
304            message: format!("Invalid seconds in time: {}", time_str),
305        });
306    }
307
308    // Parse optional fractional seconds and timezone
309    let rest = &time_str[8..];
310    let (fractional, offset_str) = if rest.starts_with('.') {
311        // Find where fractional seconds end
312        let frac_end = rest[1..]
313            .find(|c: char| !c.is_ascii_digit())
314            .map(|i| i + 1)
315            .unwrap_or(rest.len());
316
317        let frac = &rest[1..frac_end];
318        let tz = if frac_end < rest.len() {
319            Some(&rest[frac_end..])
320        } else {
321            None
322        };
323        (Some(frac), tz)
324    } else if rest.is_empty() {
325        (None, None)
326    } else {
327        (None, Some(rest))
328    };
329
330    let microseconds = parse_fractional_seconds(fractional);
331    let time_micros = hours * MICROSECONDS_PER_HOUR
332        + minutes * MICROSECONDS_PER_MINUTE
333        + seconds * MICROSECONDS_PER_SECOND
334        + microseconds;
335
336    // Validate total is within day
337    if time_micros > 86_399_999_999 {
338        return Err(DateTimeParseError {
339            message: format!("Time exceeds maximum (23:59:59.999999): {}", time_str),
340        });
341    }
342
343    let offset_min = match offset_str {
344        Some(s) => parse_timezone_offset(s)?,
345        None => 0,
346    };
347
348    Ok((time_micros, offset_min))
349}
350
351/// Formats microseconds since midnight as RFC 3339 time string.
352pub fn format_time_rfc3339(time_micros: i64, offset_min: i16) -> String {
353    let hours = time_micros / MICROSECONDS_PER_HOUR;
354    let remaining1 = time_micros % MICROSECONDS_PER_HOUR;
355    let minutes = remaining1 / MICROSECONDS_PER_MINUTE;
356    let remaining2 = remaining1 % MICROSECONDS_PER_MINUTE;
357    let seconds = remaining2 / MICROSECONDS_PER_SECOND;
358    let microseconds = remaining2 % MICROSECONDS_PER_SECOND;
359
360    let frac = format_fractional_seconds(microseconds);
361    let offset = format_timezone_offset(offset_min);
362
363    format!("{:02}:{:02}:{:02}{}{}", hours, minutes, seconds, frac, offset)
364}
365
366// =====================
367// DATETIME functions
368// =====================
369
370/// Parses an RFC 3339 datetime string and returns microseconds since Unix epoch
371/// and offset in minutes.
372pub fn parse_datetime_rfc3339(datetime_str: &str) -> Result<(i64, i16), DateTimeParseError> {
373    // Minimum length is 19 (YYYY-MM-DDTHH:MM:SS)
374    if datetime_str.len() < 19 {
375        return Err(DateTimeParseError {
376            message: format!("Invalid RFC 3339 datetime: {}", datetime_str),
377        });
378    }
379
380    // Check for T or space separator
381    let sep = datetime_str.chars().nth(10);
382    if sep != Some('T') && sep != Some(' ') {
383        return Err(DateTimeParseError {
384            message: format!("Invalid RFC 3339 datetime: {}", datetime_str),
385        });
386    }
387
388    // Parse date part
389    let date_part = &datetime_str[..10];
390    if date_part.chars().nth(4) != Some('-') || date_part.chars().nth(7) != Some('-') {
391        return Err(DateTimeParseError {
392            message: format!("Invalid RFC 3339 datetime: {}", datetime_str),
393        });
394    }
395
396    let year: i32 = date_part[..4].parse().map_err(|_| DateTimeParseError {
397        message: format!("Invalid year in datetime: {}", datetime_str),
398    })?;
399
400    let month: u32 = date_part[5..7].parse().map_err(|_| DateTimeParseError {
401        message: format!("Invalid month in datetime: {}", datetime_str),
402    })?;
403
404    let day: u32 = date_part[8..10].parse().map_err(|_| DateTimeParseError {
405        message: format!("Invalid day in datetime: {}", datetime_str),
406    })?;
407
408    // Validate month and day
409    if month < 1 || month > 12 {
410        return Err(DateTimeParseError {
411            message: format!("Invalid month in datetime: {}", datetime_str),
412        });
413    }
414    if day < 1 || day > days_in_month(year, month) {
415        return Err(DateTimeParseError {
416            message: format!("Invalid day in datetime: {}", datetime_str),
417        });
418    }
419
420    // Parse time part
421    let time_part = &datetime_str[11..];
422    if time_part.len() < 8
423        || time_part.chars().nth(2) != Some(':')
424        || time_part.chars().nth(5) != Some(':')
425    {
426        return Err(DateTimeParseError {
427            message: format!("Invalid RFC 3339 datetime: {}", datetime_str),
428        });
429    }
430
431    let hours: i64 = time_part[..2].parse().map_err(|_| DateTimeParseError {
432        message: format!("Invalid hours in datetime: {}", datetime_str),
433    })?;
434
435    let minutes: i64 = time_part[3..5].parse().map_err(|_| DateTimeParseError {
436        message: format!("Invalid minutes in datetime: {}", datetime_str),
437    })?;
438
439    let seconds: i64 = time_part[6..8].parse().map_err(|_| DateTimeParseError {
440        message: format!("Invalid seconds in datetime: {}", datetime_str),
441    })?;
442
443    // Validate ranges
444    if hours > 23 {
445        return Err(DateTimeParseError {
446            message: format!("Invalid hours in datetime: {}", datetime_str),
447        });
448    }
449    if minutes > 59 {
450        return Err(DateTimeParseError {
451            message: format!("Invalid minutes in datetime: {}", datetime_str),
452        });
453    }
454    if seconds > 59 {
455        return Err(DateTimeParseError {
456            message: format!("Invalid seconds in datetime: {}", datetime_str),
457        });
458    }
459
460    // Parse optional fractional seconds and timezone
461    let rest = &time_part[8..];
462    let (fractional, offset_str) = if rest.starts_with('.') {
463        // Find where fractional seconds end
464        let frac_end = rest[1..]
465            .find(|c: char| !c.is_ascii_digit())
466            .map(|i| i + 1)
467            .unwrap_or(rest.len());
468
469        let frac = &rest[1..frac_end];
470        let tz = if frac_end < rest.len() {
471            Some(&rest[frac_end..])
472        } else {
473            None
474        };
475        (Some(frac), tz)
476    } else if rest.is_empty() {
477        (None, None)
478    } else {
479        (None, Some(rest))
480    };
481
482    let offset_min = match offset_str {
483        Some(s) => parse_timezone_offset(s)?,
484        None => 0,
485    };
486
487    let microseconds = parse_fractional_seconds(fractional);
488
489    // Calculate epoch microseconds
490    // First, get days since epoch for the date
491    let days = date_to_days(year, month, day) as i64;
492
493    // Calculate epoch_micros for the local time components
494    let epoch_micros_utc = days * MILLISECONDS_PER_DAY * 1000
495        + hours * MICROSECONDS_PER_HOUR
496        + minutes * MICROSECONDS_PER_MINUTE
497        + seconds * MICROSECONDS_PER_SECOND
498        + microseconds;
499
500    // Adjust for timezone offset: local time = UTC + offset, so UTC = local - offset
501    let offset_us = offset_min as i64 * MICROSECONDS_PER_MINUTE;
502    let epoch_micros = epoch_micros_utc - offset_us;
503
504    Ok((epoch_micros, offset_min))
505}
506
507/// Formats microseconds since Unix epoch as RFC 3339 datetime string.
508pub fn format_datetime_rfc3339(epoch_micros: i64, offset_min: i16) -> String {
509    // Adjust for timezone offset: local time = UTC + offset
510    let offset_us = offset_min as i64 * MICROSECONDS_PER_MINUTE;
511    let local_us = epoch_micros + offset_us;
512
513    // Convert to days and time-of-day
514    let us_per_day = MILLISECONDS_PER_DAY * 1000;
515
516    // Handle negative microseconds (before epoch)
517    let (days, time_micros) = if local_us >= 0 {
518        let days = (local_us / us_per_day) as i32;
519        let time_micros = local_us % us_per_day;
520        (days, time_micros)
521    } else {
522        // For negative values, we need to adjust
523        let days = ((local_us + 1) / us_per_day - 1) as i32;
524        let time_micros = ((local_us % us_per_day) + us_per_day) % us_per_day;
525        (days, time_micros)
526    };
527
528    let (year, month, day) = days_to_date(days);
529
530    let hours = time_micros / MICROSECONDS_PER_HOUR;
531    let remaining1 = time_micros % MICROSECONDS_PER_HOUR;
532    let minutes = remaining1 / MICROSECONDS_PER_MINUTE;
533    let remaining2 = remaining1 % MICROSECONDS_PER_MINUTE;
534    let seconds = remaining2 / MICROSECONDS_PER_SECOND;
535    let microseconds = remaining2 % MICROSECONDS_PER_SECOND;
536
537    let frac = format_fractional_seconds(microseconds);
538    let offset = format_timezone_offset(offset_min);
539
540    format!(
541        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{}",
542        year, month, day, hours, minutes, seconds, frac, offset
543    )
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn test_parse_date_basic() {
552        let (days, offset) = parse_date_rfc3339("1970-01-01").unwrap();
553        assert_eq!(days, 0);
554        assert_eq!(offset, 0);
555
556        let (days, offset) = parse_date_rfc3339("1970-01-01Z").unwrap();
557        assert_eq!(days, 0);
558        assert_eq!(offset, 0);
559
560        let (days, offset) = parse_date_rfc3339("2024-03-15").unwrap();
561        assert_eq!(days, 19797);
562        assert_eq!(offset, 0);
563
564        let (days, offset) = parse_date_rfc3339("2024-03-15+05:30").unwrap();
565        assert_eq!(days, 19797);
566        assert_eq!(offset, 330);
567    }
568
569    #[test]
570    fn test_format_date() {
571        assert_eq!(format_date_rfc3339(0, 0), "1970-01-01Z");
572        assert_eq!(format_date_rfc3339(19797, 0), "2024-03-15Z");
573        assert_eq!(format_date_rfc3339(19797, 330), "2024-03-15+05:30");
574        assert_eq!(format_date_rfc3339(19797, -300), "2024-03-15-05:00");
575    }
576
577    #[test]
578    fn test_date_roundtrip() {
579        let dates = [
580            "1970-01-01Z",
581            "2024-03-15Z",
582            "2024-03-15+05:30",
583            "2024-12-31-08:00",
584            "2000-02-29Z", // leap year
585        ];
586
587        for date in dates {
588            let (days, offset) = parse_date_rfc3339(date).unwrap();
589            let formatted = format_date_rfc3339(days, offset);
590            assert_eq!(date, formatted, "Roundtrip failed for {}", date);
591        }
592    }
593
594    #[test]
595    fn test_parse_time_basic() {
596        let (time_micros, offset) = parse_time_rfc3339("00:00:00").unwrap();
597        assert_eq!(time_micros, 0);
598        assert_eq!(offset, 0);
599
600        let (time_micros, offset) = parse_time_rfc3339("14:30:00Z").unwrap();
601        assert_eq!(time_micros, 52_200_000_000);
602        assert_eq!(offset, 0);
603
604        let (time_micros, offset) = parse_time_rfc3339("14:30:00.5Z").unwrap();
605        assert_eq!(time_micros, 52_200_500_000);
606        assert_eq!(offset, 0);
607
608        let (time_micros, offset) = parse_time_rfc3339("14:30:00.123456+05:30").unwrap();
609        assert_eq!(time_micros, 52_200_123_456);
610        assert_eq!(offset, 330);
611    }
612
613    #[test]
614    fn test_format_time() {
615        assert_eq!(format_time_rfc3339(0, 0), "00:00:00Z");
616        assert_eq!(format_time_rfc3339(52_200_000_000, 0), "14:30:00Z");
617        assert_eq!(format_time_rfc3339(52_200_500_000, 0), "14:30:00.5Z");
618        assert_eq!(format_time_rfc3339(52_200_123_456, 330), "14:30:00.123456+05:30");
619    }
620
621    #[test]
622    fn test_time_roundtrip() {
623        let times = [
624            "00:00:00Z",
625            "14:30:00Z",
626            "14:30:00.5Z",
627            "14:30:00.123456Z",
628            "23:59:59.999999Z",
629            "14:30:00+05:30",
630            "14:30:00-08:00",
631        ];
632
633        for time in times {
634            let (time_micros, offset) = parse_time_rfc3339(time).unwrap();
635            let formatted = format_time_rfc3339(time_micros, offset);
636            assert_eq!(time, formatted, "Roundtrip failed for {}", time);
637        }
638    }
639
640    #[test]
641    fn test_parse_datetime_basic() {
642        let (epoch_micros, offset) = parse_datetime_rfc3339("1970-01-01T00:00:00Z").unwrap();
643        assert_eq!(epoch_micros, 0);
644        assert_eq!(offset, 0);
645
646        let (epoch_micros, offset) = parse_datetime_rfc3339("2024-03-15T14:30:00Z").unwrap();
647        assert_eq!(epoch_micros, 1710513000000000);
648        assert_eq!(offset, 0);
649
650        let (epoch_micros, offset) = parse_datetime_rfc3339("2024-03-15T14:30:00.123456Z").unwrap();
651        assert_eq!(epoch_micros, 1710513000123456);
652        assert_eq!(offset, 0);
653    }
654
655    #[test]
656    fn test_format_datetime() {
657        assert_eq!(format_datetime_rfc3339(0, 0), "1970-01-01T00:00:00Z");
658        assert_eq!(
659            format_datetime_rfc3339(1710513000000000, 0),
660            "2024-03-15T14:30:00Z"
661        );
662        assert_eq!(
663            format_datetime_rfc3339(1710513000123456, 0),
664            "2024-03-15T14:30:00.123456Z"
665        );
666    }
667
668    #[test]
669    fn test_datetime_roundtrip() {
670        let datetimes = [
671            "1970-01-01T00:00:00Z",
672            "2024-03-15T14:30:00Z",
673            "2024-03-15T14:30:00.5Z",
674            "2024-03-15T14:30:00.123456Z",
675            "2024-12-31T23:59:59.999999Z",
676        ];
677
678        for datetime in datetimes {
679            let (epoch_micros, offset) = parse_datetime_rfc3339(datetime).unwrap();
680            let formatted = format_datetime_rfc3339(epoch_micros, offset);
681            assert_eq!(datetime, formatted, "Roundtrip failed for {}", datetime);
682        }
683    }
684
685    #[test]
686    fn test_datetime_with_offset() {
687        // 2024-03-15T14:30:00+05:30 should be 2024-03-15T09:00:00Z
688        let (epoch_micros, offset) = parse_datetime_rfc3339("2024-03-15T14:30:00+05:30").unwrap();
689        assert_eq!(offset, 330);
690        // The epoch_micros should be 5.5 hours less than 2024-03-15T14:30:00Z
691        let (utc_epoch_micros, _) = parse_datetime_rfc3339("2024-03-15T09:00:00Z").unwrap();
692        assert_eq!(epoch_micros, utc_epoch_micros);
693
694        // Formatting should preserve the offset
695        let formatted = format_datetime_rfc3339(epoch_micros, offset);
696        assert_eq!(formatted, "2024-03-15T14:30:00+05:30");
697    }
698
699    #[test]
700    fn test_negative_epoch() {
701        // Before Unix epoch
702        let (epoch_micros, offset) = parse_datetime_rfc3339("1969-12-31T23:59:59Z").unwrap();
703        assert_eq!(epoch_micros, -1_000_000);
704        assert_eq!(offset, 0);
705
706        let formatted = format_datetime_rfc3339(epoch_micros, offset);
707        assert_eq!(formatted, "1969-12-31T23:59:59Z");
708    }
709
710    #[test]
711    fn test_invalid_dates() {
712        assert!(parse_date_rfc3339("2024-13-01").is_err()); // invalid month
713        assert!(parse_date_rfc3339("2024-00-01").is_err()); // invalid month
714        assert!(parse_date_rfc3339("2024-02-30").is_err()); // invalid day
715        assert!(parse_date_rfc3339("2023-02-29").is_err()); // not a leap year
716        assert!(parse_date_rfc3339("not-a-date").is_err());
717    }
718
719    #[test]
720    fn test_invalid_times() {
721        assert!(parse_time_rfc3339("24:00:00").is_err()); // invalid hour
722        assert!(parse_time_rfc3339("14:60:00").is_err()); // invalid minute
723        assert!(parse_time_rfc3339("14:30:60").is_err()); // invalid second
724        assert!(parse_time_rfc3339("not:a:time").is_err());
725    }
726
727    #[test]
728    fn test_timezone_offset_edge_cases() {
729        assert!(parse_timezone_offset("+24:00").is_ok());
730        assert!(parse_timezone_offset("-24:00").is_ok());
731        assert!(parse_timezone_offset("+24:01").is_err()); // out of range
732        assert!(parse_timezone_offset("-24:01").is_err()); // out of range
733    }
734}