Skip to main content

ito_common/id/
change_id.rs

1//! Change ID parsing and normalization.
2
3use std::fmt;
4
5use super::IdParseError;
6use super::ModuleId;
7use super::is_all_ascii_digits;
8use super::sub_module_id::SubModuleId;
9
10/// A change identifier in canonical form.
11///
12/// Supports both the legacy module format (`NNN-NN_name`, e.g.
13/// `014-01_add-rust-crate-documentation`) and the sub-module format
14/// (`NNN.SS-NN_name`, e.g. `014.01-03_add-jwt`).
15///
16/// Canonical strings are produced by [`parse_change_id`] and are always
17/// zero-padded to the minimum widths (`NNN`, `SS`, `NN`).
18#[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    /// Borrow the underlying string.
27    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)]
39/// Parsed representation of a change identifier.
40pub struct ParsedChangeId {
41    /// Canonical module id.
42    pub module_id: ModuleId,
43
44    /// Sub-module id in canonical `NNN.SS` form, present only for sub-module changes.
45    ///
46    /// `None` for legacy `NNN-NN_name` format changes without a sub-module component.
47    pub sub_module_id: Option<SubModuleId>,
48
49    /// Canonical change number (at least 2 digits).
50    pub change_num: String,
51
52    /// Canonicalized change name (lowercase).
53    pub name: String,
54
55    /// Canonical `NNN-NN_name` or `NNN.SS-NN_name` string.
56    pub canonical: ChangeId,
57}
58
59/// Parse a change identifier.
60///
61/// Accepts both the legacy `NNN-NN_name` format and the sub-module
62/// `NNN.SS-NN_name` format with flexible zero-padding; always returns a
63/// canonical representation.
64///
65/// When the input contains a sub-module component (`NNN.SS-NN_name`), the
66/// returned [`ParsedChangeId`] has `sub_module_id` set to `Some(...)`.
67/// Legacy `NNN-NN_name` inputs produce `sub_module_id = None`.
68pub 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    // Match TS hint for the common mistake: using '_' between module and change number.
85    // Example: "001_02_name" (should be "001-02_name").
86    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    // Split off the name suffix (everything after the first `_`).
103    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    // Determine whether this is a sub-module change (`NNN.SS-NN_name`) or a
122    // legacy change (`NNN-NN_name`) by checking for a `.` in the left part.
123    let (module_num, sub_module_id, change_part) = if left.contains('.') {
124        // Sub-module format: NNN.SS-NN
125        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        // Validate all three numeric parts.
140        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        // Legacy format: NNN-NN
182        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    // NOTE: Do not enforce an upper bound for change numbers.
225    // Padding is for readability/sorting only; functionality is more important.
226
227    // Validate name
228    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    // Sub-module format tests
335
336    #[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}