use serde::{Deserialize, Serialize};
use crate::core::config::{atomic_write, installs_path};
use crate::core::error::{SsError, ERR_STATE_CORRUPT};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallRecord {
pub canonical_id: String,
pub slug: String,
pub name: String,
pub kind: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub agents: Vec<String>,
#[serde(default)]
pub changes: Vec<InstallChange>,
pub installed_at: chrono::DateTime<chrono::Utc>,
#[serde(default)]
pub seen_score: Option<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InstallChange {
File { path: String },
ConfigKey {
file: String,
key: String,
#[serde(default)]
prior: Option<serde_json::Value>,
},
MarkerBlock {
file: String,
#[serde(default)]
prior: Option<String>,
},
}
pub fn load() -> Result<Vec<InstallRecord>, SsError> {
let path = installs_path();
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(SsError::new(
ERR_STATE_CORRUPT,
format!("Failed to read {}: {e}", path.display()),
))
}
};
if raw.trim().is_empty() {
return Ok(Vec::new());
}
serde_json::from_str(&raw).map_err(|e| {
SsError::new(ERR_STATE_CORRUPT, format!("installs.json is corrupt: {e}"))
.with_suggestion("Run `saferskills doctor` to repair the registry.")
})
}
pub fn save(records: &[InstallRecord]) -> Result<(), SsError> {
let json = serde_json::to_vec_pretty(records).map_err(|e| {
SsError::new(
ERR_STATE_CORRUPT,
format!("Failed to serialize registry: {e}"),
)
})?;
atomic_write(&installs_path(), &json)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> InstallRecord {
InstallRecord {
canonical_id: "id-1".into(),
slug: "acme--kit--skill-pdf".into(),
name: "pdf".into(),
kind: "skill".into(),
version: Some("abc123".into()),
agents: vec!["claude-code".into()],
changes: vec![
InstallChange::File {
path: "/x/SKILL.md".into(),
},
InstallChange::ConfigKey {
file: "~/.claude.json".into(),
key: "mcpServers.pdf".into(),
prior: None,
},
],
installed_at: chrono::DateTime::from_timestamp(0, 0).unwrap(),
seen_score: Some(87),
}
}
#[test]
fn record_roundtrips_json() {
let rec = sample();
let json = serde_json::to_string(&rec).unwrap();
let back: InstallRecord = serde_json::from_str(&json).unwrap();
assert_eq!(back.slug, rec.slug);
assert_eq!(back.changes.len(), 2);
assert_eq!(back.seen_score, Some(87));
}
#[test]
fn install_change_is_internally_tagged() {
let json = serde_json::to_string(&InstallChange::File { path: "/p".into() }).unwrap();
assert!(json.contains("\"type\":\"file\""));
}
#[test]
fn corrupt_registry_is_an_error() {
let parsed: Result<Vec<InstallRecord>, _> = serde_json::from_str("{not json");
assert!(parsed.is_err());
}
}