1use std::fmt;
4
5use super::IdParseError;
6use super::ModuleId;
7use super::is_all_ascii_digits;
8use super::sub_module_id::SubModuleId;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub struct ChangeId(String);
20
21impl ChangeId {
22 pub(crate) fn new(inner: String) -> Self {
23 Self(inner)
24 }
25
26 pub fn as_str(&self) -> &str {
28 &self.0
29 }
30}
31
32impl fmt::Display for ChangeId {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 f.write_str(&self.0)
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ParsedChangeId {
41 pub module_id: ModuleId,
43
44 pub sub_module_id: Option<SubModuleId>,
48
49 pub change_num: String,
51
52 pub name: String,
54
55 pub canonical: ChangeId,
57}
58
59pub fn parse_change_id(input: &str) -> Result<ParsedChangeId, IdParseError> {
69 let trimmed = input.trim();
70 if trimmed.is_empty() {
71 return Err(IdParseError::new(
72 "Change ID cannot be empty",
73 Some("Provide a change ID like \"1-2_my-change\" or \"001-02_my-change\""),
74 ));
75 }
76
77 if trimmed.len() > 256 {
78 return Err(IdParseError::new(
79 format!("Change ID is too long: {} bytes (max 256)", trimmed.len()),
80 Some("Provide a shorter change ID in the form \"NNN-NN_name\""),
81 ));
82 }
83
84 if trimmed.contains('_') && !trimmed.contains('-') {
87 let mut parts = trimmed.split('_');
88 let a = parts.next().unwrap_or("");
89 let b = parts.next().unwrap_or("");
90 let c = parts.next().unwrap_or("");
91
92 if is_all_ascii_digits(a) && is_all_ascii_digits(b) && !c.is_empty() {
93 return Err(IdParseError::new(
94 format!("Invalid change ID format: \"{input}\""),
95 Some(
96 "Change IDs use \"-\" between module and change number (e.g., \"001-02_name\" not \"001_02_name\")",
97 ),
98 ));
99 }
100 }
101
102 let Some((left, name_part)) = trimmed.split_once('_') else {
104 if let Some((a, b)) = trimmed.split_once('-')
105 && is_all_ascii_digits(a)
106 && is_all_ascii_digits(b)
107 {
108 return Err(IdParseError::new(
109 format!("Change ID missing name: \"{input}\""),
110 Some("Change IDs require a name suffix (e.g., \"001-02_my-change\")"),
111 ));
112 }
113 return Err(IdParseError::new(
114 format!("Invalid change ID format: \"{input}\""),
115 Some(
116 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
117 ),
118 ));
119 };
120
121 let (module_num, sub_module_id, change_part) = if left.contains('.') {
124 let Some((module_sub_part, change_str)) = left.split_once('-') else {
126 return Err(IdParseError::new(
127 format!("Invalid change ID format: \"{input}\""),
128 Some("Expected format: \"NNN.SS-NN_name\" (e.g., \"005.01-02_my-change\")"),
129 ));
130 };
131
132 let Some((module_str, sub_str)) = module_sub_part.split_once('.') else {
133 return Err(IdParseError::new(
134 format!("Invalid change ID format: \"{input}\""),
135 Some("Expected format: \"NNN.SS-NN_name\" (e.g., \"005.01-02_my-change\")"),
136 ));
137 };
138
139 if !is_all_ascii_digits(module_str)
141 || !is_all_ascii_digits(sub_str)
142 || !is_all_ascii_digits(change_str)
143 {
144 return Err(IdParseError::new(
145 format!("Invalid change ID format: \"{input}\""),
146 Some("Expected format: \"NNN.SS-NN_name\" (e.g., \"005.01-02_my-change\")"),
147 ));
148 }
149
150 let module_num: u32 = module_str.parse().map_err(|_| {
151 IdParseError::new(
152 "Change ID is required",
153 Some("Provide a change ID like \"005.01-02_my-change\""),
154 )
155 })?;
156
157 let sub_num: u32 = sub_str.parse().map_err(|_| {
158 IdParseError::new(
159 "Change ID is required",
160 Some("Provide a change ID like \"005.01-02_my-change\""),
161 )
162 })?;
163
164 if module_num > 999 {
165 return Err(IdParseError::new(
166 format!("Module number {module_num} exceeds maximum (999)"),
167 Some("Module numbers must be between 0 and 999"),
168 ));
169 }
170
171 if sub_num > 99 {
172 return Err(IdParseError::new(
173 format!("Sub-module number {sub_num} exceeds maximum (99)"),
174 Some("Sub-module numbers must be between 0 and 99"),
175 ));
176 }
177
178 let sub_id = SubModuleId::new(format!("{module_num:03}.{sub_num:02}"));
179 (module_num, Some(sub_id), change_str)
180 } else {
181 let Some((module_str, change_str)) = left.split_once('-') else {
183 return Err(IdParseError::new(
184 format!("Invalid change ID format: \"{input}\""),
185 Some(
186 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
187 ),
188 ));
189 };
190
191 if !is_all_ascii_digits(module_str) || !is_all_ascii_digits(change_str) {
192 return Err(IdParseError::new(
193 format!("Invalid change ID format: \"{input}\""),
194 Some(
195 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
196 ),
197 ));
198 }
199
200 let module_num: u32 = module_str.parse().map_err(|_| {
201 IdParseError::new(
202 "Change ID is required",
203 Some("Provide a change ID like \"1-2_my-change\" or \"001-02_my-change\""),
204 )
205 })?;
206
207 if module_num > 999 {
208 return Err(IdParseError::new(
209 format!("Module number {module_num} exceeds maximum (999)"),
210 Some("Module numbers must be between 0 and 999"),
211 ));
212 }
213
214 (module_num, None, change_str)
215 };
216
217 let change_num: u32 = change_part.parse().map_err(|_| {
218 IdParseError::new(
219 "Change ID is required",
220 Some("Provide a change ID like \"1-2_my-change\" or \"001-02_my-change\""),
221 )
222 })?;
223
224 let mut chars = name_part.chars();
229 let first = chars.next().unwrap_or('\0');
230 if !first.is_ascii_alphabetic() {
231 return Err(IdParseError::new(
232 format!("Invalid change ID format: \"{input}\""),
233 Some(
234 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
235 ),
236 ));
237 }
238 for c in chars {
239 if !(c.is_ascii_alphanumeric() || c == '-') {
240 return Err(IdParseError::new(
241 format!("Invalid change ID format: \"{input}\""),
242 Some(
243 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
244 ),
245 ));
246 }
247 }
248
249 let module_id = ModuleId::new(format!("{module_num:03}"));
250 let change_num_str = format!("{change_num:02}");
251 let name = name_part.to_ascii_lowercase();
252
253 let canonical = match &sub_module_id {
254 Some(sub_id) => ChangeId::new(format!("{sub_id}-{change_num_str}_{name}")),
255 None => ChangeId::new(format!("{module_id}-{change_num_str}_{name}")),
256 };
257
258 Ok(ParsedChangeId {
259 module_id,
260 sub_module_id,
261 change_num: change_num_str,
262 name,
263 canonical,
264 })
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn parse_change_id_pads_both_parts() {
273 let parsed = parse_change_id("1-2_Bar").unwrap();
274 assert_eq!(parsed.canonical.as_str(), "001-02_bar");
275 assert_eq!(parsed.module_id.as_str(), "001");
276 assert_eq!(parsed.change_num, "02");
277 assert_eq!(parsed.name, "bar");
278 assert_eq!(parsed.sub_module_id, None);
279 }
280
281 #[test]
282 fn parse_change_id_supports_extra_leading_zeros_for_change_num() {
283 let parsed = parse_change_id("1-00003_bar").unwrap();
284 assert_eq!(parsed.canonical.as_str(), "001-03_bar");
285 assert_eq!(parsed.sub_module_id, None);
286 }
287
288 #[test]
289 fn parse_change_id_allows_three_digit_change_numbers() {
290 let parsed = parse_change_id("1-100_Bar").unwrap();
291 assert_eq!(parsed.canonical.as_str(), "001-100_bar");
292 assert_eq!(parsed.change_num, "100");
293 }
294
295 #[test]
296 fn parse_change_id_normalizes_excessive_padding_for_large_change_numbers() {
297 let parsed = parse_change_id("1-000100_bar").unwrap();
298 assert_eq!(parsed.canonical.as_str(), "001-100_bar");
299 assert_eq!(parsed.change_num, "100");
300 }
301
302 #[test]
303 fn parse_change_id_allows_large_change_numbers() {
304 let parsed = parse_change_id("1-1234_example").unwrap();
305 assert_eq!(parsed.canonical.as_str(), "001-1234_example");
306 assert_eq!(parsed.change_num, "1234");
307 }
308
309 #[test]
310 fn parse_change_id_missing_name_has_specific_error() {
311 let err = parse_change_id("1-2").unwrap_err();
312 assert_eq!(err.error, "Change ID missing name: \"1-2\"");
313 }
314
315 #[test]
316 fn parse_change_id_uses_specific_hint_for_wrong_separator() {
317 let err = parse_change_id("001_02_name").unwrap_err();
318 assert_eq!(err.error, "Invalid change ID format: \"001_02_name\"");
319 assert_eq!(
320 err.hint.as_deref(),
321 Some(
322 "Change IDs use \"-\" between module and change number (e.g., \"001-02_name\" not \"001_02_name\")"
323 )
324 );
325 }
326
327 #[test]
328 fn parse_change_id_rejects_overlong_input() {
329 let input = format!("001-01_{}", "a".repeat(300));
330 let err = parse_change_id(&input).expect_err("overlong change id should fail");
331 assert!(err.error.contains("too long"));
332 }
333
334 #[test]
337 fn parse_change_id_sub_module_format_canonical() {
338 let parsed = parse_change_id("005.01-03_my-change").unwrap();
339 assert_eq!(parsed.canonical.as_str(), "005.01-03_my-change");
340 assert_eq!(parsed.module_id.as_str(), "005");
341 assert_eq!(parsed.change_num, "03");
342 assert_eq!(parsed.name, "my-change");
343 let sub_id = parsed.sub_module_id.as_ref().unwrap();
344 assert_eq!(sub_id.as_str(), "005.01");
345 }
346
347 #[test]
348 fn parse_change_id_sub_module_format_pads_all_parts() {
349 let parsed = parse_change_id("5.1-3_foo").unwrap();
350 assert_eq!(parsed.canonical.as_str(), "005.01-03_foo");
351 assert_eq!(parsed.module_id.as_str(), "005");
352 let sub_id = parsed.sub_module_id.as_ref().unwrap();
353 assert_eq!(sub_id.as_str(), "005.01");
354 assert_eq!(parsed.change_num, "03");
355 }
356
357 #[test]
358 fn parse_change_id_sub_module_format_lowercases_name() {
359 let parsed = parse_change_id("005.01-03_My-Change").unwrap();
360 assert_eq!(parsed.name, "my-change");
361 assert_eq!(parsed.canonical.as_str(), "005.01-03_my-change");
362 }
363
364 #[test]
365 fn parse_change_id_sub_module_rejects_sub_overflow() {
366 let err = parse_change_id("005.100-01_foo").unwrap_err();
367 assert!(err.error.contains("exceeds maximum (99)"));
368 }
369
370 #[test]
371 fn parse_change_id_sub_module_rejects_module_overflow() {
372 let err = parse_change_id("1000.01-01_foo").unwrap_err();
373 assert!(err.error.contains("exceeds maximum (999)"));
374 }
375
376 #[test]
377 fn parse_change_id_sub_module_missing_name_is_error() {
378 let err = parse_change_id("005.01-03").unwrap_err();
379 assert!(
380 err.error.contains("Invalid change ID format") || err.error.contains("missing name")
381 );
382 }
383}