use std::collections::BTreeMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use rust_embed::RustEmbed;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::{Result, SkillError};
pub const MANIFEST_FILE: &str = ".manifest.json";
pub const MANIFEST_VERSION: u32 = 1;
#[derive(RustEmbed)]
#[folder = "skills/"]
#[include = "history.json"]
struct HistoryAsset;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HistoricalVersion {
pub version: u32,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillHistory {
pub current: HistoricalVersion,
#[serde(default)]
pub history: Vec<HistoricalVersion>,
}
impl SkillHistory {
pub fn is_current(&self, sha256: &str) -> bool {
self.current.sha256.eq_ignore_ascii_case(sha256)
}
pub fn is_historical(&self, sha256: &str) -> bool {
self.history
.iter()
.any(|h| h.sha256.eq_ignore_ascii_case(sha256))
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct HistoricalHashes {
pub by_skill: BTreeMap<String, SkillHistory>,
}
impl HistoricalHashes {
pub fn load_embedded() -> Result<Self> {
match HistoryAsset::get("history.json") {
Some(asset) => {
let parsed: HistoricalHashes =
serde_json::from_slice(&asset.data).map_err(|source| {
SkillError::InvalidManifest {
path: PathBuf::from("<embedded>/history.json"),
source,
}
})?;
Ok(parsed)
}
None => Ok(Self::default()),
}
}
pub fn get(&self, name: &str) -> Option<&SkillHistory> {
self.by_skill.get(name)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledFile {
pub sha256: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledSkill {
pub version: u32,
pub installed_at: DateTime<Utc>,
pub source: String,
pub files: BTreeMap<String, InstalledFile>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub version: u32,
#[serde(default)]
pub installed_from: Option<String>,
#[serde(default)]
pub skills: BTreeMap<String, InstalledSkill>,
}
impl Default for Manifest {
fn default() -> Self {
Self {
version: MANIFEST_VERSION,
installed_from: None,
skills: BTreeMap::new(),
}
}
}
impl Manifest {
pub fn load(path: &Path) -> Result<Self> {
match std::fs::read(path) {
Ok(bytes) => {
let parsed: Manifest = serde_json::from_slice(&bytes).map_err(|source| {
SkillError::InvalidManifest {
path: path.to_path_buf(),
source,
}
})?;
Ok(parsed)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(source) => Err(SkillError::Io {
path: path.to_path_buf(),
source,
}),
}
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|source| SkillError::Io {
path: parent.to_path_buf(),
source,
})?;
}
let pretty =
serde_json::to_vec_pretty(self).map_err(|source| SkillError::InvalidManifest {
path: path.to_path_buf(),
source,
})?;
let tmp_path = path.with_extension("tmp");
std::fs::write(&tmp_path, &pretty).map_err(|source| SkillError::Io {
path: tmp_path.clone(),
source,
})?;
if path.exists() {
std::fs::remove_file(path).map_err(|source| SkillError::Io {
path: path.to_path_buf(),
source,
})?;
}
std::fs::rename(&tmp_path, path).map_err(|source| SkillError::Io {
path: path.to_path_buf(),
source,
})?;
Ok(())
}
pub fn record(&mut self, name: &str, entry: InstalledSkill) {
self.skills.insert(name.to_string(), entry);
}
pub fn forget(&mut self, name: &str) -> Option<InstalledSkill> {
self.skills.remove(name)
}
pub fn get(&self, name: &str) -> Option<&InstalledSkill> {
self.skills.get(name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallState {
Unchanged,
HistoricalSafe,
UserModified,
Unknown,
}
pub fn classify(history: &HistoricalHashes, name: &str, body: &[u8]) -> InstallState {
let sha = sha256_hex(body);
let Some(entry) = history.get(name) else {
return InstallState::Unknown;
};
if entry.is_current(&sha) {
InstallState::Unchanged
} else if entry.is_historical(&sha) {
InstallState::HistoricalSafe
} else {
InstallState::UserModified
}
}
pub fn classify_path(
history: &HistoricalHashes,
name: &str,
path: &Path,
) -> Result<Option<InstallState>> {
match std::fs::File::open(path) {
Ok(mut f) => {
let mut buf = Vec::new();
f.read_to_end(&mut buf).map_err(|source| SkillError::Io {
path: path.to_path_buf(),
source,
})?;
Ok(Some(classify(history, name, &buf)))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(SkillError::Io {
path: path.to_path_buf(),
source,
}),
}
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
hex_encode(&digest)
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0F) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn sha256_is_deterministic() {
let a = sha256_hex(b"hello");
let b = sha256_hex(b"hello");
assert_eq!(a, b);
assert_eq!(a.len(), 64);
}
#[test]
fn manifest_round_trip() {
let dir = tempdir().unwrap();
let path = dir.path().join(MANIFEST_FILE);
let mut m = Manifest {
installed_from: Some("devboy-tools 0.18.0".into()),
..Default::default()
};
let mut files = BTreeMap::new();
files.insert(
"SKILL.md".to_string(),
InstalledFile {
sha256: "aa".repeat(32),
size: 42,
},
);
m.record(
"setup",
InstalledSkill {
version: 1,
installed_at: Utc::now(),
source: "embedded".into(),
files,
},
);
m.save(&path).unwrap();
let loaded = Manifest::load(&path).unwrap();
assert_eq!(loaded.version, MANIFEST_VERSION);
assert_eq!(loaded.skills["setup"].version, 1);
}
#[test]
fn manifest_load_missing_is_empty() {
let dir = tempdir().unwrap();
let path = dir.path().join("does-not-exist.json");
let m = Manifest::load(&path).unwrap();
assert!(m.skills.is_empty());
}
#[test]
fn classify_unchanged_historical_usermod() {
let current_body = b"current";
let older_body = b"older";
let user_body = b"user-edited";
let mut history = HistoricalHashes::default();
history.by_skill.insert(
"setup".into(),
SkillHistory {
current: HistoricalVersion {
version: 2,
sha256: sha256_hex(current_body),
},
history: vec![HistoricalVersion {
version: 1,
sha256: sha256_hex(older_body),
}],
},
);
assert_eq!(
classify(&history, "setup", current_body),
InstallState::Unchanged
);
assert_eq!(
classify(&history, "setup", older_body),
InstallState::HistoricalSafe
);
assert_eq!(
classify(&history, "setup", user_body),
InstallState::UserModified
);
assert_eq!(
classify(&history, "devboy-unknown", user_body),
InstallState::Unknown
);
}
#[test]
fn skill_history_is_current_and_is_historical_are_case_insensitive() {
let hist = SkillHistory {
current: HistoricalVersion {
version: 2,
sha256: "AbCdEf1234567890".repeat(4),
},
history: vec![HistoricalVersion {
version: 1,
sha256: "11223344".repeat(8),
}],
};
assert!(hist.is_current(&"abcdef1234567890".repeat(4)));
assert!(hist.is_historical(&"11223344".repeat(8).to_uppercase()));
assert!(!hist.is_current("00".repeat(32).as_str()));
assert!(!hist.is_historical("00".repeat(32).as_str()));
}
#[test]
fn historical_hashes_load_embedded_returns_parsed_or_empty() {
let hashes = HistoricalHashes::load_embedded().expect("parses or empty");
for (name, entry) in &hashes.by_skill {
assert!(!name.is_empty(), "history keys must be non-empty");
assert!(!entry.current.sha256.is_empty());
}
}
#[test]
fn manifest_forget_and_get_round_trip() {
let mut m = Manifest::default();
assert!(m.get("ghost").is_none());
let entry = InstalledSkill {
version: 3,
installed_at: Utc::now(),
source: "embedded".into(),
files: BTreeMap::new(),
};
m.record("setup", entry.clone());
assert_eq!(m.get("setup").unwrap().version, 3);
let removed = m.forget("setup").expect("entry removed");
assert_eq!(removed.version, entry.version);
assert!(m.forget("setup").is_none());
assert!(m.get("setup").is_none());
}
#[test]
fn manifest_save_overwrites_existing_destination() {
let dir = tempdir().unwrap();
let path = dir.path().join(MANIFEST_FILE);
let m1 = Manifest {
installed_from: Some("v1".into()),
..Default::default()
};
m1.save(&path).unwrap();
let mut m2 = Manifest {
installed_from: Some("v2".into()),
..Default::default()
};
m2.record(
"setup",
InstalledSkill {
version: 7,
installed_at: Utc::now(),
source: "embedded".into(),
files: BTreeMap::new(),
},
);
m2.save(&path).unwrap();
let loaded = Manifest::load(&path).unwrap();
assert_eq!(loaded.installed_from.as_deref(), Some("v2"));
assert_eq!(loaded.skills["setup"].version, 7);
}
#[test]
fn manifest_load_rejects_corrupt_json() {
let dir = tempdir().unwrap();
let path = dir.path().join(MANIFEST_FILE);
std::fs::write(&path, "{ not json").unwrap();
let err = Manifest::load(&path).unwrap_err();
assert!(
matches!(err, SkillError::InvalidManifest { .. }),
"expected InvalidManifest, got {err:?}"
);
}
#[test]
fn classify_path_handles_missing_and_present_files() {
let dir = tempdir().unwrap();
let path = dir.path().join("SKILL.md");
let mut history = HistoricalHashes::default();
let body = b"ship";
history.by_skill.insert(
"s".into(),
SkillHistory {
current: HistoricalVersion {
version: 1,
sha256: sha256_hex(body),
},
history: vec![],
},
);
assert!(classify_path(&history, "s", &path).unwrap().is_none());
std::fs::write(&path, body).unwrap();
assert_eq!(
classify_path(&history, "s", &path).unwrap(),
Some(InstallState::Unchanged)
);
std::fs::write(&path, b"drifted").unwrap();
assert_eq!(
classify_path(&history, "s", &path).unwrap(),
Some(InstallState::UserModified)
);
}
}