fusabi_plugin_runtime/
manifest.rs

1//! Plugin manifest schema and validation.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use crate::error::{Error, Result};
7
8/// API version specification.
9#[derive(Debug, Clone, PartialEq, Eq)]
10#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11pub struct ApiVersion {
12    /// Major version.
13    pub major: u32,
14    /// Minor version.
15    pub minor: u32,
16    /// Patch version.
17    pub patch: u32,
18}
19
20impl ApiVersion {
21    /// Create a new API version.
22    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
23        Self { major, minor, patch }
24    }
25
26    /// Parse from a string like "0.18.0".
27    pub fn parse(s: &str) -> Result<Self> {
28        let parts: Vec<&str> = s.split('.').collect();
29        if parts.len() < 2 {
30            return Err(Error::invalid_manifest(format!("invalid version: {}", s)));
31        }
32
33        let major = parts[0]
34            .parse()
35            .map_err(|_| Error::invalid_manifest(format!("invalid major version: {}", s)))?;
36        let minor = parts[1]
37            .parse()
38            .map_err(|_| Error::invalid_manifest(format!("invalid minor version: {}", s)))?;
39        let patch = parts
40            .get(2)
41            .map(|p| p.parse().unwrap_or(0))
42            .unwrap_or(0);
43
44        Ok(Self { major, minor, patch })
45    }
46
47    /// Check if this version is compatible with another.
48    pub fn is_compatible_with(&self, other: &ApiVersion) -> bool {
49        // Same major version required, minor must be >= other
50        self.major == other.major && self.minor >= other.minor
51    }
52
53    /// Format as a string.
54    pub fn to_string(&self) -> String {
55        format!("{}.{}.{}", self.major, self.minor, self.patch)
56    }
57}
58
59impl Default for ApiVersion {
60    fn default() -> Self {
61        Self {
62            major: 0,
63            minor: 18,
64            patch: 0,
65        }
66    }
67}
68
69impl std::fmt::Display for ApiVersion {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
72    }
73}
74
75/// Plugin dependency specification.
76#[derive(Debug, Clone)]
77#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
78pub struct Dependency {
79    /// Dependency name.
80    pub name: String,
81    /// Version requirement (semver).
82    pub version: String,
83    /// Whether this dependency is optional.
84    #[cfg_attr(feature = "serde", serde(default))]
85    pub optional: bool,
86}
87
88impl Dependency {
89    /// Create a new required dependency.
90    pub fn required(name: impl Into<String>, version: impl Into<String>) -> Self {
91        Self {
92            name: name.into(),
93            version: version.into(),
94            optional: false,
95        }
96    }
97
98    /// Create a new optional dependency.
99    pub fn optional(name: impl Into<String>, version: impl Into<String>) -> Self {
100        Self {
101            name: name.into(),
102            version: version.into(),
103            optional: true,
104        }
105    }
106}
107
108/// Plugin manifest defining metadata and requirements.
109#[derive(Debug, Clone)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
111pub struct Manifest {
112    /// Plugin name (unique identifier).
113    pub name: String,
114
115    /// Plugin version.
116    pub version: String,
117
118    /// Human-readable description.
119    #[cfg_attr(feature = "serde", serde(default))]
120    pub description: Option<String>,
121
122    /// Plugin authors.
123    #[cfg_attr(feature = "serde", serde(default))]
124    pub authors: Vec<String>,
125
126    /// Plugin license.
127    #[cfg_attr(feature = "serde", serde(default))]
128    pub license: Option<String>,
129
130    /// Required Fusabi API version.
131    #[cfg_attr(feature = "serde", serde(rename = "api-version"))]
132    pub api_version: ApiVersion,
133
134    /// Required capabilities.
135    #[cfg_attr(feature = "serde", serde(default))]
136    pub capabilities: Vec<String>,
137
138    /// Plugin dependencies.
139    #[cfg_attr(feature = "serde", serde(default))]
140    pub dependencies: Vec<Dependency>,
141
142    /// Entry point source file (.fsx).
143    #[cfg_attr(feature = "serde", serde(default))]
144    pub source: Option<String>,
145
146    /// Pre-compiled bytecode file (.fzb).
147    #[cfg_attr(feature = "serde", serde(default))]
148    pub bytecode: Option<String>,
149
150    /// Exported functions.
151    #[cfg_attr(feature = "serde", serde(default))]
152    pub exports: Vec<String>,
153
154    /// Plugin tags for categorization.
155    #[cfg_attr(feature = "serde", serde(default))]
156    pub tags: Vec<String>,
157
158    /// Custom metadata.
159    #[cfg_attr(feature = "serde", serde(default))]
160    pub metadata: HashMap<String, String>,
161}
162
163impl Manifest {
164    /// Create a new manifest with required fields.
165    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
166        Self {
167            name: name.into(),
168            version: version.into(),
169            description: None,
170            authors: Vec::new(),
171            license: None,
172            api_version: ApiVersion::default(),
173            capabilities: Vec::new(),
174            dependencies: Vec::new(),
175            source: None,
176            bytecode: None,
177            exports: Vec::new(),
178            tags: Vec::new(),
179            metadata: HashMap::new(),
180        }
181    }
182
183    /// Load manifest from a TOML file.
184    #[cfg(feature = "serde")]
185    pub fn from_file(path: &Path) -> Result<Self> {
186        let content = std::fs::read_to_string(path)?;
187        Self::from_toml(&content)
188    }
189
190    /// Parse manifest from TOML string.
191    #[cfg(feature = "serde")]
192    pub fn from_toml(content: &str) -> Result<Self> {
193        toml::from_str(content).map_err(|e| Error::ManifestParse(e.to_string()))
194    }
195
196    /// Parse manifest from JSON string.
197    #[cfg(feature = "serde")]
198    pub fn from_json(content: &str) -> Result<Self> {
199        serde_json::from_str(content).map_err(|e| Error::ManifestParse(e.to_string()))
200    }
201
202    /// Serialize to TOML string.
203    #[cfg(feature = "serde")]
204    pub fn to_toml(&self) -> Result<String> {
205        toml::to_string_pretty(self).map_err(|e| Error::ManifestParse(e.to_string()))
206    }
207
208    /// Serialize to JSON string.
209    #[cfg(feature = "serde")]
210    pub fn to_json(&self) -> Result<String> {
211        serde_json::to_string_pretty(self).map_err(|e| Error::ManifestParse(e.to_string()))
212    }
213
214    /// Validate the manifest.
215    pub fn validate(&self) -> Result<()> {
216        // Check required fields
217        if self.name.is_empty() {
218            return Err(Error::missing_field("name"));
219        }
220
221        if self.version.is_empty() {
222            return Err(Error::missing_field("version"));
223        }
224
225        // Must have either source or bytecode
226        if self.source.is_none() && self.bytecode.is_none() {
227            return Err(Error::invalid_manifest(
228                "manifest must specify either 'source' or 'bytecode'",
229            ));
230        }
231
232        // Validate capability names
233        for cap in &self.capabilities {
234            if fusabi_host::Capability::from_name(cap).is_none() {
235                return Err(Error::invalid_manifest(format!(
236                    "unknown capability: {}",
237                    cap
238                )));
239            }
240        }
241
242        Ok(())
243    }
244
245    /// Check if this manifest requires a capability.
246    pub fn requires_capability(&self, cap: &str) -> bool {
247        self.capabilities.iter().any(|c| c == cap)
248    }
249
250    /// Check if this manifest is compatible with a host API version.
251    pub fn is_compatible_with_host(&self, host_version: &ApiVersion) -> bool {
252        host_version.is_compatible_with(&self.api_version)
253    }
254
255    /// Get the entry point path (source or bytecode).
256    pub fn entry_point(&self) -> Option<&str> {
257        self.source.as_deref().or(self.bytecode.as_deref())
258    }
259
260    /// Check if using source code (vs pre-compiled bytecode).
261    pub fn uses_source(&self) -> bool {
262        self.source.is_some()
263    }
264}
265
266/// Builder for creating manifests.
267pub struct ManifestBuilder {
268    manifest: Manifest,
269}
270
271impl ManifestBuilder {
272    /// Create a new manifest builder.
273    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
274        Self {
275            manifest: Manifest::new(name, version),
276        }
277    }
278
279    /// Set the description.
280    pub fn description(mut self, desc: impl Into<String>) -> Self {
281        self.manifest.description = Some(desc.into());
282        self
283    }
284
285    /// Add an author.
286    pub fn author(mut self, author: impl Into<String>) -> Self {
287        self.manifest.authors.push(author.into());
288        self
289    }
290
291    /// Set the license.
292    pub fn license(mut self, license: impl Into<String>) -> Self {
293        self.manifest.license = Some(license.into());
294        self
295    }
296
297    /// Set the API version.
298    pub fn api_version(mut self, version: ApiVersion) -> Self {
299        self.manifest.api_version = version;
300        self
301    }
302
303    /// Add a capability requirement.
304    pub fn capability(mut self, cap: impl Into<String>) -> Self {
305        self.manifest.capabilities.push(cap.into());
306        self
307    }
308
309    /// Add capabilities.
310    pub fn capabilities<I, S>(mut self, caps: I) -> Self
311    where
312        I: IntoIterator<Item = S>,
313        S: Into<String>,
314    {
315        self.manifest.capabilities.extend(caps.into_iter().map(Into::into));
316        self
317    }
318
319    /// Add a dependency.
320    pub fn dependency(mut self, dep: Dependency) -> Self {
321        self.manifest.dependencies.push(dep);
322        self
323    }
324
325    /// Set the source file.
326    pub fn source(mut self, path: impl Into<String>) -> Self {
327        self.manifest.source = Some(path.into());
328        self
329    }
330
331    /// Set the bytecode file.
332    pub fn bytecode(mut self, path: impl Into<String>) -> Self {
333        self.manifest.bytecode = Some(path.into());
334        self
335    }
336
337    /// Add an export.
338    pub fn export(mut self, name: impl Into<String>) -> Self {
339        self.manifest.exports.push(name.into());
340        self
341    }
342
343    /// Add exports.
344    pub fn exports<I, S>(mut self, exports: I) -> Self
345    where
346        I: IntoIterator<Item = S>,
347        S: Into<String>,
348    {
349        self.manifest.exports.extend(exports.into_iter().map(Into::into));
350        self
351    }
352
353    /// Add a tag.
354    pub fn tag(mut self, tag: impl Into<String>) -> Self {
355        self.manifest.tags.push(tag.into());
356        self
357    }
358
359    /// Add metadata.
360    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
361        self.manifest.metadata.insert(key.into(), value.into());
362        self
363    }
364
365    /// Build and validate the manifest.
366    pub fn build(self) -> Result<Manifest> {
367        self.manifest.validate()?;
368        Ok(self.manifest)
369    }
370
371    /// Build without validation.
372    pub fn build_unchecked(self) -> Manifest {
373        self.manifest
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_api_version_parse() {
383        let v = ApiVersion::parse("0.18.5").unwrap();
384        assert_eq!(v.major, 0);
385        assert_eq!(v.minor, 18);
386        assert_eq!(v.patch, 5);
387
388        let v = ApiVersion::parse("1.0").unwrap();
389        assert_eq!(v.major, 1);
390        assert_eq!(v.minor, 0);
391        assert_eq!(v.patch, 0);
392    }
393
394    #[test]
395    fn test_api_version_compatibility() {
396        let v1 = ApiVersion::new(0, 18, 0);
397        let v2 = ApiVersion::new(0, 18, 5);
398        let v3 = ApiVersion::new(0, 19, 0);
399        let v4 = ApiVersion::new(1, 0, 0);
400
401        // Same version compatible
402        assert!(v1.is_compatible_with(&v1));
403
404        // Higher patch compatible
405        assert!(v2.is_compatible_with(&v1));
406
407        // Higher minor compatible
408        assert!(v3.is_compatible_with(&v1));
409
410        // Lower minor not compatible
411        assert!(!v1.is_compatible_with(&v3));
412
413        // Different major not compatible
414        assert!(!v4.is_compatible_with(&v1));
415    }
416
417    #[test]
418    fn test_manifest_builder() {
419        let manifest = ManifestBuilder::new("test-plugin", "1.0.0")
420            .description("A test plugin")
421            .author("Test Author")
422            .license("MIT")
423            .capability("fs:read")
424            .capability("net:request")
425            .source("plugin.fsx")
426            .export("main")
427            .tag("test")
428            .build()
429            .unwrap();
430
431        assert_eq!(manifest.name, "test-plugin");
432        assert_eq!(manifest.version, "1.0.0");
433        assert_eq!(manifest.capabilities.len(), 2);
434        assert!(manifest.requires_capability("fs:read"));
435    }
436
437    #[test]
438    fn test_manifest_validation() {
439        // Missing name
440        let manifest = Manifest {
441            name: String::new(),
442            version: "1.0.0".into(),
443            ..Manifest::new("", "1.0.0")
444        };
445        assert!(manifest.validate().is_err());
446
447        // Missing entry point
448        let manifest = Manifest::new("test", "1.0.0");
449        assert!(manifest.validate().is_err());
450
451        // Invalid capability
452        let mut manifest = Manifest::new("test", "1.0.0");
453        manifest.source = Some("test.fsx".into());
454        manifest.capabilities.push("invalid:cap".into());
455        assert!(manifest.validate().is_err());
456    }
457
458    #[cfg(feature = "serde")]
459    #[test]
460    fn test_manifest_toml() {
461        let toml = r#"
462name = "my-plugin"
463version = "1.0.0"
464description = "A sample plugin"
465api-version = { major = 0, minor = 18, patch = 0 }
466capabilities = ["fs:read", "time:read"]
467source = "main.fsx"
468exports = ["init", "run"]
469"#;
470
471        let manifest = Manifest::from_toml(toml).unwrap();
472        assert_eq!(manifest.name, "my-plugin");
473        assert_eq!(manifest.capabilities.len(), 2);
474    }
475}