Skip to main content

dev_bench/
baseline.rs

1//! Baseline storage for benchmark results.
2//!
3//! In `0.1.x`, baselines were passed in as inline `Option<Duration>`.
4//! `0.4.x+` lifts that constraint: baselines can be persisted to and
5//! loaded from disk via the [`BaselineStore`] trait. The default
6//! backend is [`JsonFileBaselineStore`], which writes one JSON file
7//! per `(scope, name)` key with atomic write-temp-rename semantics.
8
9use std::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15
16/// Persisted baseline for a single benchmark.
17///
18/// # Example
19///
20/// ```
21/// use dev_bench::Baseline;
22/// use std::time::Duration;
23///
24/// let b = Baseline {
25///     name: "parse".into(),
26///     mean_ns: Duration::from_nanos(1234).as_nanos() as u64,
27///     samples: 1000,
28///     ops_per_sec: 800_000.0,
29/// };
30/// assert_eq!(b.name, "parse");
31/// ```
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct Baseline {
34    /// Stable name of the benchmark.
35    pub name: String,
36    /// Mean per-iteration duration, in nanoseconds.
37    pub mean_ns: u64,
38    /// Number of samples collected.
39    pub samples: u64,
40    /// Throughput at baseline, in ops/sec.
41    pub ops_per_sec: f64,
42}
43
44impl Baseline {
45    /// Convenience: extract `mean_ns` as a `Duration`.
46    pub fn mean(&self) -> Duration {
47        Duration::from_nanos(self.mean_ns)
48    }
49}
50
51/// A storage backend for benchmark baselines.
52///
53/// Implementations MUST treat `load` as tolerant of missing data
54/// (return `Ok(None)`). Implementations SHOULD treat `save` as
55/// atomic — partial writes that survive a crash are unacceptable
56/// because they would corrupt comparisons on the next run.
57///
58/// `scope` is a free-form key the caller uses to namespace baselines
59/// (e.g. a git SHA, a branch name, or `"latest"`). The implementation
60/// MUST treat `(scope, name)` as the identity of a baseline.
61pub trait BaselineStore {
62    /// Load a baseline if one exists for `(scope, name)`.
63    fn load(&self, scope: &str, name: &str) -> io::Result<Option<Baseline>>;
64
65    /// Persist a baseline atomically.
66    fn save(&self, scope: &str, baseline: &Baseline) -> io::Result<()>;
67}
68
69/// Filesystem-backed JSON baseline store.
70///
71/// Keys baselines as `<root>/<scope>/<name>.json`. Save uses
72/// write-temp-rename to be atomic on the same filesystem.
73///
74/// # Example
75///
76/// ```
77/// use dev_bench::{Baseline, BaselineStore, JsonFileBaselineStore};
78/// let dir = tempfile::tempdir().unwrap();
79/// let store = JsonFileBaselineStore::new(dir.path());
80/// let b = Baseline {
81///     name: "parse".into(),
82///     mean_ns: 1234,
83///     samples: 100,
84///     ops_per_sec: 800_000.0,
85/// };
86/// store.save("main", &b).unwrap();
87/// let back = store.load("main", "parse").unwrap().unwrap();
88/// assert_eq!(back, b);
89/// ```
90pub struct JsonFileBaselineStore {
91    root: PathBuf,
92}
93
94impl JsonFileBaselineStore {
95    /// Build a store rooted at `root`. The directory is created on
96    /// first save if it does not exist.
97    pub fn new(root: impl Into<PathBuf>) -> Self {
98        Self { root: root.into() }
99    }
100
101    fn path_for(&self, scope: &str, name: &str) -> PathBuf {
102        let safe_scope = sanitize(scope);
103        let safe_name = sanitize(name);
104        self.root.join(safe_scope).join(format!("{safe_name}.json"))
105    }
106}
107
108impl BaselineStore for JsonFileBaselineStore {
109    fn load(&self, scope: &str, name: &str) -> io::Result<Option<Baseline>> {
110        let path = self.path_for(scope, name);
111        match fs::read(&path) {
112            Ok(bytes) => {
113                let b: Baseline = serde_json::from_slice(&bytes).map_err(|e| {
114                    io::Error::new(
115                        io::ErrorKind::InvalidData,
116                        format!("invalid baseline at {}: {}", path.display(), e),
117                    )
118                })?;
119                Ok(Some(b))
120            }
121            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
122            Err(e) => Err(e),
123        }
124    }
125
126    fn save(&self, scope: &str, baseline: &Baseline) -> io::Result<()> {
127        let path = self.path_for(scope, &baseline.name);
128        if let Some(parent) = path.parent() {
129            fs::create_dir_all(parent)?;
130        }
131        let bytes = serde_json::to_vec_pretty(baseline)
132            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("serialize: {}", e)))?;
133        atomic_write(&path, &bytes)
134    }
135}
136
137/// Atomic write: write to a temp sibling, then rename over the target.
138///
139/// On the same filesystem, `rename` is atomic. This guarantees that a
140/// reader either sees the complete previous file or the complete new
141/// file, never a torn write.
142fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
143    let parent = path.parent().unwrap_or(Path::new("."));
144    let file_name = path
145        .file_name()
146        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "no file name"))?;
147    let temp = parent.join(format!(".{}.tmp", file_name.to_string_lossy()));
148    fs::write(&temp, bytes)?;
149    fs::rename(&temp, path)?;
150    Ok(())
151}
152
153fn sanitize(s: &str) -> String {
154    s.chars()
155        .map(|c| {
156            if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
157                c
158            } else {
159                '_'
160            }
161        })
162        .collect()
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn round_trip_baseline_through_json_store() {
171        let dir = tempfile::tempdir().unwrap();
172        let store = JsonFileBaselineStore::new(dir.path());
173        let b = Baseline {
174            name: "parse_query".into(),
175            mean_ns: 1234,
176            samples: 1000,
177            ops_per_sec: 810_000.0,
178        };
179        store.save("abc1234", &b).unwrap();
180        let back = store.load("abc1234", "parse_query").unwrap().unwrap();
181        assert_eq!(back, b);
182    }
183
184    #[test]
185    fn missing_baseline_returns_none() {
186        let dir = tempfile::tempdir().unwrap();
187        let store = JsonFileBaselineStore::new(dir.path());
188        let r = store.load("anything", "absent").unwrap();
189        assert!(r.is_none());
190    }
191
192    #[test]
193    fn save_creates_parent_directories() {
194        let dir = tempfile::tempdir().unwrap();
195        let store = JsonFileBaselineStore::new(dir.path().join("not_yet_existing"));
196        let b = Baseline {
197            name: "x".into(),
198            mean_ns: 1,
199            samples: 1,
200            ops_per_sec: 1.0,
201        };
202        store.save("main", &b).unwrap();
203        let back = store.load("main", "x").unwrap().unwrap();
204        assert_eq!(back, b);
205    }
206
207    #[test]
208    fn save_overwrites_existing() {
209        let dir = tempfile::tempdir().unwrap();
210        let store = JsonFileBaselineStore::new(dir.path());
211        let b1 = Baseline {
212            name: "x".into(),
213            mean_ns: 100,
214            samples: 1,
215            ops_per_sec: 10.0,
216        };
217        let b2 = Baseline {
218            name: "x".into(),
219            mean_ns: 200,
220            samples: 2,
221            ops_per_sec: 5.0,
222        };
223        store.save("main", &b1).unwrap();
224        store.save("main", &b2).unwrap();
225        let back = store.load("main", "x").unwrap().unwrap();
226        assert_eq!(back, b2);
227    }
228
229    #[test]
230    fn sanitize_blocks_path_traversal_in_scope_and_name() {
231        let dir = tempfile::tempdir().unwrap();
232        let store = JsonFileBaselineStore::new(dir.path());
233        let b = Baseline {
234            name: "../escaped".into(),
235            mean_ns: 1,
236            samples: 1,
237            ops_per_sec: 1.0,
238        };
239        // sanitize replaces / and . . combinations with safe chars,
240        // so save lands inside the root regardless of input.
241        store.save("../danger", &b).unwrap();
242        // Root was not escaped: a sibling of `dir.path()` should not
243        // contain anything new.
244        let parent = dir.path().parent().unwrap();
245        let entries_in_parent: usize = fs::read_dir(parent)
246            .unwrap()
247            .filter_map(|e| e.ok())
248            .filter(|e| {
249                e.path() != dir.path() && e.file_name().to_string_lossy().starts_with("danger")
250            })
251            .count();
252        assert_eq!(entries_in_parent, 0);
253    }
254
255    #[test]
256    fn corrupt_baseline_yields_invalid_data_error() {
257        let dir = tempfile::tempdir().unwrap();
258        let store = JsonFileBaselineStore::new(dir.path());
259        let path = store.path_for("main", "broken");
260        fs::create_dir_all(path.parent().unwrap()).unwrap();
261        fs::write(&path, b"{ this is not json").unwrap();
262        let err = store.load("main", "broken").unwrap_err();
263        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
264    }
265
266    #[test]
267    fn baseline_mean_returns_duration() {
268        let b = Baseline {
269            name: "x".into(),
270            mean_ns: 5_000,
271            samples: 1,
272            ops_per_sec: 1.0,
273        };
274        assert_eq!(b.mean(), Duration::from_nanos(5_000));
275    }
276}