ito_common/id/
change_id.rs1use std::fmt;
4
5use super::IdParseError;
6use super::ModuleId;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub struct ChangeId(String);
13
14impl ChangeId {
15 pub(crate) fn new(inner: String) -> Self {
16 Self(inner)
17 }
18
19 pub fn as_str(&self) -> &str {
21 &self.0
22 }
23}
24
25impl fmt::Display for ChangeId {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 f.write_str(&self.0)
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ParsedChangeId {
34 pub module_id: ModuleId,
36
37 pub change_num: String,
39
40 pub name: String,
42
43 pub canonical: ChangeId,
45}
46
47pub fn parse_change_id(input: &str) -> Result<ParsedChangeId, IdParseError> {
52 let trimmed = input.trim();
53 if trimmed.is_empty() {
54 return Err(IdParseError::new(
55 "Change ID cannot be empty",
56 Some("Provide a change ID like \"1-2_my-change\" or \"001-02_my-change\""),
57 ));
58 }
59
60 if trimmed.len() > 256 {
61 return Err(IdParseError::new(
62 format!("Change ID is too long: {} bytes (max 256)", trimmed.len()),
63 Some("Provide a shorter change ID in the form \"NNN-NN_name\""),
64 ));
65 }
66
67 if trimmed.contains('_') && !trimmed.contains('-') {
70 let mut parts = trimmed.split('_');
71 let a = parts.next().unwrap_or("");
72 let b = parts.next().unwrap_or("");
73 let c = parts.next().unwrap_or("");
74 let mut a_all_digits = true;
75 for ch in a.chars() {
76 if !ch.is_ascii_digit() {
77 a_all_digits = false;
78 break;
79 }
80 }
81
82 let mut b_all_digits = true;
83 for ch in b.chars() {
84 if !ch.is_ascii_digit() {
85 b_all_digits = false;
86 break;
87 }
88 }
89
90 if !a.is_empty() && !b.is_empty() && !c.is_empty() && a_all_digits && b_all_digits {
91 return Err(IdParseError::new(
92 format!("Invalid change ID format: \"{input}\""),
93 Some(
94 "Change IDs use \"-\" between module and change number (e.g., \"001-02_name\" not \"001_02_name\")",
95 ),
96 ));
97 }
98 }
99
100 let Some((left, name_part)) = trimmed.split_once('_') else {
102 if let Some((a, b)) = trimmed.split_once('-') {
103 let mut a_all_digits = true;
104 for c in a.chars() {
105 if !c.is_ascii_digit() {
106 a_all_digits = false;
107 break;
108 }
109 }
110
111 let mut b_all_digits = true;
112 for c in b.chars() {
113 if !c.is_ascii_digit() {
114 b_all_digits = false;
115 break;
116 }
117 }
118
119 if !a.is_empty() && !b.is_empty() && a_all_digits && b_all_digits {
120 return Err(IdParseError::new(
121 format!("Change ID missing name: \"{input}\""),
122 Some("Change IDs require a name suffix (e.g., \"001-02_my-change\")"),
123 ));
124 }
125 }
126 return Err(IdParseError::new(
127 format!("Invalid change ID format: \"{input}\""),
128 Some(
129 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
130 ),
131 ));
132 };
133
134 let Some((module_part, change_part)) = left.split_once('-') else {
135 return Err(IdParseError::new(
136 format!("Invalid change ID format: \"{input}\""),
137 Some(
138 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
139 ),
140 ));
141 };
142
143 let mut module_all_digits = true;
144 for c in module_part.chars() {
145 if !c.is_ascii_digit() {
146 module_all_digits = false;
147 break;
148 }
149 }
150
151 let mut change_all_digits = true;
152 for c in change_part.chars() {
153 if !c.is_ascii_digit() {
154 change_all_digits = false;
155 break;
156 }
157 }
158
159 if module_part.is_empty() || change_part.is_empty() || !module_all_digits || !change_all_digits
160 {
161 return Err(IdParseError::new(
162 format!("Invalid change ID format: \"{input}\""),
163 Some(
164 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
165 ),
166 ));
167 }
168
169 let module_num: u32 = module_part.parse().map_err(|_| {
170 IdParseError::new(
171 "Change ID is required",
172 Some("Provide a change ID like \"1-2_my-change\" or \"001-02_my-change\""),
173 )
174 })?;
175 let change_num: u32 = change_part.parse().map_err(|_| {
176 IdParseError::new(
177 "Change ID is required",
178 Some("Provide a change ID like \"1-2_my-change\" or \"001-02_my-change\""),
179 )
180 })?;
181
182 if module_num > 999 {
183 return Err(IdParseError::new(
184 format!("Module number {module_num} exceeds maximum (999)"),
185 Some("Module numbers must be between 0 and 999"),
186 ));
187 }
188 let mut chars = name_part.chars();
193 let first = chars.next().unwrap_or('\0');
194 if !first.is_ascii_alphabetic() {
195 return Err(IdParseError::new(
196 format!("Invalid change ID format: \"{input}\""),
197 Some(
198 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
199 ),
200 ));
201 }
202 for c in chars {
203 if !(c.is_ascii_alphanumeric() || c == '-') {
204 return Err(IdParseError::new(
205 format!("Invalid change ID format: \"{input}\""),
206 Some(
207 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
208 ),
209 ));
210 }
211 }
212
213 let module_id = ModuleId::new(format!("{module_num:03}"));
214 let change_num_str = format!("{change_num:02}");
215 let name = name_part.to_ascii_lowercase();
216 let canonical = ChangeId::new(format!("{module_id}-{change_num_str}_{name}"));
217
218 Ok(ParsedChangeId {
219 module_id,
220 change_num: change_num_str,
221 name,
222 canonical,
223 })
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn parse_change_id_pads_both_parts() {
232 let parsed = parse_change_id("1-2_Bar").unwrap();
233 assert_eq!(parsed.canonical.as_str(), "001-02_bar");
234 assert_eq!(parsed.module_id.as_str(), "001");
235 assert_eq!(parsed.change_num, "02");
236 assert_eq!(parsed.name, "bar");
237 }
238
239 #[test]
240 fn parse_change_id_supports_extra_leading_zeros_for_change_num() {
241 let parsed = parse_change_id("1-00003_bar").unwrap();
242 assert_eq!(parsed.canonical.as_str(), "001-03_bar");
243 }
244
245 #[test]
246 fn parse_change_id_allows_three_digit_change_numbers() {
247 let parsed = parse_change_id("1-100_Bar").unwrap();
248 assert_eq!(parsed.canonical.as_str(), "001-100_bar");
249 assert_eq!(parsed.change_num, "100");
250 }
251
252 #[test]
253 fn parse_change_id_normalizes_excessive_padding_for_large_change_numbers() {
254 let parsed = parse_change_id("1-000100_bar").unwrap();
255 assert_eq!(parsed.canonical.as_str(), "001-100_bar");
256 assert_eq!(parsed.change_num, "100");
257 }
258
259 #[test]
260 fn parse_change_id_allows_large_change_numbers() {
261 let parsed = parse_change_id("1-1234_example").unwrap();
262 assert_eq!(parsed.canonical.as_str(), "001-1234_example");
263 assert_eq!(parsed.change_num, "1234");
264 }
265
266 #[test]
267 fn parse_change_id_missing_name_has_specific_error() {
268 let err = parse_change_id("1-2").unwrap_err();
269 assert_eq!(err.error, "Change ID missing name: \"1-2\"");
270 }
271
272 #[test]
273 fn parse_change_id_uses_specific_hint_for_wrong_separator() {
274 let err = parse_change_id("001_02_name").unwrap_err();
275 assert_eq!(err.error, "Invalid change ID format: \"001_02_name\"");
276 assert_eq!(
277 err.hint.as_deref(),
278 Some(
279 "Change IDs use \"-\" between module and change number (e.g., \"001-02_name\" not \"001_02_name\")"
280 )
281 );
282 }
283
284 #[test]
285 fn parse_change_id_rejects_overlong_input() {
286 let input = format!("001-01_{}", "a".repeat(300));
287 let err = parse_change_id(&input).expect_err("overlong change id should fail");
288 assert!(err.error.contains("too long"));
289 }
290}