use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::SchemaVersion;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
struct StampFile {
schema_version: u32,
}
pub fn read_version_from_file(path: &Path) -> Result<SchemaVersion> {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(SchemaVersion::UNVERSIONED);
}
Err(e) => {
return Err(e).with_context(|| format!("read schema stamp at {}", path.display()));
}
};
match serde_json::from_slice::<StampFile>(&bytes) {
Ok(stamp) => Ok(SchemaVersion(stamp.schema_version)),
Err(e) => {
tracing::warn!(
"schema stamp at {} is malformed ({e}) — treating as UNVERSIONED",
path.display()
);
Ok(SchemaVersion::UNVERSIONED)
}
}
}
pub fn write_version_to_file(path: &Path, version: SchemaVersion) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create parent of schema stamp {}", path.display()))?;
}
let stamp = StampFile {
schema_version: version.0,
};
let bytes = serde_json::to_vec(&stamp).context("serialize schema stamp")?;
let tmp = {
let mut t = path.as_os_str().to_owned();
t.push(".tmp");
std::path::PathBuf::from(t)
};
std::fs::write(&tmp, &bytes)
.with_context(|| format!("write temp schema stamp {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("rename schema stamp into place at {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tempdir() -> tempfile::TempDir {
tempfile::Builder::new()
.prefix("trusty-common-stamp-test-")
.tempdir()
.expect("create scratch dir")
}
#[test]
fn concurrent_scratch_dirs_never_collide() {
let threads: Vec<_> = (0..8u32)
.map(|i| {
std::thread::spawn(move || {
let dir = tempdir();
let path = dir.path().join("schema_version.json");
write_version_to_file(&path, SchemaVersion(i)).expect("write");
let got = read_version_from_file(&path).expect("read");
(i, dir, got)
})
})
.collect();
let results: Vec<_> = threads
.into_iter()
.map(|t| t.join().expect("thread panicked"))
.collect();
let mut paths = std::collections::HashSet::new();
for (i, dir, got) in &results {
assert_eq!(*got, SchemaVersion(*i), "thread {i} read another's stamp");
assert!(
paths.insert(dir.path()),
"duplicate scratch dir {:?}",
dir.path()
);
}
}
#[test]
fn file_stamp_roundtrip() {
let dir = tempdir();
let path = dir.path().join("schema_version.json");
write_version_to_file(&path, SchemaVersion(7)).expect("write succeeds");
let got = read_version_from_file(&path).expect("read succeeds");
assert_eq!(got, SchemaVersion(7));
}
#[test]
fn read_returns_unversioned_when_missing() {
let dir = tempdir();
let path = dir.path().join("missing.json");
let got = read_version_from_file(&path).expect("missing file is not an error");
assert_eq!(got, SchemaVersion::UNVERSIONED);
}
#[test]
fn read_returns_unversioned_on_corrupt_payload() {
let dir = tempdir();
let path = dir.path().join("schema_version.json");
std::fs::write(&path, b"this is not json").expect("write garbage");
let got = read_version_from_file(&path).expect("corrupt file is not an error");
assert_eq!(got, SchemaVersion::UNVERSIONED);
}
#[test]
fn write_is_atomic_via_tmp_rename() {
let dir = tempdir();
let path = dir.path().join("schema_version.json");
write_version_to_file(&path, SchemaVersion(3)).expect("write");
let tmp = {
let mut t = path.as_os_str().to_owned();
t.push(".tmp");
std::path::PathBuf::from(t)
};
assert!(
!tmp.exists(),
"temp file should be renamed away, but {} still exists",
tmp.display()
);
assert!(path.exists(), "final stamp file must exist after write");
}
#[test]
fn write_creates_missing_parent_directories() {
let dir = tempdir();
let nested = dir.path().join("a").join("b").join("c");
let path = nested.join("schema_version.json");
write_version_to_file(&path, SchemaVersion(1))
.expect("nested write creates intermediate dirs");
assert!(path.exists());
}
#[test]
fn overwrite_replaces_existing_stamp() {
let dir = tempdir();
let path = dir.path().join("schema_version.json");
write_version_to_file(&path, SchemaVersion(1)).expect("first write");
write_version_to_file(&path, SchemaVersion(2)).expect("second write");
assert_eq!(
read_version_from_file(&path).expect("read"),
SchemaVersion(2)
);
}
}