Skip to main content

ito_common/id/
sub_module_id.rs

1//! Sub-module ID parsing and normalization.
2
3use std::fmt;
4
5use super::{IdParseError, ModuleId, is_all_ascii_digits};
6
7/// A sub-module identifier in canonical `NNN.SS` form.
8///
9/// Sub-modules partition a parent module into named sections, each with their
10/// own change sequence. The canonical form is always `NNN.SS` (e.g., `005.01`).
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub struct SubModuleId(String);
13
14impl SubModuleId {
15    /// Construct a `SubModuleId` from a pre-validated canonical string.
16    pub(crate) fn new(inner: String) -> Self {
17        Self(inner)
18    }
19
20    /// Borrow the canonical `NNN.SS` sub-module id string.
21    pub fn as_str(&self) -> &str {
22        &self.0
23    }
24}
25
26impl fmt::Display for SubModuleId {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        f.write_str(&self.0)
29    }
30}
31
32/// Parsed representation of a sub-module identifier.
33///
34/// Produced by [`parse_sub_module_id`]; carries the canonical id, the parent
35/// module id, the zero-padded sub-module number, and an optional name suffix.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ParsedSubModuleId {
38    /// Canonical sub-module id (e.g., `"005.01"`).
39    pub sub_module_id: SubModuleId,
40
41    /// Parent module id (e.g., `"005"`).
42    pub parent_module_id: ModuleId,
43
44    /// Zero-padded sub-module number (e.g., `"01"`).
45    pub sub_num: String,
46
47    /// Optional name suffix (lowercased), e.g., `"core-api"` from `"005.01_core-api"`.
48    pub sub_name: Option<String>,
49}
50
51/// Parse a sub-module identifier.
52///
53/// Accepts `NNN.SS` or `NNN.SS_name` with flexible zero-padding; always
54/// returns a canonical `NNN.SS` representation.
55///
56/// # Errors
57///
58/// Returns [`IdParseError`] when the input is empty, too long, contains
59/// non-numeric parts, or exceeds the allowed ranges (module ≤ 999, sub ≤ 99).
60pub fn parse_sub_module_id(input: &str) -> Result<ParsedSubModuleId, IdParseError> {
61    let trimmed = input.trim();
62    if trimmed.is_empty() {
63        return Err(IdParseError::new(
64            "Sub-module ID cannot be empty",
65            Some("Provide a sub-module ID like \"005.01\" or \"005.01_core-api\""),
66        ));
67    }
68
69    if trimmed.len() > 256 {
70        return Err(IdParseError::new(
71            format!(
72                "Sub-module ID is too long: {} bytes (max 256)",
73                trimmed.len()
74            ),
75            Some("Provide a shorter sub-module ID in the form \"NNN.SS\" or \"NNN.SS_name\""),
76        ));
77    }
78
79    // Strip optional name suffix (everything after the first `_`).
80    let (id_part, name_part) = match trimmed.split_once('_') {
81        Some((left, right)) => (left, Some(right)),
82        None => (trimmed, None),
83    };
84
85    // id_part must be NNN.SS
86    let Some((module_str, sub_str)) = id_part.split_once('.') else {
87        return Err(IdParseError::new(
88            format!("Invalid sub-module ID format: \"{input}\""),
89            Some(
90                "Expected format: \"NNN.SS\" or \"NNN.SS_name\" (e.g., \"005.01\", \"005.01_core-api\")",
91            ),
92        ));
93    };
94
95    if !is_all_ascii_digits(module_str) || !is_all_ascii_digits(sub_str) {
96        return Err(IdParseError::new(
97            format!("Invalid sub-module ID format: \"{input}\""),
98            Some(
99                "Expected format: \"NNN.SS\" or \"NNN.SS_name\" (e.g., \"005.01\", \"005.01_core-api\")",
100            ),
101        ));
102    }
103
104    let module_num: u32 = module_str.parse().map_err(|_| {
105        IdParseError::new(
106            "Sub-module ID is required",
107            Some("Provide a sub-module ID like \"005.01\" or \"005.01_core-api\""),
108        )
109    })?;
110
111    let sub_num: u32 = sub_str.parse().map_err(|_| {
112        IdParseError::new(
113            "Sub-module ID is required",
114            Some("Provide a sub-module ID like \"005.01\" or \"005.01_core-api\""),
115        )
116    })?;
117
118    if module_num > 999 {
119        return Err(IdParseError::new(
120            format!("Module number {module_num} exceeds maximum (999)"),
121            Some("Module numbers must be between 0 and 999"),
122        ));
123    }
124
125    if sub_num > 99 {
126        return Err(IdParseError::new(
127            format!("Sub-module number {sub_num} exceeds maximum (99)"),
128            Some("Sub-module numbers must be between 0 and 99"),
129        ));
130    }
131
132    // Validate optional name suffix.
133    let sub_name = match name_part {
134        None => None,
135        Some(name) => {
136            if name.is_empty() {
137                return Err(IdParseError::new(
138                    format!("Invalid sub-module ID format: \"{input}\""),
139                    Some(
140                        "Expected format: \"NNN.SS\" or \"NNN.SS_name\" (e.g., \"005.01\", \"005.01_core-api\")",
141                    ),
142                ));
143            }
144
145            let mut chars = name.chars();
146            let first = chars.next().unwrap_or('\0');
147            if !first.is_ascii_alphabetic() {
148                return Err(IdParseError::new(
149                    format!("Invalid sub-module ID format: \"{input}\""),
150                    Some(
151                        "Expected format: \"NNN.SS\" or \"NNN.SS_name\" (e.g., \"005.01\", \"005.01_core-api\")",
152                    ),
153                ));
154            }
155            for c in chars {
156                if !(c.is_ascii_alphanumeric() || c == '-') {
157                    return Err(IdParseError::new(
158                        format!("Invalid sub-module ID format: \"{input}\""),
159                        Some(
160                            "Expected format: \"NNN.SS\" or \"NNN.SS_name\" (e.g., \"005.01\", \"005.01_core-api\")",
161                        ),
162                    ));
163                }
164            }
165            Some(name.to_ascii_lowercase())
166        }
167    };
168
169    let parent_module_id = ModuleId::new(format!("{module_num:03}"));
170    let sub_num_str = format!("{sub_num:02}");
171    let sub_module_id = SubModuleId::new(format!("{parent_module_id}.{sub_num_str}"));
172
173    Ok(ParsedSubModuleId {
174        sub_module_id,
175        parent_module_id,
176        sub_num: sub_num_str,
177        sub_name,
178    })
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn parse_sub_module_id_canonical_form() {
187        let parsed = parse_sub_module_id("005.01").unwrap();
188        assert_eq!(parsed.sub_module_id.as_str(), "005.01");
189        assert_eq!(parsed.parent_module_id.as_str(), "005");
190        assert_eq!(parsed.sub_num, "01");
191        assert_eq!(parsed.sub_name, None);
192    }
193
194    #[test]
195    fn parse_sub_module_id_pads_both_parts() {
196        let parsed = parse_sub_module_id("5.1").unwrap();
197        assert_eq!(parsed.sub_module_id.as_str(), "005.01");
198        assert_eq!(parsed.parent_module_id.as_str(), "005");
199        assert_eq!(parsed.sub_num, "01");
200    }
201
202    #[test]
203    fn parse_sub_module_id_with_name_suffix() {
204        let parsed = parse_sub_module_id("005.01_core-api").unwrap();
205        assert_eq!(parsed.sub_module_id.as_str(), "005.01");
206        assert_eq!(parsed.sub_name.as_deref(), Some("core-api"));
207    }
208
209    #[test]
210    fn parse_sub_module_id_lowercases_name() {
211        let parsed = parse_sub_module_id("005.01_Core-API").unwrap();
212        assert_eq!(parsed.sub_name.as_deref(), Some("core-api"));
213    }
214
215    #[test]
216    fn parse_sub_module_id_strips_extra_leading_zeros() {
217        let parsed = parse_sub_module_id("005.001").unwrap();
218        assert_eq!(parsed.sub_module_id.as_str(), "005.01");
219        assert_eq!(parsed.sub_num, "01");
220    }
221
222    #[test]
223    fn parse_sub_module_id_rejects_empty() {
224        let err = parse_sub_module_id("").unwrap_err();
225        assert_eq!(err.error, "Sub-module ID cannot be empty");
226    }
227
228    #[test]
229    fn parse_sub_module_id_rejects_missing_dot() {
230        let err = parse_sub_module_id("005-01").unwrap_err();
231        assert!(err.error.contains("Invalid sub-module ID format"));
232    }
233
234    #[test]
235    fn parse_sub_module_id_rejects_module_overflow() {
236        let err = parse_sub_module_id("1000.01").unwrap_err();
237        assert!(err.error.contains("exceeds maximum (999)"));
238    }
239
240    #[test]
241    fn parse_sub_module_id_rejects_sub_overflow() {
242        let err = parse_sub_module_id("005.100").unwrap_err();
243        assert!(err.error.contains("exceeds maximum (99)"));
244    }
245
246    #[test]
247    fn parse_sub_module_id_rejects_non_digit_module() {
248        let err = parse_sub_module_id("abc.01").unwrap_err();
249        assert!(err.error.contains("Invalid sub-module ID format"));
250    }
251
252    #[test]
253    fn parse_sub_module_id_rejects_overlong_input() {
254        let input = format!("005.01_{}", "a".repeat(300));
255        let err = parse_sub_module_id(&input).expect_err("overlong sub-module id should fail");
256        assert!(err.error.contains("too long"));
257    }
258
259    #[test]
260    fn sub_module_id_display() {
261        let id = SubModuleId::new("005.01".to_string());
262        assert_eq!(id.to_string(), "005.01");
263    }
264}