fasttime/
lib.rs

1#![cfg_attr(not(feature = "std"), no_std)]
2
3//! fasttime — small UTC date/time library built around Ben Joffe's
4//! fast 64-bit days→date algorithm.
5//!
6//! Features:
7//! - `no_std` compatible (only `core`; `std` is optional).
8//! - `Date` / `Time` / `DateTime` (UTC).
9//! - `Duration` with nanosecond precision.
10//! - `UtcOffset` and `OffsetDateTime` (fixed offset, RFC 3339-style).
11//! - ISO-like formatting via `Display`.
12//! - Parsing of:
13//!   - `Date`: "YYYY-MM-DD"
14//!   - `Time`: "HH:MM:SS[.fffffffff]"
15//!   - `DateTime` (UTC): "YYYY-MM-DDTHH:MM:SS[.fffffffff]Z"
16//!   - `OffsetDateTime`: "YYYY-MM-DDTHH:MM:SS[.fffffffff][Z|±HH:MM]" (RFC 3339 subset).
17//! - `DateTime::now_utc()` when the `std` feature is enabled.
18//!
19//! ## Python Bindings
20//!
21//! When built with the `python` feature, this crate provides Python bindings via PyO3.
22//! See the `python/` directory for examples and documentation.
23
24use core::cmp::Ordering;
25use core::fmt;
26use core::str::FromStr;
27
28#[cfg(feature = "python")]
29mod python;
30
31/// Calendar weekday (ISO order, Monday = 1).
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum Weekday {
34    Monday,
35    Tuesday,
36    Wednesday,
37    Thursday,
38    Friday,
39    Saturday,
40    Sunday,
41}
42
43impl Weekday {
44    pub fn number_from_monday(self) -> u8 {
45        match self {
46            Weekday::Monday => 1,
47            Weekday::Tuesday => 2,
48            Weekday::Wednesday => 3,
49            Weekday::Thursday => 4,
50            Weekday::Friday => 5,
51            Weekday::Saturday => 6,
52            Weekday::Sunday => 7,
53        }
54    }
55}
56
57/// Errors constructing or parsing a `Date`.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum DateError {
60    /// Year/month/day combination is not a valid Gregorian date.
61    InvalidDate,
62    /// The date is outside the supported range.
63    OutOfRange,
64}
65
66/// Gregorian calendar date (proleptic).
67///
68/// This is independent of any time zone; think "calendar day in UTC".
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub struct Date {
71    pub year: i32,
72    pub month: u8, // 1..=12
73    pub day: u8,   // 1..=31
74}
75
76impl Date {
77    /// Construct a date, validating year/month/day.
78    pub fn from_ymd(year: i32, month: u8, day: u8) -> Result<Self, DateError> {
79        if !(1..=12).contains(&month) {
80            return Err(DateError::InvalidDate);
81        }
82        let dim = days_in_month(year, month);
83        if day == 0 || day > dim {
84            return Err(DateError::InvalidDate);
85        }
86        Ok(Date { year, month, day })
87    }
88
89    /// Construct a date with minimal checking; debug-only asserts.
90    ///
91    /// Panics in debug builds if the date is invalid.
92    pub const fn from_ymd_unchecked(year: i32, month: u8, day: u8) -> Self {
93        // These are simple invariants, checked in debug builds only.
94        debug_assert!(month >= 1 && month <= 12);
95        debug_assert!(day >= 1 && day <= 31);
96        Date { year, month, day }
97    }
98
99    /// Ben Joffe's fast 64-bit days→date algorithm, adapted to Rust.
100    ///
101    /// `days` is days since Unix epoch:
102    ///
103    /// - 1970-01-01 => 0
104    /// - 1969-12-31 => -1
105    pub fn from_days_since_unix_epoch(days: i64) -> Result<Self, DateError> {
106        // Constants from the article (x64 version).
107        const ERAS: i64 = 4_726_498_270;
108        const D_SHIFT: i64 = 146_097 * ERAS - 719_469;
109        const Y_SHIFT: i64 = 400 * ERAS - 1;
110        const C1: u64 = 505_054_698_555_331;
111        const C2: u64 = 50_504_432_782_230_121;
112        const C3: u64 = 8_619_973_866_219_416;
113
114        let rev: i64 = D_SHIFT - days;
115
116        // 64x64 → high 64 bit multiplies via u128 with explicit u64 casts.
117        let cen: i64 = (((rev as u64 as u128) * (C1 as u128)) >> 64) as i64;
118        let jul: i64 = rev + cen - cen / 4;
119
120        let num: u128 = (jul as u64 as u128) * (C2 as u128);
121        let yrs: i64 = Y_SHIFT - ((num >> 64) as i64);
122        let low: u64 = num as u64;
123        let ypt: i64 = ((782_432u128 * low as u128) >> 64) as i64;
124
125        let bump = ypt < 126_464;
126        let shift: i64 = if bump { 191_360 } else { 977_792 };
127
128        let n: i64 = (yrs.rem_euclid(4)) * 512 + shift - ypt;
129
130        let d: i64 = (((((n as u64) & 0xFFFF) as u128) * (C3 as u128)) >> 64) as i64;
131
132        let day_i: i64 = d + 1;
133        let month_i: i64 = n / 65_536;
134        let year_i: i64 = yrs + if bump { 1 } else { 0 };
135
136        if !(i32::MIN as i64..=i32::MAX as i64).contains(&year_i) {
137            return Err(DateError::OutOfRange);
138        }
139        let year = year_i as i32;
140        let month = month_i as u8;
141        let day = day_i as u8;
142
143        // Extra safety: validate
144        if Date::from_ymd(year, month, day).is_err() {
145            return Err(DateError::InvalidDate);
146        }
147
148        Ok(Date { year, month, day })
149    }
150
151    /// Convert to days since Unix epoch (1970-01-01 = 0).
152    ///
153    /// This uses Howard Hinnant's well-known constant-time civil→days
154    /// algorithm, which is exact for the proleptic Gregorian calendar.
155    pub fn days_since_unix_epoch(self) -> i64 {
156        days_from_civil(self.year, self.month, self.day)
157    }
158
159    /// Day of week (Monday = 1).
160    ///
161    /// Unix epoch 1970-01-01 was a Thursday, so we just offset.
162    pub fn weekday(self) -> Weekday {
163        // 1970-01-01 was Thursday (4).
164        let days = self.days_since_unix_epoch();
165        let w = days.rem_euclid(7);
166        match w {
167            0 => Weekday::Thursday,
168            1 => Weekday::Friday,
169            2 => Weekday::Saturday,
170            3 => Weekday::Sunday,
171            4 => Weekday::Monday,
172            5 => Weekday::Tuesday,
173            6 => Weekday::Wednesday,
174            _ => unreachable!(),
175        }
176    }
177
178    /// Day of year, 1..=365 (or 366 for leap years).
179    pub fn ordinal(self) -> u16 {
180        let month = self.month;
181        let day = self.day as u16;
182        const CUM_DAYS: [u16; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
183        let mut ord = CUM_DAYS[(month - 1) as usize] + day;
184        if month > 2 && is_leap_year(self.year) {
185            ord += 1;
186        }
187        ord
188    }
189
190    /// Add a number of days, returning a new `Date` or `OutOfRange`.
191    pub fn add_days(self, days: i64) -> Result<Date, DateError> {
192        let base = self.days_since_unix_epoch();
193        Date::from_days_since_unix_epoch(base + days)
194    }
195}
196
197impl PartialOrd for Date {
198    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
199        Some(self.cmp(other))
200    }
201}
202
203impl Ord for Date {
204    fn cmp(&self, other: &Self) -> Ordering {
205        let days_self = self.days_since_unix_epoch();
206        let days_other = other.days_since_unix_epoch();
207        days_self.cmp(&days_other)
208    }
209}
210
211impl fmt::Display for Date {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        // ISO-like: YYYY-MM-DD with at least 4 digits of year.
214        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
215    }
216}
217
218impl FromStr for Date {
219    type Err = DateError;
220
221    /// Parse "YYYY-MM-DD" (no timezone).
222    fn from_str(s: &str) -> Result<Self, Self::Err> {
223        let bytes = s.as_bytes();
224        if bytes.is_empty() {
225            return Err(DateError::InvalidDate);
226        }
227
228        let mut start = 0;
229        if bytes[0] == b'+' || bytes[0] == b'-' {
230            start = 1;
231            if start == bytes.len() {
232                return Err(DateError::InvalidDate);
233            }
234        }
235
236        let mut first = None;
237        let mut second = None;
238        for (i, &b) in bytes.iter().enumerate().skip(start) {
239            if b == b'-' {
240                if first.is_none() {
241                    first = Some(i);
242                } else if second.is_none() {
243                    second = Some(i);
244                } else {
245                    return Err(DateError::InvalidDate);
246                }
247            }
248        }
249
250        let (first, second) = match (first, second) {
251            (Some(first), Some(second)) => (first, second),
252            _ => return Err(DateError::InvalidDate),
253        };
254
255        let y = parse_i32_bytes(&bytes[..first]).ok_or(DateError::InvalidDate)?;
256        let m = parse_u32_bytes(&bytes[first + 1..second], 12).ok_or(DateError::InvalidDate)? as u8;
257        let d = parse_u32_bytes(&bytes[second + 1..], 31).ok_or(DateError::InvalidDate)? as u8;
258        Date::from_ymd(y, m, d)
259    }
260}
261
262/// Errors constructing or parsing a `Time`.
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum TimeError {
265    InvalidTime,
266}
267
268/// Time of day in nanoseconds since midnight.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
270pub struct Time {
271    pub hour: u8,        // 0..=23
272    pub minute: u8,      // 0..=59
273    pub second: u8,      // 0..=59 (no leap seconds)
274    pub nanosecond: u32, // 0..1_000_000_000
275}
276
277impl Time {
278    pub fn from_hms_nano(
279        hour: u8,
280        minute: u8,
281        second: u8,
282        nanosecond: u32,
283    ) -> Result<Self, TimeError> {
284        if hour > 23 || minute > 59 || second > 59 || nanosecond >= 1_000_000_000 {
285            return Err(TimeError::InvalidTime);
286        }
287        Ok(Time {
288            hour,
289            minute,
290            second,
291            nanosecond,
292        })
293    }
294
295    /// Total seconds since midnight (ignores nanoseconds).
296    pub fn seconds_since_midnight(self) -> u32 {
297        (self.hour as u32) * 3600 + (self.minute as u32) * 60 + (self.second as u32)
298    }
299
300    /// Total nanoseconds since midnight.
301    pub fn nanos_since_midnight(self) -> u64 {
302        self.seconds_since_midnight() as u64 * 1_000_000_000 + self.nanosecond as u64
303    }
304
305    /// Build from seconds and nanoseconds since midnight.
306    pub fn from_seconds_nanos(secs: u32, nanos: u32) -> Result<Self, TimeError> {
307        if secs >= 86_400 || nanos >= 1_000_000_000 {
308            return Err(TimeError::InvalidTime);
309        }
310        let hour = (secs / 3600) as u8;
311        let rem = secs % 3600;
312        let minute = (rem / 60) as u8;
313        let second = (rem % 60) as u8;
314        Time::from_hms_nano(hour, minute, second, nanos)
315    }
316}
317
318impl PartialOrd for Time {
319    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
320        Some(self.cmp(other))
321    }
322}
323
324impl Ord for Time {
325    fn cmp(&self, other: &Self) -> Ordering {
326        let nanos_self = self.nanos_since_midnight();
327        let nanos_other = other.nanos_since_midnight();
328        nanos_self.cmp(&nanos_other)
329    }
330}
331
332impl fmt::Display for Time {
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        if self.nanosecond == 0 {
335            write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)
336        } else {
337            // Print fractional seconds, trimming trailing zeros.
338            let mut frac = [b'0'; 9];
339            let mut ns = self.nanosecond;
340            for i in (0..9).rev() {
341                frac[i] = b'0' + (ns % 10) as u8;
342                ns /= 10;
343            }
344            // find last non-zero
345            let mut end = 9;
346            while end > 0 && frac[end - 1] == b'0' {
347                end -= 1;
348            }
349            let frac_str = core::str::from_utf8(&frac[..end]).unwrap_or("0");
350            write!(
351                f,
352                "{:02}:{:02}:{:02}.{}",
353                self.hour, self.minute, self.second, frac_str
354            )
355        }
356    }
357}
358
359impl FromStr for Time {
360    type Err = TimeError;
361
362    /// Parse "HH:MM:SS[.fffffffff]".
363    fn from_str(s: &str) -> Result<Self, Self::Err> {
364        let bytes = s.as_bytes();
365        let (hms_bytes, frac_bytes) = match bytes.iter().position(|&b| b == b'.') {
366            Some(idx) => (&bytes[..idx], Some(&bytes[idx + 1..])),
367            None => (bytes, None),
368        };
369
370        let mut first = None;
371        let mut second = None;
372        for (i, &b) in hms_bytes.iter().enumerate() {
373            if b == b':' {
374                if first.is_none() {
375                    first = Some(i);
376                } else if second.is_none() {
377                    second = Some(i);
378                } else {
379                    return Err(TimeError::InvalidTime);
380                }
381            }
382        }
383
384        let (first, second) = match (first, second) {
385            (Some(first), Some(second)) => (first, second),
386            _ => return Err(TimeError::InvalidTime),
387        };
388
389        let h = parse_u32_bytes(&hms_bytes[..first], 23).ok_or(TimeError::InvalidTime)? as u8;
390        let m =
391            parse_u32_bytes(&hms_bytes[first + 1..second], 59).ok_or(TimeError::InvalidTime)? as u8;
392        let sec =
393            parse_u32_bytes(&hms_bytes[second + 1..], 59).ok_or(TimeError::InvalidTime)? as u8;
394
395        let nanos = if let Some(fr) = frac_bytes {
396            parse_fraction_nanos(fr).ok_or(TimeError::InvalidTime)?
397        } else {
398            0
399        };
400
401        Time::from_hms_nano(h, m, sec, nanos)
402    }
403}
404
405/// Signed duration with nanosecond precision.
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
407pub struct Duration {
408    nanos: i128,
409}
410
411impl Duration {
412    pub const ZERO: Duration = Duration { nanos: 0 };
413
414    pub fn seconds(secs: i64) -> Duration {
415        Duration {
416            nanos: (secs as i128) * 1_000_000_000,
417        }
418    }
419
420    pub fn milliseconds(ms: i64) -> Duration {
421        Duration {
422            nanos: (ms as i128) * 1_000_000,
423        }
424    }
425
426    pub fn microseconds(us: i64) -> Duration {
427        Duration {
428            nanos: (us as i128) * 1_000,
429        }
430    }
431
432    pub fn nanoseconds(ns: i128) -> Duration {
433        Duration { nanos: ns }
434    }
435
436    pub fn total_seconds(self) -> f64 {
437        self.nanos as f64 / 1_000_000_000.0
438    }
439
440    pub fn total_nanos(self) -> i128 {
441        self.nanos
442    }
443}
444
445impl core::ops::Add for Duration {
446    type Output = Duration;
447    fn add(self, rhs: Duration) -> Duration {
448        Duration {
449            nanos: self.nanos + rhs.nanos,
450        }
451    }
452}
453
454impl core::ops::Sub for Duration {
455    type Output = Duration;
456    fn sub(self, rhs: Duration) -> Duration {
457        Duration {
458            nanos: self.nanos - rhs.nanos,
459        }
460    }
461}
462
463impl core::ops::Neg for Duration {
464    type Output = Duration;
465    fn neg(self) -> Duration {
466        Duration { nanos: -self.nanos }
467    }
468}
469
470impl PartialOrd for Duration {
471    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
472        Some(self.cmp(other))
473    }
474}
475
476impl Ord for Duration {
477    fn cmp(&self, other: &Self) -> Ordering {
478        self.nanos.cmp(&other.nanos)
479    }
480}
481
482/// Combined UTC date and time (no time zone).
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
484pub struct DateTime {
485    pub date: Date,
486    pub time: Time,
487}
488
489impl DateTime {
490    pub fn new(date: Date, time: Time) -> DateTime {
491        DateTime { date, time }
492    }
493
494    /// Build from Unix timestamp (seconds since 1970-01-01T00:00:00Z)
495    /// plus an additional nanoseconds offset (can be negative or >1e9).
496    pub fn from_unix_timestamp(secs: i64, nanos: i32) -> Result<DateTime, DateError> {
497        // Normalize (secs, nanos) pair.
498        let mut s = secs as i128;
499        let mut n = nanos as i128;
500        s += n.div_euclid(1_000_000_000);
501        n = n.rem_euclid(1_000_000_000);
502        let s_i64 = s as i64;
503
504        let days = s_i64.div_euclid(86_400);
505        let secs_of_day = s_i64.rem_euclid(86_400);
506        let date = Date::from_days_since_unix_epoch(days)?;
507        let time = Time::from_seconds_nanos(secs_of_day as u32, n as u32)
508            .map_err(|_| DateError::InvalidDate)?;
509        Ok(DateTime { date, time })
510    }
511
512    /// Seconds since Unix epoch (1970-01-01T00:00:00Z).
513    pub fn unix_timestamp(self) -> i64 {
514        let days = self.date.days_since_unix_epoch();
515        let day_secs = self.time.seconds_since_midnight() as i64;
516        days * 86_400 + day_secs
517    }
518
519    /// Nanoseconds since Unix epoch, as i128.
520    pub fn unix_timestamp_nanos(self) -> i128 {
521        self.unix_timestamp() as i128 * 1_000_000_000 + self.time.nanosecond as i128
522    }
523
524    /// Add a duration, returning a new `DateTime` (or `OutOfRange` on overflow).
525    pub fn add_duration(self, dur: Duration) -> Result<DateTime, DateError> {
526        let t = self.unix_timestamp_nanos() + dur.total_nanos();
527        let secs = t.div_euclid(1_000_000_000);
528        let nanos = t.rem_euclid(1_000_000_000);
529        DateTime::from_unix_timestamp(secs as i64, nanos as i32)
530    }
531
532    /// Difference between two instants (self - other).
533    pub fn difference(self, other: DateTime) -> Duration {
534        Duration::nanoseconds(self.unix_timestamp_nanos() - other.unix_timestamp_nanos())
535    }
536
537    /// Get the current UTC `DateTime` (requires `std` feature).
538    #[cfg(feature = "std")]
539    pub fn now_utc() -> Result<Self, DateError> {
540        use std::time::{SystemTime, UNIX_EPOCH};
541        let now = SystemTime::now();
542        match now.duration_since(UNIX_EPOCH) {
543            Ok(dur) => {
544                DateTime::from_unix_timestamp(dur.as_secs() as i64, dur.subsec_nanos() as i32)
545            }
546            Err(e) => {
547                let dur = e.duration();
548                let secs = dur.as_secs() as i64;
549                let nanos = dur.subsec_nanos() as i32;
550                DateTime::from_unix_timestamp(-secs, -nanos)
551            }
552        }
553    }
554}
555
556impl fmt::Display for DateTime {
557    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
558        // ISO 8601 / RFC 3339 UTC: YYYY-MM-DDTHH:MM:SS[.frac]Z
559        write!(f, "{}T{}Z", self.date, self.time)
560    }
561}
562
563impl FromStr for DateTime {
564    type Err = ();
565
566    /// Parse "YYYY-MM-DDTHH:MM:SS[.fffffffff]Z" (UTC only).
567    fn from_str(s: &str) -> Result<Self, Self::Err> {
568        let s = s
569            .strip_suffix('Z')
570            .or_else(|| s.strip_suffix('z'))
571            .ok_or(())?;
572        let (date_str, time_str) = s.split_once('T').or_else(|| s.split_once(' ')).ok_or(())?;
573        let date = date_str.parse::<Date>().map_err(|_| ())?;
574        let time = time_str.parse::<Time>().map_err(|_| ())?;
575        Ok(DateTime { date, time })
576    }
577}
578
579impl PartialOrd for DateTime {
580    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
581        Some(self.cmp(other))
582    }
583}
584
585impl Ord for DateTime {
586    fn cmp(&self, other: &Self) -> Ordering {
587        self.unix_timestamp_nanos()
588            .cmp(&other.unix_timestamp_nanos())
589    }
590}
591
592/// Error constructing a UTC offset.
593#[derive(Debug, Clone, Copy, PartialEq, Eq)]
594pub enum UtcOffsetError {
595    OutOfRange,
596}
597
598/// Fixed offset from UTC, in seconds (e.g. +02:00).
599#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
600pub struct UtcOffset {
601    seconds: i32,
602}
603
604impl UtcOffset {
605    /// Construct from a total number of seconds, roughly in [-24h, +24h].
606    pub fn from_seconds(seconds: i32) -> Result<Self, UtcOffsetError> {
607        // Rough sanity bounds: [-24h, +24h].
608        if !(-86_400..=86_400).contains(&seconds) {
609            return Err(UtcOffsetError::OutOfRange);
610        }
611        Ok(UtcOffset { seconds })
612    }
613
614    /// Construct from hours and minutes, with `sign_positive` sign.
615    ///
616    /// For example:
617    /// - `from_hours_minutes(true, 2, 0)` => +02:00
618    /// - `from_hours_minutes(false, 5, 30)` => -05:30
619    pub fn from_hours_minutes(
620        sign_positive: bool,
621        hours: u8,
622        minutes: u8,
623    ) -> Result<Self, UtcOffsetError> {
624        if hours > 23 || minutes > 59 {
625            return Err(UtcOffsetError::OutOfRange);
626        }
627        let total = (hours as i32) * 3600 + (minutes as i32) * 60;
628        let total = if sign_positive { total } else { -total };
629        Self::from_seconds(total)
630    }
631
632    pub fn as_seconds(self) -> i32 {
633        self.seconds
634    }
635
636    pub fn is_utc(self) -> bool {
637        self.seconds == 0
638    }
639}
640
641impl PartialOrd for UtcOffset {
642    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
643        Some(self.cmp(other))
644    }
645}
646
647impl Ord for UtcOffset {
648    fn cmp(&self, other: &Self) -> Ordering {
649        self.seconds.cmp(&other.seconds)
650    }
651}
652
653impl fmt::Display for UtcOffset {
654    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
655        let mut secs = self.seconds;
656        let sign = if secs >= 0 { '+' } else { '-' };
657        if secs < 0 {
658            secs = -secs;
659        }
660        let hours = secs / 3600;
661        let minutes = (secs % 3600) / 60;
662        write!(f, "{}{:02}:{:02}", sign, hours, minutes)
663    }
664}
665
666/// Date-time with a fixed offset from UTC (RFC 3339-style).
667#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
668pub struct OffsetDateTime {
669    /// Instant in UTC.
670    pub utc: DateTime,
671    /// Fixed offset.
672    pub offset: UtcOffset,
673}
674
675impl OffsetDateTime {
676    /// Construct from a UTC instant and an offset.
677    pub fn from_utc(utc: DateTime, offset: UtcOffset) -> Self {
678        OffsetDateTime { utc, offset }
679    }
680
681    /// Construct from a local date+time with offset, converting to UTC.
682    pub fn from_local(date: Date, time: Time, offset: UtcOffset) -> Result<Self, DateError> {
683        let local = DateTime::new(date, time);
684        let utc = local.add_duration(Duration::seconds(-(offset.as_seconds() as i64)))?;
685        Ok(OffsetDateTime { utc, offset })
686    }
687
688    /// Local date/time as seen in this offset.
689    pub fn to_local(&self) -> Result<DateTime, DateError> {
690        self.utc
691            .add_duration(Duration::seconds(self.offset.as_seconds() as i64))
692    }
693
694    /// Seconds since Unix epoch (1970-01-01T00:00:00Z).
695    pub fn unix_timestamp(&self) -> i64 {
696        self.utc.unix_timestamp()
697    }
698
699    /// Nanoseconds since Unix epoch.
700    pub fn unix_timestamp_nanos(&self) -> i128 {
701        self.utc.unix_timestamp_nanos()
702    }
703
704    /// Add a duration, keeping the same offset.
705    pub fn add_duration(&self, dur: Duration) -> Result<Self, DateError> {
706        let utc = self.utc.add_duration(dur)?;
707        Ok(OffsetDateTime {
708            utc,
709            offset: self.offset,
710        })
711    }
712
713    /// Difference between two instants (self - other).
714    pub fn difference(&self, other: OffsetDateTime) -> Duration {
715        self.utc.difference(other.utc)
716    }
717}
718
719impl fmt::Display for OffsetDateTime {
720    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
721        // RFC 3339: local "YYYY-MM-DDTHH:MM:SS[.frac]" + offset.
722        let local = self
723            .to_local()
724            .expect("OffsetDateTime local representation out of range");
725        write!(f, "{}T{}", local.date, local.time)?;
726        if self.offset.is_utc() {
727            write!(f, "Z")
728        } else {
729            write!(f, "{}", self.offset)
730        }
731    }
732}
733
734impl FromStr for OffsetDateTime {
735    type Err = ();
736
737    /// Parse RFC 3339-style:
738    /// "YYYY-MM-DDTHH:MM:SS[.fffffffff][Z|±HH:MM]"
739    fn from_str(s: &str) -> Result<Self, Self::Err> {
740        let s = s.trim();
741        let (date_part, rest) = s.split_once('T').or_else(|| s.split_once(' ')).ok_or(())?;
742        let date: Date = date_part.parse().map_err(|_| ())?;
743
744        // Parse time + offset.
745        let (time_part, offset_part) = if rest.ends_with('Z') || rest.ends_with('z') {
746            (&rest[..rest.len() - 1], "Z")
747        } else {
748            let idx = rest.rfind(['+', '-']).ok_or(())?;
749            (&rest[..idx], &rest[idx..])
750        };
751
752        let time: Time = time_part.parse().map_err(|_| ())?;
753        let offset = parse_rfc3339_offset(offset_part).map_err(|_| ())?;
754        OffsetDateTime::from_local(date, time, offset).map_err(|_| ())
755    }
756}
757
758impl PartialOrd for OffsetDateTime {
759    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
760        Some(self.cmp(other))
761    }
762}
763
764impl Ord for OffsetDateTime {
765    fn cmp(&self, other: &Self) -> Ordering {
766        self.utc.cmp(&other.utc)
767    }
768}
769
770// ===== Internal helpers =====
771
772const POW10_U32: [u32; 10] = [
773    1,
774    10,
775    100,
776    1_000,
777    10_000,
778    100_000,
779    1_000_000,
780    10_000_000,
781    100_000_000,
782    1_000_000_000,
783];
784
785fn parse_i32_bytes(bytes: &[u8]) -> Option<i32> {
786    if bytes.is_empty() {
787        return None;
788    }
789    let mut idx = 0;
790    let mut neg = false;
791    match bytes[0] {
792        b'+' => idx = 1,
793        b'-' => {
794            idx = 1;
795            neg = true;
796        }
797        _ => {}
798    }
799    if idx == bytes.len() {
800        return None;
801    }
802
803    let limit: i64 = if neg {
804        i32::MAX as i64 + 1
805    } else {
806        i32::MAX as i64
807    };
808    let mut val: i64 = 0;
809    for &b in &bytes[idx..] {
810        if !b.is_ascii_digit() {
811            return None;
812        }
813        let digit = (b - b'0') as i64;
814        if val > limit / 10 || (val == limit / 10 && digit > limit % 10) {
815            return None;
816        }
817        val = val * 10 + digit;
818    }
819
820    if neg {
821        val = -val;
822    }
823    Some(val as i32)
824}
825
826fn parse_u32_bytes(bytes: &[u8], max: u32) -> Option<u32> {
827    if bytes.is_empty() {
828        return None;
829    }
830    let mut val: u32 = 0;
831    for &b in bytes {
832        if !b.is_ascii_digit() {
833            return None;
834        }
835        let digit = (b - b'0') as u32;
836        if val > max / 10 || (val == max / 10 && digit > max % 10) {
837            return None;
838        }
839        val = val * 10 + digit;
840    }
841    Some(val)
842}
843
844fn parse_fraction_nanos(bytes: &[u8]) -> Option<u32> {
845    let len = bytes.len();
846    if len == 0 || len > 9 {
847        return None;
848    }
849    let mut val: u32 = 0;
850    for &b in bytes {
851        if !b.is_ascii_digit() {
852            return None;
853        }
854        val = val * 10 + (b - b'0') as u32;
855    }
856    let scale = 9 - len;
857    Some(val * POW10_U32[scale])
858}
859
860/// Errors parsing an RFC 3339 UTC offset.
861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
862pub enum Rfc3339OffsetError {
863    InvalidFormat,
864    OutOfRange,
865}
866
867pub fn parse_rfc3339_offset(s: &str) -> Result<UtcOffset, Rfc3339OffsetError> {
868    if s == "Z" || s == "z" {
869        return UtcOffset::from_seconds(0).map_err(|_| Rfc3339OffsetError::OutOfRange);
870    }
871    let bytes = s.as_bytes();
872    if bytes.len() < 3 {
873        return Err(Rfc3339OffsetError::InvalidFormat);
874    }
875    let sign_positive = match bytes[0] {
876        b'+' => true,
877        b'-' => false,
878        _ => return Err(Rfc3339OffsetError::InvalidFormat),
879    };
880    let body = &bytes[1..];
881
882    let mut colon = None;
883    for (idx, &b) in body.iter().enumerate() {
884        if b == b':' {
885            colon = Some(idx);
886            break;
887        }
888    }
889
890    let (h_bytes, m_bytes) = if let Some(colon_idx) = colon {
891        let h = &body[..colon_idx];
892        let m = &body[colon_idx + 1..];
893        if h.is_empty() || h.len() > 2 || m.len() > 2 {
894            return Err(Rfc3339OffsetError::InvalidFormat);
895        }
896        (h, m)
897    } else if body.len() == 2 {
898        (&body[..2], &[][..])
899    } else if body.len() == 4 {
900        (&body[..2], &body[2..])
901    } else {
902        return Err(Rfc3339OffsetError::InvalidFormat);
903    };
904
905    if h_bytes.len() > 2 || m_bytes.len() > 2 {
906        return Err(Rfc3339OffsetError::InvalidFormat);
907    }
908
909    let hours = parse_u32_bytes(h_bytes, 99).ok_or(Rfc3339OffsetError::InvalidFormat)? as u8;
910    let minutes = if m_bytes.is_empty() {
911        0
912    } else {
913        parse_u32_bytes(m_bytes, 99).ok_or(Rfc3339OffsetError::InvalidFormat)? as u8
914    };
915    UtcOffset::from_hours_minutes(sign_positive, hours, minutes)
916        .map_err(|_| Rfc3339OffsetError::OutOfRange)
917}
918
919fn is_leap_year(year: i32) -> bool {
920    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
921}
922
923fn days_in_month(year: i32, month: u8) -> u8 {
924    match month {
925        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
926        4 | 6 | 9 | 11 => 30,
927        2 => {
928            if is_leap_year(year) {
929                29
930            } else {
931                28
932            }
933        }
934        _ => 0,
935    }
936}
937
938// Howard Hinnant's civil-from-days/inverse algorithm.
939// Returns days since Unix epoch for a given Gregorian date.
940fn days_from_civil(y: i32, m: u8, d: u8) -> i64 {
941    let y = y as i64;
942    let m = m as i64;
943    let d = d as i64;
944    let y0 = y - if m <= 2 { 1 } else { 0 };
945    let era = if y0 >= 0 { y0 / 400 } else { (y0 - 399) / 400 };
946    let yoe = y0 - era * 400; // [0, 399]
947    let mp = m + if m > 2 { -3 } else { 9 }; // March=0,...,Feb=11
948    let doy = (153 * mp + 2) / 5 + d - 1; // [0, 365]
949    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
950    era * 146_097 + doe - 719_468
951}