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}