Skip to main content

ito_common/id/
module_id.rs

1use std::fmt;
2
3use super::IdParseError;
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6/// A module identifier.
7///
8/// Modules are grouped epics (e.g. `001_project-setup`).
9pub struct ModuleId(String);
10
11impl ModuleId {
12    pub(crate) fn new(inner: String) -> Self {
13        Self(inner)
14    }
15
16    /// Borrow the canonical `NNN` module id string.
17    pub fn as_str(&self) -> &str {
18        &self.0
19    }
20}
21
22impl fmt::Display for ModuleId {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        f.write_str(&self.0)
25    }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29/// Parsed representation of a module identifier.
30pub struct ParsedModuleId {
31    /// Canonical numeric id, padded to 3 digits.
32    pub module_id: ModuleId,
33
34    /// Optional module name suffix.
35    pub module_name: Option<String>,
36}
37
38/// Parse a module identifier.
39///
40/// Accepts either `NNN` or `NNN_name` (flexible padding); the returned id is
41/// always canonicalized to a 3-digit `NNN` string.
42pub fn parse_module_id(input: &str) -> Result<ParsedModuleId, IdParseError> {
43    let trimmed = input.trim();
44    if trimmed.is_empty() {
45        return Err(IdParseError::new(
46            "Module ID cannot be empty",
47            Some("Provide a module ID like \"1\", \"001\", or \"001_my-module\""),
48        ));
49    }
50
51    // TS: const FLEXIBLE_MODULE_PATTERN = /^(\d+)(?:_([a-z][a-z0-9-]*))?$/i;
52    let (num_part, name_part) = match trimmed.split_once('_') {
53        Some((left, right)) => (left, Some(right)),
54        None => (trimmed, None),
55    };
56
57    if num_part.is_empty() || !num_part.as_bytes().iter().all(|b| b.is_ascii_digit()) {
58        return Err(IdParseError::new(
59            format!("Invalid module ID format: \"{input}\""),
60            Some(
61                "Expected format: \"NNN\" or \"NNN_name\" (e.g., \"1\", \"001\", \"001_my-module\")",
62            ),
63        ));
64    }
65
66    let num: u32 = num_part.parse().map_err(|_| {
67        IdParseError::new(
68            "Module ID is required",
69            Some("Provide a module ID like \"1\", \"001\", or \"001_my-module\""),
70        )
71    })?;
72
73    if num > 999 {
74        return Err(IdParseError::new(
75            format!("Module ID {num} exceeds maximum (999)"),
76            Some("Module IDs must be between 0 and 999"),
77        ));
78    }
79
80    let module_id = ModuleId::new(format!("{num:03}"));
81
82    let module_name = if let Some(name) = name_part {
83        if name.is_empty() {
84            return Err(IdParseError::new(
85                format!("Invalid module ID format: \"{input}\""),
86                Some(
87                    "Expected format: \"NNN\" or \"NNN_name\" (e.g., \"1\", \"001\", \"001_my-module\")",
88                ),
89            ));
90        }
91
92        let mut chars = name.chars();
93        let first = chars.next().unwrap_or('\0');
94        if !first.is_ascii_alphabetic() {
95            return Err(IdParseError::new(
96                format!("Invalid module ID format: \"{input}\""),
97                Some(
98                    "Expected format: \"NNN\" or \"NNN_name\" (e.g., \"1\", \"001\", \"001_my-module\")",
99                ),
100            ));
101        }
102        for c in chars {
103            if !(c.is_ascii_alphanumeric() || c == '-') {
104                return Err(IdParseError::new(
105                    format!("Invalid module ID format: \"{input}\""),
106                    Some(
107                        "Expected format: \"NNN\" or \"NNN_name\" (e.g., \"1\", \"001\", \"001_my-module\")",
108                    ),
109                ));
110            }
111        }
112        Some(name.to_ascii_lowercase())
113    } else {
114        None
115    };
116
117    Ok(ParsedModuleId {
118        module_id,
119        module_name,
120    })
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn parse_module_id_pads_and_lowercases_name() {
129        let parsed = parse_module_id("1_Foo-Bar").unwrap();
130        assert_eq!(parsed.module_id.as_str(), "001");
131        assert_eq!(parsed.module_name.as_deref(), Some("foo-bar"));
132    }
133
134    #[test]
135    fn parse_module_id_rejects_overflow() {
136        let err = parse_module_id("1000").unwrap_err();
137        assert_eq!(err.error, "Module ID 1000 exceeds maximum (999)");
138    }
139}