use std::collections::BTreeMap;
use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use crate::domain::ownership::Sha256;
pub const SCHEMA_VERSION: u32 = 1;
pub const RECORD_PATH: &str = ".local/state/spec-driven-docs/skills.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SkillRecord {
pub schema_version: u32,
pub written: BTreeMap<Utf8PathBuf, Sha256>,
}
impl Default for SkillRecord {
fn default() -> Self {
Self::new()
}
}
impl SkillRecord {
#[must_use]
pub const fn new() -> Self {
Self {
schema_version: SCHEMA_VERSION,
written: BTreeMap::new(),
}
}
#[must_use]
pub fn load(path: &Utf8Path) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<Self>(&text).ok())
.filter(|record| record.schema_version == SCHEMA_VERSION)
.unwrap_or_default()
}
#[must_use]
pub fn to_json(&self) -> String {
let mut text = serde_json::to_string_pretty(self)
.unwrap_or_else(|_| "{\"schema_version\":1,\"written\":{}}".to_string());
text.push('\n');
text
}
#[must_use]
pub fn wrote(&self, destination: &Utf8Path, digest: &Sha256) -> bool {
self.written.get(destination) == Some(digest)
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
reason = "a test panics as its failure signal, not as control flow"
)]
use super::*;
fn path(dir: &tempfile::TempDir, name: &str) -> Utf8PathBuf {
Utf8PathBuf::from(dir.path().to_str().unwrap()).join(name)
}
#[test]
fn a_round_trip_preserves_every_entry() {
let dir = tempfile::tempdir().unwrap();
let file = path(&dir, "skills.json");
let mut record = SkillRecord::new();
record.written.insert(
Utf8PathBuf::from("/home/<user>/.claude/skills/s/SKILL.md"),
Sha256::of(b"x"),
);
std::fs::write(&file, record.to_json()).unwrap();
assert_eq!(SkillRecord::load(&file), record);
}
#[test]
fn wrote_answers_only_for_the_exact_path_and_digest() {
let mut record = SkillRecord::new();
let destination = Utf8PathBuf::from("/home/<user>/SKILL.md");
record.written.insert(destination.clone(), Sha256::of(b"x"));
assert!(record.wrote(&destination, &Sha256::of(b"x")));
assert!(!record.wrote(&destination, &Sha256::of(b"y")));
assert!(!record.wrote(Utf8Path::new("/home/<other>/SKILL.md"), &Sha256::of(b"x")));
}
#[test]
fn an_unusable_record_reads_as_empty_rather_than_failing() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
SkillRecord::load(&path(&dir, "absent.json")),
SkillRecord::new()
);
let malformed = path(&dir, "malformed.json");
std::fs::write(&malformed, "{not json").unwrap();
assert_eq!(SkillRecord::load(&malformed), SkillRecord::new());
let future = path(&dir, "future.json");
std::fs::write(&future, "{\"schema_version\":99,\"written\":{}}").unwrap();
assert_eq!(SkillRecord::load(&future), SkillRecord::new());
}
}