jmap_types/id.rs
1//! RFC 8620 §1.2/§1.4 opaque string newtypes: [`Id`], [`UTCDate`], [`Date`], [`State`].
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6/// Error returned by the fallible constructors [`Id::new_validated`],
7/// [`UTCDate::new_validated`], and [`State::new_validated`].
8#[derive(Debug, Clone, PartialEq, Eq)]
9#[non_exhaustive]
10pub struct ValidationError(pub String);
11
12impl fmt::Display for ValidationError {
13 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14 f.write_str(&self.0)
15 }
16}
17
18impl std::error::Error for ValidationError {}
19
20/// Opaque non-empty server-assigned identifier (RFC 8620 §1.2).
21///
22/// Character set: URL-safe base64 alphabet (A-Za-z0-9, `-`, `_`), max 255 octets.
23/// Clients MUST treat Id values as opaque strings — no parsing of structure.
24// #[non_exhaustive] prevents callers from pattern-matching the inner field
25// (e.g. `let Id(s) = id;`), preserving semver freedom to add fields later.
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(transparent)]
28#[non_exhaustive]
29pub struct Id(String);
30
31/// RFC 3339 UTC timestamp string (RFC 8620 §1.4).
32///
33/// Format: `YYYY-MM-DDTHH:MM:SSZ` — time-offset MUST be `Z`, letters uppercase,
34/// fractional seconds omitted if zero. Example: `"2014-10-30T06:12:00Z"`.
35///
36/// # Deserialization is NOT validated
37///
38/// `UTCDate` is `#[serde(transparent)]` over `String`. Any string that
39/// deserializes into a `String` deserializes into a `UTCDate` — including
40/// `"not-a-date"`, `"2024-01-19T18:00:00"` (no `Z` suffix), or any other
41/// shape that violates RFC 8620 §1.4. The newtype carries the
42/// **type-level intent** of "RFC 8620 UTC timestamp" but does NOT enforce
43/// it at the wire boundary.
44///
45/// Use [`UTCDate::new_validated`] when constructing from untrusted input,
46/// or call [`UTCDate::to_epoch_seconds`] when consuming a deserialized
47/// value: the latter re-validates structural format AND semantic ranges
48/// (month, day, hour, minute, second) and returns [`ValidationError`] on
49/// any deviation. Treat any field of type `Option<UTCDate>` arriving
50/// from a peer as "RFC 8620 timestamp by convention, not by contract".
51// #[non_exhaustive] prevents callers from pattern-matching the inner field,
52// preserving semver freedom to add fields later.
53#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
54#[serde(transparent)]
55#[non_exhaustive]
56pub struct UTCDate(String);
57
58/// RFC 3339 date-time string with any timezone offset (RFC 8620 §1.4).
59///
60/// Format: `YYYY-MM-DDTHH:MM:SS±HH:MM` or `Z` suffix — any valid RFC 3339 offset,
61/// letters uppercase, fractional seconds omitted if zero.
62/// Example: `"2014-10-30T14:12:00+08:00"`.
63///
64/// Distinct from [`UTCDate`], which requires the time-offset to be `Z`.
65/// Use `Date` for fields derived from RFC 5322 email headers (e.g. `sentAt`),
66/// which commonly carry non-UTC offsets.
67///
68/// # Deserialization is NOT validated
69///
70/// `Date` is `#[serde(transparent)]` over `String`. Any string that
71/// deserializes into a `String` deserializes into a `Date`, including
72/// values that violate RFC 8620 §1.4 / RFC 3339. The newtype carries
73/// the **type-level intent** but does NOT enforce it at the wire
74/// boundary. Treat any field of type `Option<Date>` arriving from a
75/// peer as "RFC 8620 timestamp by convention, not by contract".
76// #[non_exhaustive] prevents callers from pattern-matching the inner field,
77// preserving semver freedom to add fields later.
78#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
79#[serde(transparent)]
80#[non_exhaustive]
81pub struct Date(String);
82
83/// Opaque server state token (RFC 8620 §1.2).
84///
85/// Returned by `/get` and `/changes` methods. Clients echo it back in
86/// `sinceState` / `ifInState` parameters. Treat as opaque — no structure assumed.
87///
88/// # Migrating from `String`-typed code
89///
90/// Earlier revisions of this crate exposed state fields as
91/// `pub session_state: String` on [`crate::JmapResponse`] and similar
92/// shapes. The current revision uses the `State` newtype for type
93/// safety. The wire format is unchanged (`State` is
94/// `#[serde(transparent)]` over `String`). The Rust API conversions
95/// available to callers migrating from `String`:
96///
97/// - Read as `&str`: `state.as_ref()` (via `AsRef<str>`) or
98/// `format!("{state}")` (via `Display`).
99/// - Compare to a literal: `state == "abc"` (via `PartialEq<str>`).
100/// - Move into an owned `String`: `state.into_inner()`.
101/// - Borrow as `&String`: not directly available; use `as_ref()` to
102/// get `&str` instead.
103/// - Build from `&str` or `String`: `State::from("abc")` or
104/// `State::from(my_string)` (via `From<&str>` / `From<String>`).
105///
106/// Callers passing a `State` to a function that takes `String` should
107/// either change the signature to `impl AsRef<str>` or call
108/// `state.into_inner()` at the boundary. Callers keying a
109/// `HashMap<String, _>` by state token can either rekey by
110/// `HashMap<State, _>` (`State` implements `Hash + Eq`) or call
111/// `state.into_inner()` at insertion / lookup time.
112// #[non_exhaustive] prevents callers from pattern-matching the inner field,
113// preserving semver freedom to add fields later.
114#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
115#[serde(transparent)]
116#[non_exhaustive]
117pub struct State(String);
118
119/// Generates `Display`, `From<String>`, `From<&str>`, `AsRef<str>`,
120/// `PartialEq<str>`, `PartialEq<&str>`, and `into_inner` for a transparent
121/// `String` newtype.
122macro_rules! impl_string_newtype {
123 ($T:ident) => {
124 impl fmt::Display for $T {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 f.write_str(&self.0)
127 }
128 }
129 impl From<String> for $T {
130 fn from(s: String) -> Self {
131 Self(s)
132 }
133 }
134 impl From<&str> for $T {
135 fn from(s: &str) -> Self {
136 Self(s.to_owned())
137 }
138 }
139 impl AsRef<str> for $T {
140 fn as_ref(&self) -> &str {
141 &self.0
142 }
143 }
144 impl PartialEq<str> for $T {
145 fn eq(&self, other: &str) -> bool {
146 self.0 == other
147 }
148 }
149 impl PartialEq<&str> for $T {
150 fn eq(&self, other: &&str) -> bool {
151 self.0 == *other
152 }
153 }
154 impl std::borrow::Borrow<str> for $T {
155 fn borrow(&self) -> &str {
156 &self.0
157 }
158 }
159 impl $T {
160 /// Consumes the value and returns the inner `String`.
161 pub fn into_inner(self) -> String {
162 self.0
163 }
164 }
165 };
166}
167
168impl_string_newtype!(Id);
169impl_string_newtype!(UTCDate);
170impl_string_newtype!(Date);
171impl_string_newtype!(State);
172
173// ---------------------------------------------------------------------------
174// Fallible constructors — validate RFC 8620 constraints at the boundary.
175//
176// These are named constructors (not TryFrom impls) because Id/UTCDate/State
177// already implement From<String> and From<&str>. Rust's blanket impl
178// `impl<T,U> TryFrom<U> where U: Into<T>` would make TryFrom<String>
179// infallible (Error = Infallible) via the existing From impl, making it
180// impossible to add a second, fallible TryFrom<String>. Named constructors
181// achieve the same goal without the conflict.
182// ---------------------------------------------------------------------------
183
184/// Validate an [`Id`] string per RFC 8620 §1.2.
185///
186/// RFC 8620 §1.2 requires Ids to use the "URL and Filename Safe" base64
187/// alphabet defined in RFC 4648 §5, excluding the pad character `=`.
188/// That is: ASCII alphanumeric characters (`A-Z`, `a-z`, `0-9`), hyphen
189/// (`-`), and underscore (`_`). The string must also be non-empty and
190/// at most 255 bytes.
191fn validate_id(s: &str) -> Result<(), ValidationError> {
192 if s.is_empty() {
193 return Err(ValidationError("Id must not be empty".into()));
194 }
195 if s.len() > 255 {
196 return Err(ValidationError(format!(
197 "Id exceeds 255 bytes (got {})",
198 s.len()
199 )));
200 }
201 for ch in s.chars() {
202 // RFC 4648 §5 URL-safe base64 alphabet, minus the pad `=`:
203 // A-Z, a-z, 0-9, '-', '_'.
204 if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
205 return Err(ValidationError(format!(
206 "Id contains invalid character {:?} (U+{:04X})",
207 ch, ch as u32
208 )));
209 }
210 }
211 Ok(())
212}
213
214/// Validate a [`UTCDate`] string per RFC 8620 §1.4.
215///
216/// Validation has two layers:
217///
218/// 1. **Shape**: exactly 20 characters in the `YYYY-MM-DDTHH:MM:SSZ`
219/// layout with `Z` suffix, ASCII digits in every numeric position.
220/// 2. **Values**: month in `1..=12`, day in `1..=days_in_month(year,
221/// month)` (proleptic Gregorian, exact leap-year rules), hour in
222/// `0..=23`, minute in `0..=59`, second in `0..=59` (RFC 8620 §1.4
223/// does not permit leap seconds even though RFC 3339 §5.6 does).
224///
225/// No external crate needed.
226fn validate_utcdate(s: &str) -> Result<(), ValidationError> {
227 if s.len() != 20 {
228 return Err(ValidationError(format!(
229 "UTCDate must be exactly 20 characters (YYYY-MM-DDTHH:MM:SSZ), got {:?}",
230 s
231 )));
232 }
233 let b = s.as_bytes();
234 // Fixed separators: dashes, T, colons, Z.
235 if b[4] != b'-'
236 || b[7] != b'-'
237 || b[10] != b'T'
238 || b[13] != b':'
239 || b[16] != b':'
240 || b[19] != b'Z'
241 {
242 return Err(ValidationError(format!(
243 "UTCDate has wrong structure, expected YYYY-MM-DDTHH:MM:SSZ, got {:?}",
244 s
245 )));
246 }
247 // Digit positions: 0-3 (year), 5-6 (month), 8-9 (day),
248 // 11-12 (hour), 14-15 (min), 17-18 (sec).
249 for &pos in &[0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18] {
250 if !b[pos].is_ascii_digit() {
251 return Err(ValidationError(format!(
252 "UTCDate position {} is not a digit in {:?}",
253 pos, s
254 )));
255 }
256 }
257
258 // Value-range checks. Shape is now confirmed, so digit positions
259 // are guaranteed ASCII digits and the parses are infallible.
260 let parse = |start: usize, end: usize| -> i64 {
261 let mut n: i64 = 0;
262 for &byte in &b[start..end] {
263 n = n * 10 + (byte - b'0') as i64;
264 }
265 n
266 };
267 let year = parse(0, 4);
268 let month = parse(5, 7) as u32;
269 let day = parse(8, 10) as u32;
270 let hour = parse(11, 13) as u32;
271 let minute = parse(14, 16) as u32;
272 let second = parse(17, 19) as u32;
273
274 if !(1..=12).contains(&month) {
275 return Err(ValidationError(format!(
276 "UTCDate month must be 1..=12, got {month} in {s:?}"
277 )));
278 }
279 let max_day = days_in_month(year, month);
280 if !(1..=max_day).contains(&day) {
281 return Err(ValidationError(format!(
282 "UTCDate day must be 1..={max_day} for {year:04}-{month:02}, got {day} in {s:?}"
283 )));
284 }
285 if hour > 23 {
286 return Err(ValidationError(format!(
287 "UTCDate hour must be 0..=23, got {hour} in {s:?}"
288 )));
289 }
290 if minute > 59 {
291 return Err(ValidationError(format!(
292 "UTCDate minute must be 0..=59, got {minute} in {s:?}"
293 )));
294 }
295 // No leap seconds in RFC 8620 §1.4 (it specifies seconds 0..=59).
296 if second > 59 {
297 return Err(ValidationError(format!(
298 "UTCDate second must be 0..=59, got {second} in {s:?}"
299 )));
300 }
301 Ok(())
302}
303
304/// Validate a [`State`] string: must be non-empty.
305///
306/// RFC 8620 §1.2 does not restrict the character set for State beyond
307/// requiring it to be non-empty.
308fn validate_state(s: &str) -> Result<(), ValidationError> {
309 if s.is_empty() {
310 return Err(ValidationError("State must not be empty".into()));
311 }
312 Ok(())
313}
314
315impl Id {
316 /// Construct an [`Id`] with RFC 8620 §1.2 syntax validation.
317 ///
318 /// RFC 8620 §1.2 restricts Ids to the URL-safe base64 alphabet defined
319 /// in RFC 4648 §5, excluding the pad character `=`. The permitted
320 /// characters are therefore the ASCII alphanumerics (`A-Z`, `a-z`,
321 /// `0-9`), hyphen (`-`), and underscore (`_`). The string must also
322 /// be non-empty and at most 255 bytes.
323 ///
324 /// # Errors
325 ///
326 /// Returns [`ValidationError`] when the input does not satisfy
327 /// RFC 8620 §1.2. The error description contains a category keyword
328 /// that callers can match on if they need finer-grained handling:
329 ///
330 /// - `"empty"` — the input was an empty string;
331 /// - `"exceeds 255 bytes"` — the input was longer than the spec
332 /// maximum;
333 /// - `"invalid character"` — the input contained at least one
334 /// character outside `A-Z`, `a-z`, `0-9`, `-`, `_`.
335 ///
336 /// Use [`Id::from`] when the value is known to be valid (e.g. a string
337 /// received from a JMAP server response).
338 pub fn new_validated(s: impl Into<String>) -> Result<Self, ValidationError> {
339 let s = s.into();
340 validate_id(&s)?;
341 Ok(Self(s))
342 }
343}
344
345impl UTCDate {
346 /// Construct a [`UTCDate`] with RFC 8620 §1.4 validation.
347 ///
348 /// Validation has two layers:
349 ///
350 /// 1. **Shape**: exactly 20 characters in the
351 /// `YYYY-MM-DDTHH:MM:SSZ` layout with `Z` suffix and ASCII
352 /// digits in every numeric position.
353 /// 2. **Values**: month `1..=12`; day `1..=days_in_month(year,
354 /// month)` with proleptic Gregorian leap-year rules so e.g.
355 /// `2024-02-29` is accepted but `2023-02-29` and `2024-02-30`
356 /// are rejected; hour `0..=23`; minute `0..=59`; second
357 /// `0..=59` (RFC 8620 §1.4 does not permit leap seconds even
358 /// though RFC 3339 §5.6 does).
359 ///
360 /// # Errors
361 ///
362 /// Returns [`ValidationError`] when the input does not satisfy
363 /// RFC 8620 §1.4. The error description contains a substring that
364 /// callers can match on if they need finer-grained handling:
365 ///
366 /// - `"exactly 20 characters"` — wrong length;
367 /// - `"wrong structure"` — separators or `Z` suffix missing /
368 /// misplaced;
369 /// - `"is not a digit"` — non-digit at a numeric position;
370 /// - `"month must be"` — month outside `1..=12`;
371 /// - `"day must be"` — day outside `1..=days_in_month(year,
372 /// month)` (catches both out-of-range day numbers like `32` and
373 /// non-existent dates like Feb 30 or Feb 29 in a non-leap year);
374 /// - `"hour must be"`, `"minute must be"`, `"second must be"` —
375 /// the corresponding time component is out of range.
376 ///
377 /// Use [`UTCDate::from`] when the value is known to be valid.
378 pub fn new_validated(s: impl Into<String>) -> Result<Self, ValidationError> {
379 let s = s.into();
380 validate_utcdate(&s)?;
381 Ok(Self(s))
382 }
383
384 /// Convert this [`UTCDate`] to seconds since the Unix epoch
385 /// (`1970-01-01T00:00:00Z`).
386 ///
387 /// Re-validates the structural RFC 8620 §1.4 format (the value may have
388 /// been constructed via [`UTCDate::from`] without validation) and also
389 /// validates semantic ranges: month `1..=12`, day `1..=days_in_month`,
390 /// hour `0..=23`, minute `0..=59`, second `0..=59` (no leap seconds).
391 /// Returns [`ValidationError`] on any validation failure.
392 ///
393 /// Negative values are returned for dates before `1970-01-01T00:00:00Z`.
394 ///
395 /// Uses the proleptic Gregorian calendar via Hinnant's `days_from_civil`
396 /// algorithm, which is exact-integer and handles leap years and century
397 /// rules correctly. No external dependencies.
398 ///
399 /// # Examples
400 ///
401 /// ```
402 /// use jmap_types::UTCDate;
403 /// let d = UTCDate::new_validated("1970-01-01T00:00:00Z").unwrap();
404 /// assert_eq!(d.to_epoch_seconds().unwrap(), 0);
405 /// let rfc = UTCDate::new_validated("2014-10-30T06:12:00Z").unwrap();
406 /// assert_eq!(rfc.to_epoch_seconds().unwrap(), 1_414_649_520);
407 /// ```
408 pub fn to_epoch_seconds(&self) -> Result<i64, ValidationError> {
409 utcdate_to_epoch_seconds(&self.0)
410 }
411}
412
413/// Number of days in `month` of `year`, accounting for proleptic Gregorian
414/// leap-year rules (year divisible by 4, except centuries not divisible by 400).
415fn days_in_month(year: i64, month: u32) -> u32 {
416 match month {
417 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
418 4 | 6 | 9 | 11 => 30,
419 2 => {
420 if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
421 29
422 } else {
423 28
424 }
425 }
426 _ => 0, // unreachable when month range is validated by caller
427 }
428}
429
430/// Hinnant's `days_from_civil` algorithm: number of days from
431/// `1970-01-01` (positive) or to `1970-01-01` (negative) for the proleptic
432/// Gregorian date `(y, m, d)`. Source: Howard Hinnant, "chrono-Compatible
433/// Low-Level Date Algorithms", §6.
434///
435/// Preconditions (caller-validated): `m` in `1..=12`, `d` in
436/// `1..=days_in_month(y, m)`. Years are unbounded `i64`.
437fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
438 let y = if m <= 2 { y - 1 } else { y };
439 let era = if y >= 0 { y } else { y - 399 } / 400;
440 let yoe = y - era * 400; // [0, 399]
441 let m = m as i64;
442 let d = d as i64;
443 let mp = if m > 2 { m - 3 } else { m + 9 }; // [0, 11]
444 let doy = (153 * mp + 2) / 5 + d - 1; // [0, 365]
445 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
446 era * 146097 + doe - 719_468
447}
448
449/// Internal helper: convert a `YYYY-MM-DDTHH:MM:SSZ` string to epoch seconds.
450///
451/// `validate_utcdate` performs both shape and value-range validation;
452/// after it returns Ok, the per-position digit parses are infallible
453/// and the parsed values are within their RFC 8620 §1.4 ranges.
454fn utcdate_to_epoch_seconds(s: &str) -> Result<i64, ValidationError> {
455 validate_utcdate(s)?;
456 let parse = |start: usize, end: usize| -> i64 {
457 let mut n: i64 = 0;
458 for &b in &s.as_bytes()[start..end] {
459 n = n * 10 + (b - b'0') as i64;
460 }
461 n
462 };
463 let year = parse(0, 4);
464 let month = parse(5, 7) as u32;
465 let day = parse(8, 10) as u32;
466 let hour = parse(11, 13) as u32;
467 let minute = parse(14, 16) as u32;
468 let second = parse(17, 19) as u32;
469
470 let days = days_from_civil(year, month, day);
471 Ok(days * 86_400 + hour as i64 * 3_600 + minute as i64 * 60 + second as i64)
472}
473
474impl State {
475 /// Construct a [`State`] with RFC 8620 §1.2 validation.
476 ///
477 /// Rejects empty strings. RFC 8620 §1.2 requires State to be non-empty;
478 /// no character-set restriction is imposed.
479 ///
480 /// Use [`State::from`] when the value is known to be valid.
481 pub fn new_validated(s: impl Into<String>) -> Result<Self, ValidationError> {
482 let s = s.into();
483 validate_state(&s)?;
484 Ok(Self(s))
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 // Oracle: RFC 8620 §1.2 — Id is a plain JSON string, not a wrapped object.
493 #[test]
494 fn id_serializes_as_plain_string() {
495 let id = Id("abc123".to_owned());
496 let json = serde_json::to_string(&id).expect("serialize Id");
497 assert_eq!(json, "\"abc123\"");
498 }
499
500 // Oracle: RFC 8620 §1.2 — Id round-trips through JSON.
501 #[test]
502 fn id_deserializes_from_plain_string() {
503 let id: Id = serde_json::from_str("\"abc123\"").expect("deserialize Id");
504 assert_eq!(id.as_ref(), "abc123");
505 }
506
507 // Oracle: RFC 8620 §1.4 example — "2014-10-30T06:12:00Z".
508 #[test]
509 fn utcdate_serializes_as_plain_string() {
510 let d = UTCDate("2014-10-30T06:12:00Z".to_owned());
511 let json = serde_json::to_string(&d).expect("serialize UTCDate");
512 assert_eq!(json, "\"2014-10-30T06:12:00Z\"");
513 }
514
515 // Oracle: RFC 8620 §3.4.1 fixture — sessionState value is "75128aab4b1b".
516 #[test]
517 fn state_serializes_as_plain_string() {
518 let s = State("75128aab4b1b".to_owned());
519 let json = serde_json::to_string(&s).expect("serialize State");
520 assert_eq!(json, "\"75128aab4b1b\"");
521 }
522
523 // Oracle: From<&str> trait contract.
524 #[test]
525 fn id_from_str() {
526 let id = Id::from("hello");
527 assert_eq!(id.as_ref(), "hello");
528 }
529
530 // Oracle: Display delegates to inner String.
531 #[test]
532 fn id_display() {
533 let id = Id("display-test".to_owned());
534 assert_eq!(id.to_string(), "display-test");
535 }
536
537 // Oracle: AsRef<str> returns the inner string.
538 #[test]
539 fn id_as_ref_str() {
540 let id = Id("ref-test".to_owned());
541 assert_eq!(id.as_ref(), "ref-test");
542 }
543
544 // Oracle: RFC 8620 §3.4.1 — State in sessionState field round-trips correctly.
545 #[test]
546 fn state_round_trip() {
547 let s = State("75128aab4b1b".to_owned());
548 let json = serde_json::to_string(&s).expect("serialize");
549 let s2: State = serde_json::from_str(&json).expect("deserialize");
550 assert_eq!(s, s2);
551 }
552
553 // Oracle: RFC 8620 §1.4 example — Date allows non-UTC offsets, unlike UTCDate.
554 #[test]
555 fn date_accepts_non_utc_offset() {
556 let d = Date("2014-10-30T14:12:00+08:00".to_owned());
557 let json = serde_json::to_string(&d).expect("serialize Date");
558 assert_eq!(json, "\"2014-10-30T14:12:00+08:00\"");
559 let d2: Date = serde_json::from_str(&json).expect("deserialize Date");
560 assert_eq!(d, d2);
561 }
562
563 // -----------------------------------------------------------------------
564 // new_validated / ValidationError tests
565 // Oracle for all: RFC 8620 §1.2 (Id, State) and §1.4 (UTCDate).
566 // -----------------------------------------------------------------------
567
568 /// Oracle: RFC 8620 §1.2 — Id must not be empty.
569 #[test]
570 fn id_new_validated_empty_fails() {
571 let err = Id::new_validated("").unwrap_err();
572 assert!(err.0.contains("empty"), "error must mention 'empty': {err}");
573 }
574
575 /// Oracle: RFC 8620 §1.2 URL-safe base64 alphabet — space (0x20) is
576 /// not in `A-Za-z0-9-_`.
577 #[test]
578 fn id_new_validated_space_fails() {
579 let err = Id::new_validated("has space").unwrap_err();
580 assert!(err.0.contains("invalid character"), "{err}");
581 }
582
583 /// Oracle: RFC 8620 §1.2 URL-safe base64 alphabet — double-quote
584 /// (0x22) is not in `A-Za-z0-9-_`.
585 #[test]
586 fn id_new_validated_dquote_fails() {
587 let err = Id::new_validated("has\"quote").unwrap_err();
588 assert!(err.0.contains("invalid character"), "{err}");
589 }
590
591 /// Oracle: RFC 8620 §1.2 URL-safe base64 alphabet — a control
592 /// character (0x01) is not in `A-Za-z0-9-_`.
593 #[test]
594 fn id_new_validated_control_char_fails() {
595 let err = Id::new_validated("has\x01ctrl").unwrap_err();
596 assert!(err.0.contains("invalid character"), "{err}");
597 }
598
599 /// Oracle: RFC 8620 §1.2 URL-safe base64 alphabet (RFC 4648 §5) —
600 /// every visible-ASCII character outside `A-Za-z0-9-_` MUST be
601 /// rejected. These characters were accepted by the previous
602 /// (incorrect) `SAFE-CHAR` validator; this test pins the spec-
603 /// conforming rejection (bd:JMAP-6xs8.19).
604 #[test]
605 fn id_new_validated_rejects_non_url_safe_base64_chars() {
606 // Representative sample of characters that are visible ASCII
607 // (the old SAFE-CHAR set) but NOT in the RFC 4648 §5 URL-safe
608 // base64 alphabet. Each MUST be rejected with category
609 // "invalid character".
610 let rejected = [
611 '!', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '.', '/', ':', ';', '<', '=',
612 '>', '?', '@', '[', '\\', ']', '^', '`', '{', '|', '}', '~',
613 ];
614 for ch in rejected {
615 let input = format!("a{ch}z");
616 let err = Id::new_validated(&input).unwrap_err();
617 assert!(
618 err.0.contains("invalid character"),
619 "char {ch:?} (input {input:?}) must be rejected with \
620 'invalid character', got: {err}"
621 );
622 }
623 }
624
625 /// Oracle: RFC 8620 §1.2 — max length 255 bytes.
626 #[test]
627 fn id_new_validated_too_long_fails() {
628 let long = "a".repeat(256);
629 assert!(Id::new_validated(long).is_err());
630 }
631
632 /// Oracle: valid printable ASCII Id succeeds and is preserved verbatim.
633 #[test]
634 fn id_new_validated_valid_succeeds() {
635 let id = Id::new_validated("abc123-_ABC").expect("valid Id must succeed");
636 assert_eq!(id.as_ref(), "abc123-_ABC");
637 }
638
639 /// Oracle: exactly 255-byte Id succeeds.
640 #[test]
641 fn id_new_validated_max_length_succeeds() {
642 let id255 = "a".repeat(255);
643 Id::new_validated(id255).expect("255-byte Id must succeed");
644 }
645
646 /// Oracle: RFC 8620 §1.4 example "2014-10-30T06:12:00Z" must succeed.
647 #[test]
648 fn utcdate_new_validated_valid_succeeds() {
649 let d = UTCDate::new_validated("2014-10-30T06:12:00Z").expect("valid UTCDate must succeed");
650 assert_eq!(d.as_ref(), "2014-10-30T06:12:00Z");
651 }
652
653 /// Oracle: UTC date without Z suffix is not RFC 8620 §1.4 format.
654 #[test]
655 fn utcdate_new_validated_no_z_fails() {
656 assert!(UTCDate::new_validated("2014-10-30T06:12:00+00:00").is_err());
657 }
658
659 /// Oracle: empty UTCDate fails.
660 #[test]
661 fn utcdate_new_validated_empty_fails() {
662 assert!(UTCDate::new_validated("").is_err());
663 }
664
665 /// Oracle: UTCDate with wrong length fails.
666 #[test]
667 fn utcdate_new_validated_wrong_length_fails() {
668 assert!(UTCDate::new_validated("2014-10-30").is_err());
669 // fractional seconds are not permitted in RFC 8620 §1.4 format.
670 assert!(UTCDate::new_validated("2014-10-30T06:12:00.000Z").is_err());
671 }
672
673 /// Oracle: non-digit in year position fails.
674 #[test]
675 fn utcdate_new_validated_non_digit_fails() {
676 assert!(UTCDate::new_validated("XXXX-10-30T06:12:00Z").is_err());
677 }
678
679 /// Oracle: RFC 8620 §1.4 — month out of range MUST be rejected.
680 /// Pre-tightening, `validate_utcdate` was shape-only and would
681 /// accept this. bd:JMAP-6xs8.20 added semantic validation.
682 #[test]
683 fn utcdate_new_validated_rejects_month_out_of_range() {
684 let err = UTCDate::new_validated("2024-13-15T09:00:00Z").unwrap_err();
685 assert!(err.0.contains("month must be"), "{err}");
686 let err = UTCDate::new_validated("9999-99-99T09:00:00Z").unwrap_err();
687 assert!(err.0.contains("month must be"), "{err}");
688 }
689
690 /// Oracle: RFC 8620 §1.4 — day out of range MUST be rejected
691 /// including non-existent dates (Feb 30) and Feb 29 in non-leap
692 /// years. bd:JMAP-6xs8.20.
693 #[test]
694 fn utcdate_new_validated_rejects_day_out_of_range() {
695 // Day 32 in January
696 let err = UTCDate::new_validated("2024-01-32T09:00:00Z").unwrap_err();
697 assert!(err.0.contains("day must be"), "{err}");
698 // Feb 30 never exists
699 let err = UTCDate::new_validated("2024-02-30T09:00:00Z").unwrap_err();
700 assert!(err.0.contains("day must be"), "{err}");
701 // Feb 29 in non-leap year 2023 (not divisible by 4)
702 let err = UTCDate::new_validated("2023-02-29T09:00:00Z").unwrap_err();
703 assert!(err.0.contains("day must be"), "{err}");
704 // April 31 (April has 30 days)
705 let err = UTCDate::new_validated("2024-04-31T09:00:00Z").unwrap_err();
706 assert!(err.0.contains("day must be"), "{err}");
707 }
708
709 /// Oracle: RFC 8620 §1.4 — Feb 29 in a leap year MUST be accepted.
710 /// Pins the leap-year branch of the day-range check (2024 is
711 /// divisible by 4, not by 100, so leap). bd:JMAP-6xs8.20.
712 #[test]
713 fn utcdate_new_validated_accepts_feb_29_leap_year() {
714 UTCDate::new_validated("2024-02-29T00:00:00Z").expect("leap day must succeed");
715 }
716
717 /// Oracle: RFC 8620 §1.4 — time-component out of range MUST be
718 /// rejected. Includes hour 24, minute 60, second 60 (no leap
719 /// seconds per §1.4 even though RFC 3339 §5.6 permits them).
720 /// bd:JMAP-6xs8.20.
721 #[test]
722 fn utcdate_new_validated_rejects_time_out_of_range() {
723 let err = UTCDate::new_validated("2024-06-15T24:00:00Z").unwrap_err();
724 assert!(err.0.contains("hour must be"), "{err}");
725 let err = UTCDate::new_validated("2024-06-15T09:60:00Z").unwrap_err();
726 assert!(err.0.contains("minute must be"), "{err}");
727 let err = UTCDate::new_validated("2024-06-15T09:00:60Z").unwrap_err();
728 assert!(err.0.contains("second must be"), "{err}");
729 }
730
731 /// Oracle: the absurd case from the bd:JMAP-6xs8.20 bead body
732 /// (`9999-99-99T99:99:99Z`) MUST be rejected. Pinned here so a
733 /// future contributor can't accidentally regress to the old
734 /// shape-only validator.
735 #[test]
736 fn utcdate_new_validated_rejects_absurd_values() {
737 let err = UTCDate::new_validated("9999-99-99T99:99:99Z").unwrap_err();
738 assert!(
739 err.0.contains("month must be"),
740 "first rejected component (in left-to-right order) is the \
741 month; got: {err}"
742 );
743 }
744
745 /// Oracle: RFC 8620 §1.2 — State must be non-empty.
746 #[test]
747 fn state_new_validated_empty_fails() {
748 let err = State::new_validated("").unwrap_err();
749 assert!(err.0.contains("empty"), "{err}");
750 }
751
752 /// Oracle: non-empty State string succeeds.
753 #[test]
754 fn state_new_validated_valid_succeeds() {
755 let s = State::new_validated("75128aab4b1b").expect("valid State must succeed");
756 assert_eq!(s.as_ref(), "75128aab4b1b");
757 }
758
759 /// Oracle: ValidationError implements std::error::Error and Display.
760 #[test]
761 fn validation_error_implements_error() {
762 let e = Id::new_validated("").unwrap_err();
763 let _: &dyn std::error::Error = &e;
764 assert!(!e.to_string().is_empty(), "error message must not be empty");
765 assert_eq!(format!("{e}"), e.0, "Display must show the inner message");
766 }
767
768 // -----------------------------------------------------------------------
769 // UTCDate::to_epoch_seconds tests
770 //
771 // Oracle for all numeric vectors: Python 3 `datetime.datetime(...).
772 // replace(tzinfo=timezone.utc).timestamp()`, computed independently and
773 // hardcoded as integer literals per workspace AGENTS.md "Test vector
774 // discipline". The oracle is independent of the code under test — these
775 // are NOT round-trip self-tests.
776 // -----------------------------------------------------------------------
777
778 /// Oracle (Python): `1970-01-01T00:00:00Z` → 0.
779 #[test]
780 fn utcdate_to_epoch_seconds_unix_epoch() {
781 let d = UTCDate::new_validated("1970-01-01T00:00:00Z").unwrap();
782 assert_eq!(d.to_epoch_seconds().unwrap(), 0);
783 }
784
785 /// Oracle (Python): RFC 8620 §1.4 example `2014-10-30T06:12:00Z` →
786 /// 1_414_649_520.
787 #[test]
788 fn utcdate_to_epoch_seconds_rfc8620_example() {
789 let d = UTCDate::new_validated("2014-10-30T06:12:00Z").unwrap();
790 assert_eq!(d.to_epoch_seconds().unwrap(), 1_414_649_520);
791 }
792
793 /// Oracle (Python): `2000-01-01T00:00:00Z` (Y2K, century-leap year) →
794 /// 946_684_800.
795 #[test]
796 fn utcdate_to_epoch_seconds_y2k() {
797 let d = UTCDate::new_validated("2000-01-01T00:00:00Z").unwrap();
798 assert_eq!(d.to_epoch_seconds().unwrap(), 946_684_800);
799 }
800
801 /// Oracle (Python): `1999-12-31T23:59:59Z` (one second before Y2K) →
802 /// 946_684_799.
803 #[test]
804 fn utcdate_to_epoch_seconds_pre_y2k() {
805 let d = UTCDate::new_validated("1999-12-31T23:59:59Z").unwrap();
806 assert_eq!(d.to_epoch_seconds().unwrap(), 946_684_799);
807 }
808
809 /// Oracle (Python): `2024-02-29T00:00:00Z` (leap year, leap day start) →
810 /// 1_709_164_800.
811 #[test]
812 fn utcdate_to_epoch_seconds_leap_day_2024() {
813 let d = UTCDate::new_validated("2024-02-29T00:00:00Z").unwrap();
814 assert_eq!(d.to_epoch_seconds().unwrap(), 1_709_164_800);
815 }
816
817 /// Oracle (Python): `2024-02-29T23:59:59Z` (leap day end) →
818 /// 1_709_251_199.
819 #[test]
820 fn utcdate_to_epoch_seconds_leap_day_2024_end() {
821 let d = UTCDate::new_validated("2024-02-29T23:59:59Z").unwrap();
822 assert_eq!(d.to_epoch_seconds().unwrap(), 1_709_251_199);
823 }
824
825 /// Oracle (Python): `2100-03-01T00:00:00Z` exercises the
826 /// century-non-leap-year rule (2100 is not a leap year, divisible by 100
827 /// but not 400).
828 #[test]
829 fn utcdate_to_epoch_seconds_2100_non_leap_century() {
830 let d = UTCDate::new_validated("2100-03-01T00:00:00Z").unwrap();
831 assert_eq!(d.to_epoch_seconds().unwrap(), 4_107_542_400);
832 }
833
834 /// Oracle (Python): `1969-12-31T23:59:59Z` (one second before epoch) → -1.
835 /// Verifies negative-epoch handling.
836 #[test]
837 fn utcdate_to_epoch_seconds_one_before_epoch() {
838 let d = UTCDate::new_validated("1969-12-31T23:59:59Z").unwrap();
839 assert_eq!(d.to_epoch_seconds().unwrap(), -1);
840 }
841
842 /// Oracle (Python): `1900-01-01T00:00:00Z` → -2_208_988_800.
843 /// 1900 is divisible by 100 but not 400, so NOT a leap year — exercises
844 /// the same century rule as 2100 but well before the epoch.
845 #[test]
846 fn utcdate_to_epoch_seconds_1900() {
847 let d = UTCDate::new_validated("1900-01-01T00:00:00Z").unwrap();
848 assert_eq!(d.to_epoch_seconds().unwrap(), -2_208_988_800);
849 }
850
851 /// Oracle (Python): `0001-01-01T00:00:00Z` → -62_135_596_800.
852 /// Far-past boundary; verifies the proleptic Gregorian algorithm
853 /// extrapolates correctly to year 1.
854 #[test]
855 fn utcdate_to_epoch_seconds_year_one() {
856 let d = UTCDate::new_validated("0001-01-01T00:00:00Z").unwrap();
857 assert_eq!(d.to_epoch_seconds().unwrap(), -62_135_596_800);
858 }
859
860 /// Oracle (Python): `9999-12-31T23:59:59Z` (UTCDate max year) →
861 /// 253_402_300_799.
862 /// Far-future boundary within `i64` range.
863 #[test]
864 fn utcdate_to_epoch_seconds_year_9999() {
865 let d = UTCDate::new_validated("9999-12-31T23:59:59Z").unwrap();
866 assert_eq!(d.to_epoch_seconds().unwrap(), 253_402_300_799);
867 }
868
869 /// Oracle (Python): `2038-01-19T03:14:07Z` = i32::MAX = 2_147_483_647
870 /// (the "Year 2038 problem" boundary).
871 #[test]
872 fn utcdate_to_epoch_seconds_y2038_boundary() {
873 let d = UTCDate::new_validated("2038-01-19T03:14:07Z").unwrap();
874 assert_eq!(d.to_epoch_seconds().unwrap(), 2_147_483_647);
875 }
876
877 /// Oracle (Python): `2038-01-19T03:14:08Z` = i32::MAX + 1.
878 /// Verifies `i64` handles the i32 overflow point that 32-bit time_t
879 /// implementations would wrap at.
880 #[test]
881 fn utcdate_to_epoch_seconds_post_y2038() {
882 let d = UTCDate::new_validated("2038-01-19T03:14:08Z").unwrap();
883 assert_eq!(d.to_epoch_seconds().unwrap(), 2_147_483_648);
884 }
885
886 /// Oracle: `validate_utcdate` accepts month 13 structurally; semantic
887 /// validation in `to_epoch_seconds` must reject it.
888 #[test]
889 fn utcdate_to_epoch_seconds_rejects_month_13() {
890 let d = UTCDate::from("2024-13-01T00:00:00Z");
891 let err = d.to_epoch_seconds().unwrap_err();
892 assert!(err.0.contains("month"), "error must mention month: {err}");
893 }
894
895 /// Oracle: `validate_utcdate` accepts day 32 structurally; semantic
896 /// validation in `to_epoch_seconds` must reject it.
897 #[test]
898 fn utcdate_to_epoch_seconds_rejects_day_32() {
899 let d = UTCDate::from("2024-01-32T00:00:00Z");
900 let err = d.to_epoch_seconds().unwrap_err();
901 assert!(err.0.contains("day"), "error must mention day: {err}");
902 }
903
904 /// Oracle: 2023 is NOT a leap year; Feb 29 must be rejected as
905 /// out-of-range for that month.
906 #[test]
907 fn utcdate_to_epoch_seconds_rejects_feb_29_non_leap() {
908 let d = UTCDate::from("2023-02-29T00:00:00Z");
909 let err = d.to_epoch_seconds().unwrap_err();
910 assert!(err.0.contains("day"), "error must mention day: {err}");
911 }
912
913 /// Oracle: hour 24 is out of range per RFC 8620 §1.4 (no end-of-day
914 /// convention) — `to_epoch_seconds` must reject it.
915 #[test]
916 fn utcdate_to_epoch_seconds_rejects_hour_24() {
917 let d = UTCDate::from("2024-01-01T24:00:00Z");
918 let err = d.to_epoch_seconds().unwrap_err();
919 assert!(err.0.contains("hour"), "error must mention hour: {err}");
920 }
921
922 /// Oracle: minute 60 out of range.
923 #[test]
924 fn utcdate_to_epoch_seconds_rejects_minute_60() {
925 let d = UTCDate::from("2024-01-01T00:60:00Z");
926 let err = d.to_epoch_seconds().unwrap_err();
927 assert!(err.0.contains("minute"), "error must mention minute: {err}");
928 }
929
930 /// Oracle: second 60 (leap second) is not permitted by RFC 8620 §1.4 —
931 /// `to_epoch_seconds` must reject it.
932 #[test]
933 fn utcdate_to_epoch_seconds_rejects_leap_second() {
934 let d = UTCDate::from("2016-12-31T23:59:60Z");
935 let err = d.to_epoch_seconds().unwrap_err();
936 assert!(err.0.contains("second"), "error must mention second: {err}");
937 }
938
939 /// Oracle: a value constructed via `UTCDate::from` with wrong structure
940 /// must surface a structural ValidationError (re-validation in
941 /// `to_epoch_seconds`).
942 #[test]
943 fn utcdate_to_epoch_seconds_rejects_invalid_structure() {
944 let d = UTCDate::from("not-a-date");
945 assert!(d.to_epoch_seconds().is_err());
946 }
947
948 /// Oracle (Python): `2024-02-28T23:59:59Z` → `2024-02-29T00:00:00Z` is
949 /// exactly one second forward. Verifies leap-day arithmetic is
950 /// internally consistent.
951 #[test]
952 fn utcdate_to_epoch_seconds_leap_day_boundary_one_sec() {
953 let a = UTCDate::new_validated("2024-02-28T23:59:59Z").unwrap();
954 let b = UTCDate::new_validated("2024-02-29T00:00:00Z").unwrap();
955 let diff = b.to_epoch_seconds().unwrap() - a.to_epoch_seconds().unwrap();
956 assert_eq!(
957 diff, 1,
958 "Feb 28 23:59:59 → Feb 29 00:00:00 must be 1 second"
959 );
960 }
961
962 /// Oracle (Python): a 365-day duration `2023-01-01T00:00:00Z` to
963 /// `2024-01-01T00:00:00Z` is exactly `365 * 86_400 = 31_536_000` seconds
964 /// (2023 is not a leap year). Anchors the `CalendarsLimits::default()
965 /// .max_expanded_query_duration_seconds` value (`31_536_000`).
966 #[test]
967 fn utcdate_to_epoch_seconds_365_day_year_duration() {
968 let a = UTCDate::new_validated("2023-01-01T00:00:00Z").unwrap();
969 let b = UTCDate::new_validated("2024-01-01T00:00:00Z").unwrap();
970 let diff = b.to_epoch_seconds().unwrap() - a.to_epoch_seconds().unwrap();
971 assert_eq!(diff, 31_536_000);
972 }
973
974 /// Oracle (Python): a 366-day duration `2024-01-01T00:00:00Z` to
975 /// `2025-01-01T00:00:00Z` is `366 * 86_400 = 31_622_400` seconds (2024 is
976 /// a leap year).
977 #[test]
978 fn utcdate_to_epoch_seconds_366_day_leap_year_duration() {
979 let a = UTCDate::new_validated("2024-01-01T00:00:00Z").unwrap();
980 let b = UTCDate::new_validated("2025-01-01T00:00:00Z").unwrap();
981 let diff = b.to_epoch_seconds().unwrap() - a.to_epoch_seconds().unwrap();
982 assert_eq!(diff, 31_622_400);
983 }
984}