1use crate::{BUNDLE_VERSION, BUNDLE_VERSION_1_1, BundleError, BundleResult, Platform};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Manifest {
16 pub bundle_version: String,
18
19 pub plugin: PluginInfo,
21
22 pub platforms: HashMap<String, PlatformInfo>,
25
26 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub build_info: Option<BuildInfo>,
30
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub sbom: Option<Sbom>,
34
35 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub schema_checksum: Option<String>,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub notices: Option<String>,
43
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub license_file: Option<String>,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub public_key: Option<String>,
52
53 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
55 pub schemas: HashMap<String, SchemaInfo>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct PluginInfo {
61 pub name: String,
63
64 pub version: String,
66
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub description: Option<String>,
70
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub authors: Vec<String>,
74
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub license: Option<String>,
78
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub repository: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct PlatformInfo {
90 pub variants: HashMap<String, VariantInfo>,
93}
94
95impl PlatformInfo {
96 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 #[must_use]
116 pub fn release(&self) -> Option<&VariantInfo> {
117 self.variants.get("release")
118 }
119
120 #[must_use]
122 pub fn variant(&self, name: &str) -> Option<&VariantInfo> {
123 self.variants.get(name)
124 }
125
126 #[must_use]
128 pub fn default_variant(&self) -> Option<&VariantInfo> {
129 self.release()
130 }
131
132 #[must_use]
134 pub fn variant_names(&self) -> Vec<&str> {
135 self.variants.keys().map(String::as_str).collect()
136 }
137
138 #[must_use]
140 pub fn has_variant(&self, name: &str) -> bool {
141 self.variants.contains_key(name)
142 }
143
144 pub fn add_variant(&mut self, name: String, info: VariantInfo) {
146 self.variants.insert(name, info);
147 }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct VariantInfo {
159 pub library: String,
162
163 pub checksum: String,
166
167 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub build: Option<serde_json::Value>,
176
177 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub build_info: Option<BuildInfo>,
181
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub sbom: Option<Sbom>,
185
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub schema_checksum: Option<String>,
189
190 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
192 pub schemas: HashMap<String, SchemaInfo>,
193}
194
195#[derive(Debug, Clone, Default, Serialize, Deserialize)]
200pub struct BuildInfo {
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub built_by: Option<String>,
204
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub built_at: Option<String>,
208
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub host: Option<String>,
212
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub compiler: Option<String>,
216
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub rustbridge_version: Option<String>,
220
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub git: Option<GitInfo>,
224
225 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub custom: Option<HashMap<String, String>>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct GitInfo {
237 pub commit: String,
239
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub branch: Option<String>,
243
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub tag: Option<String>,
247
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub dirty: Option<bool>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct Sbom {
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub cyclonedx: Option<String>,
262
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub spdx: Option<String>,
266}
267
268fn is_valid_variant_name(name: &str) -> bool {
273 if name.is_empty() {
274 return false;
275 }
276
277 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 name.chars()
288 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct SchemaInfo {
294 pub path: String,
296
297 pub format: String,
299
300 pub checksum: String,
302
303 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub description: Option<String>,
306}
307
308impl Manifest {
309 #[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 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_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 self.platforms.insert(
357 platform_key,
358 PlatformInfo::new(library_path.to_string(), format!("sha256:{checksum}")),
359 );
360 }
361 }
362
363 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 pub fn set_public_key(&mut self, public_key: String) {
399 self.public_key = Some(public_key);
400 }
401
402 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 pub fn set_build_info(&mut self, build_info: BuildInfo) {
424 self.build_info = Some(build_info);
425 }
426
427 #[must_use]
429 pub fn get_build_info(&self) -> Option<&BuildInfo> {
430 self.build_info.as_ref()
431 }
432
433 pub fn set_sbom(&mut self, sbom: Sbom) {
435 self.sbom = Some(sbom);
436 }
437
438 #[must_use]
440 pub fn get_sbom(&self) -> Option<&Sbom> {
441 self.sbom.as_ref()
442 }
443
444 pub fn set_schema_checksum(&mut self, checksum: String) {
446 self.schema_checksum = Some(checksum);
447 }
448
449 #[must_use]
451 pub fn get_schema_checksum(&self) -> Option<&str> {
452 self.schema_checksum.as_deref()
453 }
454
455 pub fn set_notices(&mut self, path: String) {
457 self.notices = Some(path);
458 }
459
460 #[must_use]
462 pub fn get_notices(&self) -> Option<&str> {
463 self.notices.as_deref()
464 }
465
466 pub fn set_license_file(&mut self, path: String) {
468 self.license_file = Some(path);
469 }
470
471 #[must_use]
473 pub fn get_license_file(&self) -> Option<&str> {
474 self.license_file.as_deref()
475 }
476
477 #[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 #[must_use]
489 pub fn get_release_variant(&self, platform: Platform) -> Option<&VariantInfo> {
490 self.get_variant(platform, Some("release"))
491 }
492
493 #[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 #[must_use]
504 pub fn get_platform(&self, platform: Platform) -> Option<&PlatformInfo> {
505 self.platforms.get(platform.as_str())
506 }
507
508 #[must_use]
510 pub fn supports_platform(&self, platform: Platform) -> bool {
511 self.platforms.contains_key(platform.as_str())
512 }
513
514 #[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 #[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 #[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 #[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 #[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 #[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 pub fn validate(&self) -> BundleResult<()> {
590 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 if self.plugin.name.is_empty() {
606 return Err(BundleError::InvalidManifest(
607 "plugin.name is required".to_string(),
608 ));
609 }
610
611 if self.plugin.version.is_empty() {
613 return Err(BundleError::InvalidManifest(
614 "plugin.version is required".to_string(),
615 ));
616 }
617
618 if self.platforms.is_empty() {
620 return Err(BundleError::InvalidManifest(
621 "at least one platform must be defined".to_string(),
622 ));
623 }
624
625 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 if info.variants.is_empty() {
635 return Err(BundleError::InvalidManifest(format!(
636 "platform {key}: at least one variant is required"
637 )));
638 }
639
640 if !info.variants.contains_key("release") {
642 return Err(BundleError::InvalidManifest(format!(
643 "platform {key}: 'release' variant is required"
644 )));
645 }
646
647 for (variant_name, variant_info) in &info.variants {
649 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 pub fn to_json(&self) -> BundleResult<String> {
682 Ok(serde_json::to_string_pretty(self)?)
683 }
684
685 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 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(), 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 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(), 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(), 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("")); assert!(!is_valid_variant_name("RELEASE")); assert!(!is_valid_variant_name("Release")); assert!(!is_valid_variant_name("-debug")); assert!(!is_valid_variant_name("debug-")); assert!(!is_valid_variant_name("debug build")); assert!(!is_valid_variant_name("debug_build")); }
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 #[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}