spec_driven_docs/domain/
skill_record.rs1use std::collections::BTreeMap;
18
19use camino::{Utf8Path, Utf8PathBuf};
20use serde::{Deserialize, Serialize};
21
22use crate::domain::ownership::Sha256;
23
24pub const SCHEMA_VERSION: u32 = 1;
26
27pub const RECORD_PATH: &str = ".local/state/spec-driven-docs/skills.json";
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct SkillRecord {
39 pub schema_version: u32,
41 pub written: BTreeMap<Utf8PathBuf, Sha256>,
43}
44
45impl Default for SkillRecord {
46 fn default() -> Self {
47 Self::new()
48 }
49}
50
51impl SkillRecord {
52 #[must_use]
54 pub const fn new() -> Self {
55 Self {
56 schema_version: SCHEMA_VERSION,
57 written: BTreeMap::new(),
58 }
59 }
60
61 #[must_use]
69 pub fn load(path: &Utf8Path) -> Self {
70 std::fs::read_to_string(path)
71 .ok()
72 .and_then(|text| serde_json::from_str::<Self>(&text).ok())
73 .filter(|record| record.schema_version == SCHEMA_VERSION)
74 .unwrap_or_default()
75 }
76
77 #[must_use]
79 pub fn to_json(&self) -> String {
80 let mut text = serde_json::to_string_pretty(self)
81 .unwrap_or_else(|_| "{\"schema_version\":1,\"written\":{}}".to_string());
82 text.push('\n');
83 text
84 }
85
86 #[must_use]
88 pub fn wrote(&self, destination: &Utf8Path, digest: &Sha256) -> bool {
89 self.written.get(destination) == Some(digest)
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 #![allow(clippy::unwrap_used)]
96
97 use super::*;
98
99 fn path(dir: &tempfile::TempDir, name: &str) -> Utf8PathBuf {
100 Utf8PathBuf::from(dir.path().to_str().unwrap()).join(name)
101 }
102
103 #[test]
104 fn a_round_trip_preserves_every_entry() {
105 let dir = tempfile::tempdir().unwrap();
106 let file = path(&dir, "skills.json");
107 let mut record = SkillRecord::new();
108 record.written.insert(
109 Utf8PathBuf::from("/home/<user>/.claude/skills/s/SKILL.md"),
110 Sha256::of(b"x"),
111 );
112 std::fs::write(&file, record.to_json()).unwrap();
113 assert_eq!(SkillRecord::load(&file), record);
114 }
115
116 #[test]
117 fn wrote_answers_only_for_the_exact_path_and_digest() {
118 let mut record = SkillRecord::new();
119 let destination = Utf8PathBuf::from("/home/<user>/SKILL.md");
120 record.written.insert(destination.clone(), Sha256::of(b"x"));
121 assert!(record.wrote(&destination, &Sha256::of(b"x")));
122 assert!(!record.wrote(&destination, &Sha256::of(b"y")));
123 assert!(!record.wrote(Utf8Path::new("/home/<other>/SKILL.md"), &Sha256::of(b"x")));
124 }
125
126 #[test]
129 fn an_unusable_record_reads_as_empty_rather_than_failing() {
130 let dir = tempfile::tempdir().unwrap();
131 assert_eq!(
132 SkillRecord::load(&path(&dir, "absent.json")),
133 SkillRecord::new()
134 );
135
136 let malformed = path(&dir, "malformed.json");
137 std::fs::write(&malformed, "{not json").unwrap();
138 assert_eq!(SkillRecord::load(&malformed), SkillRecord::new());
139
140 let future = path(&dir, "future.json");
141 std::fs::write(&future, "{\"schema_version\":99,\"written\":{}}").unwrap();
142 assert_eq!(SkillRecord::load(&future), SkillRecord::new());
143 }
144}