protocol_droid/domain/release_date.rs
1//! `ReleaseDate` — the `YYYY-MM-DD` string the protocol negotiates on.
2//!
3//! Every published revision is identified by its release date and nothing else,
4//! so this is the one place that decides what such a string may look like. The
5//! fields are private, which means a `ReleaseDate` that exists is a valid one:
6//! the only ways to obtain one are [`FromStr`], which validates, and the
7//! associated constants, which are correct by construction.
8//!
9//! # What "strict" means here
10//!
11//! A peer that sends `2025-3-26` or `2025-03-26T00:00:00Z` is not a peer whose
12//! version we can guess at. Both are rejected rather than repaired, because
13//! guessing wrong is how a bridge ends up speaking a revision neither side
14//! agreed to. The only leniency is [`FromStr::from_str`] falling back to
15//! [`ProtocolVersion::Unknown`] for a well-formed date this build has never
16//! heard of, which is the forward-compatibility case and nothing else.
17//!
18//! [`ProtocolVersion::Unknown`]: crate::domain::ProtocolVersion::Unknown
19
20use core::fmt;
21use core::str::FromStr;
22
23/// A spec revision's release date.
24///
25/// Ordered chronologically: the derived comparison walks `year`, then `month`,
26/// then `day`, which is the only ordering in which those three fields compose
27/// correctly. Nothing else may be derived from the ordering, so the `Ord` and
28/// `PartialOrd` pair below comes from that same field order and
29/// `tests::derived_ordering_agrees_in_both_directions` pins the two together.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub struct ReleaseDate {
32 /// Proleptic Gregorian year.
33 year: u16,
34 /// Month, `1..=12`.
35 month: u8,
36 /// Day of the month, `1..=31` and within the month.
37 day: u8,
38}
39
40impl ReleaseDate {
41 /// The first published revision, `2024-11-05`.
42 ///
43 /// Also the floor for [`ProtocolVersion::from_str`]: a well-formed date
44 /// before this one names a revision that predates the protocol, which is a
45 /// parse error rather than an unknown revision.
46 ///
47 /// [`ProtocolVersion::from_str`]: crate::domain::ProtocolVersion::from_str
48 pub const V2024_11_05: Self = Self::new(2024, 11, 5);
49 /// The `2025-03-26` revision.
50 pub const V2025_03_26: Self = Self::new(2025, 3, 26);
51 /// The `2025-06-18` revision.
52 pub const V2025_06_18: Self = Self::new(2025, 6, 18);
53 /// The `2025-11-25` revision.
54 pub const V2025_11_25: Self = Self::new(2025, 11, 25);
55 /// The `2026-07-28` revision, and the newest this build knows.
56 pub const V2026_07_28: Self = Self::new(2026, 7, 28);
57
58 /// A date from three already-validated components.
59 const fn new(year: u16, month: u8, day: u8) -> Self {
60 Self { year, month, day }
61 }
62
63 /// The year, as a four-digit value.
64 #[must_use]
65 pub const fn year(self) -> u16 {
66 self.year
67 }
68
69 /// The month, `1..=12`.
70 #[must_use]
71 pub const fn month(self) -> u8 {
72 self.month
73 }
74
75 /// The day of the month.
76 #[must_use]
77 pub const fn day(self) -> u8 {
78 self.day
79 }
80
81 /// Whether this date is [`ReleaseDate::V2024_11_05`] or later.
82 ///
83 /// The comparison is on the date, not the enum: a date this build has not
84 /// heard of can still be after the first revision, and forward
85 /// compatibility depends on that case being reachable.
86 ///
87 /// Not `const`: the comparison comes from a derived `Ord`, and a derived
88 /// `Ord` is not a const-callable operator.
89 #[must_use]
90 pub fn is_at_or_after_first_revision(self) -> bool {
91 self >= Self::V2024_11_05
92 }
93}
94
95impl fmt::Display for ReleaseDate {
96 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97 let (year, month, day) = (self.year, self.month, self.day);
98 write!(formatter, "{year:04}-{month:02}-{day:02}")
99 }
100}
101
102/// Why a string is not a `YYYY-MM-DD` release date.
103///
104/// Every variant names a byte offset rather than a character offset: the parser
105/// reads bytes and never slices the `&str`, so a multi-byte character at an
106/// unexpected position is reported by its lead byte. The distinction matters
107/// only for the message, and the alternative — a `char` from `chars().nth` —
108/// would report a different character from the one that actually failed.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
110#[non_exhaustive]
111pub enum ParseReleaseDateError {
112 /// The string was not exactly ten bytes long.
113 #[error("a release date is exactly 10 bytes (`YYYY-MM-DD`); this one is {found}")]
114 WrongLength {
115 /// The length that was supplied.
116 found: usize,
117 },
118 /// A separator was expected and something else was there.
119 #[error("expected `-` at byte {index}; found {found:#04x}")]
120 NotASeparator {
121 /// Byte offset of the offending byte.
122 index: usize,
123 /// The byte that was there.
124 found: u8,
125 },
126 /// A digit was expected and something else was there.
127 #[error("expected an ASCII digit at byte {index}; found {found:#04x}")]
128 NotADigit {
129 /// Byte offset of the offending byte.
130 index: usize,
131 /// The byte that was there.
132 found: u8,
133 },
134 /// The month was outside `1..=12`.
135 #[error("month {month} is out of range 1..=12")]
136 MonthOutOfRange {
137 /// The month that was supplied, before conversion to `u8`.
138 month: u16,
139 },
140 /// The day was outside `1..=` the length of that month.
141 #[error("day {day} is out of range 1..={last} for {year:04}-{month:02}")]
142 DayOutOfRange {
143 /// The year the day was supplied for.
144 year: u16,
145 /// The month the day was supplied for.
146 month: u8,
147 /// The day that was supplied, before conversion to `u8`.
148 day: u16,
149 /// The length of that month, honouring leap years.
150 last: u8,
151 },
152 /// The date is well formed but predates the protocol.
153 ///
154 /// A separate variant from the shape errors above because the fix is
155 /// different: a `WrongLength` means the peer sent something that is not a
156 /// version, and this means the peer sent a version from before MCP existed.
157 #[error("{date} is a well-formed date but predates the first revision, 2024-11-05")]
158 BeforeFirstRevision {
159 /// The date that was supplied.
160 date: ReleaseDate,
161 },
162}
163
164impl FromStr for ReleaseDate {
165 type Err = ParseReleaseDateError;
166
167 /// Parses exactly `YYYY-MM-DD`.
168 ///
169 /// Every position is checked in order and the first failure is reported, so
170 /// a string that is both the wrong length and unparseable says so once
171 /// rather than producing a cascade. Trailing characters are rejected
172 /// because a version string is a whole token, not a prefix of one.
173 fn from_str(text: &str) -> Result<Self, Self::Err> {
174 let bytes = text.as_bytes();
175 if bytes.len() != 10 {
176 return Err(ParseReleaseDateError::WrongLength { found: bytes.len() });
177 }
178 for index in [4, 7] {
179 match bytes.get(index) {
180 Some(b'-') => {}
181 Some(&found) => return Err(ParseReleaseDateError::NotASeparator { index, found }),
182 // Unreachable: the length is ten and the index is four or seven.
183 None => {
184 return Err(ParseReleaseDateError::WrongLength { found: bytes.len() });
185 }
186 }
187 }
188
189 let year = number(bytes, 0, 4)?;
190 let month = number(bytes, 5, 2)?;
191 let day = number(bytes, 8, 2)?;
192
193 if !(1..=12).contains(&month) {
194 return Err(ParseReleaseDateError::MonthOutOfRange { month });
195 }
196 // Unreachable: the range check above admits 1..=12 only.
197 let month = u8::try_from(month).unwrap_or(0);
198
199 let last = days_in_month(year, month).unwrap_or(0);
200 if day < 1 || day > u16::from(last) {
201 return Err(ParseReleaseDateError::DayOutOfRange {
202 year,
203 month,
204 day,
205 last,
206 });
207 }
208 // Unreachable: the range check above admits 1..=31 only.
209 let day = u8::try_from(day).unwrap_or(0);
210
211 Ok(Self::new(year, month, day))
212 }
213}
214
215/// The decimal number in `bytes` at `start`, `width` digits wide.
216///
217/// Works on bytes rather than slicing the `&str`, because a byte range that
218/// does not fall on a character boundary would panic on `&str` and the parser
219/// must survive exactly the input it exists to reject.
220fn number(bytes: &[u8], start: usize, width: usize) -> Result<u16, ParseReleaseDateError> {
221 let mut total: u16 = 0;
222 for index in start..start + width {
223 let byte = *bytes
224 .get(index)
225 .ok_or(ParseReleaseDateError::WrongLength { found: bytes.len() })?;
226 let digit = byte
227 .checked_sub(b'0')
228 .filter(|digit| *digit <= 9)
229 .ok_or(ParseReleaseDateError::NotADigit { index, found: byte })?;
230 // Width is at most four, so this cannot overflow a `u16`.
231 total = total * 10 + u16::from(digit);
232 }
233 Ok(total)
234}
235
236/// The number of days in `month`, honouring the Gregorian leap-year rule.
237///
238/// `None` for a month outside `1..=12`. The caller has already checked the
239/// range, so the `None` arm is for the signature rather than for control flow.
240const fn days_in_month(year: u16, month: u8) -> Option<u8> {
241 Some(match month {
242 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
243 4 | 6 | 9 | 11 => 30,
244 2 if is_leap_year(year) => 29,
245 2 => 28,
246 _ => return None,
247 })
248}
249
250/// Whether `year` is a leap year in the proleptic Gregorian calendar.
251const fn is_leap_year(year: u16) -> bool {
252 year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
253}
254
255#[cfg(test)]
256mod tests {
257 // Test code unwraps deliberately: the strict profile exists to keep
258 // production paths fallible, and a test that cannot unwrap cannot assert.
259 // The messages name the failing precondition, which is the useful part.
260 #![expect(
261 clippy::unwrap_used,
262 reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
263 )]
264
265 use super::{ParseReleaseDateError, ReleaseDate, days_in_month, is_leap_year, number};
266
267 /// A malformed input, and the predicate saying which error it must produce.
268 ///
269 /// Checking the variant rather than the message is deliberate: the message
270 /// is for a person reading a failed run, and the variant is the part that
271 /// says the parser reached the right place.
272 type Case = (&'static str, fn(&ParseReleaseDateError) -> bool);
273
274 /// The 1.98 trap: `derive(PartialOrd)` has a fast path that assumes a
275 /// hand-written `PartialOrd` agrees with a derived `Ord`. A pair that
276 /// disagrees silently yields wrong comparisons instead of a compile error.
277 /// Every ordered type in this crate derives both traits from the same field
278 /// order and copies this test when it is added.
279 #[test]
280 fn derived_ordering_agrees_in_both_directions() {
281 assert!(ReleaseDate::V2024_11_05 < ReleaseDate::V2026_07_28);
282 assert!(ReleaseDate::V2026_07_28 > ReleaseDate::V2024_11_05);
283 assert_eq!(ReleaseDate::V2025_06_18, ReleaseDate::V2025_06_18,);
284 assert!(ReleaseDate::V2025_03_26 < ReleaseDate::V2025_06_18);
285 }
286
287 /// The field order is `year, month, day`, so every pair agrees that the
288 /// year dominates — including pairs that differ only in the day.
289 #[test]
290 fn field_order_is_chronological() {
291 assert!(ReleaseDate::V2025_03_26 < ReleaseDate::V2025_11_25);
292 assert!(ReleaseDate::V2025_11_25 > ReleaseDate::V2025_06_18);
293 // Same year, same month, one day apart.
294 assert!(ReleaseDate::V2025_06_18 < ReleaseDate::new(2025, 6, 19));
295 }
296
297 #[test]
298 fn parses_each_published_revision() {
299 let cases = [
300 ("2024-11-05", ReleaseDate::V2024_11_05),
301 ("2025-03-26", ReleaseDate::V2025_03_26),
302 ("2025-06-18", ReleaseDate::V2025_06_18),
303 ("2025-11-25", ReleaseDate::V2025_11_25),
304 ("2026-07-28", ReleaseDate::V2026_07_28),
305 ];
306 for (text, expected) in cases {
307 assert_eq!(text.parse::<ReleaseDate>().ok(), Some(expected), "{text}");
308 }
309 }
310
311 #[test]
312 fn round_trips_through_its_display_form() {
313 for date in [
314 ReleaseDate::V2024_11_05,
315 ReleaseDate::V2025_03_26,
316 ReleaseDate::V2025_06_18,
317 ReleaseDate::V2025_11_25,
318 ReleaseDate::V2026_07_28,
319 ] {
320 assert_eq!(date.to_string().parse::<ReleaseDate>(), Ok(date));
321 }
322 }
323
324 /// The failure cases the spec-shaped happy path never reaches.
325 #[test]
326 fn rejects_malformed_dates() {
327 let cases: [Case; 9] = [
328 ("", |e| {
329 matches!(e, ParseReleaseDateError::WrongLength { found: 0 })
330 }),
331 ("2025-03", |e| {
332 matches!(e, ParseReleaseDateError::WrongLength { found: 7 })
333 }),
334 ("2025-03-26T00:00:00Z", |e| {
335 matches!(e, ParseReleaseDateError::WrongLength { found: 20 })
336 }),
337 // A valid date with a leading space, which a lenient parser trims.
338 (" 2025-03-26", |e| {
339 matches!(e, ParseReleaseDateError::WrongLength { found: 11 })
340 }),
341 // Ten bytes, but not a digit where the year starts.
342 ("+025-03-26", |e| {
343 matches!(
344 e,
345 ParseReleaseDateError::NotADigit {
346 index: 0,
347 found: b'+'
348 }
349 )
350 }),
351 // Exactly ten bytes, with a three-byte character standing in for
352 // three of the year's digits. The separators land on 4 and 7 where
353 // they belong, so this reaches the digit check rather than being
354 // turned away by the length check — and a parser that sliced the
355 // `&str` would have panicked on the non-boundary before it got
356 // that far.
357 ("2€-03-26", |e| {
358 matches!(e, ParseReleaseDateError::NotADigit { index: 1, .. })
359 }),
360 ("2025/03/26", |e| {
361 matches!(
362 e,
363 ParseReleaseDateError::NotASeparator {
364 index: 4,
365 found: b'/'
366 }
367 )
368 }),
369 // The second separator, the one a parser that checks only the first
370 // would miss. A single-digit month would not reach here at all: it
371 // makes the string nine bytes and the length check turns it away.
372 ("2025-03/26", |e| {
373 matches!(
374 e,
375 ParseReleaseDateError::NotASeparator {
376 index: 7,
377 found: b'/'
378 }
379 )
380 }),
381 ("2025-13-26", |e| {
382 matches!(e, ParseReleaseDateError::MonthOutOfRange { month: 13 })
383 }),
384 ];
385 for (text, matches_expected) in cases {
386 let parsed = text.parse::<ReleaseDate>();
387 assert!(parsed.is_err(), "{text:?} should not parse");
388 let Err(error) = parsed else {
389 continue;
390 };
391 assert!(matches_expected(&error), "{text:?} gave {error}");
392 }
393 }
394
395 /// Day validation has to know the month, so it has to know February.
396 #[test]
397 fn rejects_days_the_month_does_not_have() {
398 assert!("2025-04-31".parse::<ReleaseDate>().is_err());
399 assert!("2025-06-31".parse::<ReleaseDate>().is_err());
400 assert!("2025-02-29".parse::<ReleaseDate>().is_err());
401 assert!("2025-04-30".parse::<ReleaseDate>().is_ok());
402 assert!("2025-02-28".parse::<ReleaseDate>().is_ok());
403 // 2000 is divisible by 400, 1900 is not: both are leap-year edge cases.
404 assert!("2000-02-29".parse::<ReleaseDate>().is_ok());
405 assert!("1900-02-29".parse::<ReleaseDate>().is_err());
406 // 2024-02-29 is real, which is why 2025-02-29 is the one that fails.
407 assert!("2024-02-29".parse::<ReleaseDate>().is_ok());
408 }
409
410 #[test]
411 fn reports_day_zero_rather_than_accepting_it() {
412 let error = "2025-06-00".parse::<ReleaseDate>().unwrap_err();
413 assert!(
414 matches!(
415 error,
416 ParseReleaseDateError::DayOutOfRange {
417 year: 2025,
418 month: 6,
419 day: 0,
420 last: 30
421 }
422 ),
423 "got {error}"
424 );
425 }
426
427 /// The 1.98 derive trap again, this time proving the field order is the one
428 /// that makes the calendar work, not merely that two traits agree.
429 #[test]
430 fn every_month_length_is_correct() {
431 let expected = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
432 for (index, &last) in expected.iter().enumerate() {
433 let month = u8::try_from(index + 1).unwrap_or(0);
434 assert_eq!(days_in_month(2025, month), Some(last), "month {month}");
435 }
436 assert_eq!(days_in_month(2024, 2), Some(29));
437 assert_eq!(days_in_month(1900, 2), Some(28));
438 assert_eq!(days_in_month(2000, 2), Some(29));
439 assert_eq!(days_in_month(2025, 0), None);
440 assert_eq!(days_in_month(2025, 13), None);
441 }
442
443 #[test]
444 fn leap_year_rule_follows_the_gregorian_exceptions() {
445 assert!(is_leap_year(2000));
446 assert!(!is_leap_year(1900));
447 assert!(is_leap_year(2024));
448 assert!(!is_leap_year(2025));
449 }
450
451 /// `number` is reached with a length that has already been checked, so its
452 /// out-of-range arm is defensive. It has to stay non-panicking anyway.
453 #[test]
454 fn number_does_not_panic_past_the_end() {
455 assert!(number(b"12345", 0, 9).is_err());
456 assert!(number(b"", 0, 1).is_err());
457 assert_eq!(number(b"2025-03-26", 0, 4), Ok(2025));
458 }
459
460 #[test]
461 fn components_are_readable() {
462 let date = ReleaseDate::V2025_11_25;
463 assert_eq!(date.year(), 2025);
464 assert_eq!(date.month(), 11);
465 assert_eq!(date.day(), 25);
466 assert!(date.is_at_or_after_first_revision());
467 assert!(!ReleaseDate::new(2020, 1, 1).is_at_or_after_first_revision());
468 }
469}