use serde::{Deserialize, Serialize};
use crate::error::{ItemKind, MindError, Result};
use crate::paths::Paths;
use crate::source::RecordedHook;
mod kind_serde {
use super::ItemKind;
use serde::{Deserialize, Deserializer, Serializer, de::Error};
pub fn serialize<S: Serializer>(kind: &ItemKind, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(kind.as_str())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ItemKind, D::Error> {
let raw = String::deserialize(d)?;
ItemKind::parse(&raw).ok_or_else(|| D::Error::custom(format!("unknown item kind '{raw}'")))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledItem {
#[serde(with = "kind_serde")]
pub kind: ItemKind,
pub name: String,
pub bare_name: String,
pub source: String,
pub commit: String,
pub hash: String,
pub store: String,
pub links: Vec<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub install_hooks: Vec<RecordedHook>,
}
impl InstalledItem {
pub fn key(&self) -> String {
format!("{}:{}", self.kind.as_str(), self.name)
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Manifest {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub items: std::collections::BTreeMap<String, InstalledItem>,
}
const MANIFEST_VERSION: u32 = 1;
fn default_version() -> u32 {
1
}
impl Default for Manifest {
fn default() -> Self {
Manifest {
version: MANIFEST_VERSION,
items: Default::default(),
}
}
}
impl Manifest {
pub fn load(paths: &Paths) -> Result<Self> {
let file = paths.manifest_file();
match std::fs::read(&file) {
Ok(bytes) => {
let m: Manifest = serde_json::from_slice(&bytes)
.map_err(|e| MindError::json("manifest.json", e))?;
if m.version > MANIFEST_VERSION {
return Err(MindError::StateTooNew {
what: "manifest.json",
found: m.version,
supported: MANIFEST_VERSION,
});
}
Ok(m)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Manifest::default()),
Err(e) => Err(MindError::io(&file, e)),
}
}
pub fn save(&self, paths: &Paths) -> Result<()> {
paths.ensure_layout()?;
let file = paths.manifest_file();
let json =
serde_json::to_vec_pretty(self).map_err(|e| MindError::json("manifest.json", e))?;
Paths::atomic_write(&file, &json)
}
pub fn insert(&mut self, item: InstalledItem) {
self.items.insert(item.key(), item);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
fn tmp_paths() -> (std::path::PathBuf, Paths) {
let n = N.fetch_add(1, Ordering::SeqCst);
let base =
std::env::temp_dir().join(format!("mind-manifest-ver-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let paths = Paths {
mind_home: base.clone(),
claude_home: base.join("claude"),
};
(base, paths)
}
#[test]
fn malformed_manifest_json_is_a_json_error_naming_the_file() {
let (base, paths) = tmp_paths();
std::fs::write(base.join("manifest.json"), "{\"items\": [ truncated").unwrap();
match Manifest::load(&paths) {
Err(MindError::Json { what, .. }) => assert_eq!(what, "manifest.json"),
other => panic!("expected a Json error naming manifest.json, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn manifest_missing_version_is_treated_as_one() {
let (base, paths) = tmp_paths();
std::fs::create_dir_all(&base).unwrap();
std::fs::write(base.join("manifest.json"), r#"{"items":{}}"#).unwrap();
let m = Manifest::load(&paths).expect("must load without version field");
assert_eq!(m.version, 1, "missing version must default to 1");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn manifest_version_one_loads_ok() {
let (base, paths) = tmp_paths();
std::fs::create_dir_all(&base).unwrap();
std::fs::write(base.join("manifest.json"), r#"{"version":1,"items":{}}"#).unwrap();
let m = Manifest::load(&paths).expect("version 1 must load");
assert_eq!(m.version, 1);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn manifest_too_new_version_is_state_too_new_error() {
let (base, paths) = tmp_paths();
std::fs::create_dir_all(&base).unwrap();
std::fs::write(base.join("manifest.json"), r#"{"version":99,"items":{}}"#).unwrap();
let err = Manifest::load(&paths).unwrap_err();
match err {
MindError::StateTooNew {
what,
found,
supported,
} => {
assert_eq!(what, "manifest.json");
assert_eq!(found, 99);
assert_eq!(supported, MANIFEST_VERSION);
}
other => panic!("expected StateTooNew, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&base);
}
fn stub_item(name: &str) -> InstalledItem {
InstalledItem {
kind: ItemKind::Skill,
name: name.to_string(),
bare_name: name.to_string(),
source: "local/test".to_string(),
commit: "abc123".to_string(),
hash: "deadbeef".to_string(),
store: format!("store/skill/{name}"),
links: Vec::new(),
description: None,
install_hooks: Vec::new(),
}
}
#[test]
fn install_hooks_record_round_trips_through_save_and_load() {
let (base, paths) = tmp_paths();
let mut item = stub_item("scanner");
item.install_hooks = vec![
RecordedHook {
command: "touch ran.sentinel".to_string(),
ran_at: Some("abc123".to_string()),
},
RecordedHook {
command: "touch skipped.sentinel".to_string(),
ran_at: None,
},
];
let mut manifest = Manifest::default();
manifest.insert(item.clone());
manifest.save(&paths).expect("save manifest");
let loaded = Manifest::load(&paths).expect("load manifest");
let back = loaded.items.get(&item.key()).expect("item present");
assert_eq!(
back.install_hooks, item.install_hooks,
"install_hooks must round-trip unchanged"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn install_hooks_missing_from_an_older_manifest_deserializes_as_empty() {
let (base, paths) = tmp_paths();
std::fs::write(
base.join("manifest.json"),
r#"{"version":1,"items":{"skill:scanner":{
"kind":"skill","name":"scanner","bare_name":"scanner",
"source":"local/test","commit":"abc123","hash":"deadbeef",
"store":"store/skill/scanner","links":[]
}}}"#,
)
.unwrap();
let m = Manifest::load(&paths).expect("must load a pre-HOOK-110 manifest");
let item = m.items.get("skill:scanner").expect("item present");
assert!(
item.install_hooks.is_empty(),
"a missing install_hooks field must default to empty, got {:?}",
item.install_hooks
);
let _ = std::fs::remove_dir_all(&base);
}
}