ito_common/id/
change_id.rs1use std::fmt;
2
3use super::IdParseError;
4use super::ModuleId;
5
6#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7pub struct ChangeId(String);
11
12impl ChangeId {
13 pub(crate) fn new(inner: String) -> Self {
14 Self(inner)
15 }
16
17 pub fn as_str(&self) -> &str {
19 &self.0
20 }
21}
22
23impl fmt::Display for ChangeId {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 f.write_str(&self.0)
26 }
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ParsedChangeId {
32 pub module_id: ModuleId,
34
35 pub change_num: String,
37
38 pub name: String,
40
41 pub canonical: ChangeId,
43}
44
45pub fn parse_change_id(input: &str) -> Result<ParsedChangeId, IdParseError> {
50 let trimmed = input.trim();
51 if trimmed.is_empty() {
52 return Err(IdParseError::new(
53 "Change ID cannot be empty",
54 Some("Provide a change ID like \"1-2_my-change\" or \"001-02_my-change\""),
55 ));
56 }
57
58 if trimmed.contains('_') && !trimmed.contains('-') {
61 let mut parts = trimmed.split('_');
62 let a = parts.next().unwrap_or("");
63 let b = parts.next().unwrap_or("");
64 let c = parts.next().unwrap_or("");
65 if !a.is_empty()
66 && !b.is_empty()
67 && !c.is_empty()
68 && a.chars().all(|ch| ch.is_ascii_digit())
69 && b.chars().all(|ch| ch.is_ascii_digit())
70 {
71 return Err(IdParseError::new(
72 format!("Invalid change ID format: \"{input}\""),
73 Some(
74 "Change IDs use \"-\" between module and change number (e.g., \"001-02_name\" not \"001_02_name\")",
75 ),
76 ));
77 }
78 }
79
80 let Some((left, name_part)) = trimmed.split_once('_') else {
82 if trimmed.split_once('-').is_some_and(|(a, b)| {
83 !a.is_empty()
84 && !b.is_empty()
85 && a.chars().all(|c| c.is_ascii_digit())
86 && b.chars().all(|c| c.is_ascii_digit())
87 }) {
88 return Err(IdParseError::new(
89 format!("Change ID missing name: \"{input}\""),
90 Some("Change IDs require a name suffix (e.g., \"001-02_my-change\")"),
91 ));
92 }
93 return Err(IdParseError::new(
94 format!("Invalid change ID format: \"{input}\""),
95 Some(
96 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
97 ),
98 ));
99 };
100
101 let Some((module_part, change_part)) = left.split_once('-') else {
102 return Err(IdParseError::new(
103 format!("Invalid change ID format: \"{input}\""),
104 Some(
105 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
106 ),
107 ));
108 };
109
110 if module_part.is_empty()
111 || change_part.is_empty()
112 || !module_part.chars().all(|c| c.is_ascii_digit())
113 || !change_part.chars().all(|c| c.is_ascii_digit())
114 {
115 return Err(IdParseError::new(
116 format!("Invalid change ID format: \"{input}\""),
117 Some(
118 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
119 ),
120 ));
121 }
122
123 let module_num: u32 = module_part.parse().map_err(|_| {
124 IdParseError::new(
125 "Change ID is required",
126 Some("Provide a change ID like \"1-2_my-change\" or \"001-02_my-change\""),
127 )
128 })?;
129 let change_num: u32 = change_part.parse().map_err(|_| {
130 IdParseError::new(
131 "Change ID is required",
132 Some("Provide a change ID like \"1-2_my-change\" or \"001-02_my-change\""),
133 )
134 })?;
135
136 if module_num > 999 {
137 return Err(IdParseError::new(
138 format!("Module number {module_num} exceeds maximum (999)"),
139 Some("Module numbers must be between 0 and 999"),
140 ));
141 }
142 let mut chars = name_part.chars();
147 let first = chars.next().unwrap_or('\0');
148 if !first.is_ascii_alphabetic() {
149 return Err(IdParseError::new(
150 format!("Invalid change ID format: \"{input}\""),
151 Some(
152 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
153 ),
154 ));
155 }
156 for c in chars {
157 if !(c.is_ascii_alphanumeric() || c == '-') {
158 return Err(IdParseError::new(
159 format!("Invalid change ID format: \"{input}\""),
160 Some(
161 "Expected format: \"NNN-NN_name\" (e.g., \"1-2_my-change\", \"001-02_my-change\")",
162 ),
163 ));
164 }
165 }
166
167 let module_id = ModuleId::new(format!("{module_num:03}"));
168 let change_num_str = format!("{change_num:02}");
169 let name = name_part.to_ascii_lowercase();
170 let canonical = ChangeId::new(format!("{module_id}-{change_num_str}_{name}"));
171
172 Ok(ParsedChangeId {
173 module_id,
174 change_num: change_num_str,
175 name,
176 canonical,
177 })
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn parse_change_id_pads_both_parts() {
186 let parsed = parse_change_id("1-2_Bar").unwrap();
187 assert_eq!(parsed.canonical.as_str(), "001-02_bar");
188 assert_eq!(parsed.module_id.as_str(), "001");
189 assert_eq!(parsed.change_num, "02");
190 assert_eq!(parsed.name, "bar");
191 }
192
193 #[test]
194 fn parse_change_id_supports_extra_leading_zeros_for_change_num() {
195 let parsed = parse_change_id("1-00003_bar").unwrap();
196 assert_eq!(parsed.canonical.as_str(), "001-03_bar");
197 }
198
199 #[test]
200 fn parse_change_id_allows_three_digit_change_numbers() {
201 let parsed = parse_change_id("1-100_Bar").unwrap();
202 assert_eq!(parsed.canonical.as_str(), "001-100_bar");
203 assert_eq!(parsed.change_num, "100");
204 }
205
206 #[test]
207 fn parse_change_id_normalizes_excessive_padding_for_large_change_numbers() {
208 let parsed = parse_change_id("1-000100_bar").unwrap();
209 assert_eq!(parsed.canonical.as_str(), "001-100_bar");
210 assert_eq!(parsed.change_num, "100");
211 }
212
213 #[test]
214 fn parse_change_id_allows_large_change_numbers() {
215 let parsed = parse_change_id("1-1234_example").unwrap();
216 assert_eq!(parsed.canonical.as_str(), "001-1234_example");
217 assert_eq!(parsed.change_num, "1234");
218 }
219
220 #[test]
221 fn parse_change_id_missing_name_has_specific_error() {
222 let err = parse_change_id("1-2").unwrap_err();
223 assert_eq!(err.error, "Change ID missing name: \"1-2\"");
224 }
225
226 #[test]
227 fn parse_change_id_uses_specific_hint_for_wrong_separator() {
228 let err = parse_change_id("001_02_name").unwrap_err();
229 assert_eq!(err.error, "Invalid change ID format: \"001_02_name\"");
230 assert_eq!(
231 err.hint.as_deref(),
232 Some(
233 "Change IDs use \"-\" between module and change number (e.g., \"001-02_name\" not \"001_02_name\")"
234 )
235 );
236 }
237}