use std::collections::BTreeMap;
use std::fmt::Write as _;
use camino::{Utf8Path, Utf8PathBuf};
use crate::skills::Digest;
pub const RECORD_PATH: &str = ".local/state/release-kit/skills.sha256";
const HEADER: &str = "# release-kit skill record v1";
const SEPARATOR: &str = " ";
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Record {
pub written: BTreeMap<Utf8PathBuf, Digest>,
}
impl Record {
#[must_use]
pub fn load(path: &Utf8Path) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|text| Self::parse(&text))
.unwrap_or_default()
}
fn parse(text: &str) -> Option<Self> {
let mut lines = text.lines();
if lines.next()? != HEADER {
return None;
}
let mut written = BTreeMap::new();
for line in lines.filter(|line| !line.is_empty()) {
let (digest, rest) = line.split_at_checked(64)?;
let path = rest.strip_prefix(SEPARATOR)?;
written.insert(Utf8PathBuf::from(path), Digest::parse(digest)?);
}
Some(Self { written })
}
#[must_use]
pub fn wrote(&self, destination: &Utf8Path, digest: &Digest) -> bool {
self.written.get(destination) == Some(digest)
}
#[must_use]
pub fn to_text(&self) -> String {
let mut text = format!("{HEADER}\n");
for (destination, digest) in &self.written {
let _ = writeln!(text, "{digest}{SEPARATOR}{destination}");
}
text
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use camino::Utf8PathBuf;
use super::{HEADER, Record};
use crate::skills::Digest;
fn record() -> Record {
let mut record = Record::default();
record.written.insert(
Utf8PathBuf::from("/home/<user>/.claude/skills/rk-setup/SKILL.md"),
Digest::of(b"one"),
);
record.written.insert(
Utf8PathBuf::from("/home/<user>/two spaces/SKILL.md"),
Digest::of(b"two"),
);
record
}
#[test]
fn a_record_round_trips_through_its_text_form() {
let original = record();
let parsed = Record::parse(&original.to_text()).expect("the record parses");
assert_eq!(parsed, original);
}
#[test]
fn a_record_vouches_only_for_the_digest_it_holds() {
let record = record();
let destination = Utf8PathBuf::from("/home/<user>/.claude/skills/rk-setup/SKILL.md");
assert!(record.wrote(&destination, &Digest::of(b"one")));
assert!(!record.wrote(&destination, &Digest::of(b"edited")));
assert!(!record.wrote(
Utf8PathBuf::from("/elsewhere").as_path(),
&Digest::of(b"one")
));
}
#[test]
fn every_unreadable_shape_resolves_to_an_empty_record() {
let good = record().to_text();
for text in [
String::new(),
"not a header\n".to_string(),
"# release-kit skill record v2\n".to_string(),
good.replace(HEADER, "# release-kit skill record v0"),
format!("{HEADER}\nabc /path\n"),
format!("{HEADER}\n{} /path\n", Digest::of(b"one")),
] {
assert_eq!(
Record::parse(&text),
None,
"'{text}' parsed as a valid record"
);
}
}
#[test]
fn a_missing_record_loads_empty() {
assert_eq!(
Record::load(Utf8PathBuf::from("/no/such/record").as_path()),
Record::default()
);
}
}