Skip to main content

ito_common/id/
change_id.rs

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