1use crate::{BUNDLE_VERSION, 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")]
50 pub api: Option<ApiInfo>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub public_key: Option<String>,
56
57 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
59 pub schemas: HashMap<String, SchemaInfo>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct PluginInfo {
65 pub name: String,
67
68 pub version: String,
70
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub description: Option<String>,
74
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
77 pub authors: Vec<String>,
78
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub license: Option<String>,
82
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub repository: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct PlatformInfo {
94 pub variants: HashMap<String, VariantInfo>,
97}
98
99impl PlatformInfo {
100 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 #[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)]
155pub struct VariantInfo {
156 pub library: String,
159
160 pub checksum: String,
163
164 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub build: Option<serde_json::Value>,
173}
174
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
180pub struct BuildInfo {
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub built_by: Option<String>,
184
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub built_at: Option<String>,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub host: Option<String>,
192
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub compiler: Option<String>,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub rustbridge_version: Option<String>,
200
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub git: Option<GitInfo>,
204
205 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub custom: Option<HashMap<String, String>>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct GitInfo {
217 pub commit: String,
219
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub branch: Option<String>,
223
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub tag: Option<String>,
227
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub dirty: Option<bool>,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct Sbom {
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub cyclonedx: Option<String>,
242
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub spdx: Option<String>,
246}
247
248fn is_valid_variant_name(name: &str) -> bool {
253 if name.is_empty() {
254 return false;
255 }
256
257 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 name.chars()
268 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct SchemaInfo {
274 pub path: String,
276
277 pub format: String,
279
280 pub checksum: String,
282
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub description: Option<String>,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct ApiInfo {
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub min_rustbridge_version: Option<String>,
294
295 #[serde(default)]
297 pub transports: Vec<String>,
298
299 #[serde(default)]
301 pub messages: Vec<MessageInfo>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct MessageInfo {
307 pub type_tag: String,
309
310 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub description: Option<String>,
313
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub request_schema: Option<String>,
317
318 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub response_schema: Option<String>,
321
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub message_id: Option<u32>,
325
326 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub cstruct_request: Option<String>,
329
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub cstruct_response: Option<String>,
333}
334
335impl Manifest {
336 #[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 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_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 self.platforms.insert(
381 platform_key,
382 PlatformInfo::new(library_path.to_string(), format!("sha256:{checksum}")),
383 );
384 }
385 }
386
387 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 pub fn set_public_key(&mut self, public_key: String) {
419 self.public_key = Some(public_key);
420 }
421
422 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 pub fn set_build_info(&mut self, build_info: BuildInfo) {
444 self.build_info = Some(build_info);
445 }
446
447 #[must_use]
449 pub fn get_build_info(&self) -> Option<&BuildInfo> {
450 self.build_info.as_ref()
451 }
452
453 pub fn set_sbom(&mut self, sbom: Sbom) {
455 self.sbom = Some(sbom);
456 }
457
458 #[must_use]
460 pub fn get_sbom(&self) -> Option<&Sbom> {
461 self.sbom.as_ref()
462 }
463
464 pub fn set_schema_checksum(&mut self, checksum: String) {
466 self.schema_checksum = Some(checksum);
467 }
468
469 #[must_use]
471 pub fn get_schema_checksum(&self) -> Option<&str> {
472 self.schema_checksum.as_deref()
473 }
474
475 pub fn set_notices(&mut self, path: String) {
477 self.notices = Some(path);
478 }
479
480 #[must_use]
482 pub fn get_notices(&self) -> Option<&str> {
483 self.notices.as_deref()
484 }
485
486 pub fn set_license_file(&mut self, path: String) {
488 self.license_file = Some(path);
489 }
490
491 #[must_use]
493 pub fn get_license_file(&self) -> Option<&str> {
494 self.license_file.as_deref()
495 }
496
497 #[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 #[must_use]
509 pub fn get_release_variant(&self, platform: Platform) -> Option<&VariantInfo> {
510 self.get_variant(platform, Some("release"))
511 }
512
513 #[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 #[must_use]
524 pub fn get_platform(&self, platform: Platform) -> Option<&PlatformInfo> {
525 self.platforms.get(platform.as_str())
526 }
527
528 #[must_use]
530 pub fn supports_platform(&self, platform: Platform) -> bool {
531 self.platforms.contains_key(platform.as_str())
532 }
533
534 #[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 pub fn validate(&self) -> BundleResult<()> {
545 if self.bundle_version.is_empty() {
547 return Err(BundleError::InvalidManifest(
548 "bundle_version is required".to_string(),
549 ));
550 }
551
552 if self.plugin.name.is_empty() {
554 return Err(BundleError::InvalidManifest(
555 "plugin.name is required".to_string(),
556 ));
557 }
558
559 if self.plugin.version.is_empty() {
561 return Err(BundleError::InvalidManifest(
562 "plugin.version is required".to_string(),
563 ));
564 }
565
566 if self.platforms.is_empty() {
568 return Err(BundleError::InvalidManifest(
569 "at least one platform must be defined".to_string(),
570 ));
571 }
572
573 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 if info.variants.is_empty() {
583 return Err(BundleError::InvalidManifest(format!(
584 "platform {key}: at least one variant is required"
585 )));
586 }
587
588 if !info.variants.contains_key("release") {
590 return Err(BundleError::InvalidManifest(format!(
591 "platform {key}: 'release' variant is required"
592 )));
593 }
594
595 for (variant_name, variant_info) in &info.variants {
597 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 pub fn to_json(&self) -> BundleResult<String> {
630 Ok(serde_json::to_string_pretty(self)?)
631 }
632
633 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 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(), 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 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(), 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(), 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("")); 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")); }
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}