use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{CliError, Result};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum InstallMode {
Symlink,
Copy,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Entry {
pub skill: String,
pub agent: String,
pub target: PathBuf,
pub mode: InstallMode,
pub sha256: String,
pub installed_at: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Manifest {
pub schema_version: u32,
pub entries: Vec<Entry>,
}
impl Default for Manifest {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
entries: Vec::new(),
}
}
}
impl Manifest {
pub fn empty() -> Self {
Self::default()
}
pub fn load(path: &Path) -> Result<Self> {
let bytes = fs::read(path)?;
let manifest: Manifest = serde_json::from_slice(&bytes).map_err(|_| {
CliError::Validation("manifest corrupt, run beck check --rebuild-manifest".into())
})?;
if manifest.schema_version != SCHEMA_VERSION {
return Err(CliError::Validation(format!(
"manifest schema v{} unsupported, beck only knows v{}",
manifest.schema_version, SCHEMA_VERSION
)));
}
Ok(manifest)
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp_path = tmp_sibling(path);
{
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&tmp_path)?;
let json = serde_json::to_vec_pretty(self)
.map_err(|e| CliError::Validation(format!("failed to serialize manifest: {e}")))?;
file.write_all(&json)?;
file.write_all(b"\n")?;
file.sync_all()?;
}
fs::rename(&tmp_path, path)?;
if let Some(parent) = path.parent() {
let _ = File::open(parent).and_then(|f| f.sync_all());
}
Ok(())
}
pub fn add(&mut self, entry: Entry) {
self.entries.push(entry);
}
pub fn remove(&mut self, skill: &str, agent: &str) -> Option<Entry> {
let idx = self
.entries
.iter()
.position(|e| e.skill == skill && e.agent == agent)?;
Some(self.entries.remove(idx))
}
pub fn find(&self, skill: &str, agent: &str) -> Option<&Entry> {
self.entries
.iter()
.find(|e| e.skill == skill && e.agent == agent)
}
}
fn tmp_sibling(path: &Path) -> PathBuf {
let mut os = path.as_os_str().to_os_string();
os.push(".tmp");
PathBuf::from(os)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn sample_entry(skill: &str, agent: &str) -> Entry {
Entry {
skill: skill.into(),
agent: agent.into(),
target: PathBuf::from(format!("/tmp/{skill}/SKILL.md")),
mode: InstallMode::Symlink,
sha256: "deadbeef".into(),
installed_at: "2026-04-11T02:55:00Z".into(),
}
}
fn tempdir(name: &str) -> PathBuf {
let base = std::env::temp_dir().join(format!("beck-manifest-tests-{name}"));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base).unwrap();
base
}
#[test]
fn empty_manifest_has_schema_version() {
let m = Manifest::empty();
assert_eq!(m.schema_version, SCHEMA_VERSION);
assert!(m.entries.is_empty());
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("\"schema_version\":1"));
assert!(json.contains("\"entries\":[]"));
}
#[test]
fn save_then_load_round_trip() {
let dir = tempdir("round-trip");
let path = dir.join(".beck-manifest.json");
let mut m = Manifest::empty();
m.add(sample_entry("caveman", "claude-code"));
m.add(sample_entry("compress", "claude-code"));
m.save(&path).expect("save");
let loaded = Manifest::load(&path).expect("load");
assert_eq!(loaded, m);
assert_eq!(loaded.entries.len(), 2);
assert_eq!(loaded.entries[0].skill, "caveman");
}
#[test]
fn save_is_atomic_no_leftover_tmp() {
let dir = tempdir("atomic");
let path = dir.join(".beck-manifest.json");
let m = Manifest::empty();
m.save(&path).expect("save");
let tmp = tmp_sibling(&path);
assert!(!tmp.exists(), "tmp file should be renamed away");
assert!(path.exists());
}
#[test]
fn save_overwrites_existing_atomically() {
let dir = tempdir("overwrite");
let path = dir.join(".beck-manifest.json");
let mut first = Manifest::empty();
first.add(sample_entry("one", "claude-code"));
first.save(&path).unwrap();
let mut second = Manifest::empty();
second.add(sample_entry("two", "claude-code"));
second.save(&path).unwrap();
let loaded = Manifest::load(&path).unwrap();
assert_eq!(loaded.entries.len(), 1);
assert_eq!(loaded.entries[0].skill, "two");
}
#[test]
fn add_find_remove_cycle() {
let mut m = Manifest::empty();
assert!(m.find("caveman", "claude-code").is_none());
m.add(sample_entry("caveman", "claude-code"));
assert!(m.find("caveman", "claude-code").is_some());
let removed = m.remove("caveman", "claude-code");
assert!(removed.is_some());
assert_eq!(removed.unwrap().skill, "caveman");
assert!(m.remove("caveman", "claude-code").is_none());
assert!(m.find("caveman", "claude-code").is_none());
}
#[test]
fn find_discriminates_by_agent() {
let mut m = Manifest::empty();
m.add(sample_entry("caveman", "claude-code"));
m.add(sample_entry("caveman", "cursor"));
assert!(m.find("caveman", "claude-code").is_some());
assert!(m.find("caveman", "cursor").is_some());
assert!(m.find("caveman", "nonexistent").is_none());
let removed = m.remove("caveman", "cursor").unwrap();
assert_eq!(removed.agent, "cursor");
assert_eq!(m.entries.len(), 1);
assert_eq!(m.entries[0].agent, "claude-code");
}
#[test]
fn load_rejects_corrupt_json() {
let dir = tempdir("corrupt");
let path = dir.join(".beck-manifest.json");
fs::write(&path, b"not valid json {{{").unwrap();
let err = Manifest::load(&path).expect_err("corrupt should error");
match err {
CliError::Validation(msg) => assert!(msg.contains("manifest corrupt")),
other => panic!("expected Validation, got {other:?}"),
}
}
#[test]
fn load_rejects_unknown_schema_version() {
let dir = tempdir("schema");
let path = dir.join(".beck-manifest.json");
fs::write(&path, b"{\"schema_version\":999,\"entries\":[]}").unwrap();
let err = Manifest::load(&path).expect_err("unknown schema should error");
match err {
CliError::Validation(msg) => {
assert!(msg.contains("v999"));
assert!(msg.contains("v1"));
}
other => panic!("expected Validation, got {other:?}"),
}
}
#[test]
fn install_mode_serializes_lowercase() {
let entry = sample_entry("x", "claude-code");
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"mode\":\"symlink\""));
let copy_entry = Entry {
mode: InstallMode::Copy,
..sample_entry("y", "claude-code")
};
let json = serde_json::to_string(©_entry).unwrap();
assert!(json.contains("\"mode\":\"copy\""));
}
}