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}