varve_core/
exportstamp.rs1use std::path::Path;
14
15use serde::{Deserialize, Serialize};
16
17pub const STAMP_FILE: &str = ".varve-export.json";
19
20#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)]
23pub struct ExportStamp {
24 pub layer: String,
26 pub manifest_digest: String,
28 pub kind: String,
30}
31
32#[derive(Debug, thiserror::Error)]
34pub enum ExportStampError {
35 #[error(
36 "no export stamp ({STAMP_FILE}) in {0} — not a varve export \
37 (or produced before stamping); re-run the export"
38 )]
39 Missing(String),
40 #[error("export stamp in {0} is malformed: {1}")]
41 Malformed(String, String),
42 #[error("i/o error on export stamp in {0}: {1}")]
43 Io(String, String),
44}
45
46#[derive(Debug, PartialEq, Eq)]
48pub enum ExportStatus {
49 Current,
51 Stale { stamped: String, current: String },
53}
54
55pub fn write_stamp(dir: &Path, stamp: &ExportStamp) -> Result<(), ExportStampError> {
57 let path = dir.join(STAMP_FILE);
58 std::fs::create_dir_all(dir)
59 .map_err(|e| ExportStampError::Io(dir.display().to_string(), e.to_string()))?;
60 let json = serde_json::to_string_pretty(stamp)
61 .map_err(|e| ExportStampError::Malformed(dir.display().to_string(), e.to_string()))?;
62 std::fs::write(&path, json)
63 .map_err(|e| ExportStampError::Io(dir.display().to_string(), e.to_string()))
64}
65
66pub fn read_stamp(dir: &Path) -> Result<ExportStamp, ExportStampError> {
69 let path = dir.join(STAMP_FILE);
70 let bytes = match std::fs::read(&path) {
71 Ok(b) => b,
72 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
73 return Err(ExportStampError::Missing(dir.display().to_string()));
74 }
75 Err(e) => {
76 return Err(ExportStampError::Io(
77 dir.display().to_string(),
78 e.to_string(),
79 ));
80 }
81 };
82 serde_json::from_slice(&bytes)
83 .map_err(|e| ExportStampError::Malformed(dir.display().to_string(), e.to_string()))
84}
85
86pub fn status(stamp: &ExportStamp, current_manifest_digest: &str) -> ExportStatus {
88 if stamp.manifest_digest == current_manifest_digest {
89 ExportStatus::Current
90 } else {
91 ExportStatus::Stale {
92 stamped: stamp.manifest_digest.clone(),
93 current: current_manifest_digest.to_string(),
94 }
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 #[cfg(unix)]
104 fn premise_unavailable() -> bool {
105 use std::os::unix::fs::PermissionsExt;
106 let Ok(dir) = tempfile::tempdir() else {
107 return true;
108 };
109 let probe = dir.path().join("probe");
110 if std::fs::write(&probe, b"x").is_err() {
111 return true;
112 }
113 if std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o000)).is_err() {
114 return true;
115 }
116 let readable = std::fs::read(&probe).is_ok();
117 let _ = std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o644));
118 readable
119 }
120
121 use super::*;
122
123 fn sample() -> ExportStamp {
124 ExportStamp {
125 layer: "2026.08.0".into(),
126 manifest_digest: "sha256:aaaa".into(),
127 kind: "cargo".into(),
128 }
129 }
130
131 #[test]
133 fn write_then_read_round_trips() {
134 let dir = tempfile::tempdir().unwrap();
135 let s = sample();
136 write_stamp(dir.path(), &s).unwrap();
137 assert!(dir.path().join(STAMP_FILE).exists());
138 assert_eq!(read_stamp(dir.path()).unwrap(), s);
139 }
140
141 #[test]
143 fn read_missing_stamp_is_missing_error() {
144 let dir = tempfile::tempdir().unwrap();
145 match read_stamp(dir.path()) {
146 Err(ExportStampError::Missing(_)) => {}
147 other => panic!("expected Missing, got {other:?}"),
148 }
149 }
150
151 #[cfg(unix)]
153 #[test]
154 fn an_unreadable_stamp_is_an_io_error_not_a_missing_one() {
155 if premise_unavailable() {
165 eprintln!("skipping: this environment does not deny reads on mode 000");
166 return;
167 }
168 use std::os::unix::fs::PermissionsExt;
169 let dir = tempfile::tempdir().unwrap();
170 let path = dir.path().join(STAMP_FILE);
171 std::fs::write(&path, b"{}").unwrap();
172 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
173 let got = read_stamp(dir.path());
174 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
176 match got {
177 Err(ExportStampError::Io(..)) => {}
178 other => panic!("expected Io for an unreadable stamp, got {other:?}"),
179 }
180 }
181
182 #[test]
184 fn read_malformed_stamp_is_malformed_error() {
185 let dir = tempfile::tempdir().unwrap();
186 std::fs::write(dir.path().join(STAMP_FILE), b"{not json").unwrap();
187 match read_stamp(dir.path()) {
188 Err(ExportStampError::Malformed(..)) => {}
189 other => panic!("expected Malformed, got {other:?}"),
190 }
191 }
192
193 #[test]
195 fn status_is_current_when_digests_match() {
196 assert_eq!(status(&sample(), "sha256:aaaa"), ExportStatus::Current);
197 }
198
199 #[test]
201 fn status_is_stale_when_digests_differ() {
202 assert_eq!(
203 status(&sample(), "sha256:bbbb"),
204 ExportStatus::Stale {
205 stamped: "sha256:aaaa".into(),
206 current: "sha256:bbbb".into(),
207 }
208 );
209 }
210}