arcella_types/
module_id.rs1use regex::Regex;
11use serde::{Deserialize, Deserializer, Serialize};
12use std::fmt;
13use std::str::FromStr;
14use std::sync::OnceLock;
15
16use crate::error::{ArcellaError, ArcellaResult};
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
26pub struct ModuleId {
27 pub name: String,
28 pub version: String,
29}
30
31impl ModuleId {
32 pub fn new(name: String, version: String) -> ArcellaResult<Self> {
34 if !is_valid_name(&name) {
35 return Err(ArcellaError::InvalidModuleIdName(name));
36 }
37 if !is_valid_simple_version(&version) {
38 return Err(ArcellaError::InvalidModuleIdVersion(version));
39 }
40 Ok(ModuleId { name, version })
41 }
42
43 pub fn to_string(&self) -> String {
45 format!("{}@{}", self.name, self.version)
46 }
47}
48
49impl fmt::Display for ModuleId {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 write!(f, "{}@{}", self.name, self.version)
52 }
53}
54
55impl FromStr for ModuleId {
56 type Err = ArcellaError;
57
58 fn from_str(s: &str) -> Result<Self, Self::Err> {
59 static RE: OnceLock<Regex> = OnceLock::new();
60 let re = RE.get_or_init(|| {
61 Regex::new(r"^(?P<name>[a-zA-Z0-9_-]+)@(?P<version>\d+\.\d+\.\d+)$").unwrap()
64 });
65
66 re.captures(s).ok_or_else(|| ArcellaError::InvalidModuleIdFormat(s.to_string()))
67 .and_then(|caps| {
68 let name = caps.name("name").unwrap().as_str().to_string();
69 let version = caps.name("version").unwrap().as_str().to_string();
70 ModuleId::new(name, version)
71 })
72 }
73}
74
75impl<'de> Deserialize<'de> for ModuleId {
76 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
80 where
81 D: Deserializer<'de>,
82 {
83 #[derive(Deserialize)]
84 #[serde(untagged)]
85 enum ModuleIdHelper {
86 String(String),
87 Struct { name: String, version: String },
88 }
89
90 let helper = ModuleIdHelper::deserialize(deserializer)?;
91 match helper {
92 ModuleIdHelper::String(s) => {
93 ModuleId::from_str(&s).map_err(serde::de::Error::custom)
94 }
95 ModuleIdHelper::Struct { name, version } => {
96 ModuleId::new(name, version).map_err(serde::de::Error::custom)
97 }
98 }
99 }
100}
101
102pub fn is_valid_name(name: &str) -> bool {
106 if name.is_empty() || name.len() > 128 {
107 return false;
108 }
109 name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
110}
111
112pub fn is_valid_simple_version(version: &str) -> bool {
118 let parts: Vec<&str> = version.split('.').collect();
119 if parts.len() != 3 {
120 return false;
121 }
122
123 for part in parts {
124 if part.is_empty() {
125 return false;
126 }
127 if part.len() > 1 && part.starts_with('0') {
129 return false;
130 }
131 if !part.chars().all(|c| c.is_ascii_digit()) {
132 return false;
133 }
134 }
135 true
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn test_valid_module_ids() {
144 let cases = vec![
145 "logger@1.0.0",
146 "http_handler@2.10.5",
147 "a@0.0.1",
148 "my_mod_v2@1.2.3",
149 "test-1@9.9.9",
150 ];
151
152 for case in cases {
153 let id = ModuleId::from_str(case).expect(&format!("Failed to parse: {}", case));
154 assert_eq!(id.to_string(), case);
155 }
156 }
157
158 #[test]
159 fn test_invalid_names() {
160 let invalid_names = vec![
161 "", "logger@", "@1.0.0", "logger@1.0", "logger@1.0.0.0", "logger@1.02.0", "logger@1.0.00", "logger@1.0.0a", "logger with space@1.0.0", "logger!@1.0.0", ];
172
173 for name in invalid_names {
174 assert!(ModuleId::from_str(name).is_err(), "Should fail: {}", name);
175 }
176 }
177
178 #[test]
179 fn test_is_valid_simple_version() {
180 assert!(is_valid_simple_version("1.0.0"));
181 assert!(is_valid_simple_version("0.0.0"));
182 assert!(is_valid_simple_version("123.45.6"));
183
184 assert!(!is_valid_simple_version("1.0")); assert!(!is_valid_simple_version("1.0.0.0")); assert!(!is_valid_simple_version("1.02.0")); assert!(!is_valid_simple_version("1.0.00")); assert!(!is_valid_simple_version("1.a.0")); assert!(!is_valid_simple_version("1..0")); assert!(!is_valid_simple_version("")); assert!(!is_valid_simple_version("01.0.0")); }
194
195 #[test]
196 fn test_is_valid_name() {
197 assert!(is_valid_name("a"));
198 assert!(is_valid_name("logger"));
199 assert!(is_valid_name("http_handler"));
200 assert!(is_valid_name("test-123"));
201 assert!(is_valid_name("123mod")); assert!(!is_valid_name(""));
204 assert!(!is_valid_name("logger!"));
205 assert!(!is_valid_name("logger with space"));
206 assert!(!is_valid_name(&"x".repeat(129))); }
208
209 #[test]
210 fn test_module_id_deserialize_string() {
211 let json = r#""test@1.0.0""#;
212 let id: ModuleId = serde_json::from_str(json).unwrap();
213 assert_eq!(id.name, "test");
214 assert_eq!(id.version, "1.0.0");
215 }
216
217 #[test]
218 fn test_module_id_deserialize_struct() {
219 let json = r#"{"name":"my_mod","version":"2.10.5"}"#;
220 let id: ModuleId = serde_json::from_str(json).unwrap();
221 assert_eq!(id.name, "my_mod");
222 assert_eq!(id.version, "2.10.5");
223 }
224
225 #[test]
226 fn test_module_id_serialize_always_as_struct() {
227 let id = ModuleId::new("test".into(), "1.0.0".into()).unwrap();
228 let json = serde_json::to_string(&id).unwrap();
229 assert!(json.starts_with('{'));
231 assert!(json.contains(r#""name":"test""#));
232 assert!(json.contains(r#""version":"1.0.0""#));
233 }
234
235 #[test]
236 fn test_module_id_deserialize_invalid_string() {
237 let json = r#""bad@@version""#;
238 let err = serde_json::from_str::<ModuleId>(json).unwrap_err();
239 assert!(err.to_string().contains("Invalid module ID format"));
240 }
241
242 #[test]
243 fn test_module_id_deserialize_invalid_struct() {
244 let json = r#"{"name":"in valid","version":"1.0.0"}"#;
245 let err = serde_json::from_str::<ModuleId>(json).unwrap_err();
246 assert!(err.to_string().contains("Invalid module ID name"));
247 }
248}