Skip to main content

oxihuman_core/
plugin_registry.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Plugin registration system for extensible asset loaders and target providers.
5
6#[allow(dead_code)]
7#[derive(Debug, Clone, PartialEq)]
8pub enum PluginKind {
9    AssetLoader,
10    TargetProvider,
11    Exporter,
12    Validator,
13}
14
15#[allow(dead_code)]
16#[derive(Debug, Clone)]
17pub struct PluginDescriptor {
18    pub id: String,
19    pub name: String,
20    /// Semver string e.g. "1.0.0".
21    pub version: String,
22    pub kind: PluginKind,
23    pub supported_extensions: Vec<String>,
24    pub description: String,
25}
26
27#[allow(dead_code)]
28#[derive(Debug, Default)]
29pub struct PluginRegistry {
30    plugins: Vec<PluginDescriptor>,
31}
32
33impl PluginRegistry {
34    pub fn new() -> Self {
35        Self {
36            plugins: Vec::new(),
37        }
38    }
39
40    /// Register a plugin descriptor. Returns an error if a plugin with the
41    /// same `id` already exists.
42    pub fn register(&mut self, desc: PluginDescriptor) -> Result<(), String> {
43        if self.plugins.iter().any(|p| p.id == desc.id) {
44            return Err(format!(
45                "plugin with id '{}' is already registered",
46                desc.id
47            ));
48        }
49        self.plugins.push(desc);
50        Ok(())
51    }
52
53    /// Unregister a plugin by id. Returns `true` if it was found and removed.
54    pub fn unregister(&mut self, id: &str) -> bool {
55        let before = self.plugins.len();
56        self.plugins.retain(|p| p.id != id);
57        self.plugins.len() < before
58    }
59
60    /// Find a plugin by its unique id.
61    pub fn find_by_id(&self, id: &str) -> Option<&PluginDescriptor> {
62        self.plugins.iter().find(|p| p.id == id)
63    }
64
65    /// Return all plugins that declare support for the given file extension.
66    pub fn find_by_extension(&self, ext: &str) -> Vec<&PluginDescriptor> {
67        self.plugins
68            .iter()
69            .filter(|p| p.supported_extensions.iter().any(|e| e == ext))
70            .collect()
71    }
72
73    /// Return all plugins of a given kind.
74    pub fn find_by_kind(&self, kind: &PluginKind) -> Vec<&PluginDescriptor> {
75        self.plugins.iter().filter(|p| &p.kind == kind).collect()
76    }
77
78    /// Total number of registered plugins.
79    pub fn count(&self) -> usize {
80        self.plugins.len()
81    }
82
83    /// Slice of all registered plugins.
84    pub fn all(&self) -> &[PluginDescriptor] {
85        &self.plugins
86    }
87
88    /// Serialize the plugin list to a JSON array string.
89    pub fn to_json(&self) -> String {
90        let mut out = String::from("[\n");
91        for (i, p) in self.plugins.iter().enumerate() {
92            let kind_str = match p.kind {
93                PluginKind::AssetLoader => "AssetLoader",
94                PluginKind::TargetProvider => "TargetProvider",
95                PluginKind::Exporter => "Exporter",
96                PluginKind::Validator => "Validator",
97            };
98            let exts: Vec<String> = p
99                .supported_extensions
100                .iter()
101                .map(|e| format!("\"{}\"", e))
102                .collect();
103            out.push_str(&format!(
104                "  {{\"id\":\"{}\",\"name\":\"{}\",\"version\":\"{}\",\"kind\":\"{}\",\"extensions\":[{}],\"description\":\"{}\"}}",
105                p.id, p.name, p.version, kind_str, exts.join(","), p.description
106            ));
107            if i + 1 < self.plugins.len() {
108                out.push(',');
109            }
110            out.push('\n');
111        }
112        out.push(']');
113        out
114    }
115}
116
117// ── built-in plugins ──────────────────────────────────────────────────────────
118
119/// Return the list of built-in plugin descriptors shipped with OxiHuman.
120#[allow(dead_code)]
121pub fn default_builtin_plugins() -> Vec<PluginDescriptor> {
122    vec![
123        PluginDescriptor {
124            id: "obj_loader".to_string(),
125            name: "Wavefront OBJ Loader".to_string(),
126            version: "1.0.0".to_string(),
127            kind: PluginKind::AssetLoader,
128            supported_extensions: vec!["obj".to_string()],
129            description: "Loads Wavefront .obj mesh files".to_string(),
130        },
131        PluginDescriptor {
132            id: "glb_loader".to_string(),
133            name: "GLB Loader".to_string(),
134            version: "1.0.0".to_string(),
135            kind: PluginKind::AssetLoader,
136            supported_extensions: vec!["glb".to_string(), "gltf".to_string()],
137            description: "Loads binary or JSON glTF files".to_string(),
138        },
139        PluginDescriptor {
140            id: "target_loader".to_string(),
141            name: "MakeHuman Target Loader".to_string(),
142            version: "1.0.0".to_string(),
143            kind: PluginKind::TargetProvider,
144            supported_extensions: vec!["target".to_string()],
145            description: "Loads MakeHuman .target morph files".to_string(),
146        },
147        PluginDescriptor {
148            id: "glb_exporter".to_string(),
149            name: "GLB Exporter".to_string(),
150            version: "1.0.0".to_string(),
151            kind: PluginKind::Exporter,
152            supported_extensions: vec!["glb".to_string()],
153            description: "Exports meshes to binary glTF".to_string(),
154        },
155        PluginDescriptor {
156            id: "ply_exporter".to_string(),
157            name: "PLY Exporter".to_string(),
158            version: "1.0.0".to_string(),
159            kind: PluginKind::Exporter,
160            supported_extensions: vec!["ply".to_string()],
161            description: "Exports meshes to Stanford PLY format".to_string(),
162        },
163        PluginDescriptor {
164            id: "pack_validator".to_string(),
165            name: "Pack Validator".to_string(),
166            version: "1.0.0".to_string(),
167            kind: PluginKind::Validator,
168            supported_extensions: vec!["toml".to_string(), "json".to_string()],
169            description: "Validates OxiHuman asset pack manifests".to_string(),
170        },
171    ]
172}
173
174// ── semver helpers ────────────────────────────────────────────────────────────
175
176/// Parse a semver string "major.minor.patch" into a tuple.
177/// Returns `None` if the string does not match the expected format.
178#[allow(dead_code)]
179pub fn parse_semver(s: &str) -> Option<(u32, u32, u32)> {
180    let parts: Vec<&str> = s.split('.').collect();
181    if parts.len() != 3 {
182        return None;
183    }
184    let major = parts[0].parse::<u32>().ok()?;
185    let minor = parts[1].parse::<u32>().ok()?;
186    let patch = parts[2].parse::<u32>().ok()?;
187    Some((major, minor, patch))
188}
189
190/// Return `true` if version `a` is greater than or equal to version `b`.
191#[allow(dead_code)]
192pub fn semver_gte(a: (u32, u32, u32), b: (u32, u32, u32)) -> bool {
193    a >= b
194}
195
196// ── tests ─────────────────────────────────────────────────────────────────────
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn make_desc(id: &str, kind: PluginKind, exts: &[&str]) -> PluginDescriptor {
203        PluginDescriptor {
204            id: id.to_string(),
205            name: format!("Plugin {}", id),
206            version: "1.0.0".to_string(),
207            kind,
208            supported_extensions: exts.iter().map(|e| e.to_string()).collect(),
209            description: "test".to_string(),
210        }
211    }
212
213    #[test]
214    fn new_registry_is_empty() {
215        let reg = PluginRegistry::new();
216        assert_eq!(reg.count(), 0);
217    }
218
219    #[test]
220    fn register_success() {
221        let mut reg = PluginRegistry::new();
222        let desc = make_desc("my_loader", PluginKind::AssetLoader, &["obj"]);
223        assert!(reg.register(desc).is_ok());
224        assert_eq!(reg.count(), 1);
225    }
226
227    #[test]
228    fn duplicate_id_is_rejected() {
229        let mut reg = PluginRegistry::new();
230        reg.register(make_desc("dup", PluginKind::AssetLoader, &["obj"]))
231            .expect("should succeed");
232        let result = reg.register(make_desc("dup", PluginKind::Exporter, &["glb"]));
233        assert!(result.is_err());
234        assert!(result.unwrap_err().contains("dup"));
235    }
236
237    #[test]
238    fn unregister_removes_plugin() {
239        let mut reg = PluginRegistry::new();
240        reg.register(make_desc("to_remove", PluginKind::Validator, &[]))
241            .expect("should succeed");
242        assert_eq!(reg.count(), 1);
243        let removed = reg.unregister("to_remove");
244        assert!(removed);
245        assert_eq!(reg.count(), 0);
246    }
247
248    #[test]
249    fn unregister_nonexistent_returns_false() {
250        let mut reg = PluginRegistry::new();
251        assert!(!reg.unregister("nope"));
252    }
253
254    #[test]
255    fn find_by_id_found() {
256        let mut reg = PluginRegistry::new();
257        reg.register(make_desc("finder", PluginKind::AssetLoader, &["obj"]))
258            .expect("should succeed");
259        assert!(reg.find_by_id("finder").is_some());
260    }
261
262    #[test]
263    fn find_by_id_not_found() {
264        let reg = PluginRegistry::new();
265        assert!(reg.find_by_id("ghost").is_none());
266    }
267
268    #[test]
269    fn find_by_extension_obj() {
270        let mut reg = PluginRegistry::new();
271        reg.register(make_desc("obj_l", PluginKind::AssetLoader, &["obj"]))
272            .expect("should succeed");
273        reg.register(make_desc("glb_l", PluginKind::AssetLoader, &["glb"]))
274            .expect("should succeed");
275        let results = reg.find_by_extension("obj");
276        assert_eq!(results.len(), 1);
277        assert_eq!(results[0].id, "obj_l");
278    }
279
280    #[test]
281    fn find_by_kind_count() {
282        let mut reg = PluginRegistry::new();
283        reg.register(make_desc("l1", PluginKind::AssetLoader, &[]))
284            .expect("should succeed");
285        reg.register(make_desc("l2", PluginKind::AssetLoader, &[]))
286            .expect("should succeed");
287        reg.register(make_desc("e1", PluginKind::Exporter, &[]))
288            .expect("should succeed");
289        let loaders = reg.find_by_kind(&PluginKind::AssetLoader);
290        assert_eq!(loaders.len(), 2);
291    }
292
293    #[test]
294    fn count_returns_correct_value() {
295        let mut reg = PluginRegistry::new();
296        for i in 0..5 {
297            reg.register(make_desc(&format!("p{}", i), PluginKind::Validator, &[]))
298                .expect("should succeed");
299        }
300        assert_eq!(reg.count(), 5);
301    }
302
303    #[test]
304    fn to_json_contains_id() {
305        let mut reg = PluginRegistry::new();
306        reg.register(make_desc(
307            "json_test_plugin",
308            PluginKind::Exporter,
309            &["glb"],
310        ))
311        .expect("should succeed");
312        let json = reg.to_json();
313        assert!(json.contains("json_test_plugin"));
314    }
315
316    #[test]
317    fn default_builtin_plugins_has_six_or_more() {
318        let plugins = default_builtin_plugins();
319        assert!(plugins.len() >= 6);
320    }
321
322    #[test]
323    fn parse_semver_valid() {
324        assert_eq!(parse_semver("1.2.3"), Some((1, 2, 3)));
325        assert_eq!(parse_semver("0.0.0"), Some((0, 0, 0)));
326        assert_eq!(parse_semver("10.20.30"), Some((10, 20, 30)));
327    }
328
329    #[test]
330    fn parse_semver_invalid_returns_none() {
331        assert_eq!(parse_semver("1.2"), None);
332        assert_eq!(parse_semver("a.b.c"), None);
333        assert_eq!(parse_semver(""), None);
334        assert_eq!(parse_semver("1.2.3.4"), None);
335    }
336
337    #[test]
338    fn semver_gte_comparisons() {
339        assert!(semver_gte((1, 0, 0), (1, 0, 0)));
340        assert!(semver_gte((2, 0, 0), (1, 9, 9)));
341        assert!(semver_gte((1, 1, 0), (1, 0, 9)));
342        assert!(!semver_gte((1, 0, 0), (1, 0, 1)));
343        assert!(!semver_gte((0, 9, 9), (1, 0, 0)));
344    }
345
346    #[test]
347    fn all_returns_slice_of_plugins() {
348        let mut reg = PluginRegistry::new();
349        reg.register(make_desc("a1", PluginKind::AssetLoader, &[]))
350            .expect("should succeed");
351        reg.register(make_desc("a2", PluginKind::AssetLoader, &[]))
352            .expect("should succeed");
353        assert_eq!(reg.all().len(), 2);
354    }
355}