Skip to main content

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}