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(
96 clippy::unwrap_used,
97 reason = "a test panics as its failure signal, not as control flow"
98 )]
99
100 use super::*;
101
102 fn path(dir: &tempfile::TempDir, name: &str) -> Utf8PathBuf {
103 Utf8PathBuf::from(dir.path().to_str().unwrap()).join(name)
104 }
105
106 #[test]
107 fn a_round_trip_preserves_every_entry() {
108 let dir = tempfile::tempdir().unwrap();
109 let file = path(&dir, "skills.json");
110 let mut record = SkillRecord::new();
111 record.written.insert(
112 Utf8PathBuf::from("/home/<user>/.claude/skills/s/SKILL.md"),
113 Sha256::of(b"x"),
114 );
115 std::fs::write(&file, record.to_json()).unwrap();
116 assert_eq!(SkillRecord::load(&file), record);
117 }
118
119 #[test]
120 fn wrote_answers_only_for_the_exact_path_and_digest() {
121 let mut record = SkillRecord::new();
122 let destination = Utf8PathBuf::from("/home/<user>/SKILL.md");
123 record.written.insert(destination.clone(), Sha256::of(b"x"));
124 assert!(record.wrote(&destination, &Sha256::of(b"x")));
125 assert!(!record.wrote(&destination, &Sha256::of(b"y")));
126 assert!(!record.wrote(Utf8Path::new("/home/<other>/SKILL.md"), &Sha256::of(b"x")));
127 }
128
129 #[test]
132 fn an_unusable_record_reads_as_empty_rather_than_failing() {
133 let dir = tempfile::tempdir().unwrap();
134 assert_eq!(
135 SkillRecord::load(&path(&dir, "absent.json")),
136 SkillRecord::new()
137 );
138
139 let malformed = path(&dir, "malformed.json");
140 std::fs::write(&malformed, "{not json").unwrap();
141 assert_eq!(SkillRecord::load(&malformed), SkillRecord::new());
142
143 let future = path(&dir, "future.json");
144 std::fs::write(&future, "{\"schema_version\":99,\"written\":{}}").unwrap();
145 assert_eq!(SkillRecord::load(&future), SkillRecord::new());
146 }
147}