use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{DocumentId, DocumentState, HashAlgorithm};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub codex: String,
pub id: DocumentId,
pub state: DocumentState,
pub created: DateTime<Utc>,
pub modified: DateTime<Utc>,
pub content: ContentRef,
pub metadata: Metadata,
#[serde(
rename = "hashAlgorithm",
default,
skip_serializing_if = "is_default_algorithm"
)]
pub hash_algorithm: HashAlgorithm,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub presentation: Vec<PresentationRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assets: Option<AssetManifest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub security: Option<SecurityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phantoms: Option<PhantomsRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<Extension>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lineage: Option<Lineage>,
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn is_default_algorithm(alg: &HashAlgorithm) -> bool {
*alg == HashAlgorithm::Sha256
}
impl Manifest {
#[must_use]
pub fn new(content: ContentRef, metadata: Metadata) -> Self {
let now = Utc::now();
Self {
codex: crate::SPEC_VERSION.to_string(),
id: DocumentId::pending(),
state: DocumentState::Draft,
created: now,
modified: now,
content,
metadata,
hash_algorithm: HashAlgorithm::default(),
presentation: Vec::new(),
assets: None,
security: None,
phantoms: None,
extensions: Vec::new(),
lineage: None,
}
}
#[must_use]
pub fn has_extension(&self, namespace: &str) -> bool {
self.extensions.iter().any(|ext| {
ext.id == namespace
|| ext.id == format!("codex.{namespace}")
|| ext.id.ends_with(&format!(".{namespace}"))
})
}
#[must_use]
pub fn get_extension(&self, namespace: &str) -> Option<&Extension> {
self.extensions.iter().find(|ext| {
ext.id == namespace
|| ext.id == format!("codex.{namespace}")
|| ext.id.ends_with(&format!(".{namespace}"))
})
}
#[must_use]
pub fn declared_extension_ids(&self) -> Vec<&str> {
self.extensions.iter().map(|e| e.id.as_str()).collect()
}
pub fn validate(&self) -> crate::Result<()> {
if !self.codex.starts_with("0.") {
return Err(crate::Error::UnsupportedVersion {
version: self.codex.clone(),
});
}
if self.state.requires_signature() && self.security.is_none() {
return Err(crate::Error::StateRequirementNotMet {
state: self.state,
requirement: "security signatures".to_string(),
});
}
if self.state.requires_computed_id() && self.id.is_pending() {
return Err(crate::Error::StateRequirementNotMet {
state: self.state,
requirement: "computed document ID".to_string(),
});
}
if self.state.requires_precise_layout() && !self.has_precise_layout() {
return Err(crate::Error::StateRequirementNotMet {
state: self.state,
requirement: "at least one precise layout".to_string(),
});
}
Ok(())
}
#[must_use]
pub fn has_precise_layout(&self) -> bool {
self.presentation
.iter()
.any(|p| p.presentation_type == "precise")
}
#[must_use]
pub fn precise_layouts(&self) -> Vec<&PresentationRef> {
self.presentation
.iter()
.filter(|p| p.presentation_type == "precise")
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileRef {
pub path: String,
pub hash: DocumentId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compression: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentRef {
pub path: String,
pub hash: DocumentId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compression: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "merkleRoot"
)]
pub merkle_root: Option<DocumentId>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "blockCount"
)]
pub block_count: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresentationRef {
#[serde(rename = "type")]
pub presentation_type: String,
pub path: String,
pub hash: DocumentId,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub default: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Metadata {
#[serde(rename = "dublinCore")]
pub dublin_core: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetManifest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub images: Option<AssetCategory>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fonts: Option<AssetCategory>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embeds: Option<AssetCategory>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetCategory {
pub count: u32,
#[serde(rename = "totalSize")]
pub total_size: u64,
pub index: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PhantomsRef {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hash: Option<DocumentId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityRef {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signatures: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encryption: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Extension {
pub id: String,
pub version: String,
pub required: bool,
}
impl Extension {
#[must_use]
pub fn new(id: impl Into<String>, version: impl Into<String>, required: bool) -> Self {
Self {
id: id.into(),
version: version.into(),
required,
}
}
#[must_use]
pub fn required(id: impl Into<String>, version: impl Into<String>) -> Self {
Self::new(id, version, true)
}
#[must_use]
pub fn optional(id: impl Into<String>, version: impl Into<String>) -> Self {
Self::new(id, version, false)
}
#[must_use]
pub fn namespace(&self) -> &str {
self.id.rsplit('.').next().unwrap_or(&self.id)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lineage {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<DocumentId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ancestors: Vec<DocumentId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub depth: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty", rename = "mergedFrom")]
pub merged_from: Vec<DocumentId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
impl Lineage {
#[must_use]
pub fn root() -> Self {
Self {
parent: None,
ancestors: Vec::new(),
version: Some(1),
depth: Some(0),
branch: None,
merged_from: Vec::new(),
note: None,
}
}
#[must_use]
pub fn from_parent(parent_id: DocumentId, parent_lineage: Option<&Lineage>) -> Self {
let (ancestors, depth, version) = if let Some(pl) = parent_lineage {
let mut new_ancestors = Vec::with_capacity(10);
if let Some(ref grandparent) = pl.parent {
new_ancestors.push(grandparent.clone());
}
for ancestor in pl.ancestors.iter().take(9) {
new_ancestors.push(ancestor.clone());
}
let new_depth = pl.depth.map_or(1, |d| d + 1);
let new_version = pl.version.map_or(2, |v| v + 1);
(new_ancestors, Some(new_depth), Some(new_version))
} else {
(Vec::new(), Some(1), Some(2))
};
Self {
parent: Some(parent_id),
ancestors,
version,
depth,
branch: parent_lineage.and_then(|pl| pl.branch.clone()),
merged_from: Vec::new(),
note: None,
}
}
#[must_use]
pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.note = Some(note.into());
self
}
#[must_use]
pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
self.branch = Some(branch.into());
self
}
#[must_use]
pub fn with_merge(mut self, merged_id: DocumentId) -> Self {
self.merged_from.push(merged_id);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manifest_creation() {
let content = ContentRef {
path: "content/document.json".to_string(),
hash: DocumentId::pending(),
compression: None,
merkle_root: None,
block_count: None,
};
let metadata = Metadata {
dublin_core: "metadata/dublin-core.json".to_string(),
custom: None,
};
let manifest = Manifest::new(content, metadata);
assert_eq!(manifest.codex, "0.1");
assert_eq!(manifest.state, DocumentState::Draft);
assert!(manifest.id.is_pending());
}
#[test]
fn test_manifest_validation() {
let content = ContentRef {
path: "content/document.json".to_string(),
hash: DocumentId::pending(),
compression: None,
merkle_root: None,
block_count: None,
};
let metadata = Metadata {
dublin_core: "metadata/dublin-core.json".to_string(),
custom: None,
};
let manifest = Manifest::new(content, metadata);
assert!(manifest.validate().is_ok());
}
#[test]
fn test_manifest_serialization() {
let content = ContentRef {
path: "content/document.json".to_string(),
hash: DocumentId::pending(),
compression: None,
merkle_root: None,
block_count: None,
};
let metadata = Metadata {
dublin_core: "metadata/dublin-core.json".to_string(),
custom: None,
};
let manifest = Manifest::new(content, metadata);
let json = serde_json::to_string_pretty(&manifest).unwrap();
assert!(json.contains("\"codex\": \"0.1\""));
assert!(json.contains("\"state\": \"draft\""));
}
fn test_hash() -> DocumentId {
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
.parse()
.unwrap()
}
#[test]
fn test_frozen_requires_precise_layout() {
let content = ContentRef {
path: "content/document.json".to_string(),
hash: test_hash(),
compression: None,
merkle_root: None,
block_count: None,
};
let metadata = Metadata {
dublin_core: "metadata/dublin-core.json".to_string(),
custom: None,
};
let mut manifest = Manifest::new(content, metadata);
manifest.id = test_hash();
manifest.state = DocumentState::Frozen;
manifest.security = Some(SecurityRef {
signatures: Some("security/signatures.json".to_string()),
encryption: None,
});
manifest.lineage = Some(Lineage::root());
let result = manifest.validate();
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
crate::Error::StateRequirementNotMet { .. }
));
manifest.presentation.push(PresentationRef {
presentation_type: "precise".to_string(),
path: "presentation/layouts/letter.json".to_string(),
hash: test_hash(),
default: false,
});
assert!(manifest.validate().is_ok());
}
#[test]
fn test_has_precise_layout() {
let content = ContentRef {
path: "content/document.json".to_string(),
hash: DocumentId::pending(),
compression: None,
merkle_root: None,
block_count: None,
};
let metadata = Metadata {
dublin_core: "metadata/dublin-core.json".to_string(),
custom: None,
};
let mut manifest = Manifest::new(content, metadata);
assert!(!manifest.has_precise_layout());
manifest.presentation.push(PresentationRef {
presentation_type: "paginated".to_string(),
path: "presentation/paginated.json".to_string(),
hash: test_hash(),
default: true,
});
assert!(!manifest.has_precise_layout());
manifest.presentation.push(PresentationRef {
presentation_type: "precise".to_string(),
path: "presentation/layouts/letter.json".to_string(),
hash: test_hash(),
default: false,
});
assert!(manifest.has_precise_layout());
}
#[test]
fn test_draft_does_not_require_precise_layout() {
let content = ContentRef {
path: "content/document.json".to_string(),
hash: DocumentId::pending(),
compression: None,
merkle_root: None,
block_count: None,
};
let metadata = Metadata {
dublin_core: "metadata/dublin-core.json".to_string(),
custom: None,
};
let manifest = Manifest::new(content, metadata);
assert!(manifest.validate().is_ok());
}
#[test]
fn test_lineage_root() {
let lineage = Lineage::root();
assert!(lineage.parent.is_none());
assert!(lineage.ancestors.is_empty());
assert_eq!(lineage.version, Some(1));
assert_eq!(lineage.depth, Some(0));
}
#[test]
fn test_lineage_from_parent() {
let parent_id = test_hash();
let parent_lineage = Lineage::root();
let child = Lineage::from_parent(parent_id.clone(), Some(&parent_lineage));
assert_eq!(child.parent, Some(parent_id));
assert!(child.ancestors.is_empty()); assert_eq!(child.version, Some(2));
assert_eq!(child.depth, Some(1));
}
#[test]
fn test_lineage_ancestor_chain() {
let root_id = test_hash();
let root_lineage = Lineage::root();
let v2_id: DocumentId =
"sha256:1111111111111111111111111111111111111111111111111111111111111111"
.parse()
.unwrap();
let v2_lineage = Lineage::from_parent(root_id.clone(), Some(&root_lineage));
let _v3_id: DocumentId =
"sha256:2222222222222222222222222222222222222222222222222222222222222222"
.parse()
.unwrap();
let v3_lineage = Lineage::from_parent(v2_id.clone(), Some(&v2_lineage));
assert_eq!(v3_lineage.parent, Some(v2_id));
assert_eq!(v3_lineage.ancestors.len(), 1);
assert_eq!(v3_lineage.ancestors[0], root_id);
assert_eq!(v3_lineage.depth, Some(2));
assert_eq!(v3_lineage.version, Some(3));
}
#[test]
fn test_extension_new() {
let ext = Extension::new("codex.semantic", "0.1", true);
assert_eq!(ext.id, "codex.semantic");
assert_eq!(ext.version, "0.1");
assert!(ext.required);
}
#[test]
fn test_extension_required() {
let ext = Extension::required("codex.legal", "0.1");
assert!(ext.required);
}
#[test]
fn test_extension_optional() {
let ext = Extension::optional("codex.forms", "0.1");
assert!(!ext.required);
}
#[test]
fn test_extension_namespace() {
assert_eq!(
Extension::new("codex.semantic", "0.1", true).namespace(),
"semantic"
);
assert_eq!(
Extension::new("semantic", "0.1", true).namespace(),
"semantic"
);
assert_eq!(
Extension::new("org.example.custom", "0.1", true).namespace(),
"custom"
);
}
#[test]
fn test_manifest_has_extension() {
let content = ContentRef {
path: "content/document.json".to_string(),
hash: DocumentId::pending(),
compression: None,
merkle_root: None,
block_count: None,
};
let metadata = Metadata {
dublin_core: "metadata/dublin-core.json".to_string(),
custom: None,
};
let mut manifest = Manifest::new(content, metadata);
manifest
.extensions
.push(Extension::required("codex.semantic", "0.1"));
manifest
.extensions
.push(Extension::optional("codex.legal", "0.1"));
assert!(manifest.has_extension("semantic"));
assert!(manifest.has_extension("legal"));
assert!(!manifest.has_extension("forms"));
assert!(manifest.has_extension("codex.semantic"));
assert!(manifest.has_extension("codex.legal"));
}
#[test]
fn test_manifest_get_extension() {
let content = ContentRef {
path: "content/document.json".to_string(),
hash: DocumentId::pending(),
compression: None,
merkle_root: None,
block_count: None,
};
let metadata = Metadata {
dublin_core: "metadata/dublin-core.json".to_string(),
custom: None,
};
let mut manifest = Manifest::new(content, metadata);
manifest
.extensions
.push(Extension::required("codex.semantic", "0.1"));
let ext = manifest.get_extension("semantic");
assert!(ext.is_some());
assert_eq!(ext.unwrap().id, "codex.semantic");
assert!(ext.unwrap().required);
assert!(manifest.get_extension("forms").is_none());
}
#[test]
fn test_manifest_declared_extension_ids() {
let content = ContentRef {
path: "content/document.json".to_string(),
hash: DocumentId::pending(),
compression: None,
merkle_root: None,
block_count: None,
};
let metadata = Metadata {
dublin_core: "metadata/dublin-core.json".to_string(),
custom: None,
};
let mut manifest = Manifest::new(content, metadata);
manifest
.extensions
.push(Extension::required("codex.semantic", "0.1"));
manifest
.extensions
.push(Extension::optional("codex.forms", "0.1"));
let ids = manifest.declared_extension_ids();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"codex.semantic"));
assert!(ids.contains(&"codex.forms"));
}
#[test]
fn test_extension_serialization() {
let ext = Extension::required("codex.semantic", "0.1");
let json = serde_json::to_string(&ext).unwrap();
assert!(json.contains("\"id\":\"codex.semantic\""));
assert!(json.contains("\"version\":\"0.1\""));
assert!(json.contains("\"required\":true"));
}
#[test]
fn test_extension_deserialization() {
let json = r#"{"id":"codex.legal","version":"0.1","required":false}"#;
let ext: Extension = serde_json::from_str(json).unwrap();
assert_eq!(ext.id, "codex.legal");
assert_eq!(ext.version, "0.1");
assert!(!ext.required);
}
#[test]
fn test_root_document_frozen_without_lineage() {
let content = ContentRef {
path: "content/document.json".to_string(),
hash: test_hash(),
compression: None,
merkle_root: None,
block_count: None,
};
let metadata = Metadata {
dublin_core: "metadata/dublin-core.json".to_string(),
custom: None,
};
let mut manifest = Manifest::new(content, metadata);
manifest.id = test_hash();
manifest.state = DocumentState::Frozen;
manifest.security = Some(SecurityRef {
signatures: Some("security/signatures.json".to_string()),
encryption: None,
});
manifest.presentation.push(PresentationRef {
presentation_type: "precise".to_string(),
path: "presentation/layouts/letter.json".to_string(),
hash: test_hash(),
default: false,
});
assert!(manifest.validate().is_ok());
}
#[test]
fn test_root_document_published_without_lineage() {
let content = ContentRef {
path: "content/document.json".to_string(),
hash: test_hash(),
compression: None,
merkle_root: None,
block_count: None,
};
let metadata = Metadata {
dublin_core: "metadata/dublin-core.json".to_string(),
custom: None,
};
let mut manifest = Manifest::new(content, metadata);
manifest.id = test_hash();
manifest.state = DocumentState::Published;
manifest.security = Some(SecurityRef {
signatures: Some("security/signatures.json".to_string()),
encryption: None,
});
manifest.presentation.push(PresentationRef {
presentation_type: "precise".to_string(),
path: "presentation/layouts/letter.json".to_string(),
hash: test_hash(),
default: false,
});
assert!(manifest.validate().is_ok());
}
#[test]
fn test_forked_document_frozen_with_lineage() {
let content = ContentRef {
path: "content/document.json".to_string(),
hash: test_hash(),
compression: None,
merkle_root: None,
block_count: None,
};
let metadata = Metadata {
dublin_core: "metadata/dublin-core.json".to_string(),
custom: None,
};
let mut manifest = Manifest::new(content, metadata);
manifest.id = test_hash();
manifest.state = DocumentState::Frozen;
manifest.security = Some(SecurityRef {
signatures: Some("security/signatures.json".to_string()),
encryption: None,
});
let mut lineage = Lineage::root();
lineage.parent = Some(test_hash());
manifest.lineage = Some(lineage);
manifest.presentation.push(PresentationRef {
presentation_type: "precise".to_string(),
path: "presentation/layouts/letter.json".to_string(),
hash: test_hash(),
default: false,
});
assert!(manifest.validate().is_ok());
}
#[test]
fn test_phantoms_ref_roundtrip_present() {
let phantoms = PhantomsRef {
path: "phantoms/clusters.json".to_string(),
hash: Some(test_hash()),
};
let json = serde_json::to_string(&phantoms).unwrap();
let parsed: PhantomsRef = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, phantoms);
}
#[test]
fn test_phantoms_ref_roundtrip_no_hash() {
let phantoms = PhantomsRef {
path: "phantoms/clusters.json".to_string(),
hash: None,
};
let json = serde_json::to_string(&phantoms).unwrap();
assert!(!json.contains("hash"));
let parsed: PhantomsRef = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, phantoms);
}
#[test]
fn test_manifest_phantoms_default_none() {
let content = ContentRef {
path: "content/document.json".to_string(),
hash: DocumentId::pending(),
compression: None,
merkle_root: None,
block_count: None,
};
let metadata = Metadata {
dublin_core: "metadata/dublin-core.json".to_string(),
custom: None,
};
let manifest = Manifest::new(content, metadata);
assert!(manifest.phantoms.is_none());
}
#[test]
fn test_manifest_backward_compat_no_phantoms() {
let json = r#"{
"codex": "0.1",
"id": "pending",
"state": "draft",
"created": "2024-01-01T00:00:00Z",
"modified": "2024-01-01T00:00:00Z",
"content": { "path": "content/document.json", "hash": "pending" },
"metadata": { "dublinCore": "metadata/dublin-core.json" }
}"#;
let manifest: Manifest = serde_json::from_str(json).unwrap();
assert!(manifest.phantoms.is_none());
}
}