1use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::{DocumentId, DocumentState, HashAlgorithm};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Manifest {
13 pub codex: String,
15
16 pub id: DocumentId,
18
19 pub state: DocumentState,
21
22 pub created: DateTime<Utc>,
24
25 pub modified: DateTime<Utc>,
27
28 pub content: ContentRef,
30
31 pub metadata: Metadata,
33
34 #[serde(
36 rename = "hashAlgorithm",
37 default,
38 skip_serializing_if = "is_default_algorithm"
39 )]
40 pub hash_algorithm: HashAlgorithm,
41
42 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub presentation: Vec<PresentationRef>,
45
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub assets: Option<AssetManifest>,
49
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub security: Option<SecurityRef>,
53
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub phantoms: Option<PhantomsRef>,
57
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub extensions: Vec<Extension>,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub lineage: Option<Lineage>,
65}
66
67#[allow(clippy::trivially_copy_pass_by_ref)] fn is_default_algorithm(alg: &HashAlgorithm) -> bool {
69 *alg == HashAlgorithm::Sha256
70}
71
72impl Manifest {
73 #[must_use]
75 pub fn new(content: ContentRef, metadata: Metadata) -> Self {
76 let now = Utc::now();
77 Self {
78 codex: crate::SPEC_VERSION.to_string(),
79 id: DocumentId::pending(),
80 state: DocumentState::Draft,
81 created: now,
82 modified: now,
83 content,
84 metadata,
85 hash_algorithm: HashAlgorithm::default(),
86 presentation: Vec::new(),
87 assets: None,
88 security: None,
89 phantoms: None,
90 extensions: Vec::new(),
91 lineage: None,
92 }
93 }
94
95 #[must_use]
101 pub fn has_extension(&self, namespace: &str) -> bool {
102 self.extensions.iter().any(|ext| {
104 ext.id == namespace
105 || ext.id == format!("codex.{namespace}")
106 || ext.id.ends_with(&format!(".{namespace}"))
107 })
108 }
109
110 #[must_use]
114 pub fn get_extension(&self, namespace: &str) -> Option<&Extension> {
115 self.extensions.iter().find(|ext| {
116 ext.id == namespace
117 || ext.id == format!("codex.{namespace}")
118 || ext.id.ends_with(&format!(".{namespace}"))
119 })
120 }
121
122 #[must_use]
124 pub fn declared_extension_ids(&self) -> Vec<&str> {
125 self.extensions.iter().map(|e| e.id.as_str()).collect()
126 }
127
128 pub fn validate(&self) -> crate::Result<()> {
136 if !self.codex.starts_with("0.") {
138 return Err(crate::Error::UnsupportedVersion {
139 version: self.codex.clone(),
140 });
141 }
142
143 if self.state.requires_signature() && self.security.is_none() {
145 return Err(crate::Error::StateRequirementNotMet {
146 state: self.state,
147 requirement: "security signatures".to_string(),
148 });
149 }
150
151 if self.state.requires_computed_id() && self.id.is_pending() {
157 return Err(crate::Error::StateRequirementNotMet {
158 state: self.state,
159 requirement: "computed document ID".to_string(),
160 });
161 }
162
163 if self.state.requires_precise_layout() && !self.has_precise_layout() {
165 return Err(crate::Error::StateRequirementNotMet {
166 state: self.state,
167 requirement: "at least one precise layout".to_string(),
168 });
169 }
170
171 Ok(())
172 }
173
174 #[must_use]
176 pub fn has_precise_layout(&self) -> bool {
177 self.presentation
178 .iter()
179 .any(|p| p.presentation_type == "precise")
180 }
181
182 #[must_use]
184 pub fn precise_layouts(&self) -> Vec<&PresentationRef> {
185 self.presentation
186 .iter()
187 .filter(|p| p.presentation_type == "precise")
188 .collect()
189 }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct FileRef {
195 pub path: String,
197
198 pub hash: DocumentId,
200
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub compression: Option<String>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct ContentRef {
209 pub path: String,
211
212 pub hash: DocumentId,
214
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub compression: Option<String>,
218
219 #[serde(
221 default,
222 skip_serializing_if = "Option::is_none",
223 rename = "merkleRoot"
224 )]
225 pub merkle_root: Option<DocumentId>,
226
227 #[serde(
229 default,
230 skip_serializing_if = "Option::is_none",
231 rename = "blockCount"
232 )]
233 pub block_count: Option<usize>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct PresentationRef {
239 #[serde(rename = "type")]
241 pub presentation_type: String,
242
243 pub path: String,
245
246 pub hash: DocumentId,
248
249 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
251 pub default: bool,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct Metadata {
257 #[serde(rename = "dublinCore")]
259 pub dublin_core: String,
260
261 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub custom: Option<String>,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct AssetManifest {
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub images: Option<AssetCategory>,
272
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub fonts: Option<AssetCategory>,
276
277 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub embeds: Option<AssetCategory>,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct AssetCategory {
285 pub count: u32,
287
288 #[serde(rename = "totalSize")]
290 pub total_size: u64,
291
292 pub index: String,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct PhantomsRef {
299 pub path: String,
301
302 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub hash: Option<DocumentId>,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct SecurityRef {
310 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub signatures: Option<String>,
313
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub encryption: Option<String>,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321pub struct Extension {
322 pub id: String,
324
325 pub version: String,
327
328 pub required: bool,
333}
334
335impl Extension {
336 #[must_use]
338 pub fn new(id: impl Into<String>, version: impl Into<String>, required: bool) -> Self {
339 Self {
340 id: id.into(),
341 version: version.into(),
342 required,
343 }
344 }
345
346 #[must_use]
348 pub fn required(id: impl Into<String>, version: impl Into<String>) -> Self {
349 Self::new(id, version, true)
350 }
351
352 #[must_use]
354 pub fn optional(id: impl Into<String>, version: impl Into<String>) -> Self {
355 Self::new(id, version, false)
356 }
357
358 #[must_use]
363 pub fn namespace(&self) -> &str {
364 self.id.rsplit('.').next().unwrap_or(&self.id)
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370pub struct Lineage {
371 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub parent: Option<DocumentId>,
374
375 #[serde(default, skip_serializing_if = "Vec::is_empty")]
378 pub ancestors: Vec<DocumentId>,
379
380 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub version: Option<u32>,
383
384 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub depth: Option<u32>,
387
388 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub branch: Option<String>,
391
392 #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "mergedFrom")]
394 pub merged_from: Vec<DocumentId>,
395
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub note: Option<String>,
399}
400
401impl Lineage {
402 #[must_use]
404 pub fn root() -> Self {
405 Self {
406 parent: None,
407 ancestors: Vec::new(),
408 version: Some(1),
409 depth: Some(0),
410 branch: None,
411 merged_from: Vec::new(),
412 note: None,
413 }
414 }
415
416 #[must_use]
421 pub fn from_parent(parent_id: DocumentId, parent_lineage: Option<&Lineage>) -> Self {
422 let (ancestors, depth, version) = if let Some(pl) = parent_lineage {
423 let mut new_ancestors = Vec::with_capacity(10);
425
426 if let Some(ref grandparent) = pl.parent {
428 new_ancestors.push(grandparent.clone());
429 }
430
431 for ancestor in pl.ancestors.iter().take(9) {
433 new_ancestors.push(ancestor.clone());
434 }
435
436 let new_depth = pl.depth.map_or(1, |d| d + 1);
437 let new_version = pl.version.map_or(2, |v| v + 1);
438
439 (new_ancestors, Some(new_depth), Some(new_version))
440 } else {
441 (Vec::new(), Some(1), Some(2))
443 };
444
445 Self {
446 parent: Some(parent_id),
447 ancestors,
448 version,
449 depth,
450 branch: parent_lineage.and_then(|pl| pl.branch.clone()),
451 merged_from: Vec::new(),
452 note: None,
453 }
454 }
455
456 #[must_use]
458 pub fn with_note(mut self, note: impl Into<String>) -> Self {
459 self.note = Some(note.into());
460 self
461 }
462
463 #[must_use]
465 pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
466 self.branch = Some(branch.into());
467 self
468 }
469
470 #[must_use]
472 pub fn with_merge(mut self, merged_id: DocumentId) -> Self {
473 self.merged_from.push(merged_id);
474 self
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn test_manifest_creation() {
484 let content = ContentRef {
485 path: "content/document.json".to_string(),
486 hash: DocumentId::pending(),
487 compression: None,
488 merkle_root: None,
489 block_count: None,
490 };
491 let metadata = Metadata {
492 dublin_core: "metadata/dublin-core.json".to_string(),
493 custom: None,
494 };
495
496 let manifest = Manifest::new(content, metadata);
497 assert_eq!(manifest.codex, "0.1");
498 assert_eq!(manifest.state, DocumentState::Draft);
499 assert!(manifest.id.is_pending());
500 }
501
502 #[test]
503 fn test_manifest_validation() {
504 let content = ContentRef {
505 path: "content/document.json".to_string(),
506 hash: DocumentId::pending(),
507 compression: None,
508 merkle_root: None,
509 block_count: None,
510 };
511 let metadata = Metadata {
512 dublin_core: "metadata/dublin-core.json".to_string(),
513 custom: None,
514 };
515
516 let manifest = Manifest::new(content, metadata);
517 assert!(manifest.validate().is_ok());
518 }
519
520 #[test]
521 fn test_manifest_serialization() {
522 let content = ContentRef {
523 path: "content/document.json".to_string(),
524 hash: DocumentId::pending(),
525 compression: None,
526 merkle_root: None,
527 block_count: None,
528 };
529 let metadata = Metadata {
530 dublin_core: "metadata/dublin-core.json".to_string(),
531 custom: None,
532 };
533
534 let manifest = Manifest::new(content, metadata);
535 let json = serde_json::to_string_pretty(&manifest).unwrap();
536 assert!(json.contains("\"codex\": \"0.1\""));
537 assert!(json.contains("\"state\": \"draft\""));
538 }
539
540 fn test_hash() -> DocumentId {
541 "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
543 .parse()
544 .unwrap()
545 }
546
547 #[test]
548 fn test_frozen_requires_precise_layout() {
549 let content = ContentRef {
550 path: "content/document.json".to_string(),
551 hash: test_hash(),
552 compression: None,
553 merkle_root: None,
554 block_count: None,
555 };
556 let metadata = Metadata {
557 dublin_core: "metadata/dublin-core.json".to_string(),
558 custom: None,
559 };
560
561 let mut manifest = Manifest::new(content, metadata);
562 manifest.id = test_hash();
563 manifest.state = DocumentState::Frozen;
564 manifest.security = Some(SecurityRef {
565 signatures: Some("security/signatures.json".to_string()),
566 encryption: None,
567 });
568 manifest.lineage = Some(Lineage::root());
569
570 let result = manifest.validate();
572 assert!(result.is_err());
573 assert!(matches!(
574 result.unwrap_err(),
575 crate::Error::StateRequirementNotMet { .. }
576 ));
577
578 manifest.presentation.push(PresentationRef {
580 presentation_type: "precise".to_string(),
581 path: "presentation/layouts/letter.json".to_string(),
582 hash: test_hash(),
583 default: false,
584 });
585
586 assert!(manifest.validate().is_ok());
588 }
589
590 #[test]
591 fn test_has_precise_layout() {
592 let content = ContentRef {
593 path: "content/document.json".to_string(),
594 hash: DocumentId::pending(),
595 compression: None,
596 merkle_root: None,
597 block_count: None,
598 };
599 let metadata = Metadata {
600 dublin_core: "metadata/dublin-core.json".to_string(),
601 custom: None,
602 };
603
604 let mut manifest = Manifest::new(content, metadata);
605 assert!(!manifest.has_precise_layout());
606
607 manifest.presentation.push(PresentationRef {
609 presentation_type: "paginated".to_string(),
610 path: "presentation/paginated.json".to_string(),
611 hash: test_hash(),
612 default: true,
613 });
614 assert!(!manifest.has_precise_layout());
615
616 manifest.presentation.push(PresentationRef {
618 presentation_type: "precise".to_string(),
619 path: "presentation/layouts/letter.json".to_string(),
620 hash: test_hash(),
621 default: false,
622 });
623 assert!(manifest.has_precise_layout());
624 }
625
626 #[test]
627 fn test_draft_does_not_require_precise_layout() {
628 let content = ContentRef {
629 path: "content/document.json".to_string(),
630 hash: DocumentId::pending(),
631 compression: None,
632 merkle_root: None,
633 block_count: None,
634 };
635 let metadata = Metadata {
636 dublin_core: "metadata/dublin-core.json".to_string(),
637 custom: None,
638 };
639
640 let manifest = Manifest::new(content, metadata);
641 assert!(manifest.validate().is_ok());
643 }
644
645 #[test]
646 fn test_lineage_root() {
647 let lineage = Lineage::root();
648 assert!(lineage.parent.is_none());
649 assert!(lineage.ancestors.is_empty());
650 assert_eq!(lineage.version, Some(1));
651 assert_eq!(lineage.depth, Some(0));
652 }
653
654 #[test]
655 fn test_lineage_from_parent() {
656 let parent_id = test_hash();
657 let parent_lineage = Lineage::root();
658
659 let child = Lineage::from_parent(parent_id.clone(), Some(&parent_lineage));
660
661 assert_eq!(child.parent, Some(parent_id));
662 assert!(child.ancestors.is_empty()); assert_eq!(child.version, Some(2));
664 assert_eq!(child.depth, Some(1));
665 }
666
667 #[test]
668 fn test_lineage_ancestor_chain() {
669 let root_id = test_hash();
671 let root_lineage = Lineage::root();
672
673 let v2_id: DocumentId =
674 "sha256:1111111111111111111111111111111111111111111111111111111111111111"
675 .parse()
676 .unwrap();
677 let v2_lineage = Lineage::from_parent(root_id.clone(), Some(&root_lineage));
678
679 let _v3_id: DocumentId =
680 "sha256:2222222222222222222222222222222222222222222222222222222222222222"
681 .parse()
682 .unwrap();
683 let v3_lineage = Lineage::from_parent(v2_id.clone(), Some(&v2_lineage));
684
685 assert_eq!(v3_lineage.parent, Some(v2_id));
687 assert_eq!(v3_lineage.ancestors.len(), 1);
688 assert_eq!(v3_lineage.ancestors[0], root_id);
689 assert_eq!(v3_lineage.depth, Some(2));
690 assert_eq!(v3_lineage.version, Some(3));
691 }
692
693 #[test]
696 fn test_extension_new() {
697 let ext = Extension::new("codex.semantic", "0.1", true);
698 assert_eq!(ext.id, "codex.semantic");
699 assert_eq!(ext.version, "0.1");
700 assert!(ext.required);
701 }
702
703 #[test]
704 fn test_extension_required() {
705 let ext = Extension::required("codex.legal", "0.1");
706 assert!(ext.required);
707 }
708
709 #[test]
710 fn test_extension_optional() {
711 let ext = Extension::optional("codex.forms", "0.1");
712 assert!(!ext.required);
713 }
714
715 #[test]
716 fn test_extension_namespace() {
717 assert_eq!(
718 Extension::new("codex.semantic", "0.1", true).namespace(),
719 "semantic"
720 );
721 assert_eq!(
722 Extension::new("semantic", "0.1", true).namespace(),
723 "semantic"
724 );
725 assert_eq!(
726 Extension::new("org.example.custom", "0.1", true).namespace(),
727 "custom"
728 );
729 }
730
731 #[test]
732 fn test_manifest_has_extension() {
733 let content = ContentRef {
734 path: "content/document.json".to_string(),
735 hash: DocumentId::pending(),
736 compression: None,
737 merkle_root: None,
738 block_count: None,
739 };
740 let metadata = Metadata {
741 dublin_core: "metadata/dublin-core.json".to_string(),
742 custom: None,
743 };
744
745 let mut manifest = Manifest::new(content, metadata);
746 manifest
747 .extensions
748 .push(Extension::required("codex.semantic", "0.1"));
749 manifest
750 .extensions
751 .push(Extension::optional("codex.legal", "0.1"));
752
753 assert!(manifest.has_extension("semantic"));
755 assert!(manifest.has_extension("legal"));
756 assert!(!manifest.has_extension("forms"));
757
758 assert!(manifest.has_extension("codex.semantic"));
760 assert!(manifest.has_extension("codex.legal"));
761 }
762
763 #[test]
764 fn test_manifest_get_extension() {
765 let content = ContentRef {
766 path: "content/document.json".to_string(),
767 hash: DocumentId::pending(),
768 compression: None,
769 merkle_root: None,
770 block_count: None,
771 };
772 let metadata = Metadata {
773 dublin_core: "metadata/dublin-core.json".to_string(),
774 custom: None,
775 };
776
777 let mut manifest = Manifest::new(content, metadata);
778 manifest
779 .extensions
780 .push(Extension::required("codex.semantic", "0.1"));
781
782 let ext = manifest.get_extension("semantic");
783 assert!(ext.is_some());
784 assert_eq!(ext.unwrap().id, "codex.semantic");
785 assert!(ext.unwrap().required);
786
787 assert!(manifest.get_extension("forms").is_none());
788 }
789
790 #[test]
791 fn test_manifest_declared_extension_ids() {
792 let content = ContentRef {
793 path: "content/document.json".to_string(),
794 hash: DocumentId::pending(),
795 compression: None,
796 merkle_root: None,
797 block_count: None,
798 };
799 let metadata = Metadata {
800 dublin_core: "metadata/dublin-core.json".to_string(),
801 custom: None,
802 };
803
804 let mut manifest = Manifest::new(content, metadata);
805 manifest
806 .extensions
807 .push(Extension::required("codex.semantic", "0.1"));
808 manifest
809 .extensions
810 .push(Extension::optional("codex.forms", "0.1"));
811
812 let ids = manifest.declared_extension_ids();
813 assert_eq!(ids.len(), 2);
814 assert!(ids.contains(&"codex.semantic"));
815 assert!(ids.contains(&"codex.forms"));
816 }
817
818 #[test]
819 fn test_extension_serialization() {
820 let ext = Extension::required("codex.semantic", "0.1");
821 let json = serde_json::to_string(&ext).unwrap();
822 assert!(json.contains("\"id\":\"codex.semantic\""));
823 assert!(json.contains("\"version\":\"0.1\""));
824 assert!(json.contains("\"required\":true"));
825 }
826
827 #[test]
828 fn test_extension_deserialization() {
829 let json = r#"{"id":"codex.legal","version":"0.1","required":false}"#;
830 let ext: Extension = serde_json::from_str(json).unwrap();
831 assert_eq!(ext.id, "codex.legal");
832 assert_eq!(ext.version, "0.1");
833 assert!(!ext.required);
834 }
835
836 #[test]
837 fn test_root_document_frozen_without_lineage() {
838 let content = ContentRef {
840 path: "content/document.json".to_string(),
841 hash: test_hash(),
842 compression: None,
843 merkle_root: None,
844 block_count: None,
845 };
846 let metadata = Metadata {
847 dublin_core: "metadata/dublin-core.json".to_string(),
848 custom: None,
849 };
850
851 let mut manifest = Manifest::new(content, metadata);
852 manifest.id = test_hash();
853 manifest.state = DocumentState::Frozen;
854 manifest.security = Some(SecurityRef {
855 signatures: Some("security/signatures.json".to_string()),
856 encryption: None,
857 });
858 manifest.presentation.push(PresentationRef {
860 presentation_type: "precise".to_string(),
861 path: "presentation/layouts/letter.json".to_string(),
862 hash: test_hash(),
863 default: false,
864 });
865
866 assert!(manifest.validate().is_ok());
868 }
869
870 #[test]
871 fn test_root_document_published_without_lineage() {
872 let content = ContentRef {
874 path: "content/document.json".to_string(),
875 hash: test_hash(),
876 compression: None,
877 merkle_root: None,
878 block_count: None,
879 };
880 let metadata = Metadata {
881 dublin_core: "metadata/dublin-core.json".to_string(),
882 custom: None,
883 };
884
885 let mut manifest = Manifest::new(content, metadata);
886 manifest.id = test_hash();
887 manifest.state = DocumentState::Published;
888 manifest.security = Some(SecurityRef {
889 signatures: Some("security/signatures.json".to_string()),
890 encryption: None,
891 });
892 manifest.presentation.push(PresentationRef {
893 presentation_type: "precise".to_string(),
894 path: "presentation/layouts/letter.json".to_string(),
895 hash: test_hash(),
896 default: false,
897 });
898
899 assert!(manifest.validate().is_ok());
900 }
901
902 #[test]
903 fn test_forked_document_frozen_with_lineage() {
904 let content = ContentRef {
906 path: "content/document.json".to_string(),
907 hash: test_hash(),
908 compression: None,
909 merkle_root: None,
910 block_count: None,
911 };
912 let metadata = Metadata {
913 dublin_core: "metadata/dublin-core.json".to_string(),
914 custom: None,
915 };
916
917 let mut manifest = Manifest::new(content, metadata);
918 manifest.id = test_hash();
919 manifest.state = DocumentState::Frozen;
920 manifest.security = Some(SecurityRef {
921 signatures: Some("security/signatures.json".to_string()),
922 encryption: None,
923 });
924 let mut lineage = Lineage::root();
925 lineage.parent = Some(test_hash());
926 manifest.lineage = Some(lineage);
927 manifest.presentation.push(PresentationRef {
928 presentation_type: "precise".to_string(),
929 path: "presentation/layouts/letter.json".to_string(),
930 hash: test_hash(),
931 default: false,
932 });
933
934 assert!(manifest.validate().is_ok());
935 }
936
937 #[test]
938 fn test_phantoms_ref_roundtrip_present() {
939 let phantoms = PhantomsRef {
940 path: "phantoms/clusters.json".to_string(),
941 hash: Some(test_hash()),
942 };
943 let json = serde_json::to_string(&phantoms).unwrap();
944 let parsed: PhantomsRef = serde_json::from_str(&json).unwrap();
945 assert_eq!(parsed, phantoms);
946 }
947
948 #[test]
949 fn test_phantoms_ref_roundtrip_no_hash() {
950 let phantoms = PhantomsRef {
951 path: "phantoms/clusters.json".to_string(),
952 hash: None,
953 };
954 let json = serde_json::to_string(&phantoms).unwrap();
955 assert!(!json.contains("hash"));
956 let parsed: PhantomsRef = serde_json::from_str(&json).unwrap();
957 assert_eq!(parsed, phantoms);
958 }
959
960 #[test]
961 fn test_manifest_phantoms_default_none() {
962 let content = ContentRef {
963 path: "content/document.json".to_string(),
964 hash: DocumentId::pending(),
965 compression: None,
966 merkle_root: None,
967 block_count: None,
968 };
969 let metadata = Metadata {
970 dublin_core: "metadata/dublin-core.json".to_string(),
971 custom: None,
972 };
973 let manifest = Manifest::new(content, metadata);
974 assert!(manifest.phantoms.is_none());
975 }
976
977 #[test]
978 fn test_manifest_backward_compat_no_phantoms() {
979 let json = r#"{
981 "codex": "0.1",
982 "id": "pending",
983 "state": "draft",
984 "created": "2024-01-01T00:00:00Z",
985 "modified": "2024-01-01T00:00:00Z",
986 "content": { "path": "content/document.json", "hash": "pending" },
987 "metadata": { "dublinCore": "metadata/dublin-core.json" }
988 }"#;
989 let manifest: Manifest = serde_json::from_str(json).unwrap();
990 assert!(manifest.phantoms.is_none());
991 }
992}