Skip to main content

ito_common/id/
module_id.rs

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