confium_coordinator/
plugin_manifest.rs1use serde::{Deserialize, Serialize};
4
5pub type Version = (u32, u32, u32);
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct PluginManifest {
11 pub name: String,
13 pub version: String,
15 pub description: String,
17 pub author: String,
19 pub license: String,
21 pub interfaces: Vec<String>,
23 #[serde(default)]
25 pub dependencies: Vec<PluginDependency>,
26 #[serde(default)]
28 pub algorithms: Vec<String>,
29 #[serde(default)]
31 pub homepage: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct PluginDependency {
37 pub name: String,
39 pub min_version: String,
41}
42
43#[derive(Debug)]
45pub struct ManifestValidation {
46 pub valid: bool,
47 pub errors: Vec<String>,
48}
49
50impl PluginManifest {
51 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 pub fn to_json(&self) -> Result<String, serde_json::Error> {
102 serde_json::to_string_pretty(self)
103 }
104
105 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
107 serde_json::from_str(json)
108 }
109}
110
111pub 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}