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() -> std::path::PathBuf {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let p = std::env::temp_dir().join(format!("trusty-common-stamp-test-{pid}-{nanos}"));
std::fs::create_dir_all(&p).expect("create scratch dir");
p
}
#[test]
fn file_stamp_roundtrip() {
let dir = tempdir();
let path = dir.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.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.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.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.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.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)
);
}
}