1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Manifest {
46 pub id: String,
49
50 pub name: String,
52
53 pub entity_type: EntityType,
55
56 #[serde(default)]
58 pub version: u64,
59
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub principals: Option<Principal>,
63
64 #[serde(default)]
69 pub properties: BTreeMap<String, serde_yaml::Value>,
70
71 #[serde(default)]
74 pub representations: BTreeMap<String, Representation>,
75
76 #[serde(default)]
79 pub references: BTreeMap<String, serde_yaml::Value>,
80
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub provenance: Option<Provenance>,
84
85 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
87 pub metadata: BTreeMap<String, serde_yaml::Value>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct Principal {
93 #[serde(default)]
95 pub owners: Vec<String>,
96
97 #[serde(default, skip_serializing_if = "Vec::is_empty")]
99 pub maintainers: Vec<String>,
100
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub publishers: Vec<String>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Representation {
109 pub hash: String,
111
112 pub format: String,
114
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub uri: Option<String>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub tier: Option<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct Provenance {
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub model: Option<String>,
130
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub prompt_hash: Option<String>,
134
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub seed: Option<String>,
138
139 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
141 pub parameters: BTreeMap<String, String>,
142
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub derived_from: Option<String>,
146
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub created_at: Option<DateTime<Utc>>,
150}
151
152impl Manifest {
153 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 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 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 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 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 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 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 pub fn content_hash(&self) -> Result<ContentHash, NapError> {
221 let yaml = self.to_yaml()?;
222 Ok(ContentHash::from_str_content(&yaml))
223 }
224
225 pub fn set_representation(&mut self, key: &str, repr: Representation) {
227 self.representations.insert(key.to_string(), repr);
228 }
229
230 pub fn set_property(&mut self, key: &str, value: serde_yaml::Value) {
232 self.properties.insert(key.to_string(), value);
233 }
234
235 pub fn add_reference(&mut self, key: &str, value: serde_yaml::Value) {
237 self.references.insert(key.to_string(), value);
238 }
239
240 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}