metrale_bench/
artifacts.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The Metrale Engine home (`ArtifactStore`): the per-plugin
4//! artifact and per-benchmark run directories, plus write-if-changed assets and
5//! provisioning stamps.
6//!
7//! Layout this module creates under the root:
8//! ```text
9//!   <root>/
10//!     artifacts/<plugin-id>/     provisioned material (venvs, datasets, scripts)
11//!     runs/<benchmark-id>/       run records (history.rs) and baselines (baseline.rs)
12//! ```
13//!
14//! Owner: bench (artifacts).
15//! Invariants:
16//! - `write_asset_bytes` never rewrites a file whose bytes already match; an
17//!   existing file it cannot read counts as different.
18
19use std::path::{Path, PathBuf};
20
21use anyhow::{Context, Result};
22
23mod home;
24
25pub use home::{HomeFault, HomeSource, MetraleHome};
26
27/// 2026-09-26: Handle to the artifact area: the resolved root directory.
28#[derive(Clone, Debug)]
29pub struct ArtifactStore {
30    root: PathBuf,
31}
32
33impl ArtifactStore {
34    /// 2026-09-26: The store at the resolved home: `METRALE_HOME` when set,
35    /// otherwise `$HOME/.metrale`. Neither set is an error; there is no
36    /// fallback directory. See [`MetraleHome::resolve`].
37    pub fn discover() -> Result<Self> {
38        Ok(Self {
39            root: MetraleHome::resolve()?.root,
40        })
41    }
42
43    /// 2026-09-26: The resolved home together with where it came from
44    /// ([`HomeSource`]).
45    pub fn discover_with_provenance() -> Result<MetraleHome> {
46        MetraleHome::resolve()
47    }
48
49    /// 2026-09-26: Point the store at an explicit root, bypassing resolution.
50    pub fn with_root(root: impl Into<PathBuf>) -> Self {
51        Self { root: root.into() }
52    }
53
54    pub fn root(&self) -> &Path {
55        &self.root
56    }
57
58    /// 2026-09-26: `<root>/artifacts/<plugin_id>`, created.
59    pub fn plugin_dir(&self, plugin_id: &str) -> Result<PathBuf> {
60        let dir = self.root.join("artifacts").join(plugin_id);
61        std::fs::create_dir_all(&dir)
62            .with_context(|| format!("creating artifact dir {}", dir.display()))?;
63        Ok(dir)
64    }
65
66    /// 2026-09-26: `<root>/runs/<benchmark_id>`, created.
67    pub fn runs_dir(&self, benchmark_id: &str) -> Result<PathBuf> {
68        let dir = self.root.join("runs").join(benchmark_id);
69        std::fs::create_dir_all(&dir)
70            .with_context(|| format!("creating runs dir {}", dir.display()))?;
71        Ok(dir)
72    }
73}
74
75/// 2026-09-26: Write a compiled-in asset into `dir`, but only when the bytes
76/// differ, so a provisioned script always matches the binary that ships it and
77/// an unchanged file keeps its mtime.
78///
79/// Returns `true` when the file was written.
80pub fn write_asset(dir: &Path, name: &str, contents: &str) -> Result<bool> {
81    write_asset_bytes(dir, name, contents.as_bytes())
82}
83
84/// 2026-09-26: [`write_asset`] for assets that are not text: the same
85/// compare-then-write contract, over bytes. The comparison reads bytes because
86/// `read_to_string` fails on non-UTF-8 files and would make every binary asset
87/// look changed.
88pub fn write_asset_bytes(dir: &Path, name: &str, contents: &[u8]) -> Result<bool> {
89    let path = dir.join(name);
90    if let Ok(existing) = std::fs::read(&path)
91        && existing == contents
92    {
93        return Ok(false);
94    }
95    std::fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))?;
96    Ok(true)
97}
98
99/// 2026-09-26: A provisioning stamp: a marker file whose contents identify the
100/// inputs that produced an artifact. `is_current` is true only when the file's
101/// trimmed contents equal the expected value, so changed inputs make it stale.
102pub struct Stamp {
103    path: PathBuf,
104    expected: String,
105}
106
107impl Stamp {
108    pub fn new(dir: &Path, name: &str, expected: impl Into<String>) -> Self {
109        Self {
110            path: dir.join(name),
111            expected: expected.into(),
112        }
113    }
114
115    pub fn is_current(&self) -> bool {
116        std::fs::read_to_string(&self.path).is_ok_and(|s| s.trim() == self.expected.trim())
117    }
118
119    /// 2026-09-26: Record that provisioning succeeded. Call it last: a stamp
120    /// written before the work completes marks a half-provisioned directory as
121    /// current.
122    pub fn commit(&self) -> Result<()> {
123        std::fs::write(&self.path, &self.expected)
124            .with_context(|| format!("writing stamp {}", self.path.display()))
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::home::resolve_from;
131    use super::*;
132
133    fn tmp(name: &str) -> PathBuf {
134        let d =
135            std::env::temp_dir().join(format!("metrale-plugin-test-{name}-{}", std::process::id()));
136        let _ = std::fs::remove_dir_all(&d);
137        std::fs::create_dir_all(&d).unwrap();
138        d
139    }
140
141    #[test]
142    fn dirs_are_created_under_the_configured_root() {
143        let root = tmp("dirs");
144        let store = ArtifactStore::with_root(&root);
145        let p = store.plugin_dir("bfcl").unwrap();
146        let r = store.runs_dir("bfcl-subset").unwrap();
147        assert!(p.is_dir() && r.is_dir());
148        assert_eq!(p, root.join("artifacts/bfcl"));
149        assert_eq!(r, root.join("runs/bfcl-subset"));
150    }
151
152    #[test]
153    fn write_asset_rewrites_only_on_change() {
154        let dir = tmp("asset");
155        let path = dir.join("s.py");
156        assert!(write_asset(&dir, "s.py", "print(1)").unwrap());
157
158        let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1);
159        std::fs::File::options()
160            .write(true)
161            .open(&path)
162            .unwrap()
163            .set_times(std::fs::FileTimes::new().set_modified(old))
164            .unwrap();
165        let pinned_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
166
167        assert!(!write_asset(&dir, "s.py", "print(1)").unwrap());
168        assert_eq!(
169            std::fs::metadata(&path).unwrap().modified().unwrap(),
170            pinned_mtime,
171            "unchanged contents must not rewrite the asset"
172        );
173        assert!(write_asset(&dir, "s.py", "print(2)").unwrap());
174        assert_eq!(std::fs::read_to_string(path).unwrap(), "print(2)");
175    }
176
177    #[test]
178    fn binary_assets_are_compared_as_bytes() {
179        let dir = tmp("binary-asset");
180        let path = dir.join("image.bin");
181        let bytes = [0xff, 0x00, 0xfe];
182
183        assert!(write_asset_bytes(&dir, "image.bin", &bytes).unwrap());
184        assert!(!write_asset_bytes(&dir, "image.bin", &bytes).unwrap());
185        assert_eq!(std::fs::read(path).unwrap(), bytes);
186    }
187
188    /// 2026-09-26: A scratch `$HOME` with the given directories in it. It is
189    /// passed to `resolve_from`, never exported with `set_var`, so these tests
190    /// share no process state.
191    fn home_with(tag: &str, dirs: &[&str]) -> PathBuf {
192        let home = tmp(tag);
193        for d in dirs {
194            std::fs::create_dir_all(home.join(d)).unwrap();
195        }
196        home
197    }
198
199    fn resolved(home: &Path) -> MetraleHome {
200        resolve_from(None, Some(home.as_os_str().to_owned())).unwrap()
201    }
202
203    #[test]
204    fn a_fresh_box_gets_the_default_home() {
205        let home = home_with("neither", &[]);
206        let got = resolved(&home);
207        assert_eq!(got.root, home.join(".metrale"));
208        assert_eq!(got.source, HomeSource::HomeDefault);
209    }
210
211    /// 2026-09-26: `METRALE_HOME` wins even when `$HOME/.metrale` exists.
212    #[test]
213    fn metrale_home_wins_over_the_default_directory() {
214        let home = home_with("env-wins", &[".metrale"]);
215        let explicit = home.join("elsewhere");
216        let got = resolve_from(
217            Some(explicit.as_os_str().to_owned()),
218            Some(home.as_os_str().to_owned()),
219        )
220        .unwrap();
221        assert_eq!(got.root, explicit);
222        assert_eq!(got.source, HomeSource::Env);
223    }
224
225    /// 2026-09-26: `resolve_from` creates nothing on disk.
226    #[test]
227    fn resolving_creates_nothing() {
228        let home = home_with("no-side-effects", &[]);
229        let _ = resolved(&home);
230        assert!(
231            !home.join(".metrale").exists(),
232            "resolve must not create the home it names"
233        );
234        let entries: Vec<_> = std::fs::read_dir(&home)
235            .unwrap()
236            .map(|e| e.unwrap().file_name())
237            .collect();
238        assert!(entries.is_empty(), "expected nothing, got {entries:?}");
239    }
240
241    #[test]
242    fn stamp_is_stale_until_committed_and_tracks_its_inputs() {
243        let dir = tmp("stamp");
244        let s = Stamp::new(&dir, ".provisioned", "v1");
245        assert!(!s.is_current());
246        s.commit().unwrap();
247        assert!(s.is_current());
248        assert!(!Stamp::new(&dir, ".provisioned", "v2").is_current());
249    }
250}