Skip to main content

spec_driven_docs/domain/
skill_record.rs

1//! The user-scope skill record: what this tool last wrote outside an instance.
2//!
3//! Skill destinations live under the invoking user's home, where no instance
4//! manifest reaches, so without a record the installer's only reference is
5//! the payload it currently carries. That makes a copy left by an older
6//! release indistinguishable from a file the user edited, and every release
7//! that touches a skill refuses on destinations nobody touched. This record
8//! closes that gap and nothing else: one digest per destination, written
9//! after a successful apply, read to answer one question — are these bytes
10//! ones we wrote?
11//!
12//! It is not an instance manifest and never becomes one. No verification
13//! reads it, and `distribution:user-scope-files-stay-unrecorded` keeps
14//! these paths out of the manifest that does drive verification.
15//!
16//! It is required state all the same. An apply that cannot write it fails
17//! and rolls back, because a landing this tool cannot vouch for is a
18//! landing it will refuse to take back. Reading is the forgiving half: a
19//! record that is absent, unreadable, or written by a schema this binary
20//! does not know reads as empty, and the caller loses the benefit of the
21//! doubt and nothing else.
22
23use std::collections::BTreeMap;
24
25use camino::{Utf8Path, Utf8PathBuf};
26use serde::{Deserialize, Serialize};
27
28use crate::domain::ownership::Sha256;
29
30/// The record schema this binary writes.
31pub const SCHEMA_VERSION: u32 = 2;
32
33pub use crate::domain::paths::LEGACY_SKILL_RECEIPT_PATH as RECORD_PATH;
34
35/// The digests this tool last wrote to user-scope skill destinations.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct SkillRecord {
39    /// Always [`SCHEMA_VERSION`] once parsed.
40    pub schema_version: u32,
41    /// The engine version whose apply last wrote this record.
42    #[serde(default)]
43    pub engine_version: String,
44    /// When that apply finished.
45    #[serde(default)]
46    pub installed_at: String,
47    /// Absolute destination path to the digest written there.
48    pub written: BTreeMap<Utf8PathBuf, Sha256>,
49}
50
51/// The shape schema one wrote, read through an adapter.
52///
53/// It carried the digests and nothing else. A home installed by a release
54/// that wrote this shape keeps every vouched-for file, which is the whole
55/// point of reading it rather than starting empty.
56#[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    /// An empty record at the current schema.
71    #[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    /// Read the record at `path`, or an empty one.
82    ///
83    /// Every failure resolves to an empty record: absent, unreadable,
84    /// malformed, and written by a schema this binary does not know all mean
85    /// the same thing to a caller — nothing here can vouch for a
86    /// destination. Refusing instead would let a corrupt state file block an
87    /// install that has a `--force` it does not need.
88    #[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    /// Read the record at `resolved`, or the one an older release left at
97    /// `legacy`.
98    ///
99    /// One fallback read, and only where the resolved path holds nothing.
100    /// The next apply writes the resolved path alone, so the legacy copy is
101    /// read once in a home's life and then superseded.
102    #[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    /// Parse either schema this binary reads.
112    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    /// Serialize as pretty JSON with a trailing newline.
127    #[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    /// Whether `digest` is what this tool last wrote to `destination`.
136    #[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    /// A caller that cannot read the record loses the benefit of the doubt
179    /// and nothing else, so every unreadable shape resolves the same way.
180    #[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}