Skip to main content

varve_core/
layer.rs

1//! Layer identifiers — `YYYY.MM.P`, three-part from day one (DD-004).
2//!
3//! `2026.07.0` is the initial deposit of the July 2026 line; `2026.07.1` is a
4//! patch *inside* that frozen line. A two-part identifier is rejected outright:
5//! the grammar freezes with manifest-version 1, and an identifier that could
6//! mean "the line" or "a layer in it" is exactly the resolution ambiguity a
7//! qualified pin must not have.
8
9use std::fmt;
10use std::str::FromStr;
11
12/// A release line: the `YYYY.MM` a consumer freezes on.
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct Line {
15    year: u16,
16    month: u8,
17}
18
19impl Line {
20    pub fn year(&self) -> u16 {
21        self.year
22    }
23    pub fn month(&self) -> u8 {
24        self.month
25    }
26}
27
28impl fmt::Display for Line {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        write!(f, "{:04}.{:02}", self.year, self.month)
31    }
32}
33
34impl FromStr for Line {
35    type Err = LayerIdError;
36
37    /// Parse a two-part `YYYY.MM` line (the shape a line-status document
38    /// carries). The same canonical grammar as a layer's line component:
39    /// four-digit year, two-digit month, month in 01..=12.
40    fn from_str(s: &str) -> Result<Self, Self::Err> {
41        let malformed = || LayerIdError::Malformed(s.to_string());
42        let (year, month) = s.split_once('.').ok_or_else(malformed)?;
43        if !(is_digits(year, 4) && is_digits(month, 2)) {
44            return Err(malformed());
45        }
46        let year: u16 = year.parse().map_err(|_| malformed())?;
47        let month: u8 = month.parse().map_err(|_| malformed())?;
48        if !(1..=12).contains(&month) {
49            return Err(LayerIdError::MonthOutOfRange(s.to_string()));
50        }
51        Ok(Line { year, month })
52    }
53}
54
55/// A layer identifier: one dated, immutable deposit — `YYYY.MM.P`.
56#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub struct LayerId {
58    line: Line,
59    patch: u16,
60}
61
62impl LayerId {
63    /// The frozen line this layer belongs to.
64    pub fn line(&self) -> &Line {
65        &self.line
66    }
67    pub fn patch(&self) -> u16 {
68        self.patch
69    }
70}
71
72impl FromStr for LayerId {
73    type Err = LayerIdError;
74
75    fn from_str(s: &str) -> Result<Self, Self::Err> {
76        let malformed = || LayerIdError::Malformed(s.to_string());
77        let parts: Vec<&str> = s.split('.').collect();
78        match parts.as_slice() {
79            [year, month] => {
80                // Recognisably YYYY.MM: reject with the corrective three-part
81                // guidance rather than the generic malformed error.
82                if is_digits(year, 4) && is_digits(month, 2) {
83                    Err(LayerIdError::MissingPatch(s.to_string()))
84                } else {
85                    Err(malformed())
86                }
87            }
88            [year, month, patch] => {
89                if !(is_digits(year, 4) && is_digits(month, 2)) || patch.is_empty() {
90                    return Err(malformed());
91                }
92                let year: u16 = year.parse().map_err(|_| malformed())?;
93                let month: u8 = month.parse().map_err(|_| malformed())?;
94                // Patch is a plain number, no fixed width — but no signs,
95                // whitespace, or leading emptiness, and CANONICAL: no leading
96                // zeros (else "2026.07.052" and "2026.07.52" would be two
97                // pin strings for one identity — found by fuzzing).
98                if !patch.chars().all(|c| c.is_ascii_digit()) {
99                    return Err(malformed());
100                }
101                if patch.len() > 1 && patch.starts_with('0') {
102                    return Err(malformed());
103                }
104                let patch: u16 = patch.parse().map_err(|_| malformed())?;
105                if !(1..=12).contains(&month) {
106                    return Err(LayerIdError::MonthOutOfRange(s.to_string()));
107                }
108                Ok(LayerId {
109                    line: Line { year, month },
110                    patch,
111                })
112            }
113            _ => Err(malformed()),
114        }
115    }
116}
117
118fn is_digits(s: &str, width: usize) -> bool {
119    s.len() == width && s.chars().all(|c| c.is_ascii_digit())
120}
121
122impl fmt::Display for LayerId {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        write!(f, "{}.{}", self.line, self.patch)
125    }
126}
127
128/// Why a layer identifier failed to parse.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum LayerIdError {
131    /// Two-part `YYYY.MM` — the pre-DD-004 shape, rejected with guidance.
132    MissingPatch(String),
133    /// Anything else that is not `YYYY.MM.P`.
134    Malformed(String),
135    /// Parsed, but the month is outside 01..=12.
136    MonthOutOfRange(String),
137}
138
139impl fmt::Display for LayerIdError {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        match self {
142            LayerIdError::MissingPatch(s) => write!(
143                f,
144                "layer '{s}' is missing its patch component: layer identifiers \
145                 are three-part (YYYY.MM.P) — the initial deposit of a line is \
146                 '{s}.0'"
147            ),
148            LayerIdError::Malformed(s) => {
149                write!(f, "layer '{s}' is not a valid YYYY.MM.P identifier")
150            }
151            LayerIdError::MonthOutOfRange(s) => {
152                write!(f, "layer '{s}' has a month outside 01..=12")
153            }
154        }
155    }
156}
157
158impl std::error::Error for LayerIdError {}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    // rivet: verifies REQ-PATCH-001
165    #[test]
166    fn parses_three_part_identifier_into_line_and_patch() {
167        let id: LayerId = "2026.07.0".parse().unwrap();
168        assert_eq!(id.line().year(), 2026);
169        assert_eq!(id.line().month(), 7);
170        assert_eq!(id.patch(), 0);
171        assert_eq!(id.line().to_string(), "2026.07");
172    }
173
174    // rivet: verifies REQ-PATCH-001
175    #[test]
176    fn patch_stays_inside_the_frozen_line() {
177        let base: LayerId = "2026.07.0".parse().unwrap();
178        let patch: LayerId = "2026.07.1".parse().unwrap();
179        assert_eq!(base.line(), patch.line());
180        assert!(patch > base, "a patch orders after its baseline");
181        let august: LayerId = "2026.08.0".parse().unwrap();
182        assert_ne!(patch.line(), august.line());
183    }
184
185    // rivet: verifies REQ-PATCH-001
186    #[test]
187    fn rejects_two_part_identifier_with_corrective_guidance() {
188        let err = "2026.07".parse::<LayerId>().unwrap_err();
189        assert_eq!(err, LayerIdError::MissingPatch("2026.07".into()));
190        let msg = err.to_string();
191        assert!(
192            msg.contains("three-part"),
193            "message must teach the grammar: {msg}"
194        );
195        assert!(
196            msg.contains("2026.07.0"),
197            "message must show the fix: {msg}"
198        );
199    }
200
201    // rivet: verifies REQ-PATCH-001
202    #[test]
203    fn non_canonical_leading_zero_patches_are_rejected() {
204        // Found by fuzzing: "2212.05.052" parsed to patch 52 but Displayed
205        // as "2212.05.52" — two pin strings for one identity. Canonical
206        // form only; "0" itself stays valid.
207        for bad in ["2212.05.052", "2026.07.00", "2026.07.01", "2026.07.007"] {
208            assert_eq!(
209                bad.parse::<LayerId>().unwrap_err(),
210                LayerIdError::Malformed(bad.into()),
211                "leading-zero patch {bad:?} must be rejected"
212            );
213        }
214        assert_eq!("2026.07.0".parse::<LayerId>().unwrap().patch(), 0);
215    }
216
217    // rivet: verifies REQ-PATCH-001
218    #[test]
219    fn patch_numbers_parse_to_their_value() {
220        assert_eq!("2026.07.1".parse::<LayerId>().unwrap().patch(), 1);
221        assert_eq!("2026.07.42".parse::<LayerId>().unwrap().patch(), 42);
222    }
223
224    // rivet: verifies REQ-PATCH-001
225    #[test]
226    fn two_part_guidance_requires_both_fields_well_formed() {
227        // Only a well-formed YYYY.MM earns the corrective MissingPatch
228        // guidance; a malformed two-parter is just malformed.
229        for bad in ["26.07", "2026.7", "abcd.07", "2026.xx"] {
230            assert_eq!(
231                bad.parse::<LayerId>().unwrap_err(),
232                LayerIdError::Malformed(bad.into()),
233                "input: {bad:?}"
234            );
235        }
236    }
237
238    // rivet: verifies REQ-PATCH-001
239    #[test]
240    fn rejects_malformed_identifiers() {
241        for bad in [
242            "",
243            "abc",
244            "2026",
245            "2026.7.0",
246            "2026.007.0",
247            "26.07.0",
248            "2026.07.0.1",
249            "2026.07.x",
250        ] {
251            let err = bad.parse::<LayerId>().unwrap_err();
252            assert_eq!(err, LayerIdError::Malformed(bad.into()), "input: {bad:?}");
253        }
254    }
255
256    // rivet: verifies REQ-PATCH-001
257    #[test]
258    fn rejects_month_outside_calendar_range() {
259        for bad in ["2026.00.0", "2026.13.0"] {
260            let err = bad.parse::<LayerId>().unwrap_err();
261            assert_eq!(
262                err,
263                LayerIdError::MonthOutOfRange(bad.into()),
264                "input: {bad:?}"
265            );
266        }
267    }
268
269    // rivet: verifies REQ-PATCH-001
270    #[test]
271    fn line_parses_two_part_and_rejects_non_canonical() {
272        let line: Line = "2026.07".parse().unwrap();
273        assert_eq!(line.year(), 2026);
274        assert_eq!(line.month(), 7);
275        assert_eq!(line.to_string(), "2026.07");
276        // Round-trips with a layer's own line.
277        assert_eq!(&line, "2026.07.3".parse::<LayerId>().unwrap().line());
278        for bad in ["2026.7", "26.07", "2026.13", "2026.00", "2026", "2026.07.0"] {
279            assert!(
280                bad.parse::<Line>().is_err(),
281                "{bad} must not parse as a line"
282            );
283        }
284    }
285
286    // rivet: verifies REQ-PATCH-001
287    #[test]
288    fn display_round_trips_canonically() {
289        for s in ["2026.07.0", "2026.07.1", "2026.12.10"] {
290            let id: LayerId = s.parse().unwrap();
291            assert_eq!(id.to_string(), s);
292        }
293    }
294}