1use std::fmt;
10use std::str::FromStr;
11
12#[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 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub struct LayerId {
58 line: Line,
59 patch: u16,
60}
61
62impl LayerId {
63 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum LayerIdError {
131 MissingPatch(String),
133 Malformed(String),
135 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 #[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 #[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 #[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 #[test]
203 fn non_canonical_leading_zero_patches_are_rejected() {
204 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 #[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 #[test]
226 fn two_part_guidance_requires_both_fields_well_formed() {
227 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 #[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 #[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 #[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 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 #[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}