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;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9/// A change identifier.
10///
11/// Changes are tracked as `NNN-NN_name` (e.g. `014-01_add-rust-crate-documentation`).
12pub struct ChangeId(String);
13
14impl ChangeId {
15    pub(crate) fn new(inner: String) -> Self {
16        Self(inner)
17    }
18
19    /// Borrow the underlying string.
20    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)]
32/// Parsed representation of a change identifier.
33pub struct ParsedChangeId {
34    /// Canonical module id.
35    pub module_id: ModuleId,
36
37    /// Canonical change number (at least 2 digits).
38    pub change_num: String,
39
40    /// Canonicalized change name (lowercase).
41    pub name: String,
42
43    /// Canonical `NNN-NN_name` string.
44    pub canonical: ChangeId,
45}
46
47/// Parse a change identifier.
48///
49/// Accepts flexible padding for the module and change numbers, but always
50/// returns a canonical representation.
51pub 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    // Match TS hint for the common mistake: using '_' between module and change number.
68    // Example: "001_02_name" (should be "001-02_name").
69    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    // TS: const FLEXIBLE_CHANGE_PATTERN = /^(\d+)-(\d+)_([a-z][a-z0-9-]*)$/i;
101    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    // NOTE: Do not enforce an upper bound for change numbers.
189    // Padding is for readability/sorting only; functionality is more important.
190
191    // Validate name
192    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}