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, BUNDLE_VERSION_1_1, 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                build_info: None,
106                sbom: None,
107                schema_checksum: None,
108                schemas: HashMap::new(),
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///
155/// In bundle format v1.1, variants may carry their own `build_info`, `sbom`,
156/// `schema_checksum`, and `schemas` that override the top-level manifest values.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct VariantInfo {
159    /// Relative path to the library within the bundle.
160    /// Example: "lib/linux-x86_64/release/libplugin.so"
161    pub library: String,
162
163    /// SHA256 checksum of the library file.
164    /// Format: "sha256:hexstring"
165    pub checksum: String,
166
167    /// Flexible build metadata - any JSON object.
168    /// This can contain toolchain-specific fields like:
169    /// - `profile`: "release" or "debug"
170    /// - `opt_level`: "0", "1", "2", "3", "s", "z"
171    /// - `features`: ["json", "binary"]
172    /// - `cflags`: "-O3 -march=native" (for C/C++)
173    /// - `go_tags`: ["production"] (for Go)
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub build: Option<serde_json::Value>,
176
177    // --- v1.1 variant-level metadata (overrides top-level when present) ---
178    /// Build information specific to this variant (v1.1).
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub build_info: Option<BuildInfo>,
181
182    /// SBOM paths specific to this variant (v1.1).
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub sbom: Option<Sbom>,
185
186    /// Schema checksum specific to this variant (v1.1).
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub schema_checksum: Option<String>,
189
190    /// Schema files specific to this variant (v1.1).
191    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
192    pub schemas: HashMap<String, SchemaInfo>,
193}
194
195/// Build information (all fields optional).
196///
197/// Contains metadata about when and how the bundle was built.
198/// Useful for traceability and debugging but not required.
199#[derive(Debug, Clone, Default, Serialize, Deserialize)]
200pub struct BuildInfo {
201    /// Who/what built this bundle (e.g., "GitHub Actions", "local")
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub built_by: Option<String>,
204
205    /// When the bundle was built (ISO 8601 timestamp).
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub built_at: Option<String>,
208
209    /// Host triple where the build ran.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub host: Option<String>,
212
213    /// Compiler version used.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub compiler: Option<String>,
216
217    /// rustbridge version used to create the bundle.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub rustbridge_version: Option<String>,
220
221    /// Git repository information (optional).
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub git: Option<GitInfo>,
224
225    /// Custom key/value metadata for informational purposes.
226    /// Can include arbitrary data like repository URL, CI job ID, etc.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub custom: Option<HashMap<String, String>>,
229}
230
231/// Git repository information.
232///
233/// All fields except `commit` are optional. This section is only
234/// present if the project uses git.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct GitInfo {
237    /// Full commit hash (required if git section present).
238    pub commit: String,
239
240    /// Branch name.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub branch: Option<String>,
243
244    /// Git tag (if on a tagged commit).
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub tag: Option<String>,
247
248    /// Whether the working tree had uncommitted changes.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub dirty: Option<bool>,
251}
252
253/// SBOM (Software Bill of Materials) paths.
254///
255/// Points to SBOM files within the bundle. Both CycloneDX and SPDX
256/// formats are supported and can be included simultaneously.
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct Sbom {
259    /// Path to CycloneDX SBOM file (e.g., "sbom/sbom.cdx.json").
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub cyclonedx: Option<String>,
262
263    /// Path to SPDX SBOM file (e.g., "sbom/sbom.spdx.json").
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub spdx: Option<String>,
266}
267
268/// Check if a variant name is valid.
269///
270/// Valid variant names are lowercase alphanumeric with hyphens.
271/// Examples: "release", "debug", "nightly", "opt-size"
272fn is_valid_variant_name(name: &str) -> bool {
273    if name.is_empty() {
274        return false;
275    }
276
277    // Must start and end with alphanumeric
278    let chars: Vec<char> = name.chars().collect();
279    if !chars[0].is_ascii_lowercase() && !chars[0].is_ascii_digit() {
280        return false;
281    }
282    if !chars[chars.len() - 1].is_ascii_lowercase() && !chars[chars.len() - 1].is_ascii_digit() {
283        return false;
284    }
285
286    // All characters must be lowercase alphanumeric or hyphen
287    name.chars()
288        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
289}
290
291/// Schema file information.
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct SchemaInfo {
294    /// Relative path to the schema file within the bundle.
295    pub path: String,
296
297    /// Schema format (e.g., "c-header", "json-schema").
298    pub format: String,
299
300    /// SHA256 checksum of the schema file.
301    pub checksum: String,
302
303    /// Optional description of what this schema describes.
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub description: Option<String>,
306}
307
308impl Manifest {
309    /// Create a new manifest with minimal required fields.
310    #[must_use]
311    pub fn new(name: &str, version: &str) -> Self {
312        Self {
313            bundle_version: BUNDLE_VERSION.to_string(),
314            plugin: PluginInfo {
315                name: name.to_string(),
316                version: version.to_string(),
317                description: None,
318                authors: Vec::new(),
319                license: None,
320                repository: None,
321            },
322            platforms: HashMap::new(),
323            build_info: None,
324            sbom: None,
325            schema_checksum: None,
326            notices: None,
327            license_file: None,
328            public_key: None,
329            schemas: HashMap::new(),
330        }
331    }
332
333    /// Add a platform with a release variant to the manifest.
334    ///
335    /// This is a convenience method that adds the library as the `release` variant.
336    /// For multiple variants, use `add_platform_variant` instead.
337    pub fn add_platform(&mut self, platform: Platform, library_path: &str, checksum: &str) {
338        let platform_key = platform.as_str().to_string();
339
340        if let Some(platform_info) = self.platforms.get_mut(&platform_key) {
341            // Platform exists, add/update release variant
342            platform_info.variants.insert(
343                "release".to_string(),
344                VariantInfo {
345                    library: library_path.to_string(),
346                    checksum: format!("sha256:{checksum}"),
347                    build: None,
348                    build_info: None,
349                    sbom: None,
350                    schema_checksum: None,
351                    schemas: HashMap::new(),
352                },
353            );
354        } else {
355            // New platform
356            self.platforms.insert(
357                platform_key,
358                PlatformInfo::new(library_path.to_string(), format!("sha256:{checksum}")),
359            );
360        }
361    }
362
363    /// Add a specific variant to a platform.
364    ///
365    /// If the platform doesn't exist, it will be created.
366    pub fn add_platform_variant(
367        &mut self,
368        platform: Platform,
369        variant: &str,
370        library_path: &str,
371        checksum: &str,
372        build: Option<serde_json::Value>,
373    ) {
374        let platform_key = platform.as_str().to_string();
375
376        let platform_info = self
377            .platforms
378            .entry(platform_key)
379            .or_insert_with(|| PlatformInfo {
380                variants: HashMap::new(),
381            });
382
383        platform_info.variants.insert(
384            variant.to_string(),
385            VariantInfo {
386                library: library_path.to_string(),
387                checksum: format!("sha256:{checksum}"),
388                build,
389                build_info: None,
390                sbom: None,
391                schema_checksum: None,
392                schemas: HashMap::new(),
393            },
394        );
395    }
396
397    /// Set the public key for signature verification.
398    pub fn set_public_key(&mut self, public_key: String) {
399        self.public_key = Some(public_key);
400    }
401
402    /// Add a schema file to the manifest.
403    pub fn add_schema(
404        &mut self,
405        name: String,
406        path: String,
407        format: String,
408        checksum: String,
409        description: Option<String>,
410    ) {
411        self.schemas.insert(
412            name,
413            SchemaInfo {
414                path,
415                format,
416                checksum,
417                description,
418            },
419        );
420    }
421
422    /// Set the build information.
423    pub fn set_build_info(&mut self, build_info: BuildInfo) {
424        self.build_info = Some(build_info);
425    }
426
427    /// Get the build information.
428    #[must_use]
429    pub fn get_build_info(&self) -> Option<&BuildInfo> {
430        self.build_info.as_ref()
431    }
432
433    /// Set the SBOM paths.
434    pub fn set_sbom(&mut self, sbom: Sbom) {
435        self.sbom = Some(sbom);
436    }
437
438    /// Get the SBOM paths.
439    #[must_use]
440    pub fn get_sbom(&self) -> Option<&Sbom> {
441        self.sbom.as_ref()
442    }
443
444    /// Set the schema checksum for bundle combining validation.
445    pub fn set_schema_checksum(&mut self, checksum: String) {
446        self.schema_checksum = Some(checksum);
447    }
448
449    /// Get the schema checksum.
450    #[must_use]
451    pub fn get_schema_checksum(&self) -> Option<&str> {
452        self.schema_checksum.as_deref()
453    }
454
455    /// Set the notices file path.
456    pub fn set_notices(&mut self, path: String) {
457        self.notices = Some(path);
458    }
459
460    /// Get the notices file path.
461    #[must_use]
462    pub fn get_notices(&self) -> Option<&str> {
463        self.notices.as_deref()
464    }
465
466    /// Set the license file path.
467    pub fn set_license_file(&mut self, path: String) {
468        self.license_file = Some(path);
469    }
470
471    /// Get the license file path.
472    #[must_use]
473    pub fn get_license_file(&self) -> Option<&str> {
474        self.license_file.as_deref()
475    }
476
477    /// Get a specific variant for a platform.
478    ///
479    /// Returns the release variant if `variant` is None.
480    #[must_use]
481    pub fn get_variant(&self, platform: Platform, variant: Option<&str>) -> Option<&VariantInfo> {
482        let platform_info = self.platforms.get(platform.as_str())?;
483        let variant_name = variant.unwrap_or("release");
484        platform_info.variants.get(variant_name)
485    }
486
487    /// Get the release variant for a platform (default).
488    #[must_use]
489    pub fn get_release_variant(&self, platform: Platform) -> Option<&VariantInfo> {
490        self.get_variant(platform, Some("release"))
491    }
492
493    /// List all variants for a platform.
494    #[must_use]
495    pub fn list_variants(&self, platform: Platform) -> Vec<&str> {
496        self.platforms
497            .get(platform.as_str())
498            .map(|p| p.variant_names())
499            .unwrap_or_default()
500    }
501
502    /// Get platform info for a specific platform.
503    #[must_use]
504    pub fn get_platform(&self, platform: Platform) -> Option<&PlatformInfo> {
505        self.platforms.get(platform.as_str())
506    }
507
508    /// Check if a platform is supported.
509    #[must_use]
510    pub fn supports_platform(&self, platform: Platform) -> bool {
511        self.platforms.contains_key(platform.as_str())
512    }
513
514    /// Get all supported platforms.
515    #[must_use]
516    pub fn supported_platforms(&self) -> Vec<Platform> {
517        self.platforms
518            .keys()
519            .filter_map(|k| Platform::parse(k))
520            .collect()
521    }
522
523    /// Get the effective build info for a platform/variant, with variant-level overriding top-level.
524    #[must_use]
525    pub fn get_effective_build_info(&self, platform: &str, variant: &str) -> Option<&BuildInfo> {
526        if let Some(platform_info) = self.platforms.get(platform)
527            && let Some(variant_info) = platform_info.variants.get(variant)
528            && let Some(ref bi) = variant_info.build_info
529        {
530            return Some(bi);
531        }
532        self.build_info.as_ref()
533    }
534
535    /// Get the effective SBOM for a platform/variant, with variant-level overriding top-level.
536    #[must_use]
537    pub fn get_effective_sbom(&self, platform: &str, variant: &str) -> Option<&Sbom> {
538        if let Some(platform_info) = self.platforms.get(platform)
539            && let Some(variant_info) = platform_info.variants.get(variant)
540            && let Some(ref s) = variant_info.sbom
541        {
542            return Some(s);
543        }
544        self.sbom.as_ref()
545    }
546
547    /// Get the effective schema checksum for a platform/variant, with variant-level overriding top-level.
548    #[must_use]
549    pub fn get_effective_schema_checksum(&self, platform: &str, variant: &str) -> Option<&str> {
550        if let Some(platform_info) = self.platforms.get(platform)
551            && let Some(variant_info) = platform_info.variants.get(variant)
552            && let Some(ref sc) = variant_info.schema_checksum
553        {
554            return Some(sc.as_str());
555        }
556        self.schema_checksum.as_deref()
557    }
558
559    /// Get the effective schemas for a platform/variant, with variant-level overriding top-level.
560    #[must_use]
561    pub fn get_effective_schemas(
562        &self,
563        platform: &str,
564        variant: &str,
565    ) -> &HashMap<String, SchemaInfo> {
566        if let Some(platform_info) = self.platforms.get(platform)
567            && let Some(variant_info) = platform_info.variants.get(variant)
568            && !variant_info.schemas.is_empty()
569        {
570            return &variant_info.schemas;
571        }
572        &self.schemas
573    }
574
575    /// Check if any variant in the manifest has variant-level metadata set.
576    #[must_use]
577    pub fn has_variant_level_metadata(&self) -> bool {
578        self.platforms.values().any(|p| {
579            p.variants.values().any(|v| {
580                v.build_info.is_some()
581                    || v.sbom.is_some()
582                    || v.schema_checksum.is_some()
583                    || !v.schemas.is_empty()
584            })
585        })
586    }
587
588    /// Validate the manifest.
589    pub fn validate(&self) -> BundleResult<()> {
590        // Check bundle version
591        if self.bundle_version.is_empty() {
592            return Err(BundleError::InvalidManifest(
593                "bundle_version is required".to_string(),
594            ));
595        }
596
597        if self.bundle_version != BUNDLE_VERSION && self.bundle_version != BUNDLE_VERSION_1_1 {
598            return Err(BundleError::InvalidManifest(format!(
599                "unsupported bundle_version '{}' (expected '{}' or '{}')",
600                self.bundle_version, BUNDLE_VERSION, BUNDLE_VERSION_1_1,
601            )));
602        }
603
604        // Check plugin name
605        if self.plugin.name.is_empty() {
606            return Err(BundleError::InvalidManifest(
607                "plugin.name is required".to_string(),
608            ));
609        }
610
611        // Check plugin version
612        if self.plugin.version.is_empty() {
613            return Err(BundleError::InvalidManifest(
614                "plugin.version is required".to_string(),
615            ));
616        }
617
618        // Check at least one platform is defined
619        if self.platforms.is_empty() {
620            return Err(BundleError::InvalidManifest(
621                "at least one platform must be defined".to_string(),
622            ));
623        }
624
625        // Validate each platform
626        for (key, info) in &self.platforms {
627            if Platform::parse(key).is_none() {
628                return Err(BundleError::InvalidManifest(format!(
629                    "unknown platform: {key}"
630                )));
631            }
632
633            // Each platform must have at least one variant
634            if info.variants.is_empty() {
635                return Err(BundleError::InvalidManifest(format!(
636                    "platform {key}: at least one variant is required"
637                )));
638            }
639
640            // Release variant is mandatory
641            if !info.variants.contains_key("release") {
642                return Err(BundleError::InvalidManifest(format!(
643                    "platform {key}: 'release' variant is required"
644                )));
645            }
646
647            // Validate each variant
648            for (variant_name, variant_info) in &info.variants {
649                // Validate variant name (lowercase alphanumeric + hyphens)
650                if !is_valid_variant_name(variant_name) {
651                    return Err(BundleError::InvalidManifest(format!(
652                        "platform {key}: invalid variant name '{variant_name}' \
653                         (must be lowercase alphanumeric with hyphens)"
654                    )));
655                }
656
657                if variant_info.library.is_empty() {
658                    return Err(BundleError::InvalidManifest(format!(
659                        "platform {key}, variant {variant_name}: library path is required"
660                    )));
661                }
662
663                if variant_info.checksum.is_empty() {
664                    return Err(BundleError::InvalidManifest(format!(
665                        "platform {key}, variant {variant_name}: checksum is required"
666                    )));
667                }
668
669                if !variant_info.checksum.starts_with("sha256:") {
670                    return Err(BundleError::InvalidManifest(format!(
671                        "platform {key}, variant {variant_name}: checksum must start with 'sha256:'"
672                    )));
673                }
674            }
675        }
676
677        Ok(())
678    }
679
680    /// Serialize to JSON.
681    pub fn to_json(&self) -> BundleResult<String> {
682        Ok(serde_json::to_string_pretty(self)?)
683    }
684
685    /// Deserialize from JSON.
686    pub fn from_json(json: &str) -> BundleResult<Self> {
687        Ok(serde_json::from_str(json)?)
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    #![allow(non_snake_case)]
694
695    use super::*;
696
697    #[test]
698    fn Manifest___new___creates_valid_minimal_manifest() {
699        let manifest = Manifest::new("test-plugin", "1.0.0");
700
701        assert_eq!(manifest.plugin.name, "test-plugin");
702        assert_eq!(manifest.plugin.version, "1.0.0");
703        assert_eq!(manifest.bundle_version, BUNDLE_VERSION);
704        assert!(manifest.platforms.is_empty());
705    }
706
707    #[test]
708    fn Manifest___add_platform___adds_platform_info() {
709        let mut manifest = Manifest::new("test-plugin", "1.0.0");
710        manifest.add_platform(
711            Platform::LinuxX86_64,
712            "lib/linux-x86_64/libtest.so",
713            "abc123",
714        );
715
716        assert!(manifest.supports_platform(Platform::LinuxX86_64));
717        assert!(!manifest.supports_platform(Platform::WindowsX86_64));
718
719        let info = manifest.get_platform(Platform::LinuxX86_64).unwrap();
720        let release = info.release().unwrap();
721        assert_eq!(release.library, "lib/linux-x86_64/libtest.so");
722        assert_eq!(release.checksum, "sha256:abc123");
723    }
724
725    #[test]
726    fn Manifest___add_platform___overwrites_existing() {
727        let mut manifest = Manifest::new("test", "1.0.0");
728        manifest.add_platform(Platform::LinuxX86_64, "lib/old.so", "old");
729        manifest.add_platform(Platform::LinuxX86_64, "lib/new.so", "new");
730
731        let info = manifest.get_platform(Platform::LinuxX86_64).unwrap();
732        let release = info.release().unwrap();
733        assert_eq!(release.library, "lib/new.so");
734        assert_eq!(release.checksum, "sha256:new");
735    }
736
737    #[test]
738    fn Manifest___validate___rejects_empty_name() {
739        let manifest = Manifest::new("", "1.0.0");
740        let result = manifest.validate();
741
742        assert!(result.is_err());
743        assert!(result.unwrap_err().to_string().contains("plugin.name"));
744    }
745
746    #[test]
747    fn Manifest___validate___rejects_empty_version() {
748        let manifest = Manifest::new("test", "");
749        let result = manifest.validate();
750
751        assert!(result.is_err());
752        assert!(result.unwrap_err().to_string().contains("plugin.version"));
753    }
754
755    #[test]
756    fn Manifest___validate___rejects_empty_platforms() {
757        let manifest = Manifest::new("test", "1.0.0");
758        let result = manifest.validate();
759
760        assert!(result.is_err());
761        assert!(
762            result
763                .unwrap_err()
764                .to_string()
765                .contains("at least one platform")
766        );
767    }
768
769    #[test]
770    fn Manifest___validate___rejects_invalid_checksum_format() {
771        let mut manifest = Manifest::new("test", "1.0.0");
772        // Manually insert platform with wrong checksum format (no sha256: prefix)
773        let mut variants = HashMap::new();
774        variants.insert(
775            "release".to_string(),
776            VariantInfo {
777                library: "lib/test.so".to_string(),
778                checksum: "abc123".to_string(), // Missing "sha256:" prefix
779                build: None,
780                build_info: None,
781                sbom: None,
782                schema_checksum: None,
783                schemas: HashMap::new(),
784            },
785        );
786        manifest
787            .platforms
788            .insert("linux-x86_64".to_string(), PlatformInfo { variants });
789
790        let result = manifest.validate();
791
792        assert!(result.is_err());
793        assert!(result.unwrap_err().to_string().contains("sha256:"));
794    }
795
796    #[test]
797    fn Manifest___validate___rejects_unknown_platform() {
798        let mut manifest = Manifest::new("test", "1.0.0");
799        // Manually insert invalid platform key
800        manifest.platforms.insert(
801            "invalid-platform".to_string(),
802            PlatformInfo::new("lib/test.so".to_string(), "sha256:abc123".to_string()),
803        );
804
805        let result = manifest.validate();
806
807        assert!(result.is_err());
808        assert!(result.unwrap_err().to_string().contains("unknown platform"));
809    }
810
811    #[test]
812    fn Manifest___validate___rejects_empty_library_path() {
813        let mut manifest = Manifest::new("test", "1.0.0");
814        let mut variants = HashMap::new();
815        variants.insert(
816            "release".to_string(),
817            VariantInfo {
818                library: "".to_string(),
819                checksum: "sha256:abc123".to_string(),
820                build: None,
821                build_info: None,
822                sbom: None,
823                schema_checksum: None,
824                schemas: HashMap::new(),
825            },
826        );
827        manifest
828            .platforms
829            .insert("linux-x86_64".to_string(), PlatformInfo { variants });
830
831        let result = manifest.validate();
832
833        assert!(result.is_err());
834        assert!(
835            result
836                .unwrap_err()
837                .to_string()
838                .contains("library path is required")
839        );
840    }
841
842    #[test]
843    fn Manifest___validate___rejects_empty_checksum() {
844        let mut manifest = Manifest::new("test", "1.0.0");
845        let mut variants = HashMap::new();
846        variants.insert(
847            "release".to_string(),
848            VariantInfo {
849                library: "lib/test.so".to_string(),
850                checksum: "".to_string(),
851                build: None,
852                build_info: None,
853                sbom: None,
854                schema_checksum: None,
855                schemas: HashMap::new(),
856            },
857        );
858        manifest
859            .platforms
860            .insert("linux-x86_64".to_string(), PlatformInfo { variants });
861
862        let result = manifest.validate();
863
864        assert!(result.is_err());
865        assert!(
866            result
867                .unwrap_err()
868                .to_string()
869                .contains("checksum is required")
870        );
871    }
872
873    #[test]
874    fn Manifest___validate___rejects_missing_release_variant() {
875        let mut manifest = Manifest::new("test", "1.0.0");
876        let mut variants = HashMap::new();
877        variants.insert(
878            "debug".to_string(), // Only debug, no release
879            VariantInfo {
880                library: "lib/test.so".to_string(),
881                checksum: "sha256:abc123".to_string(),
882                build: None,
883                build_info: None,
884                sbom: None,
885                schema_checksum: None,
886                schemas: HashMap::new(),
887            },
888        );
889        manifest
890            .platforms
891            .insert("linux-x86_64".to_string(), PlatformInfo { variants });
892
893        let result = manifest.validate();
894
895        assert!(result.is_err());
896        assert!(
897            result
898                .unwrap_err()
899                .to_string()
900                .contains("'release' variant is required")
901        );
902    }
903
904    #[test]
905    fn Manifest___validate___rejects_invalid_variant_name() {
906        let mut manifest = Manifest::new("test", "1.0.0");
907        let mut variants = HashMap::new();
908        variants.insert(
909            "release".to_string(),
910            VariantInfo {
911                library: "lib/test.so".to_string(),
912                checksum: "sha256:abc123".to_string(),
913                build: None,
914                build_info: None,
915                sbom: None,
916                schema_checksum: None,
917                schemas: HashMap::new(),
918            },
919        );
920        variants.insert(
921            "INVALID".to_string(), // Uppercase is invalid
922            VariantInfo {
923                library: "lib/test.so".to_string(),
924                checksum: "sha256:abc123".to_string(),
925                build: None,
926                build_info: None,
927                sbom: None,
928                schema_checksum: None,
929                schemas: HashMap::new(),
930            },
931        );
932        manifest
933            .platforms
934            .insert("linux-x86_64".to_string(), PlatformInfo { variants });
935
936        let result = manifest.validate();
937
938        assert!(result.is_err());
939        assert!(
940            result
941                .unwrap_err()
942                .to_string()
943                .contains("invalid variant name")
944        );
945    }
946
947    #[test]
948    fn Manifest___validate___accepts_valid_manifest() {
949        let mut manifest = Manifest::new("test-plugin", "1.0.0");
950        manifest.add_platform(
951            Platform::LinuxX86_64,
952            "lib/linux-x86_64/libtest.so",
953            "abc123",
954        );
955
956        assert!(manifest.validate().is_ok());
957    }
958
959    #[test]
960    fn Manifest___validate___accepts_all_platforms() {
961        let mut manifest = Manifest::new("all-platforms", "1.0.0");
962        for platform in Platform::all() {
963            manifest.add_platform(
964                *platform,
965                &format!("lib/{}/libtest", platform.as_str()),
966                "hash",
967            );
968        }
969
970        assert!(manifest.validate().is_ok());
971        assert_eq!(manifest.supported_platforms().len(), 6);
972    }
973
974    #[test]
975    fn Manifest___json_roundtrip___preserves_data() {
976        let mut manifest = Manifest::new("test-plugin", "1.0.0");
977        manifest.plugin.description = Some("A test plugin".to_string());
978        manifest.add_platform(
979            Platform::LinuxX86_64,
980            "lib/linux-x86_64/libtest.so",
981            "abc123",
982        );
983        manifest.add_platform(
984            Platform::DarwinAarch64,
985            "lib/darwin-aarch64/libtest.dylib",
986            "def456",
987        );
988
989        let json = manifest.to_json().unwrap();
990        let parsed = Manifest::from_json(&json).unwrap();
991
992        assert_eq!(parsed.plugin.name, manifest.plugin.name);
993        assert_eq!(parsed.plugin.version, manifest.plugin.version);
994        assert_eq!(parsed.plugin.description, manifest.plugin.description);
995        assert_eq!(parsed.platforms.len(), 2);
996    }
997
998    #[test]
999    fn Manifest___json_roundtrip___preserves_all_plugin_fields() {
1000        let mut manifest = Manifest::new("full-plugin", "2.3.4");
1001        manifest.plugin.description = Some("Full description".to_string());
1002        manifest.plugin.authors = vec!["Author 1".to_string(), "Author 2".to_string()];
1003        manifest.plugin.license = Some("Apache-2.0".to_string());
1004        manifest.plugin.repository = Some("https://github.com/test/repo".to_string());
1005        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1006
1007        let json = manifest.to_json().unwrap();
1008        let parsed = Manifest::from_json(&json).unwrap();
1009
1010        assert_eq!(parsed.plugin.description, manifest.plugin.description);
1011        assert_eq!(parsed.plugin.authors, manifest.plugin.authors);
1012        assert_eq!(parsed.plugin.license, manifest.plugin.license);
1013        assert_eq!(parsed.plugin.repository, manifest.plugin.repository);
1014    }
1015
1016    #[test]
1017    fn Manifest___json_roundtrip___preserves_schemas() {
1018        let mut manifest = Manifest::new("schema-plugin", "1.0.0");
1019        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1020        manifest.add_schema(
1021            "messages.h".to_string(),
1022            "schema/messages.h".to_string(),
1023            "c-header".to_string(),
1024            "sha256:abc".to_string(),
1025            Some("C header for binary transport".to_string()),
1026        );
1027
1028        let json = manifest.to_json().unwrap();
1029        let parsed = Manifest::from_json(&json).unwrap();
1030
1031        assert_eq!(parsed.schemas.len(), 1);
1032        let schema = parsed.schemas.get("messages.h").unwrap();
1033        assert_eq!(schema.path, "schema/messages.h");
1034        assert_eq!(schema.format, "c-header");
1035        assert_eq!(schema.checksum, "sha256:abc");
1036        assert_eq!(
1037            schema.description,
1038            Some("C header for binary transport".to_string())
1039        );
1040    }
1041
1042    #[test]
1043    fn Manifest___json_roundtrip___preserves_public_key() {
1044        let mut manifest = Manifest::new("signed-plugin", "1.0.0");
1045        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1046        manifest.set_public_key("RWSxxxxxxxxxxxxxxxx".to_string());
1047
1048        let json = manifest.to_json().unwrap();
1049        let parsed = Manifest::from_json(&json).unwrap();
1050
1051        assert_eq!(parsed.public_key, Some("RWSxxxxxxxxxxxxxxxx".to_string()));
1052    }
1053
1054    #[test]
1055    fn Manifest___from_json___invalid_json___returns_error() {
1056        let result = Manifest::from_json("{ invalid }");
1057
1058        assert!(result.is_err());
1059    }
1060
1061    #[test]
1062    fn Manifest___from_json___missing_required_fields___returns_error() {
1063        let result = Manifest::from_json(r#"{"bundle_version": "1.0"}"#);
1064
1065        assert!(result.is_err());
1066    }
1067
1068    #[test]
1069    fn Manifest___supported_platforms___returns_all_platforms() {
1070        let mut manifest = Manifest::new("test", "1.0.0");
1071        manifest.add_platform(Platform::LinuxX86_64, "lib/a.so", "a");
1072        manifest.add_platform(Platform::DarwinAarch64, "lib/b.dylib", "b");
1073
1074        let platforms = manifest.supported_platforms();
1075        assert_eq!(platforms.len(), 2);
1076        assert!(platforms.contains(&Platform::LinuxX86_64));
1077        assert!(platforms.contains(&Platform::DarwinAarch64));
1078    }
1079
1080    #[test]
1081    fn Manifest___get_platform___returns_none_for_unsupported() {
1082        let mut manifest = Manifest::new("test", "1.0.0");
1083        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1084
1085        assert!(manifest.get_platform(Platform::LinuxX86_64).is_some());
1086        assert!(manifest.get_platform(Platform::WindowsX86_64).is_none());
1087    }
1088
1089    #[test]
1090    fn BuildInfo___default___all_fields_none() {
1091        let build_info = BuildInfo::default();
1092
1093        assert!(build_info.built_by.is_none());
1094        assert!(build_info.built_at.is_none());
1095        assert!(build_info.host.is_none());
1096        assert!(build_info.compiler.is_none());
1097        assert!(build_info.rustbridge_version.is_none());
1098        assert!(build_info.git.is_none());
1099    }
1100
1101    #[test]
1102    fn Manifest___build_info___roundtrip() {
1103        let mut manifest = Manifest::new("test", "1.0.0");
1104        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1105        manifest.set_build_info(BuildInfo {
1106            built_by: Some("GitHub Actions".to_string()),
1107            built_at: Some("2025-01-26T10:30:00Z".to_string()),
1108            host: Some("x86_64-unknown-linux-gnu".to_string()),
1109            compiler: Some("rustc 1.90.0".to_string()),
1110            rustbridge_version: Some("0.2.0".to_string()),
1111            git: Some(GitInfo {
1112                commit: "abc123".to_string(),
1113                branch: Some("main".to_string()),
1114                tag: Some("v1.0.0".to_string()),
1115                dirty: Some(false),
1116            }),
1117            custom: None,
1118        });
1119
1120        let json = manifest.to_json().unwrap();
1121        let parsed = Manifest::from_json(&json).unwrap();
1122
1123        let build_info = parsed.get_build_info().unwrap();
1124        assert_eq!(build_info.built_by, Some("GitHub Actions".to_string()));
1125        assert_eq!(build_info.compiler, Some("rustc 1.90.0".to_string()));
1126
1127        let git = build_info.git.as_ref().unwrap();
1128        assert_eq!(git.commit, "abc123");
1129        assert_eq!(git.branch, Some("main".to_string()));
1130        assert_eq!(git.dirty, Some(false));
1131    }
1132
1133    #[test]
1134    fn Manifest___sbom___roundtrip() {
1135        let mut manifest = Manifest::new("test", "1.0.0");
1136        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1137        manifest.set_sbom(Sbom {
1138            cyclonedx: Some("sbom/sbom.cdx.json".to_string()),
1139            spdx: Some("sbom/sbom.spdx.json".to_string()),
1140        });
1141
1142        let json = manifest.to_json().unwrap();
1143        let parsed = Manifest::from_json(&json).unwrap();
1144
1145        let sbom = parsed.get_sbom().unwrap();
1146        assert_eq!(sbom.cyclonedx, Some("sbom/sbom.cdx.json".to_string()));
1147        assert_eq!(sbom.spdx, Some("sbom/sbom.spdx.json".to_string()));
1148    }
1149
1150    #[test]
1151    fn Manifest___variants___roundtrip() {
1152        let mut manifest = Manifest::new("test", "1.0.0");
1153        manifest.add_platform_variant(
1154            Platform::LinuxX86_64,
1155            "release",
1156            "lib/linux-x86_64/release/libtest.so",
1157            "hash1",
1158            Some(serde_json::json!({
1159                "profile": "release",
1160                "opt_level": "3"
1161            })),
1162        );
1163        manifest.add_platform_variant(
1164            Platform::LinuxX86_64,
1165            "debug",
1166            "lib/linux-x86_64/debug/libtest.so",
1167            "hash2",
1168            Some(serde_json::json!({
1169                "profile": "debug",
1170                "opt_level": "0"
1171            })),
1172        );
1173
1174        let json = manifest.to_json().unwrap();
1175        let parsed = Manifest::from_json(&json).unwrap();
1176
1177        let variants = parsed.list_variants(Platform::LinuxX86_64);
1178        assert_eq!(variants.len(), 2);
1179        assert!(variants.contains(&"release"));
1180        assert!(variants.contains(&"debug"));
1181
1182        let release = parsed
1183            .get_variant(Platform::LinuxX86_64, Some("release"))
1184            .unwrap();
1185        assert_eq!(release.library, "lib/linux-x86_64/release/libtest.so");
1186        assert_eq!(
1187            release.build.as_ref().unwrap()["profile"],
1188            serde_json::json!("release")
1189        );
1190    }
1191
1192    #[test]
1193    fn Manifest___schema_checksum___roundtrip() {
1194        let mut manifest = Manifest::new("test", "1.0.0");
1195        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1196        manifest.set_schema_checksum("sha256:abcdef123456".to_string());
1197
1198        let json = manifest.to_json().unwrap();
1199        let parsed = Manifest::from_json(&json).unwrap();
1200
1201        assert_eq!(parsed.get_schema_checksum(), Some("sha256:abcdef123456"));
1202    }
1203
1204    #[test]
1205    fn Manifest___notices___roundtrip() {
1206        let mut manifest = Manifest::new("test", "1.0.0");
1207        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1208        manifest.set_notices("docs/NOTICES.txt".to_string());
1209
1210        let json = manifest.to_json().unwrap();
1211        let parsed = Manifest::from_json(&json).unwrap();
1212
1213        assert_eq!(parsed.get_notices(), Some("docs/NOTICES.txt"));
1214    }
1215
1216    #[test]
1217    fn Manifest___license_file___roundtrip() {
1218        let mut manifest = Manifest::new("test", "1.0.0");
1219        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1220        manifest.set_license_file("legal/LICENSE".to_string());
1221
1222        let json = manifest.to_json().unwrap();
1223        let parsed = Manifest::from_json(&json).unwrap();
1224
1225        assert_eq!(parsed.get_license_file(), Some("legal/LICENSE"));
1226    }
1227
1228    #[test]
1229    fn is_valid_variant_name___accepts_valid_names() {
1230        assert!(is_valid_variant_name("release"));
1231        assert!(is_valid_variant_name("debug"));
1232        assert!(is_valid_variant_name("nightly"));
1233        assert!(is_valid_variant_name("opt-size"));
1234        assert!(is_valid_variant_name("v1"));
1235        assert!(is_valid_variant_name("build123"));
1236    }
1237
1238    #[test]
1239    fn is_valid_variant_name___rejects_invalid_names() {
1240        assert!(!is_valid_variant_name("")); // Empty
1241        assert!(!is_valid_variant_name("RELEASE")); // Uppercase
1242        assert!(!is_valid_variant_name("Release")); // Mixed case
1243        assert!(!is_valid_variant_name("-debug")); // Starts with hyphen
1244        assert!(!is_valid_variant_name("debug-")); // Ends with hyphen
1245        assert!(!is_valid_variant_name("debug build")); // Contains space
1246        assert!(!is_valid_variant_name("debug_build")); // Contains underscore
1247    }
1248
1249    #[test]
1250    fn PlatformInfo___new___creates_release_variant() {
1251        let platform_info =
1252            PlatformInfo::new("lib/test.so".to_string(), "sha256:abc123".to_string());
1253
1254        assert!(platform_info.has_variant("release"));
1255        let release = platform_info.release().unwrap();
1256        assert_eq!(release.library, "lib/test.so");
1257        assert_eq!(release.checksum, "sha256:abc123");
1258    }
1259
1260    #[test]
1261    fn PlatformInfo___variant_names___returns_all_variants() {
1262        let mut platform_info =
1263            PlatformInfo::new("lib/release.so".to_string(), "sha256:abc".to_string());
1264        platform_info.add_variant(
1265            "debug".to_string(),
1266            VariantInfo {
1267                library: "lib/debug.so".to_string(),
1268                checksum: "sha256:def".to_string(),
1269                build: None,
1270                build_info: None,
1271                sbom: None,
1272                schema_checksum: None,
1273                schemas: HashMap::new(),
1274            },
1275        );
1276
1277        let names = platform_info.variant_names();
1278        assert_eq!(names.len(), 2);
1279        assert!(names.contains(&"release"));
1280        assert!(names.contains(&"debug"));
1281    }
1282
1283    // ========================================================================
1284    // v1.1 variant-level metadata tests
1285    // ========================================================================
1286
1287    #[test]
1288    fn Manifest___validate___accepts_version_1_1() {
1289        let mut manifest = Manifest::new("test", "1.0.0");
1290        manifest.bundle_version = "1.1".to_string();
1291        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1292
1293        assert!(manifest.validate().is_ok());
1294    }
1295
1296    #[test]
1297    fn Manifest___validate___rejects_unknown_version() {
1298        let mut manifest = Manifest::new("test", "1.0.0");
1299        manifest.bundle_version = "2.0".to_string();
1300        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1301
1302        let result = manifest.validate();
1303
1304        assert!(result.is_err());
1305        assert!(
1306            result
1307                .unwrap_err()
1308                .to_string()
1309                .contains("unsupported bundle_version")
1310        );
1311    }
1312
1313    #[test]
1314    fn Manifest___json_roundtrip___preserves_variant_level_build_info() {
1315        let mut manifest = Manifest::new("test", "1.0.0");
1316        manifest.bundle_version = "1.1".to_string();
1317        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1318
1319        let platform_info = manifest.platforms.get_mut("linux-x86_64").unwrap();
1320        let release = platform_info.variants.get_mut("release").unwrap();
1321        release.build_info = Some(BuildInfo {
1322            built_by: Some("Linux CI".to_string()),
1323            host: Some("x86_64-unknown-linux-gnu".to_string()),
1324            ..Default::default()
1325        });
1326
1327        let json = manifest.to_json().unwrap();
1328        let parsed = Manifest::from_json(&json).unwrap();
1329
1330        let vi = parsed
1331            .get_variant(Platform::LinuxX86_64, Some("release"))
1332            .unwrap();
1333        let bi = vi.build_info.as_ref().unwrap();
1334        assert_eq!(bi.built_by, Some("Linux CI".to_string()));
1335        assert_eq!(bi.host, Some("x86_64-unknown-linux-gnu".to_string()));
1336    }
1337
1338    #[test]
1339    fn Manifest___json_roundtrip___preserves_variant_level_sbom() {
1340        let mut manifest = Manifest::new("test", "1.0.0");
1341        manifest.bundle_version = "1.1".to_string();
1342        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1343
1344        let platform_info = manifest.platforms.get_mut("linux-x86_64").unwrap();
1345        let release = platform_info.variants.get_mut("release").unwrap();
1346        release.sbom = Some(Sbom {
1347            cyclonedx: Some("sbom/linux.cdx.json".to_string()),
1348            spdx: None,
1349        });
1350
1351        let json = manifest.to_json().unwrap();
1352        let parsed = Manifest::from_json(&json).unwrap();
1353
1354        let vi = parsed
1355            .get_variant(Platform::LinuxX86_64, Some("release"))
1356            .unwrap();
1357        let sbom = vi.sbom.as_ref().unwrap();
1358        assert_eq!(sbom.cyclonedx, Some("sbom/linux.cdx.json".to_string()));
1359    }
1360
1361    #[test]
1362    fn Manifest___json_roundtrip___preserves_variant_level_schema_checksum() {
1363        let mut manifest = Manifest::new("test", "1.0.0");
1364        manifest.bundle_version = "1.1".to_string();
1365        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1366
1367        let platform_info = manifest.platforms.get_mut("linux-x86_64").unwrap();
1368        let release = platform_info.variants.get_mut("release").unwrap();
1369        release.schema_checksum = Some("sha256:variant_checksum".to_string());
1370
1371        let json = manifest.to_json().unwrap();
1372        let parsed = Manifest::from_json(&json).unwrap();
1373
1374        let vi = parsed
1375            .get_variant(Platform::LinuxX86_64, Some("release"))
1376            .unwrap();
1377        assert_eq!(
1378            vi.schema_checksum,
1379            Some("sha256:variant_checksum".to_string())
1380        );
1381    }
1382
1383    #[test]
1384    fn Manifest___json_roundtrip___preserves_variant_level_schemas() {
1385        let mut manifest = Manifest::new("test", "1.0.0");
1386        manifest.bundle_version = "1.1".to_string();
1387        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1388
1389        let platform_info = manifest.platforms.get_mut("linux-x86_64").unwrap();
1390        let release = platform_info.variants.get_mut("release").unwrap();
1391        release.schemas.insert(
1392            "messages.h".to_string(),
1393            SchemaInfo {
1394                path: "schema/messages.h".to_string(),
1395                format: "c-header".to_string(),
1396                checksum: "sha256:abc".to_string(),
1397                description: None,
1398            },
1399        );
1400
1401        let json = manifest.to_json().unwrap();
1402        let parsed = Manifest::from_json(&json).unwrap();
1403
1404        let vi = parsed
1405            .get_variant(Platform::LinuxX86_64, Some("release"))
1406            .unwrap();
1407        assert_eq!(vi.schemas.len(), 1);
1408        assert!(vi.schemas.contains_key("messages.h"));
1409    }
1410
1411    #[test]
1412    fn get_effective_build_info___variant_overrides_top_level() {
1413        let mut manifest = Manifest::new("test", "1.0.0");
1414        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1415        manifest.set_build_info(BuildInfo {
1416            built_by: Some("top-level".to_string()),
1417            ..Default::default()
1418        });
1419
1420        let platform_info = manifest.platforms.get_mut("linux-x86_64").unwrap();
1421        let release = platform_info.variants.get_mut("release").unwrap();
1422        release.build_info = Some(BuildInfo {
1423            built_by: Some("variant-level".to_string()),
1424            ..Default::default()
1425        });
1426
1427        let effective = manifest
1428            .get_effective_build_info("linux-x86_64", "release")
1429            .unwrap();
1430        assert_eq!(effective.built_by, Some("variant-level".to_string()));
1431    }
1432
1433    #[test]
1434    fn get_effective_build_info___falls_back_to_top_level() {
1435        let mut manifest = Manifest::new("test", "1.0.0");
1436        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1437        manifest.set_build_info(BuildInfo {
1438            built_by: Some("top-level".to_string()),
1439            ..Default::default()
1440        });
1441
1442        let effective = manifest
1443            .get_effective_build_info("linux-x86_64", "release")
1444            .unwrap();
1445        assert_eq!(effective.built_by, Some("top-level".to_string()));
1446    }
1447
1448    #[test]
1449    fn get_effective_build_info___returns_none_when_neither_set() {
1450        let mut manifest = Manifest::new("test", "1.0.0");
1451        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1452
1453        assert!(
1454            manifest
1455                .get_effective_build_info("linux-x86_64", "release")
1456                .is_none()
1457        );
1458    }
1459
1460    #[test]
1461    fn has_variant_level_metadata___true_when_build_info_set() {
1462        let mut manifest = Manifest::new("test", "1.0.0");
1463        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1464
1465        let platform_info = manifest.platforms.get_mut("linux-x86_64").unwrap();
1466        let release = platform_info.variants.get_mut("release").unwrap();
1467        release.build_info = Some(BuildInfo::default());
1468
1469        assert!(manifest.has_variant_level_metadata());
1470    }
1471
1472    #[test]
1473    fn has_variant_level_metadata___false_when_no_variant_metadata() {
1474        let mut manifest = Manifest::new("test", "1.0.0");
1475        manifest.add_platform(Platform::LinuxX86_64, "lib/test.so", "hash");
1476
1477        assert!(!manifest.has_variant_level_metadata());
1478    }
1479
1480    #[test]
1481    fn Manifest___from_json___v10_without_new_fields___backward_compat() {
1482        let json = r#"{
1483            "bundle_version": "1.0",
1484            "plugin": { "name": "test", "version": "1.0.0" },
1485            "platforms": {
1486                "linux-x86_64": {
1487                    "variants": {
1488                        "release": {
1489                            "library": "lib/linux-x86_64/release/libtest.so",
1490                            "checksum": "sha256:abc123"
1491                        }
1492                    }
1493                }
1494            }
1495        }"#;
1496
1497        let manifest = Manifest::from_json(json).unwrap();
1498
1499        assert_eq!(manifest.bundle_version, "1.0");
1500        let vi = manifest
1501            .get_variant(Platform::LinuxX86_64, Some("release"))
1502            .unwrap();
1503        assert!(vi.build_info.is_none());
1504        assert!(vi.sbom.is_none());
1505        assert!(vi.schema_checksum.is_none());
1506        assert!(vi.schemas.is_empty());
1507    }
1508}