Skip to main content

t2t_core/
rfc3339.rs

1//! A [`Timestamp`] written as RFC 3339 in UTC, and read back; a [`TaiTimestamp`] written and read
2//! the same way, in its own zone.
3
4use core::fmt::{self, Write as _};
5use core::str::FromStr;
6
7use arrayvec::ArrayString;
8use itoa::Buffer;
9
10use crate::spelling::pad;
11use crate::{ParseTaiTimestampError, ParseTimestampError, TaiTimestamp, Timestamp, UtcDateTime};
12
13/// The digits of a fraction of a second.
14const FRACTION_DIGITS: usize = 9;
15
16/// The longest spelling, a [`TaiTimestamp`]'s with every fraction digit.
17const LONGEST: usize = "2026-09-16T07:46:12.123456789 TAI".len();
18
19/// Nanoseconds in a second: a count of nanoseconds past a second, plus it, has ten digits, the
20/// first a `1`.
21const NANOS_PER_SECOND: u32 = 1_000_000_000;
22
23/// What each digit of a fraction is worth in nanoseconds, the first to the ninth.
24const FRACTION_SCALES: [u32; FRACTION_DIGITS] =
25    [100_000_000, 10_000_000, 1_000_000, 100_000, 10_000, 1_000, 100, 10, 1];
26
27/// Each number below 100 as its two ASCII digits: an instant is written four and a half times as
28/// fast through it as through `write!`'s `{:02}`, 24 µs against 109 µs for 1,024 on a Graviton4,
29/// measured under `benches/results/2026-10-03T08-54Z-635b659-calendar-and-digit-pairs`.
30#[rustfmt::skip]
31const DIGIT_PAIRS: [&str; 100] = [
32    "00", "01", "02", "03", "04", "05", "06", "07", "08", "09",
33    "10", "11", "12", "13", "14", "15", "16", "17", "18", "19",
34    "20", "21", "22", "23", "24", "25", "26", "27", "28", "29",
35    "30", "31", "32", "33", "34", "35", "36", "37", "38", "39",
36    "40", "41", "42", "43", "44", "45", "46", "47", "48", "49",
37    "50", "51", "52", "53", "54", "55", "56", "57", "58", "59",
38    "60", "61", "62", "63", "64", "65", "66", "67", "68", "69",
39    "70", "71", "72", "73", "74", "75", "76", "77", "78", "79",
40    "80", "81", "82", "83", "84", "85", "86", "87", "88", "89",
41    "90", "91", "92", "93", "94", "95", "96", "97", "98", "99",
42];
43
44/// The UTC zone, as [`Display`](fmt::Display) writes it.
45const UTC: &str = "Z";
46
47/// The TAI zone, as [`Display`](fmt::Display) writes it.
48const TAI: &str = " TAI";
49
50/// RFC 3339 in UTC, with `Z`: `2026-09-16T07:45:35.123456789Z`.
51///
52/// The precision chooses the fraction's digits, nine by default and at most nine: `{:.3}` writes
53/// milliseconds and `{:.0}` none. A width pads the spelling.
54impl fmt::Display for Timestamp {
55    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write_date_time(formatter, *self, UTC)
57    }
58}
59
60/// RFC 3339's date and time, in the zone `TAI`: `2026-09-16T07:46:12.123456789 TAI`.
61///
62/// The precision chooses the fraction's digits, and a width pads the spelling, as for a
63/// [`Timestamp`].
64impl fmt::Display for TaiTimestamp {
65    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66        write_date_time(formatter, Timestamp(self.0), TAI)
67    }
68}
69
70/// Writes the date and time `instant` names, the fraction to the formatter's precision, then
71/// `zone`.
72fn write_date_time(
73    formatter: &mut fmt::Formatter<'_>, instant: Timestamp, zone: &str,
74) -> fmt::Result {
75    let digits = formatter.precision().unwrap_or(FRACTION_DIGITS).min(FRACTION_DIGITS);
76    let UtcDateTime { year, month, day, hour, minute, second, nanosecond } = instant.to_utc();
77    // A timestamp's year has four digits, from 1677 to 2262.
78    let year = year.cast_unsigned();
79    let mut text = ArrayString::<LONGEST>::new();
80    for (separator, number) in [
81        ("", year / 100),
82        ("", year),
83        ("-", month.into()),
84        ("-", day.into()),
85        ("T", hour.into()),
86        (":", minute.into()),
87        (":", second.into()),
88    ] {
89        text.write_str(separator)?;
90        text.write_str(pair(number))?;
91    }
92    if digits > 0 {
93        #[expect(clippy::arithmetic_side_effects, reason = "below two billion, which fits `u32`")]
94        let shifted = NANOS_PER_SECOND + nanosecond;
95        let mut buffer = Buffer::new();
96        let fraction = buffer.format(shifted).get(1..=digits).ok_or(fmt::Error)?;
97        text.write_str(".")?;
98        text.write_str(fraction)?;
99    }
100    text.write_str(zone)?;
101    pad(formatter, &text)
102}
103
104/// The last two digits of `number`.
105#[inline]
106#[expect(
107    clippy::indexing_slicing,
108    clippy::as_conversions,
109    reason = "a remainder of 100, widened, indexes a table of 100; `From` takes no `u32`"
110)]
111const fn pair(number: u32) -> &'static str {
112    DIGIT_PAIRS[(number % 100) as usize]
113}
114
115/// Reads what [`Display`](fmt::Display) writes, `T` and `Z` in either case, and a fraction of one
116/// to nine digits or none.
117///
118/// It refuses an offset, a 60th second, a date that does not exist, and an instant outside
119/// 1677-09-21 to 2262-04-11.
120impl FromStr for Timestamp {
121    type Err = ParseTimestampError;
122
123    fn from_str(text: &str) -> Result<Self, Self::Err> {
124        let instant = parse(text)
125            .filter(|&(_, zone)| matches!(zone, [b'Z' | b'z']))
126            .and_then(|(date_time, _)| date_time.to_timestamp());
127        instant.ok_or(ParseTimestampError)
128    }
129}
130
131/// Reads what [`Display`](fmt::Display) writes: RFC 3339's date and time as a [`Timestamp`]
132/// reads them, then the zone ` TAI`.
133impl FromStr for TaiTimestamp {
134    type Err = ParseTaiTimestampError;
135
136    fn from_str(text: &str) -> Result<Self, Self::Err> {
137        let instant = parse(text)
138            .filter(|&(_, zone)| zone == TAI.as_bytes())
139            .and_then(|(date_time, _)| date_time.to_timestamp());
140        instant.map(|instant| Self(instant.0)).ok_or(ParseTaiTimestampError)
141    }
142}
143
144/// Reads the date and time `text` opens with, without checking it is a real one, and the zone
145/// after it.
146#[expect(clippy::arithmetic_side_effects, reason = "four digits fit `i32`")]
147fn parse(text: &str) -> Option<(UtcDateTime, &[u8])> {
148    let (date_time, rest) = text.as_bytes().split_first_chunk()?;
149    let [
150        year_thousands,
151        year_hundreds,
152        year_tens,
153        year_ones,
154        b'-',
155        month_tens,
156        month_ones,
157        b'-',
158        day_tens,
159        day_ones,
160        b'T' | b't',
161        hour_tens,
162        hour_ones,
163        b':',
164        minute_tens,
165        minute_ones,
166        b':',
167        second_tens,
168        second_ones,
169    ] = *date_time
170    else {
171        return None;
172    };
173    let (nanosecond, zone) = fraction(rest)?;
174    let century = two_digits(year_thousands, year_hundreds)?;
175    let year_of_century = two_digits(year_tens, year_ones)?;
176    let date_time = UtcDateTime {
177        year: i32::from(century) * 100 + i32::from(year_of_century),
178        month: two_digits(month_tens, month_ones)?,
179        day: two_digits(day_tens, day_ones)?,
180        hour: two_digits(hour_tens, hour_ones)?,
181        minute: two_digits(minute_tens, minute_ones)?,
182        second: two_digits(second_tens, second_ones)?,
183        nanosecond,
184    };
185    Some((date_time, zone))
186}
187
188/// Two ASCII digits as a number, or `None` where either is not a digit.
189#[inline]
190#[expect(clippy::arithmetic_side_effects, reason = "two digits fit `u8`")]
191fn two_digits(tens: u8, ones: u8) -> Option<u8> {
192    let (tens, ones) = (tens.wrapping_sub(b'0'), ones.wrapping_sub(b'0'));
193    (tens < 10 && ones < 10).then(|| tens * 10 + ones)
194}
195
196/// The nanoseconds of the fraction `text` opens with, `.` and one to nine digits, and the rest.
197///
198/// A `text` that opens with no point has no fraction: zero, and the whole `text`.
199#[expect(clippy::arithmetic_side_effects, reason = "nine digits, each below its scale, fit `u32`")]
200fn fraction(text: &[u8]) -> Option<(u32, &[u8])> {
201    let Some(digits) = text.strip_prefix(b".") else {
202        return Some((0, text));
203    };
204    let mut nanos = 0;
205    let mut rest = digits;
206    for scale in FRACTION_SCALES {
207        let Some((byte, tail)) = rest.split_first() else {
208            break;
209        };
210        let digit = byte.wrapping_sub(b'0');
211        if digit >= 10 {
212            break;
213        }
214        nanos += u32::from(digit) * scale;
215        rest = tail;
216    }
217    let has_digits = rest.len() < digits.len();
218    let has_tenth_digit = rest.first().is_some_and(u8::is_ascii_digit);
219    (has_digits && !has_tenth_digit).then_some((nanos, rest))
220}
221
222#[cfg(test)]
223mod tests {
224    use alloc::format;
225    use alloc::string::ToString as _;
226
227    use proptest::prelude::{any, prop_assert_eq, proptest};
228    use rstest::rstest;
229
230    use crate::{ParseTaiTimestampError, ParseTimestampError, TaiTimestamp, Timestamp};
231
232    #[test]
233    fn an_instant_is_written_with_the_precision_choosing_its_fraction() {
234        let instant = Timestamp::from_nanos(1_789_544_735_123_456_789);
235        assert_eq!(instant.to_string(), "2026-09-16T07:45:35.123456789Z", "nine digits");
236        assert_eq!(format!("{instant:.3}"), "2026-09-16T07:45:35.123Z", "three");
237        assert_eq!(format!("{instant:.0}"), "2026-09-16T07:45:35Z", "none");
238        assert_eq!(format!("{instant:.12}"), instant.to_string(), "and nine at most");
239        let padded = format!("[{instant:>24.0}]");
240        assert_eq!(padded, "[    2026-09-16T07:45:35Z]", "a width pads, and never cuts");
241    }
242
243    #[test]
244    fn a_tai_instant_is_written_and_read_in_its_own_zone() {
245        let instant = TaiTimestamp::from_nanos(1_789_544_772_123_456_789);
246        assert_eq!(instant.to_string(), "2026-09-16T07:46:12.123456789 TAI", "written");
247        assert_eq!(format!("{instant:.0}"), "2026-09-16T07:46:12 TAI", "to the precision");
248        assert_eq!("2026-09-16T07:46:12.123456789 TAI".parse(), Ok(instant), "and read back");
249        let utc = "2026-09-16T07:46:12Z".parse::<TaiTimestamp>();
250        assert_eq!(utc, Err(ParseTaiTimestampError), "UTC is not its zone");
251    }
252
253    #[test]
254    fn a_short_fraction_reads_as_its_leading_digits_and_either_case_is_read() {
255        let half = "1970-01-01T00:00:00.5Z".parse();
256        assert_eq!(half, Ok(Timestamp::from_millis(500)), "a digit worth tenths");
257        assert_eq!("1970-01-01t00:00:00z".parse(), Ok(Timestamp::UNIX_EPOCH), "a lower case");
258    }
259
260    #[rstest]
261    #[case::the_earliest(Timestamp::MIN)]
262    #[case::the_latest(Timestamp::MAX)]
263    fn the_range_ends_read_back(#[case] instant: Timestamp) {
264        assert_eq!(instant.to_string().parse(), Ok(instant), "{instant} reads back");
265    }
266
267    #[rstest]
268    #[case::empty("")]
269    #[case::a_date_alone("2026-09-16")]
270    #[case::no_zone("2026-09-16T07:45:35")]
271    #[case::an_offset("2026-09-16T07:45:35+01:00")]
272    #[case::the_tai_zone("2026-09-16T07:45:35 TAI")]
273    #[case::trailing_text("2026-09-16T07:45:35Z ")]
274    #[case::february_30th("2026-02-30T07:45:35Z")]
275    #[case::the_24th_hour("2026-09-16T24:45:35Z")]
276    #[case::a_leap_second("2026-09-16T07:45:60Z")]
277    #[case::a_letter_for_a_digit("2026-09-1xT07:45:35Z")]
278    #[case::a_point_without_digits("2026-09-16T07:45:35.Z")]
279    #[case::ten_fraction_digits("2026-09-16T07:45:35.1234567890Z")]
280    #[case::after_the_range("2263-01-01T00:00:00Z")]
281    #[case::before_the_range("1677-01-01T00:00:00Z")]
282    fn a_malformed_instant_is_refused(#[case] text: &str) {
283        assert_eq!(text.parse::<Timestamp>(), Err(ParseTimestampError), "{text:?} is refused");
284    }
285
286    proptest! {
287        #[test]
288        fn every_instant_reads_back_from_its_spelling(nanos in any::<i64>()) {
289            let instant = Timestamp::from_nanos(nanos);
290            prop_assert_eq!(instant.to_string().parse(), Ok(instant), "in UTC");
291            let tai = TaiTimestamp::from_nanos(nanos);
292            prop_assert_eq!(tai.to_string().parse(), Ok(tai), "and in TAI");
293        }
294
295        #[test]
296        fn whatever_any_text_reads_as_reads_back_the_same(text in "[0-9T:Zz.-]{0,32}") {
297            if let Ok(instant) = text.parse::<Timestamp>() {
298                prop_assert_eq!(instant.to_string().parse(), Ok(instant), "what was read writes back");
299            }
300        }
301    }
302}