1use std::collections::HashMap;
4use std::path::Path;
5
6use crate::error::{Error, Result};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11pub struct ApiVersion {
12 pub major: u32,
14 pub minor: u32,
16 pub patch: u32,
18}
19
20impl ApiVersion {
21 pub fn new(major: u32, minor: u32, patch: u32) -> Self {
23 Self { major, minor, patch }
24 }
25
26 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 pub fn is_compatible_with(&self, other: &ApiVersion) -> bool {
49 self.major == other.major && self.minor >= other.minor
51 }
52
53 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#[derive(Debug, Clone)]
77#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
78pub struct Dependency {
79 pub name: String,
81 pub version: String,
83 #[cfg_attr(feature = "serde", serde(default))]
85 pub optional: bool,
86}
87
88impl Dependency {
89 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 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#[derive(Debug, Clone)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
111pub struct Manifest {
112 pub name: String,
114
115 pub version: String,
117
118 #[cfg_attr(feature = "serde", serde(default))]
120 pub description: Option<String>,
121
122 #[cfg_attr(feature = "serde", serde(default))]
124 pub authors: Vec<String>,
125
126 #[cfg_attr(feature = "serde", serde(default))]
128 pub license: Option<String>,
129
130 #[cfg_attr(feature = "serde", serde(rename = "api-version"))]
132 pub api_version: ApiVersion,
133
134 #[cfg_attr(feature = "serde", serde(default))]
136 pub capabilities: Vec<String>,
137
138 #[cfg_attr(feature = "serde", serde(default))]
140 pub dependencies: Vec<Dependency>,
141
142 #[cfg_attr(feature = "serde", serde(default))]
144 pub source: Option<String>,
145
146 #[cfg_attr(feature = "serde", serde(default))]
148 pub bytecode: Option<String>,
149
150 #[cfg_attr(feature = "serde", serde(default))]
152 pub exports: Vec<String>,
153
154 #[cfg_attr(feature = "serde", serde(default))]
156 pub tags: Vec<String>,
157
158 #[cfg_attr(feature = "serde", serde(default))]
160 pub metadata: HashMap<String, String>,
161}
162
163impl Manifest {
164 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 #[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 #[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 #[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 #[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 #[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 pub fn validate(&self) -> Result<()> {
216 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 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 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 pub fn requires_capability(&self, cap: &str) -> bool {
247 self.capabilities.iter().any(|c| c == cap)
248 }
249
250 pub fn is_compatible_with_host(&self, host_version: &ApiVersion) -> bool {
252 host_version.is_compatible_with(&self.api_version)
253 }
254
255 pub fn entry_point(&self) -> Option<&str> {
257 self.source.as_deref().or(self.bytecode.as_deref())
258 }
259
260 pub fn uses_source(&self) -> bool {
262 self.source.is_some()
263 }
264}
265
266pub struct ManifestBuilder {
268 manifest: Manifest,
269}
270
271impl ManifestBuilder {
272 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
274 Self {
275 manifest: Manifest::new(name, version),
276 }
277 }
278
279 pub fn description(mut self, desc: impl Into<String>) -> Self {
281 self.manifest.description = Some(desc.into());
282 self
283 }
284
285 pub fn author(mut self, author: impl Into<String>) -> Self {
287 self.manifest.authors.push(author.into());
288 self
289 }
290
291 pub fn license(mut self, license: impl Into<String>) -> Self {
293 self.manifest.license = Some(license.into());
294 self
295 }
296
297 pub fn api_version(mut self, version: ApiVersion) -> Self {
299 self.manifest.api_version = version;
300 self
301 }
302
303 pub fn capability(mut self, cap: impl Into<String>) -> Self {
305 self.manifest.capabilities.push(cap.into());
306 self
307 }
308
309 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 pub fn dependency(mut self, dep: Dependency) -> Self {
321 self.manifest.dependencies.push(dep);
322 self
323 }
324
325 pub fn source(mut self, path: impl Into<String>) -> Self {
327 self.manifest.source = Some(path.into());
328 self
329 }
330
331 pub fn bytecode(mut self, path: impl Into<String>) -> Self {
333 self.manifest.bytecode = Some(path.into());
334 self
335 }
336
337 pub fn export(mut self, name: impl Into<String>) -> Self {
339 self.manifest.exports.push(name.into());
340 self
341 }
342
343 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 pub fn tag(mut self, tag: impl Into<String>) -> Self {
355 self.manifest.tags.push(tag.into());
356 self
357 }
358
359 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 pub fn build(self) -> Result<Manifest> {
367 self.manifest.validate()?;
368 Ok(self.manifest)
369 }
370
371 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 assert!(v1.is_compatible_with(&v1));
403
404 assert!(v2.is_compatible_with(&v1));
406
407 assert!(v3.is_compatible_with(&v1));
409
410 assert!(!v1.is_compatible_with(&v3));
412
413 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 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 let manifest = Manifest::new("test", "1.0.0");
449 assert!(manifest.validate().is_err());
450
451 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}