Skip to main content

cdx_core/
manifest.rs

1//! Manifest structure and types.
2//!
3//! The manifest (`manifest.json`) is the root metadata structure of a Codex document.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::{DocumentId, DocumentState, HashAlgorithm};
9
10/// Document manifest - the root metadata structure.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Manifest {
13    /// Specification version (e.g., "0.1").
14    pub codex: String,
15
16    /// Content-addressable document identifier.
17    pub id: DocumentId,
18
19    /// Document lifecycle state.
20    pub state: DocumentState,
21
22    /// Creation timestamp.
23    pub created: DateTime<Utc>,
24
25    /// Last modification timestamp.
26    pub modified: DateTime<Utc>,
27
28    /// Content layer reference.
29    pub content: ContentRef,
30
31    /// Metadata references.
32    pub metadata: Metadata,
33
34    /// Hash algorithm used (defaults to SHA-256).
35    #[serde(
36        rename = "hashAlgorithm",
37        default,
38        skip_serializing_if = "is_default_algorithm"
39    )]
40    pub hash_algorithm: HashAlgorithm,
41
42    /// Presentation layer references.
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub presentation: Vec<PresentationRef>,
45
46    /// Asset manifest.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub assets: Option<AssetManifest>,
49
50    /// Security layer reference.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub security: Option<SecurityRef>,
53
54    /// Phantom clusters reference.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub phantoms: Option<PhantomsRef>,
57
58    /// Active extensions.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub extensions: Vec<Extension>,
61
62    /// Version history and parent reference.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub lineage: Option<Lineage>,
65}
66
67#[allow(clippy::trivially_copy_pass_by_ref)] // Required by serde skip_serializing_if
68fn is_default_algorithm(alg: &HashAlgorithm) -> bool {
69    *alg == HashAlgorithm::Sha256
70}
71
72impl Manifest {
73    /// Create a new manifest with required fields.
74    #[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    /// Check if an extension is declared in the manifest.
96    ///
97    /// Extension IDs use dot notation like "codex.semantic" or "codex.legal".
98    /// This method checks if the given namespace (e.g., "semantic", "legal")
99    /// matches any declared extension.
100    #[must_use]
101    pub fn has_extension(&self, namespace: &str) -> bool {
102        // Check for exact match or codex.{namespace} format
103        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    /// Get a declared extension by namespace.
111    ///
112    /// Returns the extension declaration if found.
113    #[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    /// Get all declared extension IDs.
123    #[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    /// Check if the manifest is valid.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if:
133    /// - The Codex version is unsupported
134    /// - State requirements are not met (e.g., frozen documents without signatures)
135    pub fn validate(&self) -> crate::Result<()> {
136        // Check version
137        if !self.codex.starts_with("0.") {
138            return Err(crate::Error::UnsupportedVersion {
139                version: self.codex.clone(),
140            });
141        }
142
143        // Check state requirements
144        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        // Note: requires_lineage() indicates lineage *may* be required (for forked documents).
152        // Per spec, lineage is only mandatory for forked documents (those with a parent).
153        // Root documents can transition directly to Frozen/Published without lineage.
154        // Enforcement for forked documents is handled at the Document level.
155
156        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        // Check precise layout requirement for frozen/published states
164        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    /// Check if the manifest contains a precise layout reference.
175    #[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    /// Get all precise layout references.
183    #[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/// Reference to a file within the archive.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct FileRef {
195    /// Relative path within archive.
196    pub path: String,
197
198    /// Hash of file contents.
199    pub hash: DocumentId,
200
201    /// Compression method used.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub compression: Option<String>,
204}
205
206/// Reference to the content layer.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct ContentRef {
209    /// Relative path within archive.
210    pub path: String,
211
212    /// Hash of file contents.
213    pub hash: DocumentId,
214
215    /// Compression method used.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub compression: Option<String>,
218
219    /// Merkle root hash of the content blocks.
220    #[serde(
221        default,
222        skip_serializing_if = "Option::is_none",
223        rename = "merkleRoot"
224    )]
225    pub merkle_root: Option<DocumentId>,
226
227    /// Number of content blocks.
228    #[serde(
229        default,
230        skip_serializing_if = "Option::is_none",
231        rename = "blockCount"
232    )]
233    pub block_count: Option<usize>,
234}
235
236/// Reference to a presentation layer.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct PresentationRef {
239    /// Presentation type identifier.
240    #[serde(rename = "type")]
241    pub presentation_type: String,
242
243    /// Relative path within archive.
244    pub path: String,
245
246    /// Hash of file contents.
247    pub hash: DocumentId,
248
249    /// Whether this is the default presentation.
250    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
251    pub default: bool,
252}
253
254/// Metadata references.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct Metadata {
257    /// Path to Dublin Core metadata.
258    #[serde(rename = "dublinCore")]
259    pub dublin_core: String,
260
261    /// Path to custom metadata.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub custom: Option<String>,
264}
265
266/// Asset manifest.
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct AssetManifest {
269    /// Image assets.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub images: Option<AssetCategory>,
272
273    /// Font assets.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub fonts: Option<AssetCategory>,
276
277    /// Embedded file assets.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub embeds: Option<AssetCategory>,
280}
281
282/// Asset category summary.
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct AssetCategory {
285    /// Number of assets.
286    pub count: u32,
287
288    /// Total size in bytes.
289    #[serde(rename = "totalSize")]
290    pub total_size: u64,
291
292    /// Path to asset index file.
293    pub index: String,
294}
295
296/// Phantom clusters reference.
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct PhantomsRef {
299    /// Path to phantoms file within the archive.
300    pub path: String,
301
302    /// Hash of the phantoms file contents.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub hash: Option<DocumentId>,
305}
306
307/// Security layer reference.
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct SecurityRef {
310    /// Path to signatures file.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub signatures: Option<String>,
313
314    /// Path to encryption metadata.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub encryption: Option<String>,
317}
318
319/// Extension declaration.
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321pub struct Extension {
322    /// Extension identifier (e.g., "codex.semantic", "codex.legal").
323    pub id: String,
324
325    /// Extension version.
326    pub version: String,
327
328    /// Whether extension is required for correct rendering.
329    ///
330    /// If `true`, readers that don't support this extension should fail.
331    /// If `false`, readers may render fallback content or skip extension blocks.
332    pub required: bool,
333}
334
335impl Extension {
336    /// Create a new extension declaration.
337    #[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    /// Create a new required extension declaration.
347    #[must_use]
348    pub fn required(id: impl Into<String>, version: impl Into<String>) -> Self {
349        Self::new(id, version, true)
350    }
351
352    /// Create a new optional extension declaration.
353    #[must_use]
354    pub fn optional(id: impl Into<String>, version: impl Into<String>) -> Self {
355        Self::new(id, version, false)
356    }
357
358    /// Extract the namespace from the extension ID.
359    ///
360    /// For "codex.semantic", returns "semantic".
361    /// For "semantic", returns "semantic".
362    #[must_use]
363    pub fn namespace(&self) -> &str {
364        self.id.rsplit('.').next().unwrap_or(&self.id)
365    }
366}
367
368/// Version history and document relationships.
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370pub struct Lineage {
371    /// Document ID of parent version.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub parent: Option<DocumentId>,
374
375    /// Up to 10 levels of ancestors for efficient chain verification.
376    /// Ordered from nearest (parent's parent) to furthest ancestor.
377    #[serde(default, skip_serializing_if = "Vec::is_empty")]
378    pub ancestors: Vec<DocumentId>,
379
380    /// Sequential version number.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub version: Option<u32>,
383
384    /// Distance from the root document (0 for root, 1 for first child, etc.).
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub depth: Option<u32>,
387
388    /// Branch identifier.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub branch: Option<String>,
391
392    /// Document IDs of documents merged into this version.
393    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "mergedFrom")]
394    pub merged_from: Vec<DocumentId>,
395
396    /// Description of changes.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub note: Option<String>,
399}
400
401impl Lineage {
402    /// Create a new root lineage (first version of a document).
403    #[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    /// Create lineage that derives from a parent document.
417    ///
418    /// This automatically computes the ancestor chain (up to 10 levels)
419    /// and increments the depth.
420    #[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            // Build ancestor chain: parent's ancestors, prepended with parent's parent
424            let mut new_ancestors = Vec::with_capacity(10);
425
426            // Add parent's parent (if any) as first ancestor
427            if let Some(ref grandparent) = pl.parent {
428                new_ancestors.push(grandparent.clone());
429            }
430
431            // Add parent's ancestors (up to 9 more to keep total at 10)
432            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            // Parent has no lineage, this becomes depth 1
442            (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    /// Add a note describing the changes in this version.
457    #[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    /// Set the branch name.
464    #[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    /// Record that this version was created by merging another document.
471    #[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        // Valid SHA256 hash (64 hex chars = 32 bytes)
542        "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        // Without precise layout, validation should fail
571        let result = manifest.validate();
572        assert!(result.is_err());
573        assert!(matches!(
574            result.unwrap_err(),
575            crate::Error::StateRequirementNotMet { .. }
576        ));
577
578        // Add precise layout reference
579        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        // Now validation should pass
587        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        // Add a reactive presentation
608        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        // Add a precise layout
617        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        // Draft state should validate without precise layout
642        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()); // Root has no parent, so child has no ancestors
663        assert_eq!(child.version, Some(2));
664        assert_eq!(child.depth, Some(1));
665    }
666
667    #[test]
668    fn test_lineage_ancestor_chain() {
669        // Create a chain: root -> v2 -> v3
670        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        // v3 should have v2 as parent and root_id in ancestors
686        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    // Extension tests
694
695    #[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        // Check by namespace
754        assert!(manifest.has_extension("semantic"));
755        assert!(manifest.has_extension("legal"));
756        assert!(!manifest.has_extension("forms"));
757
758        // Check by full ID
759        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        // Per spec, root documents (non-forked) can go to Frozen without lineage
839        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        // No lineage set — this is a root document
859        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        // Root document in Frozen state without lineage should be valid
867        assert!(manifest.validate().is_ok());
868    }
869
870    #[test]
871    fn test_root_document_published_without_lineage() {
872        // Per spec, root documents can also go to Published without lineage
873        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        // Forked document with lineage in Frozen state should still pass
905        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        // Deserializing a manifest without the phantoms field should default to None
980        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}