Skip to main content

rustbridge_bundle/
manifest.rs

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