spec_driven_docs/domain/
skill_record.rs1use std::collections::BTreeMap;
24
25use camino::{Utf8Path, Utf8PathBuf};
26use serde::{Deserialize, Serialize};
27
28use crate::domain::ownership::Sha256;
29
30pub const SCHEMA_VERSION: u32 = 2;
32
33pub use crate::domain::paths::LEGACY_SKILL_RECEIPT_PATH as RECORD_PATH;
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct SkillRecord {
39 pub schema_version: u32,
41 #[serde(default)]
43 pub engine_version: String,
44 #[serde(default)]
46 pub installed_at: String,
47 pub written: BTreeMap<Utf8PathBuf, Sha256>,
49}
50
51#[derive(Debug, Deserialize)]
57#[serde(deny_unknown_fields)]
58struct SchemaOne {
59 schema_version: u32,
60 written: BTreeMap<Utf8PathBuf, Sha256>,
61}
62
63impl Default for SkillRecord {
64 fn default() -> Self {
65 Self::new()
66 }
67}
68
69impl SkillRecord {
70 #[must_use]
72 pub const fn new() -> Self {
73 Self {
74 schema_version: SCHEMA_VERSION,
75 engine_version: String::new(),
76 installed_at: String::new(),
77 written: BTreeMap::new(),
78 }
79 }
80
81 #[must_use]
89 pub fn load(path: &Utf8Path) -> Self {
90 std::fs::read_to_string(path)
91 .ok()
92 .and_then(|text| Self::parse(&text))
93 .unwrap_or_default()
94 }
95
96 #[must_use]
103 pub fn load_with_fallback(resolved: &Utf8Path, legacy: &Utf8Path) -> Self {
104 let held = Self::load(resolved);
105 if !held.written.is_empty() || resolved == legacy {
106 return held;
107 }
108 Self::load(legacy)
109 }
110
111 fn parse(text: &str) -> Option<Self> {
113 if let Ok(current) = serde_json::from_str::<Self>(text)
114 && current.schema_version == SCHEMA_VERSION
115 {
116 return Some(current);
117 }
118 let older: SchemaOne = serde_json::from_str(text).ok()?;
119 (older.schema_version == 1).then(|| Self {
120 schema_version: SCHEMA_VERSION,
121 written: older.written,
122 ..Self::new()
123 })
124 }
125
126 #[must_use]
128 pub fn to_json(&self) -> String {
129 let mut text = serde_json::to_string_pretty(self)
130 .unwrap_or_else(|_| "{\"schema_version\":2,\"written\":{}}".to_string());
131 text.push('\n');
132 text
133 }
134
135 #[must_use]
137 pub fn wrote(&self, destination: &Utf8Path, digest: &Sha256) -> bool {
138 self.written.get(destination) == Some(digest)
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 #![allow(
145 clippy::unwrap_used,
146 reason = "a test panics as its failure signal, not as control flow"
147 )]
148
149 use super::*;
150
151 fn path(dir: &tempfile::TempDir, name: &str) -> Utf8PathBuf {
152 Utf8PathBuf::from(dir.path().to_str().unwrap()).join(name)
153 }
154
155 #[test]
156 fn a_round_trip_preserves_every_entry() {
157 let dir = tempfile::tempdir().unwrap();
158 let file = path(&dir, "skills.json");
159 let mut record = SkillRecord::new();
160 record.written.insert(
161 Utf8PathBuf::from("/home/<user>/.claude/skills/s/SKILL.md"),
162 Sha256::of(b"x"),
163 );
164 std::fs::write(&file, record.to_json()).unwrap();
165 assert_eq!(SkillRecord::load(&file), record);
166 }
167
168 #[test]
169 fn wrote_answers_only_for_the_exact_path_and_digest() {
170 let mut record = SkillRecord::new();
171 let destination = Utf8PathBuf::from("/home/<user>/SKILL.md");
172 record.written.insert(destination.clone(), Sha256::of(b"x"));
173 assert!(record.wrote(&destination, &Sha256::of(b"x")));
174 assert!(!record.wrote(&destination, &Sha256::of(b"y")));
175 assert!(!record.wrote(Utf8Path::new("/home/<other>/SKILL.md"), &Sha256::of(b"x")));
176 }
177
178 #[test]
181 fn an_unusable_record_reads_as_empty_rather_than_failing() {
182 let dir = tempfile::tempdir().unwrap();
183 assert_eq!(
184 SkillRecord::load(&path(&dir, "absent.json")),
185 SkillRecord::new()
186 );
187
188 let malformed = path(&dir, "malformed.json");
189 std::fs::write(&malformed, "{not json").unwrap();
190 assert_eq!(SkillRecord::load(&malformed), SkillRecord::new());
191
192 let future = path(&dir, "future.json");
193 std::fs::write(&future, "{\"schema_version\":99,\"written\":{}}").unwrap();
194 assert_eq!(SkillRecord::load(&future), SkillRecord::new());
195 }
196
197 #[test]
198 fn a_schema_one_record_reads_through_the_adapter() {
199 let dir = tempfile::tempdir().unwrap();
200 let older = path(&dir, "older.json");
201 let destination = Utf8PathBuf::from("/home/<user>/.claude/skills/s/SKILL.md");
202 std::fs::write(
203 &older,
204 format!(
205 "{{\"schema_version\":1,\"written\":{{\"{destination}\":\"{}\"}}}}",
206 Sha256::of(b"x")
207 ),
208 )
209 .unwrap();
210 let read = SkillRecord::load(&older);
211 assert_eq!(read.schema_version, SCHEMA_VERSION);
212 assert!(read.wrote(&destination, &Sha256::of(b"x")));
213 }
214
215 #[test]
216 fn the_legacy_path_is_read_only_where_the_resolved_one_holds_nothing() {
217 let dir = tempfile::tempdir().unwrap();
218 let resolved = path(&dir, "resolved.json");
219 let legacy = path(&dir, "legacy.json");
220 let mut older = SkillRecord::new();
221 older
222 .written
223 .insert(Utf8PathBuf::from("/x/SKILL.md"), Sha256::of(b"x"));
224 std::fs::write(&legacy, older.to_json()).unwrap();
225 assert_eq!(SkillRecord::load_with_fallback(&resolved, &legacy), older);
226
227 let mut current = SkillRecord::new();
228 current
229 .written
230 .insert(Utf8PathBuf::from("/y/SKILL.md"), Sha256::of(b"y"));
231 std::fs::write(&resolved, current.to_json()).unwrap();
232 assert_eq!(SkillRecord::load_with_fallback(&resolved, &legacy), current);
233 }
234}