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    use camino::Utf8PathBuf;
102
103    use super::{HEADER, Record};
104    use crate::skills::Digest;
105
106    fn record() -> Record {
107        let mut record = Record::default();
108        record.written.insert(
109            Utf8PathBuf::from("/home/<user>/.claude/skills/rk-setup/SKILL.md"),
110            Digest::of(b"one"),
111        );
112        record.written.insert(
113            // Two spaces in the path: the separator must not decide the split.
114            Utf8PathBuf::from("/home/<user>/two  spaces/SKILL.md"),
115            Digest::of(b"two"),
116        );
117        record
118    }
119
120    #[test]
121    fn a_record_round_trips_through_its_text_form() {
122        let original = record();
123        let parsed = Record::parse(&original.to_text()).expect("the record parses");
124        assert_eq!(parsed, original);
125    }
126
127    #[test]
128    fn a_record_vouches_only_for_the_digest_it_holds() {
129        let record = record();
130        let destination = Utf8PathBuf::from("/home/<user>/.claude/skills/rk-setup/SKILL.md");
131        assert!(record.wrote(&destination, &Digest::of(b"one")));
132        assert!(!record.wrote(&destination, &Digest::of(b"edited")));
133        assert!(!record.wrote(
134            Utf8PathBuf::from("/elsewhere").as_path(),
135            &Digest::of(b"one")
136        ));
137    }
138
139    #[test]
140    fn every_unreadable_shape_resolves_to_an_empty_record() {
141        let good = record().to_text();
142        for text in [
143            String::new(),
144            "not a header\n".to_string(),
145            "# release-kit skill record v2\n".to_string(),
146            good.replace(HEADER, "# release-kit skill record v0"),
147            // A truncated digest, and a line missing its separator.
148            format!("{HEADER}\nabc  /path\n"),
149            format!("{HEADER}\n{} /path\n", Digest::of(b"one")),
150        ] {
151            assert_eq!(
152                Record::parse(&text),
153                None,
154                "'{text}' parsed as a valid record"
155            );
156        }
157    }
158
159    #[test]
160    fn a_missing_record_loads_empty() {
161        assert_eq!(
162            Record::load(Utf8PathBuf::from("/no/such/record").as_path()),
163            Record::default()
164        );
165    }
166}