1use std::cmp::Ordering;
13use std::fmt;
14
15use serde::{Deserialize, Deserializer, Serialize, Serializer};
16use time::{Date, Month, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset};
17
18use crate::error::{NormativeReason, ValidationError};
19
20#[derive(Clone)]
26pub struct Timestamp {
27 raw: String,
28 instant: OffsetDateTime,
29}
30
31impl Timestamp {
32 pub fn parse(text: &str) -> Result<Self, ValidationError> {
34 let instant = parse_instant(text).map_err(|_| {
35 ValidationError::normative(
36 "/timestamp",
37 "rfc3339_profile",
38 NormativeReason::UnparseableTimestamp,
39 )
40 })?;
41 Ok(Self {
42 raw: text.to_string(),
43 instant,
44 })
45 }
46
47 pub fn as_str(&self) -> &str {
49 &self.raw
50 }
51
52 pub fn compare(&self, other: &Self) -> i8 {
56 match self.instant.cmp(&other.instant) {
57 Ordering::Less => -1,
58 Ordering::Equal => 0,
59 Ordering::Greater => 1,
60 }
61 }
62
63 pub fn saturating_add(&self, duration: std::time::Duration) -> Self {
65 let extra = time::Duration::new(duration.as_secs() as i64, duration.subsec_nanos() as i32);
66 match self.instant.checked_add(extra) {
67 Some(instant) => Self {
68 raw: format_utc(instant),
69 instant,
70 },
71 None => self.clone(),
72 }
73 }
74
75 pub fn duration_until(&self, later: &Self) -> std::time::Duration {
77 if later.instant <= self.instant {
78 return std::time::Duration::ZERO;
79 }
80 let delta = later.instant - self.instant;
81 let secs = delta.whole_seconds().max(0) as u64;
82 let nanos = delta.subsec_nanoseconds();
83 let nanos = if nanos < 0 { 0 } else { nanos as u32 };
84 std::time::Duration::new(secs, nanos)
85 }
86}
87
88fn format_utc(instant: OffsetDateTime) -> String {
89 let utc = instant.to_offset(UtcOffset::UTC);
90 let date = utc.date();
91 let t = utc.time();
92 let ns = t.nanosecond();
93 if ns == 0 {
94 format!(
95 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
96 date.year(),
97 u8::from(date.month()),
98 date.day(),
99 t.hour(),
100 t.minute(),
101 t.second()
102 )
103 } else {
104 format!(
105 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
106 date.year(),
107 u8::from(date.month()),
108 date.day(),
109 t.hour(),
110 t.minute(),
111 t.second(),
112 ns / 1000
113 )
114 }
115}
116
117impl PartialEq for Timestamp {
118 fn eq(&self, other: &Self) -> bool {
119 self.instant == other.instant
120 }
121}
122
123impl Eq for Timestamp {}
124
125impl PartialOrd for Timestamp {
126 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
127 Some(self.cmp(other))
128 }
129}
130
131impl Ord for Timestamp {
132 fn cmp(&self, other: &Self) -> Ordering {
133 self.instant.cmp(&other.instant)
134 }
135}
136
137impl fmt::Debug for Timestamp {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 f.debug_struct("Timestamp")
140 .field("profile", &self.raw)
141 .finish()
142 }
143}
144
145impl fmt::Display for Timestamp {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 f.write_str(&self.raw)
148 }
149}
150
151impl Serialize for Timestamp {
152 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
153 serializer.serialize_str(&self.raw)
154 }
155}
156
157impl<'de> Deserialize<'de> for Timestamp {
158 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
159 let raw = String::deserialize(deserializer)?;
160 Timestamp::parse(&raw).map_err(serde::de::Error::custom)
161 }
162}
163
164pub fn parse(text: &str) -> Result<Timestamp, ValidationError> {
166 Timestamp::parse(text)
167}
168
169pub fn compare(left: &str, right: &str) -> Result<i8, ValidationError> {
173 Ok(Timestamp::parse(left)?.compare(&Timestamp::parse(right)?))
174}
175
176fn parse_instant(text: &str) -> Result<OffsetDateTime, ()> {
177 if text.is_empty() || text != text.trim() {
178 return Err(());
179 }
180
181 let bytes = text.as_bytes();
182 if bytes.len() < 20 {
184 return Err(());
185 }
186
187 let year = parse_n_digits(&bytes[0..4], 4)?;
188 expect(bytes, 4, b'-')?;
189 let month = parse_n_digits(&bytes[5..7], 2)?;
190 expect(bytes, 7, b'-')?;
191 let day = parse_n_digits(&bytes[8..10], 2)?;
192 if bytes[10] != b'T' && bytes[10] != b't' {
193 return Err(());
194 }
195 let hour = parse_n_digits(&bytes[11..13], 2)?;
196 expect(bytes, 13, b':')?;
197 let minute = parse_n_digits(&bytes[14..16], 2)?;
198 expect(bytes, 16, b':')?;
199 let second = parse_n_digits(&bytes[17..19], 2)?;
200
201 if hour > 23 || minute > 59 {
202 return Err(());
203 }
204 if second == 60 {
205 return Err(());
207 }
208 if second > 60 {
209 return Err(());
210 }
211
212 let mut idx = 19;
213 let mut nanosecond = 0u32;
214 if idx < bytes.len() && bytes[idx] == b'.' {
215 idx += 1;
216 let start = idx;
217 while idx < bytes.len() && bytes[idx].is_ascii_digit() {
218 idx += 1;
219 }
220 if idx == start {
221 return Err(());
222 }
223 let digits = &text[start..idx];
227 let mut padded = digits.to_string();
228 while padded.len() < 6 {
229 padded.push('0');
230 }
231 if padded.len() > 6 {
232 padded.truncate(6);
233 }
234 let microsecond: u32 = padded.parse().map_err(|_| ())?;
235 nanosecond = microsecond.checked_mul(1000).ok_or(())?;
236 }
237
238 if idx >= bytes.len() {
239 return Err(());
240 }
241
242 let offset = if bytes[idx] == b'Z' || bytes[idx] == b'z' {
243 if idx + 1 != bytes.len() {
244 return Err(());
245 }
246 UtcOffset::UTC
247 } else if bytes[idx] == b'+' || bytes[idx] == b'-' {
248 let sign = if bytes[idx] == b'+' { 1i8 } else { -1i8 };
249 idx += 1;
250 if idx + 5 != bytes.len() {
251 return Err(());
252 }
253 let off_h = parse_n_digits(&bytes[idx..idx + 2], 2)?;
254 if bytes[idx + 2] != b':' {
255 return Err(());
256 }
257 let off_m = parse_n_digits(&bytes[idx + 3..idx + 5], 2)?;
258 if off_h > 23 || off_m > 59 {
259 return Err(());
260 }
261 UtcOffset::from_hms(sign * off_h as i8, sign * off_m as i8, 0).map_err(|_| ())?
262 } else {
263 return Err(());
264 };
265
266 let month = Month::try_from(month as u8).map_err(|_| ())?;
267 let date = Date::from_calendar_date(year as i32, month, day as u8).map_err(|_| ())?;
268 let time =
269 Time::from_hms_nano(hour as u8, minute as u8, second as u8, nanosecond).map_err(|_| ())?;
270 Ok(PrimitiveDateTime::new(date, time)
271 .assume_offset(offset)
272 .to_offset(UtcOffset::UTC))
273}
274
275fn expect(bytes: &[u8], idx: usize, want: u8) -> Result<(), ()> {
276 if idx < bytes.len() && bytes[idx] == want {
277 Ok(())
278 } else {
279 Err(())
280 }
281}
282
283fn parse_n_digits(slice: &[u8], n: usize) -> Result<u32, ()> {
284 if slice.len() != n || !slice.iter().all(|b| b.is_ascii_digit()) {
285 return Err(());
286 }
287 let mut value = 0u32;
288 for b in slice {
289 value = value * 10 + u32::from(b - b'0');
290 }
291 Ok(value)
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 fn reject(label: &str, text: &str) {
299 assert!(
300 Timestamp::parse(text).is_err(),
301 "{label}: expected reject of timestamp"
302 );
303 }
304
305 fn accept(label: &str, text: &str) {
306 assert!(
307 Timestamp::parse(text).is_ok(),
308 "{label}: expected accept of timestamp"
309 );
310 }
311
312 #[test]
313 fn profile_self_test() {
314 reject("empty", "");
315 reject("whitespace", " ");
316 reject("garbage", "not-a-timestamp");
317 reject("date-only", "2026-08-15");
318 reject("naive", "2026-08-15T17:00:00");
319 reject("space-separator", "2026-08-15 17:00:00Z");
320 reject("basic-date-time", "20260815T170000Z");
321 reject("week-date", "2026-W33-6T17:00:00Z");
322 reject("ordinal-date", "2026-227T17:00:00Z");
323 reject("missing-seconds", "2026-08-15T17:00Z");
324 reject("offset-without-colon", "2026-08-15T17:00:00+0000");
325 reject("comma-fraction", "2026-08-15T17:00:00,123Z");
326 reject("invalid-calendar", "2026-02-30T17:00:00Z");
327 reject("hour-24", "2026-08-15T24:00:00Z");
328 reject("leap-second-utc", "2016-12-31T23:59:60Z");
329 reject("leap-second-offset", "2016-12-31T23:59:60+00:00");
330
331 accept("zulu", "2026-08-15T17:00:00Z");
332 accept("lowercase", "2026-08-15t17:00:00z");
333 accept("numeric-offset", "2026-08-15T17:00:00+00:00");
334 accept("negative-offset", "2026-08-15T13:00:00-04:00");
335 accept("fractional-seconds", "2026-08-15T17:00:00.123Z");
336
337 assert_eq!(
338 compare("1970-01-01T00:00:00Z", "1970-01-01T00:00:00+00:00").unwrap(),
339 0
340 );
341 assert_eq!(
342 compare("1970-01-01T01:00:00+01:00", "1970-01-01T00:00:00Z").unwrap(),
343 0
344 );
345 assert_eq!(
346 compare("2026-08-15T17:00:00+00:00", "2026-08-15T17:00:00Z").unwrap(),
347 0
348 );
349 assert_eq!(
350 compare("1970-01-01T00:00:00Z", "1970-01-01T00:00:01Z").unwrap(),
351 -1
352 );
353 assert_eq!(
354 compare("1970-01-01T00:00:01Z", "1970-01-01T00:00:00Z").unwrap(),
355 1
356 );
357 assert_eq!(
358 compare("2026-08-15T17:00:00.100Z", "2026-08-15T17:00:00Z").unwrap(),
359 1
360 );
361 assert_eq!(
362 compare("2026-08-15T16:00:10Z", "2026-08-15T17:00:00+01:00").unwrap(),
363 1
364 );
365 }
366
367 #[test]
368 fn equivalent_offsets_are_equal_on_the_newtype() {
369 let z = Timestamp::parse("2026-08-15T17:00:00Z").unwrap();
370 let offset = Timestamp::parse("2026-08-15T17:00:00+00:00").unwrap();
371 assert_eq!(z, offset);
372 assert_eq!(z.as_str(), "2026-08-15T17:00:00Z");
373 assert_eq!(offset.as_str(), "2026-08-15T17:00:00+00:00");
374 }
375
376 #[test]
377 fn seven_digit_fractions_compare_equal_after_six_digit_truncate() {
378 assert_eq!(
379 compare(
380 "2026-08-15T17:00:00.1234567Z",
381 "2026-08-15T17:00:00.1234568Z"
382 )
383 .unwrap(),
384 0
385 );
386 }
387
388 #[test]
389 fn fraction_compare_matches_pinned_six_digit_oracle() {
390 assert_eq!(
392 compare("2026-08-15T17:00:00.123Z", "2026-08-15T17:00:00.123000Z").unwrap(),
393 0
394 );
395 assert_eq!(
396 compare(
397 "2026-08-15T17:00:00.1234569Z",
398 "2026-08-15T17:00:00.1234560Z"
399 )
400 .unwrap(),
401 0
402 );
403 assert_eq!(
405 compare("2026-08-15T17:00:00.123456Z", "2026-08-15T17:00:00.123457Z").unwrap(),
406 -1
407 );
408 assert_eq!(
409 compare(
410 "2026-08-15T17:00:00.1234559Z",
411 "2026-08-15T17:00:00.1234560Z"
412 )
413 .unwrap(),
414 -1
415 );
416 assert_eq!(
417 compare("2026-08-15T17:00:00.100Z", "2026-08-15T17:00:00Z").unwrap(),
418 1
419 );
420 }
421
422 #[test]
423 fn rfc3339_six_digit_compare_pads_truncates_and_normalizes_offsets() {
424 assert_eq!(
426 compare("2026-08-15T17:00:00.1Z", "2026-08-15T17:00:00.100000Z").unwrap(),
427 0
428 );
429 assert_eq!(
430 compare("2026-08-15T17:00:00.12Z", "2026-08-15T17:00:00.120000Z").unwrap(),
431 0
432 );
433 assert_eq!(
435 compare(
436 "2026-08-15T17:00:00.123456999Z",
437 "2026-08-15T17:00:00.123456000Z"
438 )
439 .unwrap(),
440 0
441 );
442 assert_eq!(
443 compare(
444 "2026-08-15T17:00:00.123456000Z",
445 "2026-08-15T17:00:00.123457000Z"
446 )
447 .unwrap(),
448 -1
449 );
450 assert_eq!(
452 compare(
453 "2026-08-15T17:00:00.123456Z",
454 "2026-08-15T18:00:00.123456+01:00"
455 )
456 .unwrap(),
457 0
458 );
459 let left = Timestamp::parse("2026-08-15T17:00:00.1234567Z").unwrap();
460 let right = Timestamp::parse("2026-08-15T17:00:00.1234568Z").unwrap();
461 assert_eq!(left.compare(&right), 0);
462 assert_eq!(left, right);
463 }
464
465 #[test]
466 fn one_millisecond_is_a_distinct_logical_instant() {
467 let a = Timestamp::parse("2026-08-15T16:05:00.001000Z").unwrap();
470 let b = Timestamp::parse("2026-08-15T16:05:00.002000Z").unwrap();
471 assert_ne!(a, b);
472 assert_eq!(a.compare(&b), -1);
473 let micro_a = Timestamp::parse("2026-08-15T16:05:00.000001Z").unwrap();
474 let micro_b = Timestamp::parse("2026-08-15T16:05:00.000002Z").unwrap();
475 assert_ne!(micro_a, micro_b);
476 assert_eq!(micro_a.compare(µ_b), -1);
477 let padded = Timestamp::parse("2026-08-15T16:05:00.001Z").unwrap();
478 assert_eq!(a, padded);
479 }
480
481 #[test]
482 fn saturating_add_and_duration_until_round_trip() {
483 let start = Timestamp::parse("2026-08-15T16:00:00Z").unwrap();
484 let later = start.saturating_add(std::time::Duration::from_secs(90));
485 assert_eq!(later.as_str(), "2026-08-15T16:01:30Z");
486 assert_eq!(
487 start.duration_until(&later),
488 std::time::Duration::from_secs(90)
489 );
490 assert_eq!(later.duration_until(&start), std::time::Duration::ZERO);
491 }
492}