ito_common/id/
sub_module_id.rs1use std::fmt;
4
5use super::{IdParseError, ModuleId, is_all_ascii_digits};
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub struct SubModuleId(String);
13
14impl SubModuleId {
15 pub(crate) fn new(inner: String) -> Self {
17 Self(inner)
18 }
19
20 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#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ParsedSubModuleId {
38 pub sub_module_id: SubModuleId,
40
41 pub parent_module_id: ModuleId,
43
44 pub sub_num: String,
46
47 pub sub_name: Option<String>,
49}
50
51pub 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 let (id_part, name_part) = match trimmed.split_once('_') {
81 Some((left, right)) => (left, Some(right)),
82 None => (trimmed, None),
83 };
84
85 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 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}