use std::collections::BTreeMap;
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::content::ContentHash;
use crate::error::NapError;
use crate::types::EntityType;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub id: String,
pub name: String,
pub entity_type: EntityType,
#[serde(default)]
pub version: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub principals: Option<Principal>,
#[serde(default)]
pub properties: BTreeMap<String, serde_yaml::Value>,
#[serde(default)]
pub representations: BTreeMap<String, Representation>,
#[serde(default)]
pub references: BTreeMap<String, serde_yaml::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<Provenance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Principal {
#[serde(default)]
pub owners: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub maintainers: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub publishers: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Representation {
pub hash: String,
pub format: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tier: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Provenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub parameters: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub derived_from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
impl Manifest {
pub fn new(universe: &str, entity_type: EntityType, entity_id: &str, name: &str) -> Self {
let id = match entity_type {
EntityType::World => format!("nap://{universe}/world/{universe}"),
_ => format!("nap://{universe}/{entity_type}/{entity_id}"),
};
Self {
id,
name: name.to_string(),
entity_type,
version: 0,
principals: None,
properties: BTreeMap::new(),
representations: BTreeMap::new(),
references: BTreeMap::new(),
provenance: None,
head: None,
metadata: BTreeMap::new(),
}
}
pub fn to_yaml(&self) -> Result<String, NapError> {
serde_yaml::to_string(self).map_err(|e| NapError::ManifestValidationError(e.to_string()))
}
pub fn from_yaml(yaml: &str) -> Result<Self, NapError> {
serde_yaml::from_str(yaml).map_err(|e| NapError::ManifestParseError {
path: "<string>".to_string(),
source: e,
})
}
pub fn from_file(path: &Path) -> Result<Self, NapError> {
let content = std::fs::read_to_string(path)?;
serde_yaml::from_str(&content).map_err(|e| NapError::ManifestParseError {
path: path.display().to_string(),
source: e,
})
}
pub fn to_file(&self, path: &Path) -> Result<(), NapError> {
let yaml = self.to_yaml()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, yaml).map_err(|e| NapError::ManifestWriteError {
path: path.display().to_string(),
source: e,
})
}
pub fn to_value(&self) -> Result<serde_yaml::Value, NapError> {
serde_yaml::to_value(self).map_err(|e| NapError::ManifestValidationError(e.to_string()))
}
pub fn to_json_value(&self) -> Result<serde_json::Value, NapError> {
serde_json::to_value(self).map_err(|e| NapError::ManifestValidationError(e.to_string()))
}
pub fn content_hash(&self) -> Result<ContentHash, NapError> {
let yaml = self.to_yaml()?;
Ok(ContentHash::from_str_content(&yaml))
}
pub fn set_representation(&mut self, key: &str, repr: Representation) {
self.representations.insert(key.to_string(), repr);
}
pub fn set_property(&mut self, key: &str, value: serde_yaml::Value) {
self.properties.insert(key.to_string(), value);
}
pub fn add_reference(&mut self, key: &str, value: serde_yaml::Value) {
self.references.insert(key.to_string(), value);
}
pub fn bump_version(&mut self) {
self.version += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manifest_new() {
let manifest = Manifest::new(
"starwars",
EntityType::Character,
"lukeskywalker",
"Luke Skywalker",
);
assert_eq!(manifest.id, "nap://starwars/character/lukeskywalker");
assert_eq!(manifest.name, "Luke Skywalker");
assert_eq!(manifest.entity_type, EntityType::Character);
assert_eq!(manifest.version, 0);
}
#[test]
fn test_manifest_yaml_roundtrip() {
let mut manifest = Manifest::new(
"starwars",
EntityType::Character,
"lukeskywalker",
"Luke Skywalker",
);
manifest.set_property("species", serde_yaml::Value::String("human".to_string()));
manifest.set_representation(
"reference_image",
Representation {
hash: "blake3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.to_string(),
format: "png".to_string(),
uri: Some("gs://assets/luke/ref.png".to_string()),
tier: Some("production".to_string()),
},
);
let yaml = manifest.to_yaml().unwrap();
let parsed = Manifest::from_yaml(&yaml).unwrap();
assert_eq!(parsed.id, manifest.id);
assert_eq!(parsed.name, manifest.name);
assert!(parsed.properties.contains_key("species"));
assert!(parsed.representations.contains_key("reference_image"));
}
#[test]
fn test_manifest_world_uri() {
let manifest = Manifest::new(
"starwars",
EntityType::World,
"starwars",
"Star Wars Universe",
);
assert_eq!(manifest.id, "nap://starwars/world/starwars");
}
#[test]
fn test_manifest_content_hash_deterministic() {
let manifest = Manifest::new(
"starwars",
EntityType::Character,
"lukeskywalker",
"Luke Skywalker",
);
let hash_a = manifest.content_hash().unwrap();
let hash_b = manifest.content_hash().unwrap();
assert_eq!(hash_a, hash_b);
}
}