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