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