1use std::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct Baseline {
34 pub name: String,
36 pub mean_ns: u64,
38 pub samples: u64,
40 pub ops_per_sec: f64,
42}
43
44impl Baseline {
45 pub fn mean(&self) -> Duration {
47 Duration::from_nanos(self.mean_ns)
48 }
49}
50
51pub trait BaselineStore {
62 fn load(&self, scope: &str, name: &str) -> io::Result<Option<Baseline>>;
64
65 fn save(&self, scope: &str, baseline: &Baseline) -> io::Result<()>;
67}
68
69pub struct JsonFileBaselineStore {
91 root: PathBuf,
92}
93
94impl JsonFileBaselineStore {
95 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
137fn 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 store.save("../danger", &b).unwrap();
242 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}