use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::core::agent_manifest::{atomic_write, checksum};
use crate::core::error::Result;
pub const SKILL_MANIFEST_FILE: &str = ".trusty-mpm-skills-manifest.json";
const SKILL_MANIFEST_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillManifestEntry {
pub checksum: String,
pub deployed_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillManifest {
pub version: u32,
pub managed: HashMap<String, SkillManifestEntry>,
}
impl Default for SkillManifest {
fn default() -> Self {
Self {
version: SKILL_MANIFEST_VERSION,
managed: HashMap::new(),
}
}
}
impl SkillManifest {
pub fn load(target_dir: &Path) -> Self {
let path = target_dir.join(SKILL_MANIFEST_FILE);
match std::fs::read_to_string(&path) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn save(&self, target_dir: &Path) -> Result<()> {
std::fs::create_dir_all(target_dir)?;
let path = target_dir.join(SKILL_MANIFEST_FILE);
let json = serde_json::to_string_pretty(self)?;
atomic_write(&path, &json)?;
Ok(())
}
pub fn is_managed(&self, filename: &str) -> bool {
self.managed.contains_key(filename)
}
pub fn checksum_matches(&self, filename: &str, content: &str) -> bool {
self.managed
.get(filename)
.is_some_and(|entry| entry.checksum == checksum(content))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample_entry() -> SkillManifestEntry {
SkillManifestEntry {
checksum: checksum("hello world"),
deployed_at: "2026-05-19T00:00:00Z".into(),
}
}
#[test]
fn skill_manifest_load_missing_returns_empty() {
let tmp = TempDir::new().unwrap();
let manifest = SkillManifest::load(tmp.path());
assert_eq!(manifest.version, SKILL_MANIFEST_VERSION);
assert!(manifest.managed.is_empty());
}
#[test]
fn skill_manifest_round_trip() {
let tmp = TempDir::new().unwrap();
let mut manifest = SkillManifest::default();
manifest
.managed
.insert("tm-doctor.md".into(), sample_entry());
manifest.save(tmp.path()).unwrap();
let loaded = SkillManifest::load(tmp.path());
assert_eq!(loaded, manifest);
assert!(tmp.path().join(SKILL_MANIFEST_FILE).exists());
}
#[test]
fn skill_manifest_checksum_matches() {
let mut manifest = SkillManifest::default();
manifest
.managed
.insert("tm-doctor.md".into(), sample_entry());
assert!(manifest.checksum_matches("tm-doctor.md", "hello world"));
assert!(!manifest.checksum_matches("tm-doctor.md", "hello world!"));
assert!(!manifest.checksum_matches("other.md", "hello world"));
}
#[test]
fn skill_manifest_is_managed() {
let mut manifest = SkillManifest::default();
manifest
.managed
.insert("tm-doctor.md".into(), sample_entry());
assert!(manifest.is_managed("tm-doctor.md"));
assert!(!manifest.is_managed("user-skill.md"));
}
#[test]
fn skill_manifest_file_name_differs_from_agent_manifest() {
assert_ne!(
SKILL_MANIFEST_FILE,
crate::core::agent_manifest::MANIFEST_FILE
);
}
}