use std::path::Path;
use serde::{Deserialize, Serialize};
pub const STAMP_FILE: &str = ".varve-export.json";
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)]
pub struct ExportStamp {
pub layer: String,
pub manifest_digest: String,
pub kind: String,
}
#[derive(Debug, thiserror::Error)]
pub enum ExportStampError {
#[error(
"no export stamp ({STAMP_FILE}) in {0} — not a varve export \
(or produced before stamping); re-run the export"
)]
Missing(String),
#[error("export stamp in {0} is malformed: {1}")]
Malformed(String, String),
#[error("i/o error on export stamp in {0}: {1}")]
Io(String, String),
}
#[derive(Debug, PartialEq, Eq)]
pub enum ExportStatus {
Current,
Stale { stamped: String, current: String },
}
pub fn write_stamp(dir: &Path, stamp: &ExportStamp) -> Result<(), ExportStampError> {
let path = dir.join(STAMP_FILE);
std::fs::create_dir_all(dir)
.map_err(|e| ExportStampError::Io(dir.display().to_string(), e.to_string()))?;
let json = serde_json::to_string_pretty(stamp)
.map_err(|e| ExportStampError::Malformed(dir.display().to_string(), e.to_string()))?;
std::fs::write(&path, json)
.map_err(|e| ExportStampError::Io(dir.display().to_string(), e.to_string()))
}
pub fn read_stamp(dir: &Path) -> Result<ExportStamp, ExportStampError> {
let path = dir.join(STAMP_FILE);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(ExportStampError::Missing(dir.display().to_string()));
}
Err(e) => {
return Err(ExportStampError::Io(
dir.display().to_string(),
e.to_string(),
));
}
};
serde_json::from_slice(&bytes)
.map_err(|e| ExportStampError::Malformed(dir.display().to_string(), e.to_string()))
}
pub fn status(stamp: &ExportStamp, current_manifest_digest: &str) -> ExportStatus {
if stamp.manifest_digest == current_manifest_digest {
ExportStatus::Current
} else {
ExportStatus::Stale {
stamped: stamp.manifest_digest.clone(),
current: current_manifest_digest.to_string(),
}
}
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
fn premise_unavailable() -> bool {
use std::os::unix::fs::PermissionsExt;
let Ok(dir) = tempfile::tempdir() else {
return true;
};
let probe = dir.path().join("probe");
if std::fs::write(&probe, b"x").is_err() {
return true;
}
if std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o000)).is_err() {
return true;
}
let readable = std::fs::read(&probe).is_ok();
let _ = std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o644));
readable
}
use super::*;
fn sample() -> ExportStamp {
ExportStamp {
layer: "2026.08.0".into(),
manifest_digest: "sha256:aaaa".into(),
kind: "cargo".into(),
}
}
#[test]
fn write_then_read_round_trips() {
let dir = tempfile::tempdir().unwrap();
let s = sample();
write_stamp(dir.path(), &s).unwrap();
assert!(dir.path().join(STAMP_FILE).exists());
assert_eq!(read_stamp(dir.path()).unwrap(), s);
}
#[test]
fn read_missing_stamp_is_missing_error() {
let dir = tempfile::tempdir().unwrap();
match read_stamp(dir.path()) {
Err(ExportStampError::Missing(_)) => {}
other => panic!("expected Missing, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn an_unreadable_stamp_is_an_io_error_not_a_missing_one() {
if premise_unavailable() {
eprintln!("skipping: this environment does not deny reads on mode 000");
return;
}
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(STAMP_FILE);
std::fs::write(&path, b"{}").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
let got = read_stamp(dir.path());
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
match got {
Err(ExportStampError::Io(..)) => {}
other => panic!("expected Io for an unreadable stamp, got {other:?}"),
}
}
#[test]
fn read_malformed_stamp_is_malformed_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(STAMP_FILE), b"{not json").unwrap();
match read_stamp(dir.path()) {
Err(ExportStampError::Malformed(..)) => {}
other => panic!("expected Malformed, got {other:?}"),
}
}
#[test]
fn status_is_current_when_digests_match() {
assert_eq!(status(&sample(), "sha256:aaaa"), ExportStatus::Current);
}
#[test]
fn status_is_stale_when_digests_differ() {
assert_eq!(
status(&sample(), "sha256:bbbb"),
ExportStatus::Stale {
stamped: "sha256:aaaa".into(),
current: "sha256:bbbb".into(),
}
);
}
}