use serde::{Deserialize, Serialize};
use crate::error::{ItemKind, MindError, Result};
use crate::paths::Paths;
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>,
}
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 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);
}
}