Skip to main content

toml_spanner/
time.rs

1use std::{mem::MaybeUninit, str::FromStr};
2
3#[cfg(test)]
4#[path = "./time_tests.rs"]
5mod tests;
6
7/// A calendar date with year, month, and day components.
8///
9/// Represents the date portion of a TOML datetime value. Field ranges are
10/// validated during parsing:
11///
12/// - `year`: 0–9999
13/// - `month`: 1–12
14/// - `day`: 1–31 (upper bound depends on month and leap year rules)
15///
16/// # Examples
17///
18/// ```
19/// use toml_spanner::{Arena, DateTime};
20///
21/// let dt: DateTime = "2026-03-15".parse().unwrap();
22/// let date = dt.date().unwrap();
23/// assert_eq!(date.year, 2026);
24/// assert_eq!(date.month, 3);
25/// assert_eq!(date.day, 15);
26/// ```
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub struct Date {
29    /// Calendar year (0–9999).
30    pub year: u16,
31    /// Month of the year (1–12).
32    pub month: u8,
33    /// Day of the month (1–31).
34    pub day: u8,
35}
36
37impl Date {
38    /// Builds a [`Date`] from its year, month, and day components.
39    ///
40    /// Returns [`None`] when the values fall outside the TOML calendar range:
41    ///
42    /// - `year` in `0..=9999`
43    /// - `month` in `1..=12`
44    /// - `day` in the range of the given month, accounting for leap years
45    ///
46    /// # Examples
47    ///
48    /// ```
49    /// use toml_spanner::Date;
50    ///
51    /// assert!(Date::new(2024, 2, 29).is_some());
52    /// assert!(Date::new(2023, 2, 29).is_none());
53    /// assert!(Date::new(10000, 1, 1).is_none());
54    /// ```
55    pub fn new(year: u16, month: u8, day: u8) -> Option<Date> {
56        if year > 9999 || month == 0 || month > 12 {
57            return None;
58        }
59        if day == 0 || day > days_in_month(year, month) {
60            return None;
61        }
62        Some(Date { year, month, day })
63    }
64}
65
66/// A UTC offset attached to an offset date-time.
67///
68/// TOML offset date-times include a timezone offset suffix such as `Z`,
69/// `+05:30`, or `-08:00`. This enum represents that offset.
70///
71/// # Examples
72///
73/// ```
74/// use toml_spanner::{DateTime, TimeOffset};
75///
76/// let dt: DateTime = "2026-01-04T12:00:00Z".parse().unwrap();
77/// assert_eq!(dt.offset(), Some(TimeOffset::Z));
78///
79/// let dt: DateTime = "2026-01-04T12:00:00+05:30".parse().unwrap();
80/// assert_eq!(dt.offset(), Some(TimeOffset::Custom { minutes: 330 }));
81///
82/// let dt: DateTime = "2026-01-04T12:00:00-08:00".parse().unwrap();
83/// assert_eq!(dt.offset(), Some(TimeOffset::Custom { minutes: -480 }));
84/// ```
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum TimeOffset {
87    /// UTC offset `Z`.
88    Z,
89    /// Fixed offset from UTC in minutes (e.g. `+05:30` = 330, `-08:00` = -480).
90    Custom {
91        /// Minutes from UTC (positive = east, negative = west).
92        minutes: i16,
93    },
94}
95
96/// Represents the time of day portion of a TOML datetime value.
97///
98/// Field ranges are
99/// validated during parsing:
100///
101/// - `hour`: 0–23
102/// - `minute`: 0–59
103/// - `second`: 0–60 (60 is permitted for leap seconds)
104/// - `nanosecond`: 0–999999999
105///
106/// When seconds are omitted in the source (e.g. `12:30`), `second` defaults
107/// to 0. Use [`has_seconds`](Self::has_seconds) to distinguish this from an
108/// explicit `:00`.
109///
110/// # Examples
111///
112/// ```
113/// use toml_spanner::DateTime;
114///
115/// let dt: DateTime = "14:30:05.123".parse().unwrap();
116/// let time = dt.time().unwrap();
117/// assert_eq!(time.hour, 14);
118/// assert_eq!(time.minute, 30);
119/// assert_eq!(time.second, 5);
120/// assert_eq!(time.nanosecond, 123000000);
121/// assert_eq!(time.subsecond_precision(), 3);
122/// ```
123#[derive(Clone, Copy)]
124pub struct Time {
125    flags: u8,
126    /// Hour of the day (0–23).
127    pub hour: u8,
128    /// Minute of the hour (0–59).
129    pub minute: u8,
130    /// Second of the minute (0–60).
131    pub second: u8,
132    /// Sub-second component in nanoseconds (0–999999999).
133    pub nanosecond: u32,
134}
135
136impl std::fmt::Debug for Time {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        f.debug_struct("Time")
139            .field("hour", &self.hour)
140            .field("minute", &self.minute)
141            .field("second", &self.second)
142            .field("nanosecond", &self.nanosecond)
143            .finish()
144    }
145}
146
147impl PartialEq for Time {
148    fn eq(&self, other: &Self) -> bool {
149        self.hour == other.hour
150            && self.minute == other.minute
151            && self.second == other.second
152            && self.nanosecond == other.nanosecond
153    }
154}
155
156impl Eq for Time {}
157
158impl Time {
159    /// Returns the number of fractional-second digits present in the source.
160    ///
161    /// Returns 0 when no fractional part was written (e.g. `12:30:00`),
162    /// and 1–9 for `.1` through `.123456789`.
163    pub fn subsecond_precision(&self) -> u8 {
164        self.flags >> NANO_SHIFT
165    }
166    /// Returns `true` if seconds were explicitly written in the source.
167    ///
168    /// When the input omits seconds (e.g. `12:30`), [`second`](Self::second)
169    /// is set to 0 but this method returns `false`.
170    pub fn has_seconds(&self) -> bool {
171        self.flags & HAS_SECONDS != 0
172    }
173
174    /// Builds a [`Time`] from its clock components.
175    ///
176    /// Returns [`None`] when the values fall outside the TOML range:
177    ///
178    /// - `hour` in `0..=23`
179    /// - `minute` in `0..=59`
180    /// - `second` in `0..=60` (`60` is reserved for leap seconds)
181    /// - `nanosecond` in `0..=999_999_999`
182    ///
183    /// Serialization emits only the fractional digits needed to round-trip
184    /// `nanosecond` exactly, so a trailing zero you did not provide will not
185    /// appear in the output:
186    ///
187    /// | `nanosecond` | serialized fraction |
188    /// | -- | -- |
189    /// | `0` | (none) |
190    /// | `500_000_000` | `.5` |
191    /// | `123_000_000` | `.123` |
192    /// | `123` | `.000000123` |
193    ///
194    /// # Examples
195    ///
196    /// ```
197    /// use toml_spanner::Time;
198    ///
199    /// let time = Time::new(14, 30, 5, 123_000_000).unwrap();
200    /// assert_eq!(time.second, 5);
201    /// assert_eq!(time.subsecond_precision(), 3);
202    ///
203    /// assert!(Time::new(24, 0, 0, 0).is_none());
204    /// assert!(Time::new(0, 0, 0, 1_000_000_000).is_none());
205    /// ```
206    pub fn new(hour: u8, minute: u8, second: u8, nanosecond: u32) -> Option<Time> {
207        if hour > 23 || minute > 59 || second > 60 || nanosecond > 999_999_999 {
208            return None;
209        }
210        let mut precision: u8 = if nanosecond == 0 { 0 } else { 9 };
211        let mut n = nanosecond;
212        while precision > 0 && n.is_multiple_of(10) {
213            n /= 10;
214            precision -= 1;
215        }
216        let flags = HAS_SECONDS | (precision << NANO_SHIFT);
217        Some(Time {
218            flags,
219            hour,
220            minute,
221            second,
222            nanosecond,
223        })
224    }
225}
226
227/// Container for temporal values for TOML format, based on RFC 3339.
228///
229/// General bounds are in forced during parsing but leniently, so things like exact
230/// leap second rules are not enforced, you should generally being converting
231/// these time values, to a more complete time library like jiff before use.
232///
233/// The `DateTime` type is essentially more compact version of:
234/// ```
235/// use toml_spanner::{Date, Time, TimeOffset};
236/// struct DateTime {
237///     date: Option<Date>,
238///     time: Option<Time>,
239///     offset: Option<TimeOffset>,
240/// }
241/// ```
242/// For more details on support formats inside TOML documents please reference the [TOML v1.1.0 Specification](https://toml.io/en/v1.1.0#offset-date-time).
243///
244/// Mapping [`DateTime`] to the TOML time kinds works like the following:
245///
246/// ```rust
247/// #[rustfmt::skip]
248/// fn datetime_to_toml_kind(value: &toml_spanner::DateTime) -> &'static str {
249///     match (value.date(),value.time(),value.offset()) {
250///           (Some(_date), Some(_time), Some(_offset)) => "Offset Date-Time",
251///           (Some(_date), Some(_time), None         ) => "Local Date-Time",
252///           (Some(_date), None       , None         ) => "Local Date",
253///           (None       , Some(_time), None         ) => "Local Time",
254///         _ => unreachable!("for a DateTime produced from the toml-spanner::parse"),
255///     }
256/// }
257/// ```
258///
259/// # Constructing a `DateTime`
260///
261/// Parsing from a TOML document is the usual path. For ad hoc values,
262/// [`FromStr`] accepts any RFC 3339 form:
263///
264/// ```
265/// use toml_spanner::{Date, DateTime, TimeOffset};
266/// let value: DateTime = "2026-01-04T12:30:45Z".parse().unwrap();
267/// assert_eq!(value.date(), Some(Date { year: 2026, month: 1, day: 4 }));
268/// assert_eq!(value.offset(), Some(TimeOffset::Z));
269/// ```
270///
271/// To build one from parts, use [`Date::new`] and [`Time::new`] for the
272/// components then pick the constructor matching the TOML kind you want:
273/// [`DateTime::local_date`], [`DateTime::local_time`],
274/// [`DateTime::local_datetime`], or [`DateTime::offset_datetime`]. The
275/// result serializes back to its RFC 3339 form via [`DateTime::format`]:
276///
277/// ```
278/// use std::mem::MaybeUninit;
279/// use toml_spanner::{Date, DateTime, Time, TimeOffset};
280///
281/// let dt = DateTime::offset_datetime(
282///     Date::new(2026, 1, 4).unwrap(),
283///     Time::new(12, 30, 45, 0).unwrap(),
284///     TimeOffset::Z,
285/// ).unwrap();
286///
287/// let mut buf = MaybeUninit::uninit();
288/// assert_eq!(dt.format(&mut buf), "2026-01-04T12:30:45Z");
289/// ```
290///
291/// <details>
292/// <summary>Toggle Jiff Conversions Examples</summary>
293///
294/// This example is kept in sync with
295/// `crates/third-party-integration-tests/src/main.rs`. Edits here should be
296/// mirrored there.
297///
298/// ```ignore
299/// use toml_spanner::{
300///     Arena, Date, DateTime, Error as TomlError, FromToml, Item, Key, Span as TomlSpan,
301///     Table, Time, TimeOffset, ToToml, ToTomlError,
302/// };
303///
304/// fn extract_date(
305///     datetime: &toml_spanner::DateTime,
306///     span: TomlSpan,
307/// ) -> Result<jiff::civil::Date, TomlError> {
308///     let Some(date) = datetime.date() else {
309///         return Err(TomlError::custom("Missing date component", span));
310///     };
311///     match jiff::civil::Date::new(date.year as i16, date.month as i8, date.day as i8) {
312///         Ok(value) => Ok(value),
313///         Err(err) => Err(TomlError::custom(format!("Invalid date: {err}"), span)),
314///     }
315/// }
316///
317/// fn extract_time(
318///     datetime: &toml_spanner::DateTime,
319///     span: TomlSpan,
320/// ) -> Result<jiff::civil::Time, TomlError> {
321///     let Some(time) = datetime.time() else {
322///         return Err(TomlError::custom("Missing time component", span));
323///     };
324///     match jiff::civil::Time::new(
325///         time.hour as i8,
326///         time.minute as i8,
327///         time.second as i8,
328///         time.nanosecond as i32,
329///     ) {
330///         Ok(value) => Ok(value),
331///         Err(err) => Err(TomlError::custom(format!("Invalid time: {err}"), span)),
332///     }
333/// }
334///
335/// fn extract_timezone(
336///     datetime: &toml_spanner::DateTime,
337///     span: TomlSpan,
338/// ) -> Result<jiff::tz::TimeZone, TomlError> {
339///     let Some(offset) = datetime.offset() else {
340///         return Err(TomlError::custom("Missing offset component", span));
341///     };
342///     match offset {
343///         toml_spanner::TimeOffset::Z => Ok(jiff::tz::TimeZone::UTC),
344///         toml_spanner::TimeOffset::Custom { minutes } => {
345///             match jiff::tz::Offset::from_seconds(minutes as i32 * 60) {
346///                 Ok(jiff_offset) => Ok(jiff::tz::TimeZone::fixed(jiff_offset)),
347///                 Err(err) => Err(TomlError::custom(format!("Invalid offset: {err}"), span)),
348///             }
349///         }
350///     }
351/// }
352///
353/// fn to_jiff_date(item: &toml_spanner::Item<'_>) -> Result<jiff::civil::Date, TomlError> {
354///     let Some(datetime) = item.as_datetime() else {
355///         return Err(item.expected(&"date"));
356///     };
357///     if datetime.time().is_some() {
358///         return Err(TomlError::custom(
359///             "Expected lone date but found time",
360///             item.span(),
361///         ));
362///     };
363///     extract_date(datetime, item.span())
364/// }
365///
366/// fn to_jiff_datetime(item: &toml_spanner::Item<'_>) -> Result<jiff::civil::DateTime, TomlError> {
367///     let Some(datetime) = item.as_datetime() else {
368///         return Err(item.expected(&"civil datetime"));
369///     };
370///     if datetime.offset().is_some() {
371///         return Err(TomlError::custom(
372///             "Expected naive timestamp but found offset",
373///             item.span(),
374///         ));
375///     };
376///     Ok(jiff::civil::DateTime::from_parts(
377///         extract_date(datetime, item.span())?,
378///         extract_time(datetime, item.span())?,
379///     ))
380/// }
381///
382/// fn to_jiff_timestamp(item: &toml_spanner::Item<'_>) -> Result<jiff::Timestamp, TomlError> {
383///     let Some(datetime) = item.as_datetime() else {
384///         return Err(item.expected(&"timestamp"));
385///     };
386///     let civil = jiff::civil::DateTime::from_parts(
387///         extract_date(datetime, item.span())?,
388///         extract_time(datetime, item.span())?,
389///     );
390///     let timezone = extract_timezone(datetime, item.span())?;
391///     match timezone.to_timestamp(civil) {
392///         Ok(value) => Ok(value),
393///         Err(err) => Err(TomlError::custom(
394///             format!("Invalid timestamp: {err}"),
395///             item.span(),
396///         )),
397///     }
398/// }
399///
400/// fn from_jiff_date(date: jiff::civil::Date) -> Result<Date, ToTomlError> {
401///     let year = date.year();
402///     if year < 0 {
403///         return Err(ToTomlError::from("year out of TOML range (0..=9999)"));
404///     }
405///     Date::new(year as u16, date.month() as u8, date.day() as u8)
406///         .ok_or_else(|| ToTomlError::from("date out of TOML range"))
407/// }
408///
409/// fn from_jiff_time(time: jiff::civil::Time) -> Result<Time, ToTomlError> {
410///     Time::new(
411///         time.hour() as u8,
412///         time.minute() as u8,
413///         time.second() as u8,
414///         time.subsec_nanosecond() as u32,
415///     )
416///     .ok_or_else(|| ToTomlError::from("time out of TOML range"))
417/// }
418///
419/// fn from_jiff_civil_datetime(dt: jiff::civil::DateTime) -> Result<DateTime, ToTomlError> {
420///     Ok(DateTime::local_datetime(
421///         from_jiff_date(dt.date())?,
422///         from_jiff_time(dt.time())?,
423///     ))
424/// }
425///
426/// fn from_jiff_timestamp(ts: jiff::Timestamp) -> Result<DateTime, ToTomlError> {
427///     let civil = ts.to_zoned(jiff::tz::TimeZone::UTC).datetime();
428///     Ok(DateTime::offset_datetime(
429///         from_jiff_date(civil.date())?,
430///         from_jiff_time(civil.time())?,
431///         TimeOffset::Z,
432///     )
433///     .expect("TimeOffset::Z is always valid"))
434/// }
435///
436/// #[derive(Debug, PartialEq)]
437/// pub struct TimeConfig {
438///     pub date: jiff::civil::Date,
439///     pub datetime: jiff::civil::DateTime,
440///     pub timestamp: jiff::Timestamp,
441/// }
442///
443/// impl<'de> FromToml<'de> for TimeConfig {
444///     fn from_toml(
445///         ctx: &mut toml_spanner::Context<'de>,
446///         value: &toml_spanner::Item<'de>,
447///     ) -> Result<Self, toml_spanner::Failed> {
448///         let mut th = value.table_helper(ctx)?;
449///         let config = TimeConfig {
450///             date: th.required_mapped("date", to_jiff_date)?,
451///             datetime: th.required_mapped("datetime", to_jiff_datetime)?,
452///             timestamp: th.required_mapped("timestamp", to_jiff_timestamp)?,
453///         };
454///         Ok(config)
455///     }
456/// }
457///
458/// impl ToToml for TimeConfig {
459///     fn to_toml<'a>(&'a self, arena: &'a Arena) -> Result<Item<'a>, ToTomlError> {
460///         let Some(mut table) = Table::try_with_capacity(3, arena) else {
461///             return Err(ToTomlError::from("table capacity exceeded"));
462///         };
463///         table.insert_unique(
464///             Key::new("date"),
465///             Item::from(DateTime::local_date(from_jiff_date(self.date)?)),
466///             arena,
467///         );
468///         table.insert_unique(
469///             Key::new("datetime"),
470///             Item::from(from_jiff_civil_datetime(self.datetime)?),
471///             arena,
472///         );
473///         table.insert_unique(
474///             Key::new("timestamp"),
475///             Item::from(from_jiff_timestamp(self.timestamp)?),
476///             arena,
477///         );
478///         Ok(table.into_item())
479///     }
480/// }
481///
482/// fn main() {
483///     let arena = toml_spanner::Arena::new();
484///
485///     let toml_doc = r#"
486///         date = 1997-02-28
487///         datetime = 2066-01-30T14:45:00
488///         timestamp = 3291-12-01T00:45:00Z
489///     "#;
490///     let mut doc = toml_spanner::parse(toml_doc, &arena).unwrap();
491///     let config: TimeConfig = doc.to().unwrap();
492///
493///     let emitted = toml_spanner::to_string(&config).unwrap();
494///
495///     let round_trip_arena = toml_spanner::Arena::new();
496///     let mut round_trip_doc = toml_spanner::parse(&emitted, &round_trip_arena).unwrap();
497///     let round_trip: TimeConfig = round_trip_doc.to().unwrap();
498///     assert_eq!(config, round_trip);
499/// }
500/// ```
501///
502/// </details>
503#[derive(Clone, Copy)]
504#[repr(C, align(8))]
505pub struct DateTime {
506    date: Date,
507
508    flags: u8,
509
510    hour: u8,
511    minute: u8,
512    seconds: u8,
513
514    nanos: u32,
515    offset_minutes: i16,
516}
517
518impl std::fmt::Debug for DateTime {
519    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
520        f.debug_struct("DateTime")
521            .field("date", &self.date())
522            .field("time", &self.time())
523            .field("offset", &self.offset())
524            .finish()
525    }
526}
527
528impl PartialEq for DateTime {
529    fn eq(&self, other: &Self) -> bool {
530        // The obvious implementation ends up with huge amount of binary bloat.
531        // this unsafety dropped llvm-lines by 350.
532        #[repr(C)]
533        struct Raw {
534            header: u64,
535            offset: u32,
536            nanos: i16,
537        }
538        // Safety: DateTime and Raw have identical layouts, so transmuting between them is safe.
539        let rhs = unsafe { &*(self as *const _ as *const Raw) };
540        let lhs = unsafe { &*(other as *const _ as *const Raw) };
541        (rhs.header == lhs.header) & (rhs.offset == lhs.offset) & (rhs.nanos == lhs.nanos)
542    }
543}
544
545impl Eq for DateTime {}
546
547const HAS_DATE: u8 = 1 << 0;
548const HAS_TIME: u8 = 1 << 1;
549const HAS_SECONDS: u8 = 1 << 2;
550const NANO_SHIFT: u8 = 4;
551
552fn is_leap_year(year: u16) -> bool {
553    (((year as u64 * 1073750999) as u32) & 3221352463) <= 126976
554}
555
556fn days_in_month(year: u16, month: u8) -> u8 {
557    const DAYS: [u8; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
558    if month == 2 && is_leap_year(year) {
559        29
560    } else {
561        DAYS[month as usize]
562    }
563}
564
565/// Error returned when parsing a [`DateTime`] from a string via [`FromStr`].
566#[non_exhaustive]
567#[derive(Debug)]
568pub enum DateTimeError {
569    /// The input string is not a valid TOML datetime.
570    Invalid,
571}
572
573impl std::fmt::Display for DateTimeError {
574    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
575        <DateTimeError as std::fmt::Debug>::fmt(self, f)
576    }
577}
578
579impl std::error::Error for DateTimeError {}
580
581impl FromStr for DateTime {
582    type Err = DateTimeError;
583
584    fn from_str(s: &str) -> Result<Self, Self::Err> {
585        DateTime::munch(s.as_bytes())
586            .ok()
587            .filter(|(amount, _)| *amount == s.len())
588            .map(|(_, dt)| dt)
589            .ok_or(DateTimeError::Invalid)
590    }
591}
592
593impl DateTime {
594    /// Builds a [`DateTime`] holding only a calendar date.
595    ///
596    /// Serialized as a TOML [local date].
597    ///
598    /// [local date]: https://toml.io/en/v1.1.0#local-date
599    ///
600    /// # Examples
601    ///
602    /// ```
603    /// use toml_spanner::{Date, DateTime};
604    ///
605    /// let dt = DateTime::local_date(Date::new(2026, 3, 15).unwrap());
606    /// assert_eq!(dt.date().unwrap().month, 3);
607    /// assert!(dt.time().is_none());
608    /// ```
609    pub fn local_date(date: Date) -> DateTime {
610        DateTime {
611            date,
612            flags: HAS_DATE,
613            hour: 0,
614            minute: 0,
615            seconds: 0,
616            nanos: 0,
617            offset_minutes: i16::MIN,
618        }
619    }
620
621    /// Builds a [`DateTime`] holding only a time of day.
622    ///
623    /// Serialized as a TOML [local time].
624    ///
625    /// [local time]: https://toml.io/en/v1.1.0#local-time
626    ///
627    /// # Examples
628    ///
629    /// ```
630    /// use toml_spanner::{Time, DateTime};
631    ///
632    /// let dt = DateTime::local_time(Time::new(14, 30, 5, 0).unwrap());
633    /// assert_eq!(dt.time().unwrap().hour, 14);
634    /// assert!(dt.date().is_none());
635    /// ```
636    pub fn local_time(time: Time) -> DateTime {
637        DateTime {
638            date: Date {
639                year: 0,
640                month: 0,
641                day: 0,
642            },
643            flags: HAS_TIME | (time.flags & 0xF4),
644            hour: time.hour,
645            minute: time.minute,
646            seconds: time.second,
647            nanos: time.nanosecond,
648            offset_minutes: i16::MIN,
649        }
650    }
651
652    /// Builds a [`DateTime`] holding a date and time without a UTC offset.
653    ///
654    /// Serialized as a TOML [local date-time]. Use this for wall-clock values
655    /// whose timezone is implied by context rather than recorded in the data.
656    ///
657    /// [local date-time]: https://toml.io/en/v1.1.0#local-date-time
658    ///
659    /// # Examples
660    ///
661    /// ```
662    /// use toml_spanner::{Date, Time, DateTime};
663    ///
664    /// let dt = DateTime::local_datetime(
665    ///     Date::new(2026, 3, 15).unwrap(),
666    ///     Time::new(14, 30, 5, 0).unwrap(),
667    /// );
668    /// assert_eq!(dt.date().unwrap().day, 15);
669    /// assert!(dt.offset().is_none());
670    /// ```
671    pub fn local_datetime(date: Date, time: Time) -> DateTime {
672        DateTime {
673            date,
674            flags: HAS_DATE | HAS_TIME | (time.flags & 0xF4),
675            hour: time.hour,
676            minute: time.minute,
677            seconds: time.second,
678            nanos: time.nanosecond,
679            offset_minutes: i16::MIN,
680        }
681    }
682
683    /// Builds a [`DateTime`] holding a date, time, and UTC offset.
684    ///
685    /// Serialized as a TOML [offset date-time], the form to use when the
686    /// value refers to an absolute moment in time.
687    ///
688    /// Returns [`None`] when `offset` is a [`TimeOffset::Custom`] whose
689    /// `minutes` fall outside `±23:59`. [`TimeOffset::Z`] always succeeds.
690    ///
691    /// [offset date-time]: https://toml.io/en/v1.1.0#offset-date-time
692    ///
693    /// # Examples
694    ///
695    /// ```
696    /// use toml_spanner::{Date, Time, DateTime, TimeOffset};
697    ///
698    /// let dt = DateTime::offset_datetime(
699    ///     Date::new(2026, 3, 15).unwrap(),
700    ///     Time::new(14, 30, 5, 0).unwrap(),
701    ///     TimeOffset::Z,
702    /// ).unwrap();
703    /// assert_eq!(dt.offset(), Some(TimeOffset::Z));
704    ///
705    /// assert!(DateTime::offset_datetime(
706    ///     Date::new(2026, 3, 15).unwrap(),
707    ///     Time::new(14, 30, 5, 0).unwrap(),
708    ///     TimeOffset::Custom { minutes: 1440 },
709    /// ).is_none());
710    /// ```
711    pub fn offset_datetime(date: Date, time: Time, offset: TimeOffset) -> Option<DateTime> {
712        let offset_minutes = match offset {
713            TimeOffset::Z => i16::MAX,
714            TimeOffset::Custom { minutes } => {
715                if !(-1439..=1439).contains(&minutes) {
716                    return None;
717                }
718                minutes
719            }
720        };
721        Some(DateTime {
722            date,
723            flags: HAS_DATE | HAS_TIME | (time.flags & 0xF4),
724            hour: time.hour,
725            minute: time.minute,
726            seconds: time.second,
727            nanos: time.nanosecond,
728            offset_minutes,
729        })
730    }
731
732    /// Maximum number of bytes produced by [`DateTime::format`].
733    ///
734    /// Use this to size the [`MaybeUninit`] buffer passed to [`DateTime::format`].
735    ///
736    /// [`MaybeUninit`]: std::mem::MaybeUninit
737    pub const MAX_FORMAT_LEN: usize = 40;
738    /// Returns the time component, or [`None`] for a local-date value.
739    pub fn time(&self) -> Option<Time> {
740        if self.flags & HAS_TIME != 0 {
741            Some(Time {
742                flags: self.flags,
743                hour: self.hour,
744                minute: self.minute,
745                second: self.seconds,
746                nanosecond: self.nanos,
747            })
748        } else {
749            None
750        }
751    }
752    pub(crate) fn munch(input: &[u8]) -> Result<(usize, DateTime), &'static str> {
753        enum State {
754            Year,
755            Month,
756            Day,
757            Hour,
758            Minute,
759            Second,
760            Frac,
761            OffHour,
762            OffMin,
763        }
764        let mut n = 0;
765        while n < input.len() && input[n].is_ascii_digit() {
766            n += 1;
767        }
768        let mut state = match input.get(n) {
769            Some(b':') if n == 2 => State::Hour,
770            Some(b'-') if n >= 2 => State::Year,
771            _ => return Err(""),
772        };
773
774        let mut value = DateTime {
775            date: Date {
776                year: 0,
777                month: 0,
778                day: 0,
779            },
780            flags: 0,
781            hour: 0,
782            minute: 0,
783            seconds: 0,
784            offset_minutes: i16::MIN,
785            nanos: 0,
786        };
787
788        let mut current = 0u32;
789        let mut len = 0u32;
790        let mut off_sign: i16 = 1;
791        let mut off_hour: u8 = 0;
792        let mut i = 0usize;
793        let valid: bool;
794
795        'outer: loop {
796            let byte = input.get(i).copied().unwrap_or(0);
797            if byte.is_ascii_digit() {
798                len += 1;
799                if len <= 9 {
800                    current = current * 10 + (byte - b'0') as u32;
801                }
802                i += 1;
803                continue;
804            }
805            'next: {
806                match state {
807                    State::Year => {
808                        if len != 4 {
809                            return Err("expected 4-digit year");
810                        }
811                        if byte != b'-' {
812                            return Err("");
813                        }
814                        value.date.year = current as u16;
815                        state = State::Month;
816                        break 'next;
817                    }
818                    State::Month => {
819                        let m = current as u8;
820                        if len != 2 {
821                            return Err("expected 2-digit month");
822                        }
823                        if byte != b'-' {
824                            return Err("");
825                        }
826                        if m < 1 || m > 12 {
827                            return Err("month is out of range");
828                        }
829                        value.date.month = m;
830                        state = State::Day;
831                        break 'next;
832                    }
833                    State::Day => {
834                        let d = current as u8;
835                        if len != 2 {
836                            return Err("expected 2-digit day");
837                        }
838                        if d < 1 || d > days_in_month(value.date.year, value.date.month) {
839                            return Err("day is out of range");
840                        }
841                        value.date.day = d;
842                        value.flags |= HAS_DATE;
843                        if byte == b'T'
844                            || byte == b't'
845                            || (byte == b' '
846                                && input.get(i + 1).is_some_and(|b| b.is_ascii_digit()))
847                        {
848                            state = State::Hour;
849                            break 'next;
850                        } else {
851                            valid = true;
852                            break 'outer;
853                        }
854                    }
855                    State::Hour => {
856                        let h = current as u8;
857                        if len != 2 {
858                            return Err("expected 2-digit hour");
859                        }
860                        if byte != b':' {
861                            return Err("incomplete time");
862                        }
863                        if h > 23 {
864                            return Err("hour is out of range");
865                        }
866                        value.hour = h;
867                        state = State::Minute;
868                        break 'next;
869                    }
870                    State::Minute => {
871                        let m = current as u8;
872                        if len != 2 {
873                            return Err("expected 2-digit minute");
874                        }
875                        if m > 59 {
876                            return Err("minute is out of range");
877                        }
878                        value.minute = m;
879                        value.flags |= HAS_TIME;
880                        if byte == b':' {
881                            state = State::Second;
882                            break 'next;
883                        }
884                    }
885                    State::Second => {
886                        let s = current as u8;
887                        if len != 2 {
888                            return Err("expected 2-digit second");
889                        }
890                        if s > 60 {
891                            return Err("second is out of range");
892                        }
893                        value.seconds = s;
894                        value.flags |= HAS_SECONDS;
895                        if byte == b'.' {
896                            state = State::Frac;
897                            break 'next;
898                        }
899                    }
900                    State::Frac => {
901                        if len == 0 {
902                            return Err("expected fractional digits after decimal point");
903                        }
904                        let digit_count = if len > 9 { 9u8 } else { len as u8 };
905                        let mut nanos = current;
906                        let mut s = digit_count;
907                        while s < 9 {
908                            nanos *= 10;
909                            s += 1;
910                        }
911                        value.nanos = nanos;
912                        value.flags |= digit_count << NANO_SHIFT;
913                    }
914                    State::OffHour => {
915                        let h = current as u8;
916                        if len != 2 {
917                            return Err("expected 2-digit offset hour");
918                        }
919                        if byte != b':' {
920                            return Err("incomplete offset");
921                        }
922                        if h > 23 {
923                            return Err("offset hour is out of range");
924                        }
925                        off_hour = h;
926                        state = State::OffMin;
927                        break 'next;
928                    }
929                    State::OffMin => {
930                        if len != 2 {
931                            return Err("expected 2-digit offset minute");
932                        }
933                        if current > 59 {
934                            return Err("offset minute is out of range");
935                        }
936                        value.offset_minutes = off_sign * (off_hour as i16 * 60 + current as i16);
937                        valid = true;
938                        break 'outer;
939                    }
940                }
941                match byte {
942                    b'Z' | b'z' => {
943                        value.offset_minutes = i16::MAX;
944                        i += 1;
945                        valid = true;
946                        break 'outer;
947                    }
948                    b'+' => {
949                        off_sign = 1;
950                        state = State::OffHour;
951                    }
952                    b'-' => {
953                        off_sign = -1;
954                        state = State::OffHour;
955                    }
956                    _ => {
957                        valid = true;
958                        break 'outer;
959                    }
960                }
961            }
962            i += 1;
963            current = 0;
964            len = 0;
965        }
966        if !valid || (value.flags & HAS_DATE == 0 && value.offset_minutes != i16::MIN) {
967            return Err("");
968        }
969        Ok((i, value))
970    }
971
972    /// Formats this datetime into the provided buffer and returns the result as a `&str`.
973    ///
974    /// The output follows RFC 3339 formatting and matches the TOML serialization
975    /// of the value. The caller must supply an uninitializebuffer of [`MAX_FORMAT_LEN`](Self::MAX_FORMAT_LEN) bytes;
976    /// the returned `&str` borrows from that buffer, starting from the beginning.
977    ///
978    /// # Examples
979    ///
980    /// ```
981    /// use std::mem::MaybeUninit;
982    /// use toml_spanner::DateTime;
983    ///
984    /// let dt: DateTime = "2026-01-04T12:30:45Z".parse().unwrap();
985    /// let mut buf = MaybeUninit::uninit();
986    /// assert_eq!(dt.format(&mut buf), "2026-01-04T12:30:45Z");
987    /// assert_eq!(size_of_val(&buf), DateTime::MAX_FORMAT_LEN);
988    /// ```
989    pub fn format<'a>(&self, buf: &'a mut MaybeUninit<[u8; DateTime::MAX_FORMAT_LEN]>) -> &'a str {
990        #[inline(always)]
991        fn write_byte(
992            buf: &mut [MaybeUninit<u8>; DateTime::MAX_FORMAT_LEN],
993            pos: &mut usize,
994            b: u8,
995        ) {
996            buf[*pos].write(b);
997            *pos += 1;
998        }
999
1000        #[inline(always)]
1001        fn write_2(
1002            buf: &mut [MaybeUninit<u8>; DateTime::MAX_FORMAT_LEN],
1003            pos: &mut usize,
1004            val: u8,
1005        ) {
1006            buf[*pos].write(b'0' + val / 10);
1007            buf[*pos + 1].write(b'0' + val % 10);
1008            *pos += 2;
1009        }
1010
1011        #[inline(always)]
1012        fn write_4(
1013            buf: &mut [MaybeUninit<u8>; DateTime::MAX_FORMAT_LEN],
1014            pos: &mut usize,
1015            val: u16,
1016        ) {
1017            buf[*pos].write(b'0' + (val / 1000) as u8);
1018            buf[*pos + 1].write(b'0' + ((val / 100) % 10) as u8);
1019            buf[*pos + 2].write(b'0' + ((val / 10) % 10) as u8);
1020            buf[*pos + 3].write(b'0' + (val % 10) as u8);
1021            *pos += 4;
1022        }
1023
1024        #[inline(always)]
1025        fn write_frac(
1026            buf: &mut [MaybeUninit<u8>; DateTime::MAX_FORMAT_LEN],
1027            pos: &mut usize,
1028            nanos: u32,
1029            digit_count: u8,
1030        ) {
1031            let mut val = nanos;
1032            let mut i: usize = 8;
1033            loop {
1034                buf[*pos + i].write(b'0' + (val % 10) as u8);
1035                val /= 10;
1036                if i == 0 {
1037                    break;
1038                }
1039                i -= 1;
1040            }
1041            *pos += digit_count as usize;
1042        }
1043
1044        // SAFETY: MaybeUninit<u8> has identical layout to u8
1045        let buf: &mut [MaybeUninit<u8>; Self::MAX_FORMAT_LEN] = unsafe {
1046            &mut *buf
1047                .as_mut_ptr()
1048                .cast::<[MaybeUninit<u8>; Self::MAX_FORMAT_LEN]>()
1049        };
1050        let mut pos: usize = 0;
1051
1052        if self.flags & HAS_DATE != 0 {
1053            write_4(buf, &mut pos, self.date.year);
1054            write_byte(buf, &mut pos, b'-');
1055            write_2(buf, &mut pos, self.date.month);
1056            write_byte(buf, &mut pos, b'-');
1057            write_2(buf, &mut pos, self.date.day);
1058
1059            if self.flags & HAS_TIME != 0 {
1060                write_byte(buf, &mut pos, b'T');
1061            }
1062        }
1063
1064        if self.flags & HAS_TIME != 0 {
1065            write_2(buf, &mut pos, self.hour);
1066            write_byte(buf, &mut pos, b':');
1067            write_2(buf, &mut pos, self.minute);
1068            write_byte(buf, &mut pos, b':');
1069            write_2(buf, &mut pos, self.seconds);
1070
1071            if self.flags & HAS_SECONDS != 0 {
1072                let digit_count = (self.flags >> NANO_SHIFT) & 0xF;
1073                if digit_count > 0 {
1074                    write_byte(buf, &mut pos, b'.');
1075                    write_frac(buf, &mut pos, self.nanos, digit_count);
1076                }
1077            }
1078
1079            if self.offset_minutes != i16::MIN {
1080                if self.offset_minutes == i16::MAX {
1081                    write_byte(buf, &mut pos, b'Z');
1082                } else {
1083                    let (sign, abs) = if self.offset_minutes < 0 {
1084                        (b'-', (-self.offset_minutes) as u16)
1085                    } else {
1086                        (b'+', self.offset_minutes as u16)
1087                    };
1088                    write_byte(buf, &mut pos, sign);
1089                    write_2(buf, &mut pos, (abs / 60) as u8);
1090                    write_byte(buf, &mut pos, b':');
1091                    write_2(buf, &mut pos, (abs % 60) as u8);
1092                }
1093            }
1094        }
1095
1096        // SAFETY: buf[..pos] has been fully initialized by the write calls above,
1097        // and all written bytes are valid ASCII digits/punctuation.
1098        unsafe {
1099            std::str::from_utf8_unchecked(std::slice::from_raw_parts(buf.as_ptr().cast(), pos))
1100        }
1101    }
1102
1103    /// Returns the date component, or [`None`] for a local-time value.
1104    pub fn date(&self) -> Option<Date> {
1105        if self.flags & HAS_DATE != 0 {
1106            Some(self.date)
1107        } else {
1108            None
1109        }
1110    }
1111
1112    /// Returns the UTC offset, or [`None`] for local date-times and local times.
1113    pub fn offset(&self) -> Option<TimeOffset> {
1114        match self.offset_minutes {
1115            i16::MAX => Some(TimeOffset::Z),
1116            i16::MIN => None,
1117            minutes => Some(TimeOffset::Custom { minutes }),
1118        }
1119    }
1120}