Skip to main content

nap_core/
manifest.rs

1//! NAP Manifest — the core primitive.
2//!
3//! The manifest is the durable representation of a narrative resource.
4//! It is:
5//! - **Human-editable** — YAML, readable by worldbuilders
6//! - **Machine-editable** — structured, schema-validated
7//! - **Agent-readable** — subtree-queryable for AI workflows
8//! - **Mergeable** — YAML maps merge cleanly
9//! - **Portable** — no runtime dependency, just a file
10//! - **Signable** — hash the content, sign the hash
11//! - **Versionable** — the manifest IS what gets committed
12//!
13//! # Design: Manifest is current state. History is external.
14//!
15//! The manifest stores `head` (a pointer to the latest commit hash).
16//! The full commit history lives in the VCS, NOT inside the manifest.
17//! This prevents manifests from growing unboundedly with every edit.
18
19use std::collections::BTreeMap;
20use std::path::Path;
21
22use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24
25use crate::content::ContentHash;
26use crate::error::NapError;
27use crate::types::EntityType;
28
29/// A NAP manifest — the canonical representation of a narrative resource.
30///
31/// # Example (YAML)
32/// ```yaml
33/// id: "nap://starwars/character/lukeskywalker"
34/// name: "Luke Skywalker"
35/// entity_type: character
36/// version: 17
37/// properties:
38///   homeworld: "nap://starwars/location/tatooine"
39///   species: human
40/// representations:
41///   reference_image:
42///     hash: "blake3:af1349b9..."
43///     format: png
44/// head: "a72c9f3b..."
45/// ```
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Manifest {
48    /// The canonical NAP URI for this resource.
49    /// e.g., `"nap://starwars/character/lukeskywalker"`
50    pub id: String,
51
52    /// Human-readable name.
53    pub name: String,
54
55    /// The kind of entity this manifest describes.
56    pub entity_type: EntityType,
57
58    /// Monotonic version counter. Incremented on each commit.
59    #[serde(default)]
60    pub version: u64,
61
62    /// Access control. Owners have full control. Optional for v0.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub principals: Option<Principal>,
65
66    /// Entity-specific key-value properties.
67    /// Character: personality, species, homeworld, etc.
68    /// Scene: setting, time_of_day, mood, etc.
69    /// Location: geography, atmosphere, etc.
70    #[serde(default)]
71    pub properties: BTreeMap<String, serde_yaml::Value>,
72
73    /// Content-addressed representations of this entity.
74    /// e.g., reference_image, voice_model, mesh, splat, etc.
75    #[serde(default)]
76    pub representations: BTreeMap<String, Representation>,
77
78    /// Cross-references to other NAP resources.
79    /// e.g., appears_in, relationships, contains, etc.
80    #[serde(default)]
81    pub references: BTreeMap<String, serde_yaml::Value>,
82
83    /// AI generation provenance — which model, prompt, seed, etc.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub provenance: Option<Provenance>,
86
87    /// Pointer to the latest VCS commit hash (BLAKE3). History lives in the VCS.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub head: Option<String>,
90
91    /// Arbitrary extension metadata. Future-proof escape hatch.
92    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
93    pub metadata: BTreeMap<String, serde_yaml::Value>,
94}
95
96/// Access control principals for a manifest.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct Principal {
99    /// Full control — can modify, transfer, delete.
100    #[serde(default)]
101    pub owners: Vec<String>,
102
103    /// Can modify content but not transfer ownership.
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub maintainers: Vec<String>,
106
107    /// Can publish/distribute but not modify source.
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub publishers: Vec<String>,
110}
111
112/// A content-addressed representation of an entity.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct Representation {
115    /// BLAKE3 content hash. e.g., `"blake3:af1349b9..."`.
116    pub hash: String,
117
118    /// File format. e.g., `"png"`, `"glb"`, `"onnx"`, `"spz"`.
119    pub format: String,
120
121    /// Optional storage URI. e.g., `"gs://assets/starwars/luke/ref.png"`.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub uri: Option<String>,
124
125    /// Optional quality tier: draft, production, distribution.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub tier: Option<String>,
128}
129
130/// AI generation provenance metadata.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct Provenance {
133    /// Which AI model generated this entity.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub model: Option<String>,
136
137    /// Content-addressed hash of the prompt used.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub prompt_hash: Option<String>,
140
141    /// Generation seed for reproducibility.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub seed: Option<String>,
144
145    /// Additional generation parameters.
146    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
147    pub parameters: BTreeMap<String, String>,
148
149    /// What this entity was derived from (parent entity URI).
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub derived_from: Option<String>,
152
153    /// When this entity was created.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub created_at: Option<DateTime<Utc>>,
156}
157
158impl Manifest {
159    /// Create a new manifest with minimal required fields.
160    pub fn new(universe: &str, entity_type: EntityType, entity_id: &str, name: &str) -> Self {
161        let id = match entity_type {
162            EntityType::World => format!("nap://{universe}/world/{universe}"),
163            _ => format!("nap://{universe}/{entity_type}/{entity_id}"),
164        };
165
166        Self {
167            id,
168            name: name.to_string(),
169            entity_type,
170            version: 0,
171            principals: None,
172            properties: BTreeMap::new(),
173            representations: BTreeMap::new(),
174            references: BTreeMap::new(),
175            provenance: None,
176            head: None,
177            metadata: BTreeMap::new(),
178        }
179    }
180
181    /// Serialize this manifest to YAML.
182    pub fn to_yaml(&self) -> Result<String, NapError> {
183        serde_yaml::to_string(self).map_err(|e| NapError::ManifestValidationError(e.to_string()))
184    }
185
186    /// Deserialize a manifest from a YAML string.
187    pub fn from_yaml(yaml: &str) -> Result<Self, NapError> {
188        serde_yaml::from_str(yaml).map_err(|e| NapError::ManifestParseError {
189            path: "<string>".to_string(),
190            source: e,
191        })
192    }
193
194    /// Read a manifest from a YAML file on disk.
195    pub fn from_file(path: &Path) -> Result<Self, NapError> {
196        let content = std::fs::read_to_string(path)?;
197        serde_yaml::from_str(&content).map_err(|e| NapError::ManifestParseError {
198            path: path.display().to_string(),
199            source: e,
200        })
201    }
202
203    /// Write this manifest to a YAML file on disk.
204    pub fn to_file(&self, path: &Path) -> Result<(), NapError> {
205        let yaml = self.to_yaml()?;
206        if let Some(parent) = path.parent() {
207            std::fs::create_dir_all(parent)?;
208        }
209        std::fs::write(path, yaml).map_err(|e| NapError::ManifestWriteError {
210            path: path.display().to_string(),
211            source: e,
212        })
213    }
214
215    /// Convert the manifest to a serde_yaml::Value for query traversal.
216    pub fn to_value(&self) -> Result<serde_yaml::Value, NapError> {
217        serde_yaml::to_value(self).map_err(|e| NapError::ManifestValidationError(e.to_string()))
218    }
219
220    /// Convert the manifest to a serde_json::Value for JSON output.
221    pub fn to_json_value(&self) -> Result<serde_json::Value, NapError> {
222        serde_json::to_value(self).map_err(|e| NapError::ManifestValidationError(e.to_string()))
223    }
224
225    /// Compute the BLAKE3 hash of this manifest's YAML representation.
226    pub fn content_hash(&self) -> Result<ContentHash, NapError> {
227        let yaml = self.to_yaml()?;
228        Ok(ContentHash::from_str_content(&yaml))
229    }
230
231    /// Add or update a representation.
232    pub fn set_representation(&mut self, key: &str, repr: Representation) {
233        self.representations.insert(key.to_string(), repr);
234    }
235
236    /// Add or update a property.
237    pub fn set_property(&mut self, key: &str, value: serde_yaml::Value) {
238        self.properties.insert(key.to_string(), value);
239    }
240
241    /// Add a cross-reference.
242    pub fn add_reference(&mut self, key: &str, value: serde_yaml::Value) {
243        self.references.insert(key.to_string(), value);
244    }
245
246    /// Increment the version counter.
247    pub fn bump_version(&mut self) {
248        self.version += 1;
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_manifest_new() {
258        let manifest = Manifest::new(
259            "starwars",
260            EntityType::Character,
261            "lukeskywalker",
262            "Luke Skywalker",
263        );
264        assert_eq!(manifest.id, "nap://starwars/character/lukeskywalker");
265        assert_eq!(manifest.name, "Luke Skywalker");
266        assert_eq!(manifest.entity_type, EntityType::Character);
267        assert_eq!(manifest.version, 0);
268    }
269
270    #[test]
271    fn test_manifest_yaml_roundtrip() {
272        let mut manifest = Manifest::new(
273            "starwars",
274            EntityType::Character,
275            "lukeskywalker",
276            "Luke Skywalker",
277        );
278        manifest.set_property("species", serde_yaml::Value::String("human".to_string()));
279        manifest.set_representation(
280            "reference_image",
281            Representation {
282                hash: "blake3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
283                    .to_string(),
284                format: "png".to_string(),
285                uri: Some("gs://assets/luke/ref.png".to_string()),
286                tier: Some("production".to_string()),
287            },
288        );
289
290        let yaml = manifest.to_yaml().unwrap();
291        let parsed = Manifest::from_yaml(&yaml).unwrap();
292
293        assert_eq!(parsed.id, manifest.id);
294        assert_eq!(parsed.name, manifest.name);
295        assert!(parsed.properties.contains_key("species"));
296        assert!(parsed.representations.contains_key("reference_image"));
297    }
298
299    #[test]
300    fn test_manifest_world_uri() {
301        let manifest = Manifest::new(
302            "starwars",
303            EntityType::World,
304            "starwars",
305            "Star Wars Universe",
306        );
307        assert_eq!(manifest.id, "nap://starwars/world/starwars");
308    }
309
310    #[test]
311    fn test_manifest_content_hash_deterministic() {
312        let manifest = Manifest::new(
313            "starwars",
314            EntityType::Character,
315            "lukeskywalker",
316            "Luke Skywalker",
317        );
318        let hash_a = manifest.content_hash().unwrap();
319        let hash_b = manifest.content_hash().unwrap();
320        assert_eq!(hash_a, hash_b);
321    }
322}