ito_common/id/
module_id.rs1use std::fmt;
4
5use super::IdParseError;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
8pub struct ModuleId(String);
12
13impl ModuleId {
14 pub(crate) fn new(inner: String) -> Self {
15 Self(inner)
16 }
17
18 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)]
31pub struct ParsedModuleId {
33 pub module_id: ModuleId,
35
36 pub module_name: Option<String>,
38}
39
40pub 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 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}