Skip to main content

rustbridge_bundle/
manifest.rs

1//! Manifest schema for plugin bundles.
2//!
3//! The manifest describes the plugin metadata, supported platforms,
4//! and available API messages. Supports multi-variant builds (release, debug, etc.)
5//! with the `release` variant being mandatory and the implicit default.
6
7use crate::{BUNDLE_VERSION, BundleError, BundleResult, Platform};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Bundle manifest - the main descriptor for a plugin bundle.
12///
13/// This corresponds to the `manifest.json` file in the bundle root.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Manifest {
16    /// Bundle format version (e.g., "1.0").
17    pub bundle_version: String,
18
19    /// Plugin metadata.
20    pub plugin: PluginInfo,
21
22    /// Platform-specific library information.
23    /// Key is the platform string (e.g., "linux-x86_64").
24    pub platforms: HashMap<String, PlatformInfo>,
25
26    /// Build information (optional).
27    /// Contains metadata about when/how the bundle was built.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub build_info: Option<BuildInfo>,
30
31    /// SBOM (Software Bill of Materials) paths.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub sbom: Option<Sbom>,
34
35    /// Combined checksum of all schema files.
36    /// Used to verify schema compatibility when combining bundles.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub schema_checksum: Option<String>,
39
40    /// Path to license notices file within the bundle.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub notices: Option<String>,
43
44    /// Path to the plugin's own license file within the bundle.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub license_file: Option<String>,
47
48    /// Minisign public key for signature verification (base64-encoded).
49    /// Format: "RWS..." (standard minisign public key format).
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub public_key: Option<String>,
52
53    /// Schema files embedded in the bundle.
54    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
55    pub schemas: HashMap<String, SchemaInfo>,
56}
57
58/// Plugin metadata.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct PluginInfo {
61    /// Plugin name (e.g., "my-plugin").
62    pub name: String,
63
64    /// Plugin version (semver, e.g., "1.0.0").
65    pub version: String,
66
67    /// Short description.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub description: Option<String>,
70
71    /// List of authors.
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub authors: Vec<String>,
74
75    /// License identifier (e.g., "MIT").
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub license: Option<String>,
78
79    /// Repository URL.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub repository: Option<String>,
82}
83
84/// Platform-specific library information with variant support.
85///
86/// Each platform must have at least a `release` variant.
87/// The `release` variant is the implicit default when no variant is specified.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct PlatformInfo {
90    /// Available variants for this platform.
91    /// Must contain at least `release` (mandatory).
92    pub variants: HashMap<String, VariantInfo>,
93}
94
95impl PlatformInfo {
96    /// Create new platform info with a single release variant.
97    pub fn new(library: String, checksum: String) -> Self {
98        let mut variants = HashMap::new();
99        variants.insert(
100            "release".to_string(),
101            VariantInfo {
102                library,
103                checksum,
104                build: None,
105            },
106        );
107        Self { variants }
108    }
109
110    /// Get the release variant (always present after validation).
111    #[must_use]
112    pub fn release(&self) -> Option<&VariantInfo> {
113        self.variants.get("release")
114    }
115
116    /// Get a specific variant by name.
117    #[must_use]
118    pub fn variant(&self, name: &str) -> Option<&VariantInfo> {
119        self.variants.get(name)
120    }
121
122    /// Get the default variant (release).
123    #[must_use]
124    pub fn default_variant(&self) -> Option<&VariantInfo> {
125        self.release()
126    }
127
128    /// List all available variant names.
129    #[must_use]
130    pub fn variant_names(&self) -> Vec<&str> {
131        self.variants.keys().map(String::as_str).collect()
132    }
133
134    /// Check if a variant exists.
135    #[must_use]
136    pub fn has_variant(&self, name: &str) -> bool {
137        self.variants.contains_key(name)
138    }
139
140    /// Add a variant to this platform.
141    pub fn add_variant(&mut self, name: String, info: VariantInfo) {
142        self.variants.insert(name, info);
143    }
144}
145
146/// Variant-specific library information.
147///
148/// Each variant represents a different build configuration (release, debug, etc.)
149/// of the same plugin for a specific platform.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct VariantInfo {
152    /// Relative path to the library within the bundle.
153    /// Example: "lib/linux-x86_64/release/libplugin.so"
154    pub library: String,
155
156    /// SHA256 checksum of the library file.
157    /// Format: "sha256:hexstring"
158    pub checksum: String,
159
160    /// Flexible build metadata - any JSON object.
161    /// This can contain toolchain-specific fields like:
162    /// - `profile`: "release" or "debug"
163    /// - `opt_level`: "0", "1", "2", "3", "s", "z"
164    /// - `features`: ["json", "binary"]
165    /// - `cflags`: "-O3 -march=native" (for C/C++)
166    /// - `go_tags`: ["production"] (for Go)
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub build: Option<serde_json::Value>,
169}
170
171/// Build information (all fields optional).
172///
173/// Contains metadata about when and how the bundle was built.
174/// Useful for traceability and debugging but not required.
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176pub struct BuildInfo {
177    /// Who/what built this bundle (e.g., "GitHub Actions", "local")
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub built_by: Option<String>,
180
181    /// When the bundle was built (ISO 8601 timestamp).
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub built_at: Option<String>,
184
185    /// Host triple where the build ran.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub host: Option<String>,
188
189    /// Compiler version used.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub compiler: Option<String>,
192
193    /// rustbridge version used to create the bundle.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub rustbridge_version: Option<String>,
196
197    /// Git repository information (optional).
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub git: Option<GitInfo>,
200
201    /// Custom key/value metadata for informational purposes.
202    /// Can include arbitrary data like repository URL, CI job ID, etc.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub custom: Option<HashMap<String, String>>,
205}
206
207/// Git repository information.
208///
209/// All fields except `commit` are optional. This section is only
210/// present if the project uses git.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct GitInfo {
213    /// Full commit hash (required if git section present).
214    pub commit: String,
215
216    /// Branch name.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub branch: Option<String>,
219
220    /// Git tag (if on a tagged commit).
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub tag: Option<String>,
223
224    /// Whether the working tree had uncommitted changes.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub dirty: Option<bool>,
227}
228
229/// SBOM (Software Bill of Materials) paths.
230///
231/// Points to SBOM files within the bundle. Both CycloneDX and SPDX
232/// formats are supported and can be included simultaneously.
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct Sbom {
235    /// Path to CycloneDX SBOM file (e.g., "sbom/sbom.cdx.json").
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub cyclonedx: Option<String>,
238
239    /// Path to SPDX SBOM file (e.g., "sbom/sbom.spdx.json").
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub spdx: Option<String>,
242}
243
244/// Check if a variant name is valid.
245///
246/// Valid variant names are lowercase alphanumeric with hyphens.
247/// Examples: "release", "debug", "nightly", "opt-size"
248fn is_valid_variant_name(name: &str) -> bool {
249    if name.is_empty() {
250        return false;
251    }
252
253    // Must start and end with alphanumeric
254    let chars: Vec<char> = name.chars().collect();
255    if !chars[0].is_ascii_lowercase() && !chars[0].is_ascii_digit() {
256        return false;
257    }
258    if !chars[chars.len() - 1].is_ascii_lowercase() && !chars[chars.len() - 1].is_ascii_digit() {
259        return false;
260    }
261
262    // All characters must be lowercase alphanumeric or hyphen
263    name.chars()
264        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
265}
266
267/// Schema file information.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct SchemaInfo {
270    /// Relative path to the schema file within the bundle.
271    pub path: String,
272
273    /// Schema format (e.g., "c-header", "json-schema").
274    pub format: String,
275
276    /// SHA256 checksum of the schema file.
277    pub checksum: String,
278
279    /// Optional description of what this schema describes.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub description: Option<String>,
282}
283
284impl Manifest {
285    /// Create a new manifest with minimal required fields.
286    #[must_use]
287    pub fn new(name: &str, version: &str) -> Self {
288        Self {
289            bundle_version: BUNDLE_VERSION.to_string(),
290            plugin: PluginInfo {
291                name: name.to_string(),
292                version: version.to_string(),
293                description: None,
294                authors: Vec::new(),
295                license: None,
296                repository: None,
297            },
298            platforms: HashMap::new(),
299            build_info: None,
300            sbom: None,
301            schema_checksum: None,
302            notices: None,
303            license_file: None,
304            public_key: None,
305            schemas: HashMap::new(),
306        }
307    }
308
309    /// Add a platform with a release variant to the manifest.
310    ///
311    /// This is a convenience method that adds the library as the `release` variant.
312    /// For multiple variants, use `add_platform_variant` instead.
313    pub fn add_platform(&mut self, platform: Platform, library_path: &str, checksum: &str) {
314        let platform_key = platform.as_str().to_string();
315
316        if let Some(platform_info) = self.platforms.get_mut(&platform_key) {
317            // Platform exists, add/update release variant
318            platform_info.variants.insert(
319                "release".to_string(),
320                VariantInfo {
321                    library: library_path.to_string(),
322                    checksum: format!("sha256:{checksum}"),
323                    build: None,
324                },
325            );
326        } else {
327            // New platform
328            self.platforms.insert(
329                platform_key,
330                PlatformInfo::new(library_path.to_string(), format!("sha256:{checksum}")),
331            );
332        }
333    }
334
335    /// Add a specific variant to a platform.
336    ///
337    /// If the platform doesn't exist, it will be created.
338    pub fn add_platform_variant(
339        &mut self,
340        platform: Platform,
341        variant: &str,
342        library_path: &str,
343        checksum: &str,
344        build: Option<serde_json::Value>,
345    ) {
346        let platform_key = platform.as_str().to_string();
347
348        let platform_info = self
349            .platforms
350            .entry(platform_key)
351            .or_insert_with(|| PlatformInfo {
352                variants: HashMap::new(),
353            });
354
355        platform_info.variants.insert(
356            variant.to_string(),
357            VariantInfo {
358                library: library_path.to_string(),
359                checksum: format!("sha256:{checksum}"),
360                build,
361            },
362        );
363    }
364
365    /// Set the public key for signature verification.
366    pub fn set_public_key(&mut self, public_key: String) {
367        self.public_key = Some(public_key);
368    }
369
370    /// Add a schema file to the manifest.
371    pub fn add_schema(
372        &mut self,
373        name: String,
374        path: String,
375        format: String,
376        checksum: String,
377        description: Option<String>,
378    ) {
379        self.schemas.insert(
380            name,
381            SchemaInfo {
382                path,
383                format,
384                checksum,
385                description,
386            },
387        );
388    }
389
390    /// Set the build information.
391    pub fn set_build_info(&mut self, build_info: BuildInfo) {
392        self.build_info = Some(build_info);
393    }
394
395    /// Get the build information.
396    #[must_use]
397    pub fn get_build_info(&self) -> Option<&BuildInfo> {
398        self.build_info.as_ref()
399    }
400
401    /// Set the SBOM paths.
402    pub fn set_sbom(&mut self, sbom: Sbom) {
403        self.sbom = Some(sbom);
404    }
405
406    /// Get the SBOM paths.
407    #[must_use]
408    pub fn get_sbom(&self) -> Option<&Sbom> {
409        self.sbom.as_ref()
410    }
411
412    /// Set the schema checksum for bundle combining validation.
413    pub fn set_schema_checksum(&mut self, checksum: String) {
414        self.schema_checksum = Some(checksum);
415    }
416
417    /// Get the schema checksum.
418    #[must_use]
419    pub fn get_schema_checksum(&self) -> Option<&str> {
420        self.schema_checksum.as_deref()
421    }
422
423    /// Set the notices file path.
424    pub fn set_notices(&mut self, path: String) {
425        self.notices = Some(path);
426    }
427
428    /// Get the notices file path.
429    #[must_use]
430    pub fn get_notices(&self) -> Option<&str> {
431        self.notices.as_deref()
432    }
433
434    /// Set the license file path.
435    pub fn set_license_file(&mut self, path: String) {
436        self.license_file = Some(path);
437    }
438
439    /// Get the license file path.
440    #[must_use]
441    pub fn get_license_file(&self) -> Option<&str> {
442        self.license_file.as_deref()
443    }
444
445    /// Get a specific variant for a platform.
446    ///
447    /// Returns the release variant if `variant` is None.
448    #[must_use]
449    pub fn get_variant(&self, platform: Platform, variant: Option<&str>) -> Option<&VariantInfo> {
450        let platform_info = self.platforms.get(platform.as_str())?;
451        let variant_name = variant.unwrap_or("release");
452        platform_info.variants.get(variant_name)
453    }
454
455    /// Get the release variant for a platform (default).
456    #[must_use]
457    pub fn get_release_variant(&self, platform: Platform) -> Option<&VariantInfo> {
458        self.get_variant(platform, Some("release"))
459    }
460
461    /// List all variants for a platform.
462    #[must_use]
463    pub fn list_variants(&self, platform: Platform) -> Vec<&str> {
464        self.platforms
465            .get(platform.as_str())
466            .map(|p| p.variant_names())
467            .unwrap_or_default()
468    }
469
470    /// Get platform info for a specific platform.
471    #[must_use]
472    pub fn get_platform(&self, platform: Platform) -> Option<&PlatformInfo> {
473        self.platforms.get(platform.as_str())
474    }
475
476    /// Check if a platform is supported.
477    #[must_use]
478    pub fn supports_platform(&self, platform: Platform) -> bool {
479        self.platforms.contains_key(platform.as_str())
480    }
481
482    /// Get all supported platforms.
483    #[must_use]
484    pub fn supported_platforms(&self) -> Vec<Platform> {
485        self.platforms
486            .keys()
487            .filter_map(|k| Platform::parse(k))
488            .collect()
489    }
490
491    /// Validate the manifest.
492    pub fn validate(&self) -> BundleResult<()> {
493        // Check bundle version
494        if self.bundle_version.is_empty() {
495            return Err(BundleError::InvalidManifest(
496                "bundle_version is required".to_string(),
497            ));
498        }
499
500        // Check plugin name
501        if self.plugin.name.is_empty() {
502            return Err(BundleError::InvalidManifest(
503                "plugin.name is required".to_string(),
504            ));
505        }
506
507        // Check plugin version
508        if self.plugin.version.is_empty() {
509            return Err(BundleError::InvalidManifest(
510                "plugin.version is required".to_string(),
511            ));
512        }
513
514        // Check at least one platform is defined
515        if self.platforms.is_empty() {
516            return Err(BundleError::InvalidManifest(
517                "at least one platform must be defined".to_string(),
518            ));
519        }
520
521        // Validate each platform
522        for (key, info) in &self.platforms {
523            if Platform::parse(key).is_none() {
524                return Err(BundleError::InvalidManifest(format!(
525                    "unknown platform: {key}"
526                )));
527            }
528
529            // Each platform must have at least one variant
530            if info.variants.is_empty() {
531                return Err(BundleError::InvalidManifest(format!(
532                    "platform {key}: at least one variant is required"
533                )));
534            }
535
536            // Release variant is mandatory
537            if !info.variants.contains_key("release") {
538                return Err(BundleError::InvalidManifest(format!(
539                    "platform {key}: 'release' variant is required"
540                )));
541            }
542
543            // Validate each variant
544            for (variant_name, variant_info) in &info.variants {
545                // Validate variant name (lowercase alphanumeric + hyphens)
546                if !is_valid_variant_name(variant_name) {
547                    return Err(BundleError::InvalidManifest(format!(
548                        "platform {key}: invalid variant name '{variant_name}' \
549                         (must be lowercase alphanumeric with hyphens)"
550                    )));
551                }
552
553                if variant_info.library.is_empty() {
554                    return Err(BundleError::InvalidManifest(format!(
555                        "platform {key}, variant {variant_name}: library path is required"
556                    )));
557                }
558
559                if variant_info.checksum.is_empty() {
560                    return Err(BundleError::InvalidManifest(format!(
561                        "platform {key}, variant {variant_name}: checksum is required"
562                    )));
563                }
564
565                if !variant_info.checksum.starts_with("sha256:") {
566                    return Err(BundleError::InvalidManifest(format!(
567                        "platform {key}, variant {variant_name}: checksum must start with 'sha256:'"
568                    )));
569                }
570            }
571        }
572
573        Ok(())
574    }
575
576    /// Serialize to JSON.
577    pub fn to_json(&self) -> BundleResult<String> {
578        Ok(serde_json::to_string_pretty(self)?)
579    }
580
581    /// Deserialize from JSON.
582    pub fn from_json(json: &str) -> BundleResult<Self> {
583        Ok(serde_json::from_str(json)?)
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    #![allow(non_snake_case)]
590
591    use super::*;
592
593    #[test]
594    fn Manifest___new___creates_valid_minimal_manifest() {
595        let manifest = Manifest::new("test-plugin", "1.0.0");
596
597        assert_eq!(manifest.plugin.name, "test-plugin");
598        assert_eq!(manifest.plugin.version, "1.0.0");
599        assert_eq!(manifest.bundle_version, BUNDLE_VERSION);
600        assert!(manifest.platforms.is_empty());
601    }
602
603    #[test]
604    fn Manifest___add_platform___adds_platform_info() {
605        let mut manifest = Manifest::new("test-plugin", "1.0.0");
606        manifest.add_platform(
607            Platform::LinuxX86_64,
608            "lib/linux-x86_64/libtest.so",
609            "abc123",
610        );
611
612        assert!(manifest.supports_platform(Platform::LinuxX86_64));
613        assert!(!manifest.supports_platform(Platform::WindowsX86_64));
614
615        let info = manifest.get_platform(Platform::LinuxX86_64).unwrap();
616        let release = info.release().unwrap();
617        assert_eq!(release.library, "lib/linux-x86_64/libtest.so");
618        assert_eq!(release.checksum, "sha256:abc123");
619    }
620
621    #[test]
622    fn Manifest___add_platform___overwrites_existing() {
623        let mut manifest = Manifest::new("test", "1.0.0");
624        manifest.add_platform(Platform::LinuxX86_64, "lib/old.so", "old");
625        manifest.add_platform(Platform::LinuxX86_64, "lib/new.so", "new");
626
627        let info = manifest.get_platform(Platform::LinuxX86_64).unwrap();
628        let release = info.release().unwrap();
629        assert_eq!(release.library, "lib/new.so");
630        assert_eq!(release.checksum, "sha256:new");
631    }
632
633    #[test]
634    fn Manifest___validate___rejects_empty_name() {
635        let manifest = Manifest::new("", "1.0.0");
636        let result = manifest.validate();
637
638        assert!(result.is_err());
639        assert!(result.unwrap_err().to_string().contains("plugin.name"));
640    }
641
642    #[test]
643    fn Manifest___validate___rejects_empty_version() {
644        let manifest = Manifest::new("test", "");
645        let result = manifest.validate();
646
647        assert!(result.is_err());
648        assert!(result.unwrap_err().to_string().contains("plugin.version"));
649    }
650
651    #[test]
652    fn Manifest___validate___rejects_empty_platforms() {
653        let manifest = Manifest::new("test", "1.0.0");
654        let result = manifest.validate();
655
656        assert!(result.is_err());
657        assert!(
658            result
659                .unwrap_err()
660                .to_string()
661                .contains("at least one platform")
662        );
663    }
664
665    #[test]
666    fn Manifest___validate___rejects_invalid_checksum_format() {
667        let mut manifest = Manifest::new("test", "1.0.0");
668        // Manually insert platform with wrong checksum format (no sha256: prefix)
669        let mut variants = HashMap::new();
670        variants.insert(
671            "release".to_string(),
672            VariantInfo {
673                library: "lib/test.so".to_string(),
674                checksum: "abc123".to_string(), // Missing "sha256:" prefix
675                build: None,
676            },
677        );
678        manifest
679            .platforms
680            .insert("linux-x86_64".to_string(), PlatformInfo { variants });
681
682        let result = manifest.validate();
683
684        assert!(result.is_err());
685        assert!(result.unwrap_err().to_string().contains("sha256:"));
686    }
687
688    #[test]
689    fn Manifest___validate___rejects_unknown_platform() {
690        let mut manifest = Manifest::new("test", "1.0.0");
691        // Manually insert invalid platform key
692        manifest.platforms.insert(
693            "invalid-platform".to_string(),
694            PlatformInfo::new("lib/test.so".to_string(), "sha256:abc123".to_string()),
695        );
696
697        let result = manifest.validate();
698
699        assert!(result.is_err());
700        assert!(result.unwrap_err().to_string().contains("unknown platform"));
701    }
702
703    #[test]
704    fn Manifest___validate___rejects_empty_library_path() {
705        let mut manifest = Manifest::new("test", "1.0.0");
706        let mut variants = HashMap::new();
707        variants.insert(
708            "release".to_string(),
709            VariantInfo {
710                library: "".to_string(),
711                checksum: "sha256:abc123".to_string(),
712                build: None,
713            },
714        );
715        manifest
716            .platforms
717            .insert("linux-x86_64".to_string(), PlatformInfo { variants });
718
719        let result = manifest.validate();
720
721        assert!(result.is_err());
722        assert!(
723            result
724                .unwrap_err()
725                .to_string()
726                .contains("library path is required")
727        );
728    }
729
730    #[test]
731    fn Manifest___validate___rejects_empty_checksum() {
732        let mut manifest = Manifest::new("test", "1.0.0");
733        let mut variants = HashMap::new();
734        variants.insert(
735            "release".to_string(),
736            VariantInfo {
737                library: "lib/test.so".to_string(),
738                checksum: "".to_string(),
739                build: None,
740            },
741        );
742        manifest
743            .platforms
744            .insert("linux-x86_64".to_string(), PlatformInfo { variants });
745
746        let result = manifest.validate();
747
748        assert!(result.is_err());
749        assert!(
750            result
751                .unwrap_err()
752                .to_string()
753                .contains("checksum is required")
754        );
755    }
756
757    #[test]
758    fn Manifest___validate___rejects_missing_release_variant() {
759        let mut manifest = Manifest::new("test", "1.0.0");
760        let mut variants = HashMap::new();
761        variants.insert(
762            "debug".to_string(), // Only debug, no release
763            VariantInfo {
764                library: "lib/test.so".to_string(),
765                checksum: "sha256:abc123".to_string(),
766                build: None,
767            },
768        );
769        manifest
770            .platforms
771            .insert("linux-x86_64".to_string(), PlatformInfo { variants });
772
773        let result = manifest.validate();
774
775        assert!(result.is_err());
776        assert!(
777            result
778                .unwrap_err()
779                .to_string()
780                .contains("'release' variant is required")
781        );
782    }
783
784    #[test]
785    fn Manifest___validate___rejects_invalid_variant_name() {
786        let mut manifest = Manifest::new("test", "1.0.0");
787        let mut variants = HashMap::new();
788        variants.insert(
789            "release".to_string(),
790            VariantInfo {
791                library: "lib/test.so".to_string(),
792                checksum: "sha256:abc123".to_string(),
793                build: None,
794            },
795        );
796        variants.insert(
797            "INVALID".to_string(), // Uppercase is invalid
798            VariantInfo {
799                library: "lib/test.so".to_string(),
800                checksum: "sha256:abc123".to_string(),
801                build: None,
802            },
803        );
804        manifest
805            .platforms
806            .insert("linux-x86_64".to_string(), PlatformInfo { variants });
807
808        let result = manifest.validate();
809
810        assert!(result.is_err());
811        assert!(
812            result
813                .unwrap_err()
814                .to_string()
815                .contains("invalid variant name")
816        );
817    }
818
819    #[test]
820    fn Manifest___validate___accepts_valid_manifest() {
821        let mut manifest = Manifest::new("test-plugin", "1.0.0");
822        manifest.add_platform(
823            Platform::LinuxX86_64,
824            "lib/linux-x86_64/libtest.so",
825            "abc123",
826        );
827
828        assert!(manifest.validate().is_ok());
829    }
830
831    #[test]
832    fn Manifest___validate___accepts_all_platforms() {
833        let mut manifest = Manifest::new("all-platforms", "1.0.0");
834        for platform in Platform::all() {
835            manifest.add_platform(
836                *platform,
837                &format!("lib/{}/libtest", platform.as_str()),
838                "hash",
839            );
840        }
841
842        assert!(manifest.validate().is_ok());
843        assert_eq!(manifest.supported_platforms().len(), 6);
844    }
845
846    #[test]
847    fn Manifest___json_roundtrip___preserves_data() {
848        let mut manifest = Manifest::new("test-plugin", "1.0.0");
849        manifest.plugin.description = Some("A test plugin".to_string());
850        manifest.add_platform(
851            Platform::LinuxX86_64,
852            "lib/linux-x86_64/libtest.so",
853            "abc123",
854        );
855        manifest.add_platform(
856            Platform::DarwinAarch64,
857            "lib/darwin-aarch64/libtest.dylib",
858            "def456",
859        );
860
861        let json = manifest.to_json().unwrap();
862        let parsed = Manifest::from_json(&json).unwrap();
863
864        assert_eq!(parsed.plugin.name, manifest.plugin.name);
865        assert_eq!(parsed.plugin.version, manifest.plugin.version);
866        assert_eq!(parsed.plugin.description, manifest.plugin.description);
867        assert_eq!(parsed.platforms.len(), 2);
868    }
869
870    #[test]
871    fn Manifest___json_roundtrip___preserves_all_plugin_fields() {
872        let mut manifest = Manifest::new("full-plugin", "2.3.4");
873        manifest.plugin.description = Some("Full description".to_string());
874        manifest.plugin.authors = vec!["Author 1".to_string(), "Author 2".to_string()];
875        manifest.plugin.license = Some("Apache-2.0".to_string());
876        manifest.plugin.repository = Some("https://github.com/test/repo".to_string());
877        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
878
879        let json = manifest.to_json().unwrap();
880        let parsed = Manifest::from_json(&json).unwrap();
881
882        assert_eq!(parsed.plugin.description, manifest.plugin.description);
883        assert_eq!(parsed.plugin.authors, manifest.plugin.authors);
884        assert_eq!(parsed.plugin.license, manifest.plugin.license);
885        assert_eq!(parsed.plugin.repository, manifest.plugin.repository);
886    }
887
888    #[test]
889    fn Manifest___json_roundtrip___preserves_schemas() {
890        let mut manifest = Manifest::new("schema-plugin", "1.0.0");
891        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
892        manifest.add_schema(
893            "messages.h".to_string(),
894            "schema/messages.h".to_string(),
895            "c-header".to_string(),
896            "sha256:abc".to_string(),
897            Some("C header for binary transport".to_string()),
898        );
899
900        let json = manifest.to_json().unwrap();
901        let parsed = Manifest::from_json(&json).unwrap();
902
903        assert_eq!(parsed.schemas.len(), 1);
904        let schema = parsed.schemas.get("messages.h").unwrap();
905        assert_eq!(schema.path, "schema/messages.h");
906        assert_eq!(schema.format, "c-header");
907        assert_eq!(schema.checksum, "sha256:abc");
908        assert_eq!(
909            schema.description,
910            Some("C header for binary transport".to_string())
911        );
912    }
913
914    #[test]
915    fn Manifest___json_roundtrip___preserves_public_key() {
916        let mut manifest = Manifest::new("signed-plugin", "1.0.0");
917        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
918        manifest.set_public_key("RWSxxxxxxxxxxxxxxxx".to_string());
919
920        let json = manifest.to_json().unwrap();
921        let parsed = Manifest::from_json(&json).unwrap();
922
923        assert_eq!(parsed.public_key, Some("RWSxxxxxxxxxxxxxxxx".to_string()));
924    }
925
926    #[test]
927    fn Manifest___from_json___invalid_json___returns_error() {
928        let result = Manifest::from_json("{ invalid }");
929
930        assert!(result.is_err());
931    }
932
933    #[test]
934    fn Manifest___from_json___missing_required_fields___returns_error() {
935        let result = Manifest::from_json(r#"{"bundle_version": "1.0"}"#);
936
937        assert!(result.is_err());
938    }
939
940    #[test]
941    fn Manifest___supported_platforms___returns_all_platforms() {
942        let mut manifest = Manifest::new("test", "1.0.0");
943        manifest.add_platform(Platform::LinuxX86_64, "lib/a.so", "a");
944        manifest.add_platform(Platform::DarwinAarch64, "lib/b.dylib", "b");
945
946        let platforms = manifest.supported_platforms();
947        assert_eq!(platforms.len(), 2);
948        assert!(platforms.contains(&Platform::LinuxX86_64));
949        assert!(platforms.contains(&Platform::DarwinAarch64));
950    }
951
952    #[test]
953    fn Manifest___get_platform___returns_none_for_unsupported() {
954        let mut manifest = Manifest::new("test", "1.0.0");
955        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
956
957        assert!(manifest.get_platform(Platform::LinuxX86_64).is_some());
958        assert!(manifest.get_platform(Platform::WindowsX86_64).is_none());
959    }
960
961    #[test]
962    fn BuildInfo___default___all_fields_none() {
963        let build_info = BuildInfo::default();
964
965        assert!(build_info.built_by.is_none());
966        assert!(build_info.built_at.is_none());
967        assert!(build_info.host.is_none());
968        assert!(build_info.compiler.is_none());
969        assert!(build_info.rustbridge_version.is_none());
970        assert!(build_info.git.is_none());
971    }
972
973    #[test]
974    fn Manifest___build_info___roundtrip() {
975        let mut manifest = Manifest::new("test", "1.0.0");
976        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
977        manifest.set_build_info(BuildInfo {
978            built_by: Some("GitHub Actions".to_string()),
979            built_at: Some("2025-01-26T10:30:00Z".to_string()),
980            host: Some("x86_64-unknown-linux-gnu".to_string()),
981            compiler: Some("rustc 1.90.0".to_string()),
982            rustbridge_version: Some("0.2.0".to_string()),
983            git: Some(GitInfo {
984                commit: "abc123".to_string(),
985                branch: Some("main".to_string()),
986                tag: Some("v1.0.0".to_string()),
987                dirty: Some(false),
988            }),
989            custom: None,
990        });
991
992        let json = manifest.to_json().unwrap();
993        let parsed = Manifest::from_json(&json).unwrap();
994
995        let build_info = parsed.get_build_info().unwrap();
996        assert_eq!(build_info.built_by, Some("GitHub Actions".to_string()));
997        assert_eq!(build_info.compiler, Some("rustc 1.90.0".to_string()));
998
999        let git = build_info.git.as_ref().unwrap();
1000        assert_eq!(git.commit, "abc123");
1001        assert_eq!(git.branch, Some("main".to_string()));
1002        assert_eq!(git.dirty, Some(false));
1003    }
1004
1005    #[test]
1006    fn Manifest___sbom___roundtrip() {
1007        let mut manifest = Manifest::new("test", "1.0.0");
1008        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1009        manifest.set_sbom(Sbom {
1010            cyclonedx: Some("sbom/sbom.cdx.json".to_string()),
1011            spdx: Some("sbom/sbom.spdx.json".to_string()),
1012        });
1013
1014        let json = manifest.to_json().unwrap();
1015        let parsed = Manifest::from_json(&json).unwrap();
1016
1017        let sbom = parsed.get_sbom().unwrap();
1018        assert_eq!(sbom.cyclonedx, Some("sbom/sbom.cdx.json".to_string()));
1019        assert_eq!(sbom.spdx, Some("sbom/sbom.spdx.json".to_string()));
1020    }
1021
1022    #[test]
1023    fn Manifest___variants___roundtrip() {
1024        let mut manifest = Manifest::new("test", "1.0.0");
1025        manifest.add_platform_variant(
1026            Platform::LinuxX86_64,
1027            "release",
1028            "lib/linux-x86_64/release/libtest.so",
1029            "hash1",
1030            Some(serde_json::json!({
1031                "profile": "release",
1032                "opt_level": "3"
1033            })),
1034        );
1035        manifest.add_platform_variant(
1036            Platform::LinuxX86_64,
1037            "debug",
1038            "lib/linux-x86_64/debug/libtest.so",
1039            "hash2",
1040            Some(serde_json::json!({
1041                "profile": "debug",
1042                "opt_level": "0"
1043            })),
1044        );
1045
1046        let json = manifest.to_json().unwrap();
1047        let parsed = Manifest::from_json(&json).unwrap();
1048
1049        let variants = parsed.list_variants(Platform::LinuxX86_64);
1050        assert_eq!(variants.len(), 2);
1051        assert!(variants.contains(&"release"));
1052        assert!(variants.contains(&"debug"));
1053
1054        let release = parsed
1055            .get_variant(Platform::LinuxX86_64, Some("release"))
1056            .unwrap();
1057        assert_eq!(release.library, "lib/linux-x86_64/release/libtest.so");
1058        assert_eq!(
1059            release.build.as_ref().unwrap()["profile"],
1060            serde_json::json!("release")
1061        );
1062    }
1063
1064    #[test]
1065    fn Manifest___schema_checksum___roundtrip() {
1066        let mut manifest = Manifest::new("test", "1.0.0");
1067        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1068        manifest.set_schema_checksum("sha256:abcdef123456".to_string());
1069
1070        let json = manifest.to_json().unwrap();
1071        let parsed = Manifest::from_json(&json).unwrap();
1072
1073        assert_eq!(parsed.get_schema_checksum(), Some("sha256:abcdef123456"));
1074    }
1075
1076    #[test]
1077    fn Manifest___notices___roundtrip() {
1078        let mut manifest = Manifest::new("test", "1.0.0");
1079        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1080        manifest.set_notices("docs/NOTICES.txt".to_string());
1081
1082        let json = manifest.to_json().unwrap();
1083        let parsed = Manifest::from_json(&json).unwrap();
1084
1085        assert_eq!(parsed.get_notices(), Some("docs/NOTICES.txt"));
1086    }
1087
1088    #[test]
1089    fn Manifest___license_file___roundtrip() {
1090        let mut manifest = Manifest::new("test", "1.0.0");
1091        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1092        manifest.set_license_file("legal/LICENSE".to_string());
1093
1094        let json = manifest.to_json().unwrap();
1095        let parsed = Manifest::from_json(&json).unwrap();
1096
1097        assert_eq!(parsed.get_license_file(), Some("legal/LICENSE"));
1098    }
1099
1100    #[test]
1101    fn is_valid_variant_name___accepts_valid_names() {
1102        assert!(is_valid_variant_name("release"));
1103        assert!(is_valid_variant_name("debug"));
1104        assert!(is_valid_variant_name("nightly"));
1105        assert!(is_valid_variant_name("opt-size"));
1106        assert!(is_valid_variant_name("v1"));
1107        assert!(is_valid_variant_name("build123"));
1108    }
1109
1110    #[test]
1111    fn is_valid_variant_name___rejects_invalid_names() {
1112        assert!(!is_valid_variant_name("")); // Empty
1113        assert!(!is_valid_variant_name("RELEASE")); // Uppercase
1114        assert!(!is_valid_variant_name("Release")); // Mixed case
1115        assert!(!is_valid_variant_name("-debug")); // Starts with hyphen
1116        assert!(!is_valid_variant_name("debug-")); // Ends with hyphen
1117        assert!(!is_valid_variant_name("debug build")); // Contains space
1118        assert!(!is_valid_variant_name("debug_build")); // Contains underscore
1119    }
1120
1121    #[test]
1122    fn PlatformInfo___new___creates_release_variant() {
1123        let platform_info =
1124            PlatformInfo::new("lib/test.so".to_string(), "sha256:abc123".to_string());
1125
1126        assert!(platform_info.has_variant("release"));
1127        let release = platform_info.release().unwrap();
1128        assert_eq!(release.library, "lib/test.so");
1129        assert_eq!(release.checksum, "sha256:abc123");
1130    }
1131
1132    #[test]
1133    fn PlatformInfo___variant_names___returns_all_variants() {
1134        let mut platform_info =
1135            PlatformInfo::new("lib/release.so".to_string(), "sha256:abc".to_string());
1136        platform_info.add_variant(
1137            "debug".to_string(),
1138            VariantInfo {
1139                library: "lib/debug.so".to_string(),
1140                checksum: "sha256:def".to_string(),
1141                build: None,
1142            },
1143        );
1144
1145        let names = platform_info.variant_names();
1146        assert_eq!(names.len(), 2);
1147        assert!(names.contains(&"release"));
1148        assert!(names.contains(&"debug"));
1149    }
1150}