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)]
268#[path = "change_id_tests.rs"]
269mod change_id_tests;