Skip to main content

release_kit/skills/
record.rs

1//! The user-scope skill record: what this tool last wrote outside a project.
2//!
3//! Skill destinations live under the invoking user's home, where no target
4//! repository reaches, so without a record the installer's only reference is
5//! the payload it currently carries. That makes a copy an older release wrote
6//! indistinguishable from a file the user edited, and every release touching a
7//! skill then refuses on destinations nobody touched. The record closes that
8//! gap and nothing else: one digest per destination, written after a
9//! successful apply, read to answer one question — are these bytes ones we
10//! wrote?
11//!
12//! It is state, not a manifest. Nothing verifies against it, and every
13//! unreadable shape resolves to an empty record, so a lost one costs only the
14//! benefit of the doubt. The format is the one `sha256sum` prints, under a
15//! version line: a file that cheap to lose earns no parser that can fail in
16//! more than one way.
17
18use std::collections::BTreeMap;
19use std::fmt::Write as _;
20
21use camino::{Utf8Path, Utf8PathBuf};
22
23use crate::skills::Digest;
24
25/// Where the record sits, relative to the home directory.
26///
27/// Home-relative rather than `XDG_STATE_HOME`-relative on purpose: the
28/// destinations it speaks for are `$HOME/.claude` and `$HOME/.agents`, which
29/// no XDG variable moves. A record reachable under a different home than the
30/// roots it vouches for would be worse than no record at all.
31pub const RECORD_PATH: &str = ".local/state/release-kit/skills.sha256";
32
33/// The first line of a record this binary understands.
34const HEADER: &str = "# release-kit skill record v1";
35
36/// The separator `sha256sum` writes between a digest and its path.
37const SEPARATOR: &str = "  ";
38
39/// The digests this tool last wrote to user-scope skill destinations.
40#[derive(Debug, Default, Clone, PartialEq, Eq)]
41pub struct Record {
42    /// Destination path to the digest written there.
43    pub written: BTreeMap<Utf8PathBuf, Digest>,
44}
45
46impl Record {
47    /// Read the record at `path`, or an empty one.
48    ///
49    /// Every failure resolves to an empty record: absent, unreadable,
50    /// malformed, and written under a header this binary does not know all
51    /// mean the same thing to a caller — nothing here can vouch for a
52    /// destination. Refusing instead would let a corrupt state file block an
53    /// install that has a `--force` it does not need.
54    #[must_use]
55    pub fn load(path: &Utf8Path) -> Self {
56        std::fs::read_to_string(path)
57            .ok()
58            .and_then(|text| Self::parse(&text))
59            .unwrap_or_default()
60    }
61
62    /// Parse a record body, or reject it whole.
63    fn parse(text: &str) -> Option<Self> {
64        let mut lines = text.lines();
65        if lines.next()? != HEADER {
66            return None;
67        }
68        let mut written = BTreeMap::new();
69        for line in lines.filter(|line| !line.is_empty()) {
70            // Split at the digest's fixed width rather than on the first
71            // separator, so a destination path holding two spaces still
72            // round-trips.
73            let (digest, rest) = line.split_at_checked(64)?;
74            let path = rest.strip_prefix(SEPARATOR)?;
75            written.insert(Utf8PathBuf::from(path), Digest::parse(digest)?);
76        }
77        Some(Self { written })
78    }
79
80    /// Whether this record says it wrote `digest` at `destination`.
81    #[must_use]
82    pub fn wrote(&self, destination: &Utf8Path, digest: &Digest) -> bool {
83        self.written.get(destination) == Some(digest)
84    }
85
86    /// Serialize in the `sha256sum` shape, under the version header.
87    #[must_use]
88    pub fn to_text(&self) -> String {
89        let mut text = format!("{HEADER}\n");
90        for (destination, digest) in &self.written {
91            // Writing into a String cannot fail; the result is discarded so
92            // no caller has to handle an error that cannot happen.
93            let _ = writeln!(text, "{digest}{SEPARATOR}{destination}");
94        }
95        text
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    #![allow(clippy::expect_used)]
102
103    use camino::Utf8PathBuf;
104
105    use super::{HEADER, Record};
106    use crate::skills::Digest;
107
108    fn record() -> Record {
109        let mut record = Record::default();
110        record.written.insert(
111            Utf8PathBuf::from("/home/<user>/.claude/skills/rk-setup/SKILL.md"),
112            Digest::of(b"one"),
113        );
114        record.written.insert(
115            // Two spaces in the path: the separator must not decide the split.
116            Utf8PathBuf::from("/home/<user>/two  spaces/SKILL.md"),
117            Digest::of(b"two"),
118        );
119        record
120    }
121
122    #[test]
123    fn a_record_round_trips_through_its_text_form() {
124        let original = record();
125        let parsed = Record::parse(&original.to_text()).expect("the record parses");
126        assert_eq!(parsed, original);
127    }
128
129    #[test]
130    fn a_record_vouches_only_for_the_digest_it_holds() {
131        let record = record();
132        let destination = Utf8PathBuf::from("/home/<user>/.claude/skills/rk-setup/SKILL.md");
133        assert!(record.wrote(&destination, &Digest::of(b"one")));
134        assert!(!record.wrote(&destination, &Digest::of(b"edited")));
135        assert!(!record.wrote(
136            Utf8PathBuf::from("/elsewhere").as_path(),
137            &Digest::of(b"one")
138        ));
139    }
140
141    #[test]
142    fn every_unreadable_shape_resolves_to_an_empty_record() {
143        let good = record().to_text();
144        for text in [
145            String::new(),
146            "not a header\n".to_string(),
147            "# release-kit skill record v2\n".to_string(),
148            good.replace(HEADER, "# release-kit skill record v0"),
149            // A truncated digest, and a line missing its separator.
150            format!("{HEADER}\nabc  /path\n"),
151            format!("{HEADER}\n{} /path\n", Digest::of(b"one")),
152        ] {
153            assert_eq!(
154                Record::parse(&text),
155                None,
156                "'{text}' parsed as a valid record"
157            );
158        }
159    }
160
161    #[test]
162    fn a_missing_record_loads_empty() {
163        assert_eq!(
164            Record::load(Utf8PathBuf::from("/no/such/record").as_path()),
165            Record::default()
166        );
167    }
168}