arcella_types/
module_id.rs

1// arcella-types/src/module_id.rs
2//
3// Copyright (c) 2025 Alexey Rybakov, Arcella Team
4//
5// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE>
6// or the MIT license <LICENSE-MIT>, at your option.
7// This file may not be copied, modified, or distributed
8// except according to those terms.
9
10use 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/// Unique identifier for an installed module, in the form `name@version`.
19///
20/// - `name`: must match `^[a-zA-Z0-9_-]+$`
21/// - `version`: must be a **strict** semantic version `x.y.z` (three numeric parts, no leading zeros)
22///
23/// Build metadata (`+...`) and pre-release (`-...`) are **not supported** to ensure uniqueness
24/// and simplify dependency resolution.
25#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
26pub struct ModuleId {
27    pub name: String,
28    pub version: String,
29}
30
31impl ModuleId {
32    /// Creates a new `ModuleId` after validating name and version.
33    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    /// Returns the string representation: `name@version`.
44    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            // name: 1+ alphanum, underscore, dash
62            // version: x.y.z where x,y,z are numbers (no leading zeros except for "0")
63            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    /// Deserializes from:
77    /// - String: `"name@version"`
78    /// - Struct: `{ "name": "...", "version": "..." }`
79    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
102/// Validates component/module name.
103///
104/// Must be non-empty and contain only ASCII letters, digits, underscores, or hyphens.
105pub 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
112/// Validates a simple version string like "1.2.3".
113///
114/// - Must have exactly three dot-separated numeric parts.
115/// - Each part must be a non-negative integer.
116/// - No leading zeros (except for "0" itself).
117pub 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        // Check for leading zeros (e.g., "01" is invalid, but "0" is OK)
128        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            "",                     // empty
162            "logger@",              // no version
163            "@1.0.0",               // no name
164            "logger@1.0",           // bad version format (only two parts)
165            "logger@1.0.0.0",       // too many parts
166            "logger@1.02.0",        // leading zero in minor
167            "logger@1.0.00",        // leading zero in patch
168            "logger@1.0.0a",        // non-numeric
169            "logger with space@1.0.0", // invalid char
170            "logger!@1.0.0",        // invalid char
171        ];
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        // Invalid cases
185        assert!(!is_valid_simple_version("1.0"));        // too short
186        assert!(!is_valid_simple_version("1.0.0.0"));    // too long
187        assert!(!is_valid_simple_version("1.02.0"));     // leading zero
188        assert!(!is_valid_simple_version("1.0.00"));     // leading zero
189        assert!(!is_valid_simple_version("1.a.0"));      // non-digit
190        assert!(!is_valid_simple_version("1..0"));       // empty part
191        assert!(!is_valid_simple_version(""));           // empty
192        assert!(!is_valid_simple_version("01.0.0"));     // leading zero in major
193    }
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")); // starts with digit — OK
202
203        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))); // too long
207    }
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        // Должно быть объектом, не строкой
230        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}