1use 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
13const FRACTION_DIGITS: usize = 9;
15
16const LONGEST: usize = "2026-09-16T07:46:12.123456789 TAI".len();
18
19const NANOS_PER_SECOND: u32 = 1_000_000_000;
22
23const 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#[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
44const UTC: &str = "Z";
46
47const TAI: &str = " TAI";
49
50impl fmt::Display for Timestamp {
55 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56 write_date_time(formatter, *self, UTC)
57 }
58}
59
60impl 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
70fn 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 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#[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
115impl 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
131impl 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#[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#[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#[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}