Skip to main content

oximedia_plugin/
manifest.rs

1//! Plugin manifest file parsing and validation.
2//!
3//! Each plugin can ship with a `plugin.json` manifest that describes
4//! its metadata and capabilities. This allows the host to discover
5//! plugins without loading their shared libraries first.
6
7use crate::error::{PluginError, PluginResult};
8use crate::traits::PLUGIN_API_VERSION;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::Path;
12
13// ── Semantic Versioning ─────────────────────────────────────────────────
14
15/// A parsed semantic version (major.minor.patch with optional pre-release).
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct SemVer {
18    /// Major version number.
19    pub major: u64,
20    /// Minor version number.
21    pub minor: u64,
22    /// Patch version number.
23    pub patch: u64,
24    /// Optional pre-release identifier (e.g. "alpha.1").
25    pub pre: Option<String>,
26}
27
28impl SemVer {
29    /// Parse a semver string such as `"1.2.3"` or `"1.0.0-beta.2"`.
30    ///
31    /// # Errors
32    ///
33    /// Returns a descriptive string on parse failure.
34    pub fn parse(s: &str) -> Result<Self, String> {
35        let s = s.trim();
36        let (version_part, pre) = if let Some((v, p)) = s.split_once('-') {
37            (v, Some(p.to_string()))
38        } else {
39            (s, None)
40        };
41
42        let parts: Vec<&str> = version_part.split('.').collect();
43        if parts.len() < 2 || parts.len() > 3 {
44            return Err(format!("Expected 2-3 dot-separated numbers, got '{s}'"));
45        }
46
47        let major = parts[0]
48            .parse::<u64>()
49            .map_err(|e| format!("Invalid major: {e}"))?;
50        let minor = parts[1]
51            .parse::<u64>()
52            .map_err(|e| format!("Invalid minor: {e}"))?;
53        let patch = if parts.len() == 3 {
54            parts[2]
55                .parse::<u64>()
56                .map_err(|e| format!("Invalid patch: {e}"))?
57        } else {
58            0
59        };
60
61        Ok(Self {
62            major,
63            minor,
64            patch,
65            pre,
66        })
67    }
68
69    /// Compare two versions, ignoring pre-release (numeric only).
70    fn cmp_numeric(&self, other: &Self) -> std::cmp::Ordering {
71        self.major
72            .cmp(&other.major)
73            .then(self.minor.cmp(&other.minor))
74            .then(self.patch.cmp(&other.patch))
75    }
76
77    /// Check if this version is compatible with `other` under caret semantics.
78    ///
79    /// Caret compatibility (`^`): versions are compatible if the left-most
80    /// non-zero component is the same.
81    ///
82    /// - `^1.2.3` matches `>=1.2.3, <2.0.0`
83    /// - `^0.2.3` matches `>=0.2.3, <0.3.0`
84    /// - `^0.0.3` matches `>=0.0.3, <0.0.4`
85    pub fn is_caret_compatible(&self, req: &Self) -> bool {
86        if self.cmp_numeric(req) == std::cmp::Ordering::Less {
87            return false;
88        }
89        if req.major > 0 {
90            self.major == req.major
91        } else if req.minor > 0 {
92            self.major == 0 && self.minor == req.minor
93        } else {
94            self.major == 0 && self.minor == 0 && self.patch == req.patch
95        }
96    }
97
98    /// Check if this version is compatible under tilde semantics.
99    ///
100    /// - `~1.2.3` matches `>=1.2.3, <1.3.0`
101    /// - `~1.2`   matches `>=1.2.0, <1.3.0`
102    pub fn is_tilde_compatible(&self, req: &Self) -> bool {
103        if self.cmp_numeric(req) == std::cmp::Ordering::Less {
104            return false;
105        }
106        self.major == req.major && self.minor == req.minor
107    }
108}
109
110impl std::fmt::Display for SemVer {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
113        if let Some(ref pre) = self.pre {
114            write!(f, "-{pre}")?;
115        }
116        Ok(())
117    }
118}
119
120/// Operator used in a semver requirement.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum SemVerOp {
123    /// Exact match `=` / bare version.
124    Exact,
125    /// Greater than `>`.
126    Gt,
127    /// Greater or equal `>=`.
128    Gte,
129    /// Less than `<`.
130    Lt,
131    /// Less or equal `<=`.
132    Lte,
133    /// Caret `^` (compatible updates).
134    Caret,
135    /// Tilde `~` (patch-level updates).
136    Tilde,
137    /// Wildcard `*` (any version).
138    Wildcard,
139}
140
141/// A semver requirement such as `">=1.0.0"` or `"^2.3"`.
142#[derive(Debug, Clone)]
143pub struct SemVerReq {
144    /// The comparison operator.
145    pub op: SemVerOp,
146    /// The version to compare against (ignored for `Wildcard`).
147    pub version: SemVer,
148}
149
150impl SemVerReq {
151    /// Parse a requirement string.
152    ///
153    /// Accepted forms:
154    /// - `"*"` — any version
155    /// - `"1.2.3"` — exact match
156    /// - `">=1.0.0"`, `">1.0.0"`, `"<=1.0.0"`, `"<1.0.0"` — comparison
157    /// - `"^1.0.0"` — caret
158    /// - `"~1.0.0"` — tilde
159    /// - `"=1.0.0"` — explicit exact
160    ///
161    /// # Errors
162    ///
163    /// Returns a descriptive string on parse failure.
164    pub fn parse(s: &str) -> Result<Self, String> {
165        let s = s.trim();
166        if s == "*" {
167            return Ok(Self {
168                op: SemVerOp::Wildcard,
169                version: SemVer {
170                    major: 0,
171                    minor: 0,
172                    patch: 0,
173                    pre: None,
174                },
175            });
176        }
177
178        let (op, rest) = if let Some(r) = s.strip_prefix(">=") {
179            (SemVerOp::Gte, r)
180        } else if let Some(r) = s.strip_prefix("<=") {
181            (SemVerOp::Lte, r)
182        } else if let Some(r) = s.strip_prefix('>') {
183            (SemVerOp::Gt, r)
184        } else if let Some(r) = s.strip_prefix('<') {
185            (SemVerOp::Lt, r)
186        } else if let Some(r) = s.strip_prefix('^') {
187            (SemVerOp::Caret, r)
188        } else if let Some(r) = s.strip_prefix('~') {
189            (SemVerOp::Tilde, r)
190        } else if let Some(r) = s.strip_prefix('=') {
191            (SemVerOp::Exact, r)
192        } else {
193            (SemVerOp::Exact, s)
194        };
195
196        let version = SemVer::parse(rest.trim())?;
197        Ok(Self { op, version })
198    }
199
200    /// Check whether a given version satisfies this requirement.
201    pub fn matches(&self, version: &SemVer) -> bool {
202        match self.op {
203            SemVerOp::Wildcard => true,
204            SemVerOp::Exact => version.cmp_numeric(&self.version) == std::cmp::Ordering::Equal,
205            SemVerOp::Gt => version.cmp_numeric(&self.version) == std::cmp::Ordering::Greater,
206            SemVerOp::Gte => version.cmp_numeric(&self.version) != std::cmp::Ordering::Less,
207            SemVerOp::Lt => version.cmp_numeric(&self.version) == std::cmp::Ordering::Less,
208            SemVerOp::Lte => version.cmp_numeric(&self.version) != std::cmp::Ordering::Greater,
209            SemVerOp::Caret => version.is_caret_compatible(&self.version),
210            SemVerOp::Tilde => version.is_tilde_compatible(&self.version),
211        }
212    }
213}
214
215impl std::fmt::Display for SemVerReq {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        match self.op {
218            SemVerOp::Wildcard => write!(f, "*"),
219            SemVerOp::Exact => write!(f, "={}", self.version),
220            SemVerOp::Gt => write!(f, ">{}", self.version),
221            SemVerOp::Gte => write!(f, ">={}", self.version),
222            SemVerOp::Lt => write!(f, "<{}", self.version),
223            SemVerOp::Lte => write!(f, "<={}", self.version),
224            SemVerOp::Caret => write!(f, "^{}", self.version),
225            SemVerOp::Tilde => write!(f, "~{}", self.version),
226        }
227    }
228}
229
230// ── Dependency Resolution ──────────────────────────────────────────────
231
232/// Result of resolving plugin dependencies.
233#[derive(Debug, Clone)]
234pub struct DependencyResolution {
235    /// Plugins in topological load order (dependencies first).
236    pub load_order: Vec<String>,
237    /// Any unresolvable dependencies: (plugin, missing dep, requirement).
238    pub missing: Vec<(String, String, String)>,
239    /// Any version conflicts: (plugin, dep, requirement, available version).
240    pub conflicts: Vec<(String, String, String, String)>,
241}
242
243impl DependencyResolution {
244    /// Returns `true` if all dependencies are satisfied.
245    pub fn is_satisfied(&self) -> bool {
246        self.missing.is_empty() && self.conflicts.is_empty()
247    }
248}
249
250/// Resolve dependency order for a set of plugin manifests.
251///
252/// Performs a topological sort on the dependency graph and checks that
253/// each dependency's version satisfies the declared requirement.
254///
255/// # Errors
256///
257/// Returns [`PluginError::InvalidManifest`] if a dependency cycle is detected.
258pub fn resolve_dependencies(manifests: &[PluginManifest]) -> PluginResult<DependencyResolution> {
259    // Build name → manifest index lookup.
260    let mut index_by_name: HashMap<&str, usize> = HashMap::new();
261    for (i, m) in manifests.iter().enumerate() {
262        index_by_name.insert(&m.name, i);
263    }
264
265    let mut missing: Vec<(String, String, String)> = Vec::new();
266    let mut conflicts: Vec<(String, String, String, String)> = Vec::new();
267
268    // Validate every dependency's version requirement.
269    for manifest in manifests {
270        for (dep_name, req_str) in &manifest.dependencies {
271            if let Some(&dep_idx) = index_by_name.get(dep_name.as_str()) {
272                let dep_manifest = &manifests[dep_idx];
273                let req = SemVerReq::parse(req_str).map_err(|e| {
274                    PluginError::InvalidManifest(format!(
275                        "Bad requirement for dep '{dep_name}' of '{}': {e}",
276                        manifest.name
277                    ))
278                })?;
279                let dep_ver = SemVer::parse(&dep_manifest.version).map_err(|e| {
280                    PluginError::InvalidManifest(format!(
281                        "Bad version in dep '{}': {e}",
282                        dep_manifest.name
283                    ))
284                })?;
285                if !req.matches(&dep_ver) {
286                    conflicts.push((
287                        manifest.name.clone(),
288                        dep_name.clone(),
289                        req_str.clone(),
290                        dep_manifest.version.clone(),
291                    ));
292                }
293            } else {
294                missing.push((manifest.name.clone(), dep_name.clone(), req_str.clone()));
295            }
296        }
297    }
298
299    // Topological sort (Kahn's algorithm).
300    let n = manifests.len();
301    let mut in_degree = vec![0usize; n];
302    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
303
304    for (i, manifest) in manifests.iter().enumerate() {
305        for dep_name in manifest.dependencies.keys() {
306            if let Some(&dep_idx) = index_by_name.get(dep_name.as_str()) {
307                adj[dep_idx].push(i);
308                in_degree[i] += 1;
309            }
310        }
311    }
312
313    let mut queue: std::collections::VecDeque<usize> = in_degree
314        .iter()
315        .enumerate()
316        .filter(|(_, &d)| d == 0)
317        .map(|(i, _)| i)
318        .collect();
319
320    let mut load_order: Vec<String> = Vec::with_capacity(n);
321
322    while let Some(idx) = queue.pop_front() {
323        load_order.push(manifests[idx].name.clone());
324        for &succ in &adj[idx] {
325            in_degree[succ] = in_degree[succ].saturating_sub(1);
326            if in_degree[succ] == 0 {
327                queue.push_back(succ);
328            }
329        }
330    }
331
332    if load_order.len() < n {
333        return Err(PluginError::InvalidManifest(
334            "Dependency cycle detected among plugins".to_string(),
335        ));
336    }
337
338    Ok(DependencyResolution {
339        load_order,
340        missing,
341        conflicts,
342    })
343}
344
345// ────────────────────────────────────────────────────────────────────────
346
347/// Plugin manifest file (`plugin.json`).
348///
349/// This file sits alongside the shared library and provides metadata
350/// for plugin discovery without loading the binary.
351///
352/// # Example JSON
353///
354/// ```json
355/// {
356///   "name": "oximedia-plugin-h264",
357///   "version": "1.0.0",
358///   "api_version": 1,
359///   "description": "H.264 decoder/encoder plugin",
360///   "author": "Example Corp",
361///   "license": "proprietary",
362///   "patent_encumbered": true,
363///   "library": "libh264_plugin.so",
364///   "codecs": [
365///     {
366///       "name": "h264",
367///       "decode": true,
368///       "encode": true,
369///       "description": "H.264/AVC video codec"
370///     }
371///   ]
372/// }
373/// ```
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct PluginManifest {
376    /// Plugin name (must be unique).
377    pub name: String,
378    /// Plugin version (semver).
379    pub version: String,
380    /// API version this plugin targets.
381    pub api_version: u32,
382    /// Human-readable description.
383    pub description: String,
384    /// Plugin author or organization.
385    pub author: String,
386    /// License identifier.
387    pub license: String,
388    /// Whether the plugin contains patent-encumbered codecs.
389    pub patent_encumbered: bool,
390    /// Shared library filename (e.g., "libh264_plugin.so").
391    pub library: String,
392    /// List of codecs provided by this plugin.
393    pub codecs: Vec<ManifestCodec>,
394    /// Dependencies on other plugins (plugin name → semver requirement).
395    #[serde(default)]
396    pub dependencies: HashMap<String, String>,
397    /// Minimum host version required (semver requirement string, e.g. ">=0.1.0").
398    #[serde(default)]
399    pub min_host_version: Option<String>,
400}
401
402/// A codec entry in the plugin manifest.
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct ManifestCodec {
405    /// Codec name (e.g., "h264", "aac").
406    pub name: String,
407    /// Whether decoding is supported.
408    pub decode: bool,
409    /// Whether encoding is supported.
410    pub encode: bool,
411    /// Human-readable description.
412    pub description: String,
413}
414
415impl PluginManifest {
416    /// Parse a manifest from a JSON string.
417    ///
418    /// # Errors
419    ///
420    /// Returns [`PluginError::InvalidManifest`] if the JSON is malformed.
421    pub fn from_json(json: &str) -> PluginResult<Self> {
422        serde_json::from_str(json).map_err(|e| PluginError::InvalidManifest(e.to_string()))
423    }
424
425    /// Serialize the manifest to a JSON string.
426    ///
427    /// # Errors
428    ///
429    /// Returns [`PluginError::Json`] if serialization fails.
430    pub fn to_json(&self) -> PluginResult<String> {
431        serde_json::to_string_pretty(self).map_err(PluginError::Json)
432    }
433
434    /// Load a manifest from a file on disk.
435    ///
436    /// # Errors
437    ///
438    /// Returns [`PluginError::Io`] if the file cannot be read, or
439    /// [`PluginError::InvalidManifest`] if the content is invalid.
440    pub fn from_file(path: &Path) -> PluginResult<Self> {
441        let content = std::fs::read_to_string(path)?;
442        Self::from_json(&content)
443    }
444
445    /// Validate the manifest for correctness and compatibility.
446    ///
447    /// Checks:
448    /// - Name is non-empty
449    /// - Version is non-empty
450    /// - API version matches current host
451    /// - Library filename is non-empty
452    /// - At least one codec is declared
453    /// - Each codec has a non-empty name
454    /// - Each codec supports at least decode or encode
455    ///
456    /// # Errors
457    ///
458    /// Returns [`PluginError::InvalidManifest`] with a description
459    /// of the first validation failure.
460    pub fn validate(&self) -> PluginResult<()> {
461        if self.name.is_empty() {
462            return Err(PluginError::InvalidManifest(
463                "Plugin name must not be empty".to_string(),
464            ));
465        }
466
467        if self.version.is_empty() {
468            return Err(PluginError::InvalidManifest(
469                "Plugin version must not be empty".to_string(),
470            ));
471        }
472
473        if self.api_version != PLUGIN_API_VERSION {
474            return Err(PluginError::ApiIncompatible(format!(
475                "Manifest declares API v{}, host expects v{PLUGIN_API_VERSION}",
476                self.api_version
477            )));
478        }
479
480        if self.library.is_empty() {
481            return Err(PluginError::InvalidManifest(
482                "Library filename must not be empty".to_string(),
483            ));
484        }
485
486        if self.codecs.is_empty() {
487            return Err(PluginError::InvalidManifest(
488                "Plugin must declare at least one codec".to_string(),
489            ));
490        }
491
492        for (i, codec) in self.codecs.iter().enumerate() {
493            if codec.name.is_empty() {
494                return Err(PluginError::InvalidManifest(format!(
495                    "Codec at index {i} has empty name"
496                )));
497            }
498
499            if !codec.decode && !codec.encode {
500                return Err(PluginError::InvalidManifest(format!(
501                    "Codec '{}' must support at least decode or encode",
502                    codec.name
503                )));
504            }
505        }
506
507        Ok(())
508    }
509
510    /// Validate that the plugin version is valid semver.
511    ///
512    /// # Errors
513    ///
514    /// Returns [`PluginError::InvalidManifest`] if the version string is not
515    /// valid semantic versioning.
516    pub fn validate_version(&self) -> PluginResult<SemVer> {
517        SemVer::parse(&self.version).map_err(|e| {
518            PluginError::InvalidManifest(format!(
519                "Invalid plugin version '{}': {}",
520                self.version, e
521            ))
522        })
523    }
524
525    /// Check if this manifest declares a specific codec.
526    pub fn has_codec(&self, name: &str) -> bool {
527        self.codecs.iter().any(|c| c.name == name)
528    }
529
530    /// Check whether this plugin's version satisfies a semver requirement string.
531    ///
532    /// The requirement string can be:
533    /// - `"1.2.3"` — exact match
534    /// - `">=1.0.0"` — greater or equal
535    /// - `"^1.0.0"` — caret (compatible with 1.x.y)
536    /// - `"~1.2.0"` — tilde (compatible with 1.2.x)
537    ///
538    /// # Errors
539    ///
540    /// Returns error if either the version or requirement cannot be parsed.
541    pub fn satisfies_requirement(&self, requirement: &str) -> PluginResult<bool> {
542        let version = self.validate_version()?;
543        let req = SemVerReq::parse(requirement).map_err(|e| {
544            PluginError::InvalidManifest(format!("Invalid requirement '{requirement}': {e}"))
545        })?;
546        Ok(req.matches(&version))
547    }
548
549    /// Validate all dependency requirement strings are parseable.
550    ///
551    /// # Errors
552    ///
553    /// Returns error on the first invalid dependency requirement string.
554    pub fn validate_dependencies(&self) -> PluginResult<()> {
555        for (dep_name, req_str) in &self.dependencies {
556            SemVerReq::parse(req_str).map_err(|e| {
557                PluginError::InvalidManifest(format!(
558                    "Invalid dependency requirement for '{dep_name}': '{req_str}' — {e}"
559                ))
560            })?;
561        }
562        Ok(())
563    }
564
565    /// Get the library path relative to the manifest directory.
566    ///
567    /// Given the path to the manifest file, returns the expected
568    /// path to the shared library.
569    pub fn library_path(&self, manifest_path: &Path) -> Option<std::path::PathBuf> {
570        manifest_path.parent().map(|dir| dir.join(&self.library))
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    fn sample_manifest() -> PluginManifest {
579        PluginManifest {
580            name: "test-plugin".to_string(),
581            version: "1.0.0".to_string(),
582            api_version: PLUGIN_API_VERSION,
583            description: "A test plugin".to_string(),
584            author: "Test Author".to_string(),
585            license: "MIT".to_string(),
586            patent_encumbered: false,
587            library: "libtest_plugin.so".to_string(),
588            codecs: vec![ManifestCodec {
589                name: "test-codec".to_string(),
590                decode: true,
591                encode: false,
592                description: "A test codec".to_string(),
593            }],
594            dependencies: HashMap::new(),
595            min_host_version: None,
596        }
597    }
598
599    #[test]
600    fn test_manifest_roundtrip() {
601        let manifest = sample_manifest();
602        let json = manifest.to_json().expect("serialization should succeed");
603        let parsed = PluginManifest::from_json(&json).expect("deserialization should succeed");
604        assert_eq!(parsed.name, "test-plugin");
605        assert_eq!(parsed.version, "1.0.0");
606        assert_eq!(parsed.codecs.len(), 1);
607        assert_eq!(parsed.codecs[0].name, "test-codec");
608    }
609
610    #[test]
611    fn test_manifest_validate_success() {
612        let manifest = sample_manifest();
613        manifest.validate().expect("validation should succeed");
614    }
615
616    #[test]
617    fn test_manifest_validate_empty_name() {
618        let mut manifest = sample_manifest();
619        manifest.name = String::new();
620        let err = manifest.validate().expect_err("should fail");
621        assert!(err.to_string().contains("name must not be empty"));
622    }
623
624    #[test]
625    fn test_manifest_validate_empty_version() {
626        let mut manifest = sample_manifest();
627        manifest.version = String::new();
628        let err = manifest.validate().expect_err("should fail");
629        assert!(err.to_string().contains("version must not be empty"));
630    }
631
632    #[test]
633    fn test_manifest_validate_wrong_api_version() {
634        let mut manifest = sample_manifest();
635        manifest.api_version = 999;
636        let err = manifest.validate().expect_err("should fail");
637        assert!(err.to_string().contains("API"));
638    }
639
640    #[test]
641    fn test_manifest_validate_empty_library() {
642        let mut manifest = sample_manifest();
643        manifest.library = String::new();
644        let err = manifest.validate().expect_err("should fail");
645        assert!(err.to_string().contains("Library filename"));
646    }
647
648    #[test]
649    fn test_manifest_validate_no_codecs() {
650        let mut manifest = sample_manifest();
651        manifest.codecs.clear();
652        let err = manifest.validate().expect_err("should fail");
653        assert!(err.to_string().contains("at least one codec"));
654    }
655
656    #[test]
657    fn test_manifest_validate_codec_empty_name() {
658        let mut manifest = sample_manifest();
659        manifest.codecs[0].name = String::new();
660        let err = manifest.validate().expect_err("should fail");
661        assert!(err.to_string().contains("empty name"));
662    }
663
664    #[test]
665    fn test_manifest_validate_codec_no_capability() {
666        let mut manifest = sample_manifest();
667        manifest.codecs[0].decode = false;
668        manifest.codecs[0].encode = false;
669        let err = manifest.validate().expect_err("should fail");
670        assert!(err.to_string().contains("at least decode or encode"));
671    }
672
673    #[test]
674    fn test_manifest_has_codec() {
675        let manifest = sample_manifest();
676        assert!(manifest.has_codec("test-codec"));
677        assert!(!manifest.has_codec("nonexistent"));
678    }
679
680    #[test]
681    fn test_manifest_library_path() {
682        let manifest = sample_manifest();
683        let manifest_path = Path::new("/usr/lib/oximedia/plugins/test/plugin.json");
684        let lib_path = manifest.library_path(manifest_path);
685        assert_eq!(
686            lib_path,
687            Some(std::path::PathBuf::from(
688                "/usr/lib/oximedia/plugins/test/libtest_plugin.so"
689            ))
690        );
691    }
692
693    #[test]
694    fn test_manifest_from_invalid_json() {
695        let result = PluginManifest::from_json("not json");
696        assert!(result.is_err());
697    }
698
699    #[test]
700    fn test_manifest_from_file_not_found() {
701        let result = PluginManifest::from_file(Path::new("/nonexistent/plugin.json"));
702        assert!(result.is_err());
703    }
704
705    #[test]
706    fn test_manifest_from_file_roundtrip() {
707        let manifest = sample_manifest();
708        let json = manifest.to_json().expect("serialization should succeed");
709
710        let dir = std::env::temp_dir().join("oximedia-plugin-test-manifest");
711        std::fs::create_dir_all(&dir).expect("dir creation should succeed");
712        let path = dir.join("plugin.json");
713        std::fs::write(&path, &json).expect("write should succeed");
714
715        let loaded = PluginManifest::from_file(&path).expect("load should succeed");
716        assert_eq!(loaded.name, "test-plugin");
717
718        let _ = std::fs::remove_dir_all(&dir);
719    }
720
721    // ── SemVer tests ──
722
723    #[test]
724    fn test_semver_parse_full() {
725        let v = SemVer::parse("1.2.3").expect("parse should succeed");
726        assert_eq!(v.major, 1);
727        assert_eq!(v.minor, 2);
728        assert_eq!(v.patch, 3);
729        assert!(v.pre.is_none());
730    }
731
732    #[test]
733    fn test_semver_parse_with_pre() {
734        let v = SemVer::parse("0.1.0-alpha.1").expect("parse should succeed");
735        assert_eq!(v.major, 0);
736        assert_eq!(v.minor, 1);
737        assert_eq!(v.patch, 0);
738        assert_eq!(v.pre, Some("alpha.1".to_string()));
739    }
740
741    #[test]
742    fn test_semver_parse_two_parts() {
743        let v = SemVer::parse("2.5").expect("parse should succeed");
744        assert_eq!(v.major, 2);
745        assert_eq!(v.minor, 5);
746        assert_eq!(v.patch, 0);
747    }
748
749    #[test]
750    fn test_semver_parse_invalid() {
751        assert!(SemVer::parse("abc").is_err());
752        assert!(SemVer::parse("1.2.3.4").is_err());
753    }
754
755    #[test]
756    fn test_semver_display() {
757        let v = SemVer::parse("1.2.3").expect("parse should succeed");
758        assert_eq!(v.to_string(), "1.2.3");
759
760        let v2 = SemVer::parse("0.1.0-beta").expect("parse should succeed");
761        assert_eq!(v2.to_string(), "0.1.0-beta");
762    }
763
764    #[test]
765    fn test_semver_caret_compatibility() {
766        let v = SemVer::parse("1.5.2").expect("parse should succeed");
767        let req = SemVer::parse("1.0.0").expect("parse should succeed");
768        assert!(v.is_caret_compatible(&req));
769
770        let req2 = SemVer::parse("2.0.0").expect("parse should succeed");
771        assert!(!v.is_caret_compatible(&req2));
772
773        // ^0.2.0 → [0.2.0, 0.3.0)
774        let v03 = SemVer::parse("0.2.5").expect("parse should succeed");
775        let req03 = SemVer::parse("0.2.0").expect("parse should succeed");
776        assert!(v03.is_caret_compatible(&req03));
777
778        let v04 = SemVer::parse("0.3.0").expect("parse should succeed");
779        assert!(!v04.is_caret_compatible(&req03));
780    }
781
782    #[test]
783    fn test_semver_tilde_compatibility() {
784        let v = SemVer::parse("1.2.5").expect("parse should succeed");
785        let req = SemVer::parse("1.2.0").expect("parse should succeed");
786        assert!(v.is_tilde_compatible(&req));
787
788        let v_bad = SemVer::parse("1.3.0").expect("parse should succeed");
789        assert!(!v_bad.is_tilde_compatible(&req));
790    }
791
792    // ── SemVerReq tests ──
793
794    #[test]
795    fn test_semver_req_exact() {
796        let req = SemVerReq::parse("1.0.0").expect("parse should succeed");
797        assert!(req.matches(&SemVer::parse("1.0.0").expect("parse")));
798        assert!(!req.matches(&SemVer::parse("1.0.1").expect("parse")));
799    }
800
801    #[test]
802    fn test_semver_req_gte() {
803        let req = SemVerReq::parse(">=1.0.0").expect("parse should succeed");
804        assert!(req.matches(&SemVer::parse("1.0.0").expect("parse")));
805        assert!(req.matches(&SemVer::parse("2.0.0").expect("parse")));
806        assert!(!req.matches(&SemVer::parse("0.9.9").expect("parse")));
807    }
808
809    #[test]
810    fn test_semver_req_caret() {
811        let req = SemVerReq::parse("^1.2.0").expect("parse should succeed");
812        assert!(req.matches(&SemVer::parse("1.5.0").expect("parse")));
813        assert!(!req.matches(&SemVer::parse("2.0.0").expect("parse")));
814        assert!(!req.matches(&SemVer::parse("1.1.0").expect("parse")));
815    }
816
817    #[test]
818    fn test_semver_req_tilde() {
819        let req = SemVerReq::parse("~1.2.0").expect("parse should succeed");
820        assert!(req.matches(&SemVer::parse("1.2.5").expect("parse")));
821        assert!(!req.matches(&SemVer::parse("1.3.0").expect("parse")));
822    }
823
824    #[test]
825    fn test_semver_req_wildcard() {
826        let req = SemVerReq::parse("*").expect("parse should succeed");
827        assert!(req.matches(&SemVer::parse("0.0.1").expect("parse")));
828        assert!(req.matches(&SemVer::parse("99.99.99").expect("parse")));
829    }
830
831    #[test]
832    fn test_semver_req_lt() {
833        let req = SemVerReq::parse("<2.0.0").expect("parse should succeed");
834        assert!(req.matches(&SemVer::parse("1.9.9").expect("parse")));
835        assert!(!req.matches(&SemVer::parse("2.0.0").expect("parse")));
836    }
837
838    // ── Manifest version validation tests ──
839
840    #[test]
841    fn test_manifest_validate_version() {
842        let m = sample_manifest();
843        let v = m.validate_version().expect("should parse");
844        assert_eq!(v.major, 1);
845    }
846
847    #[test]
848    fn test_manifest_satisfies_requirement() {
849        let m = sample_manifest(); // version = "1.0.0"
850        assert!(m.satisfies_requirement(">=0.5.0").expect("should parse"));
851        assert!(m.satisfies_requirement("^1.0.0").expect("should parse"));
852        assert!(!m.satisfies_requirement(">=2.0.0").expect("should parse"));
853    }
854
855    #[test]
856    fn test_manifest_validate_dependencies_ok() {
857        let mut m = sample_manifest();
858        m.dependencies
859            .insert("other-plugin".to_string(), "^1.0.0".to_string());
860        assert!(m.validate_dependencies().is_ok());
861    }
862
863    #[test]
864    fn test_manifest_validate_dependencies_bad_req() {
865        let mut m = sample_manifest();
866        m.dependencies
867            .insert("other".to_string(), "not-a-version!!!".to_string());
868        assert!(m.validate_dependencies().is_err());
869    }
870
871    // ── Dependency resolution tests ──
872
873    #[test]
874    fn test_resolve_dependencies_no_deps() {
875        let m = sample_manifest();
876        let res = resolve_dependencies(&[m]).expect("should resolve");
877        assert!(res.is_satisfied());
878        assert_eq!(res.load_order.len(), 1);
879    }
880
881    #[test]
882    fn test_resolve_dependencies_linear_chain() {
883        let mut a = sample_manifest();
884        a.name = "plugin-a".to_string();
885        a.version = "1.0.0".to_string();
886        a.dependencies.clear();
887
888        let mut b = sample_manifest();
889        b.name = "plugin-b".to_string();
890        b.version = "2.0.0".to_string();
891        b.dependencies
892            .insert("plugin-a".to_string(), "^1.0.0".to_string());
893
894        let res = resolve_dependencies(&[a, b]).expect("should resolve");
895        assert!(res.is_satisfied());
896        // plugin-a should come before plugin-b
897        let pos_a = res
898            .load_order
899            .iter()
900            .position(|n| n == "plugin-a")
901            .expect("should exist");
902        let pos_b = res
903            .load_order
904            .iter()
905            .position(|n| n == "plugin-b")
906            .expect("should exist");
907        assert!(pos_a < pos_b);
908    }
909
910    #[test]
911    fn test_resolve_dependencies_missing() {
912        let mut m = sample_manifest();
913        m.dependencies
914            .insert("nonexistent".to_string(), ">=1.0.0".to_string());
915
916        let res = resolve_dependencies(&[m]).expect("should resolve");
917        assert!(!res.is_satisfied());
918        assert_eq!(res.missing.len(), 1);
919        assert_eq!(res.missing[0].1, "nonexistent");
920    }
921
922    #[test]
923    fn test_resolve_dependencies_version_conflict() {
924        let mut a = sample_manifest();
925        a.name = "provider".to_string();
926        a.version = "1.0.0".to_string();
927
928        let mut b = sample_manifest();
929        b.name = "consumer".to_string();
930        b.dependencies
931            .insert("provider".to_string(), ">=2.0.0".to_string());
932
933        let res = resolve_dependencies(&[a, b]).expect("should resolve");
934        assert!(!res.is_satisfied());
935        assert_eq!(res.conflicts.len(), 1);
936    }
937
938    #[test]
939    fn test_resolve_dependencies_cycle() {
940        let mut a = sample_manifest();
941        a.name = "cycle-a".to_string();
942        a.dependencies
943            .insert("cycle-b".to_string(), "*".to_string());
944
945        let mut b = sample_manifest();
946        b.name = "cycle-b".to_string();
947        b.dependencies
948            .insert("cycle-a".to_string(), "*".to_string());
949
950        let res = resolve_dependencies(&[a, b]);
951        assert!(res.is_err());
952        assert!(res
953            .expect_err("should be an error")
954            .to_string()
955            .contains("cycle"));
956    }
957}