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