use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct SpecDigestReader {
root: PathBuf,
}
impl SpecDigestReader {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn path_for(&self, revision: &str, file: &str) -> PathBuf {
self.root.join(revision).join(file)
}
pub fn digest(&self, revision: &str, file: &str) -> Result<String, String> {
let path = self.path_for(revision, file);
let bytes = fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
Ok(hex_digest(&bytes))
}
pub fn files_in(&self, revision: &str) -> Result<Vec<String>, String> {
let dir = self.root.join(revision);
let entries = fs::read_dir(&dir).map_err(|err| format!("{}: {err}", dir.display()))?;
let mut names = Vec::new();
for entry in entries {
let entry = entry.map_err(|err| format!("{}: {err}", dir.display()))?;
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if entry
.file_type()
.map_err(|err| format!("{}: {err}", entry.path().display()))?
.is_file()
{
names.push(name.to_owned());
}
}
names.sort();
Ok(names)
}
pub fn read_manifest(&self) -> Result<String, String> {
let path = self.root.join("MANIFEST.toml");
fs::read_to_string(&path).map_err(|err| format!("{}: {err}", path.display()))
}
}
pub use crate::domain::manifest::hex_digest;
#[cfg(test)]
mod tests {
#![expect(
clippy::expect_used,
reason = "tests assert on fallible calls; propagating errors would replace a clear failure message with noise"
)]
use super::SpecDigestReader;
fn scratch(name: &str) -> (std::path::PathBuf, Scratch) {
let root = std::env::temp_dir().join(format!("protocol-droid-digest-{name}"));
let _ = std::fs::remove_dir_all(&root);
(root.clone(), Scratch(root))
}
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn digest_matches_the_known_sha256_of_its_contents() {
let (root, _guard) = scratch("digest");
std::fs::create_dir_all(root.join("2025-06-18")).expect("create revision dir");
std::fs::write(root.join("2025-06-18/schema.json"), b"{}").expect("write schema");
let reader = SpecDigestReader::new(&root);
assert_eq!(
reader.digest("2025-06-18", "schema.json").expect("digest"),
"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
);
}
#[test]
fn digest_of_a_missing_file_is_an_error_not_a_panic() {
let (root, _guard) = scratch("missing");
std::fs::create_dir_all(&root).expect("create root");
let reader = SpecDigestReader::new(&root);
let error = reader
.digest("2025-06-18", "schema.json")
.expect_err("must fail");
assert!(
error.contains("2025-06-18"),
"error should name the path: {error}"
);
}
#[test]
fn files_in_lists_only_files_sorted() {
let (root, _guard) = scratch("files-in");
let revision = root.join("2025-06-18");
std::fs::create_dir_all(&revision).expect("create revision dir");
std::fs::write(revision.join("schema.ts"), b"ts").expect("write");
std::fs::write(revision.join("CHANGELOG.md"), b"ch").expect("write");
std::fs::write(revision.join("schema.json"), b"{}").expect("write");
std::fs::create_dir_all(revision.join("nested")).expect("create subdir");
let reader = SpecDigestReader::new(&root);
assert_eq!(
reader.files_in("2025-06-18").expect("list"),
vec!["CHANGELOG.md", "schema.json", "schema.ts"]
);
}
#[test]
fn files_in_of_a_missing_revision_is_an_error() {
let (root, _guard) = scratch("missing-revision");
std::fs::create_dir_all(&root).expect("create root");
let reader = SpecDigestReader::new(&root);
reader.files_in("nope").expect_err("must fail");
}
#[test]
fn read_manifest_reads_from_the_spec_root() {
let (root, _guard) = scratch("read-manifest");
std::fs::create_dir_all(&root).expect("create root");
std::fs::write(root.join("MANIFEST.toml"), "schema_version = 1\n").expect("write manifest");
let reader = SpecDigestReader::new(&root);
assert_eq!(
reader.read_manifest().expect("read"),
"schema_version = 1\n"
);
}
#[test]
fn read_manifest_of_a_missing_file_is_an_error() {
let (root, _guard) = scratch("no-manifest");
std::fs::create_dir_all(&root).expect("create root");
SpecDigestReader::new(&root)
.read_manifest()
.expect_err("must fail");
}
#[test]
fn path_for_and_root_locate_the_same_file() {
let reader = SpecDigestReader::new("/tmp/example-spec-root");
assert_eq!(reader.root().to_str(), Some("/tmp/example-spec-root"));
assert_eq!(
reader.path_for("2025-06-18", "schema.json"),
std::path::Path::new("/tmp/example-spec-root/2025-06-18/schema.json")
);
}
}