Skip to main content

confium_coordinator/
plugin_manifest.rs

1//! Plugin manifest schema — typed metadata for Confium plugins.
2
3use serde::{Deserialize, Serialize};
4
5/// Semantic version components.
6pub type Version = (u32, u32, u32);
7
8/// A plugin manifest describing a Confium plugin's metadata.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct PluginManifest {
11    /// Plugin name (kebab-case, unique in the registry).
12    pub name: String,
13    /// Semantic version.
14    pub version: String,
15    /// Brief description.
16    pub description: String,
17    /// Author or organization.
18    pub author: String,
19    /// License identifier (SPDX).
20    pub license: String,
21    /// Interfaces this plugin implements.
22    pub interfaces: Vec<String>,
23    /// Other plugins this depends on.
24    #[serde(default)]
25    pub dependencies: Vec<PluginDependency>,
26    /// Supported algorithms.
27    #[serde(default)]
28    pub algorithms: Vec<String>,
29    /// Homepage URL.
30    #[serde(default)]
31    pub homepage: Option<String>,
32}
33
34/// A dependency on another plugin.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct PluginDependency {
37    /// Dependency plugin name.
38    pub name: String,
39    /// Minimum required version.
40    pub min_version: String,
41}
42
43/// Validation result for a manifest.
44#[derive(Debug)]
45pub struct ManifestValidation {
46    pub valid: bool,
47    pub errors: Vec<String>,
48}
49
50impl PluginManifest {
51    /// Validate the manifest fields.
52    pub fn validate(&self) -> ManifestValidation {
53        let mut errors = Vec::new();
54
55        if self.name.is_empty() {
56            errors.push("name must not be empty".into());
57        }
58        if !self
59            .name
60            .chars()
61            .all(|c| c.is_ascii_lowercase() || c == '-' || c.is_ascii_digit())
62        {
63            errors.push("name must be kebab-case (lowercase, digits, hyphens)".into());
64        }
65        if self.version.is_empty() {
66            errors.push("version must not be empty".into());
67        } else if parse_version(&self.version).is_none() {
68            errors.push(format!(
69                "version '{}' is not valid semver (X.Y.Z)",
70                self.version
71            ));
72        }
73        if self.description.is_empty() {
74            errors.push("description must not be empty".into());
75        }
76        if self.author.is_empty() {
77            errors.push("author must not be empty".into());
78        }
79        if self.license.is_empty() {
80            errors.push("license must not be empty".into());
81        }
82        if self.interfaces.is_empty() {
83            errors.push("at least one interface must be declared".into());
84        }
85        for dep in &self.dependencies {
86            if dep.name.is_empty() {
87                errors.push("dependency name must not be empty".into());
88            }
89            if parse_version(&dep.min_version).is_none() {
90                errors.push(format!("dependency '{}' has invalid min_version", dep.name));
91            }
92        }
93
94        ManifestValidation {
95            valid: errors.is_empty(),
96            errors,
97        }
98    }
99
100    /// Serialize to JSON.
101    pub fn to_json(&self) -> Result<String, serde_json::Error> {
102        serde_json::to_string_pretty(self)
103    }
104
105    /// Deserialize from JSON.
106    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
107        serde_json::from_str(json)
108    }
109}
110
111/// Parse a "X.Y.Z" version string.
112pub fn parse_version(s: &str) -> Option<Version> {
113    let parts: Vec<&str> = s.split('.').collect();
114    if parts.len() != 3 {
115        return None;
116    }
117    let major = parts[0].parse().ok()?;
118    let minor = parts[1].parse().ok()?;
119    let patch = parts[2].parse().ok()?;
120    Some((major, minor, patch))
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    fn make_valid_manifest() -> PluginManifest {
128        PluginManifest {
129            name: "my-hash-plugin".into(),
130            version: "1.0.0".into(),
131            description: "A hash plugin".into(),
132            author: "Confium".into(),
133            license: "BSD-2-Clause".into(),
134            interfaces: vec!["hash".into()],
135            dependencies: vec![],
136            algorithms: vec!["SHA-256".into()],
137            homepage: Some("https://confium.org".into()),
138        }
139    }
140
141    #[test]
142    fn valid_manifest_passes() {
143        let manifest = make_valid_manifest();
144        let validation = manifest.validate();
145        assert!(validation.valid);
146    }
147
148    #[test]
149    fn empty_name_rejected() {
150        let mut m = make_valid_manifest();
151        m.name = "".into();
152        assert!(!m.validate().valid);
153    }
154
155    #[test]
156    fn non_kebab_name_rejected() {
157        let mut m = make_valid_manifest();
158        m.name = "MyPlugin".into();
159        assert!(!m.validate().valid);
160    }
161
162    #[test]
163    fn invalid_version_rejected() {
164        let mut m = make_valid_manifest();
165        m.version = "1.0".into();
166        assert!(!m.validate().valid);
167    }
168
169    #[test]
170    fn no_interfaces_rejected() {
171        let mut m = make_valid_manifest();
172        m.interfaces = vec![];
173        assert!(!m.validate().valid);
174    }
175
176    #[test]
177    fn json_round_trip() {
178        let manifest = make_valid_manifest();
179        let json = manifest.to_json().unwrap();
180        let recovered = PluginManifest::from_json(&json).unwrap();
181        assert_eq!(recovered.name, manifest.name);
182        assert_eq!(recovered.version, manifest.version);
183        assert_eq!(recovered.interfaces, manifest.interfaces);
184    }
185
186    #[test]
187    fn parse_version_valid() {
188        assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
189    }
190
191    #[test]
192    fn parse_version_invalid() {
193        assert!(parse_version("1.2").is_none());
194        assert!(parse_version("a.b.c").is_none());
195        assert!(parse_version("").is_none());
196    }
197
198    #[test]
199    fn dependency_with_bad_version_rejected() {
200        let mut m = make_valid_manifest();
201        m.dependencies.push(PluginDependency {
202            name: "dep".into(),
203            min_version: "bad".into(),
204        });
205        assert!(!m.validate().valid);
206    }
207
208    #[test]
209    fn valid_dependency_accepted() {
210        let mut m = make_valid_manifest();
211        m.dependencies.push(PluginDependency {
212            name: "dep-plugin".into(),
213            min_version: "0.1.0".into(),
214        });
215        assert!(m.validate().valid);
216    }
217}