1use std::fs;
14use std::io;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct Baseline {
36 pub name: String,
41 pub line_pct: f64,
43 pub function_pct: f64,
45 pub region_pct: f64,
47}
48
49pub trait BaselineStore {
60 fn load(&self, scope: &str, name: &str) -> io::Result<Option<Baseline>>;
62
63 fn save(&self, scope: &str, baseline: &Baseline) -> io::Result<()>;
65}
66
67#[derive(Debug, Clone)]
91pub struct JsonFileBaselineStore {
92 root: PathBuf,
93}
94
95impl JsonFileBaselineStore {
96 pub fn new(root: impl Into<PathBuf>) -> Self {
99 Self { root: root.into() }
100 }
101
102 pub fn path_for(&self, scope: &str, name: &str) -> PathBuf {
104 self.root.join(scope).join(format!("{name}.json"))
105 }
106
107 fn write_atomic(target: &Path, contents: &str) -> io::Result<()> {
108 if let Some(parent) = target.parent() {
109 fs::create_dir_all(parent)?;
110 }
111 let tmp = target.with_extension("json.tmp");
112 fs::write(&tmp, contents)?;
113 if target.exists() {
116 fs::remove_file(target)?;
117 }
118 fs::rename(&tmp, target)
119 }
120}
121
122impl BaselineStore for JsonFileBaselineStore {
123 fn load(&self, scope: &str, name: &str) -> io::Result<Option<Baseline>> {
124 let path = self.path_for(scope, name);
125 if !path.exists() {
126 return Ok(None);
127 }
128 let text = fs::read_to_string(&path)?;
129 let baseline: Baseline = serde_json::from_str(&text).map_err(io::Error::other)?;
130 Ok(Some(baseline))
131 }
132
133 fn save(&self, scope: &str, baseline: &Baseline) -> io::Result<()> {
134 let path = self.path_for(scope, &baseline.name);
135 let serialized = serde_json::to_string_pretty(baseline).map_err(io::Error::other)?;
136 Self::write_atomic(&path, &serialized)
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 fn fixture() -> Baseline {
145 Baseline {
146 name: "my-crate".into(),
147 line_pct: 87.5,
148 function_pct: 90.0,
149 region_pct: 80.0,
150 }
151 }
152
153 #[test]
154 fn load_missing_returns_ok_none() {
155 let dir = tempfile::tempdir().unwrap();
156 let store = JsonFileBaselineStore::new(dir.path());
157 let got = store.load("main", "nothing-here").unwrap();
158 assert!(got.is_none());
159 }
160
161 #[test]
162 fn save_then_load_round_trips() {
163 let dir = tempfile::tempdir().unwrap();
164 let store = JsonFileBaselineStore::new(dir.path());
165 let b = fixture();
166 store.save("main", &b).unwrap();
167 let back = store.load("main", "my-crate").unwrap();
168 assert_eq!(back, Some(b));
169 }
170
171 #[test]
172 fn save_overwrites_existing() {
173 let dir = tempfile::tempdir().unwrap();
174 let store = JsonFileBaselineStore::new(dir.path());
175 let mut b = fixture();
176 store.save("main", &b).unwrap();
177 b.line_pct = 99.0;
178 store.save("main", &b).unwrap();
179 let back = store.load("main", "my-crate").unwrap().unwrap();
180 assert_eq!(back.line_pct, 99.0);
181 }
182
183 #[test]
184 fn scopes_are_independent() {
185 let dir = tempfile::tempdir().unwrap();
186 let store = JsonFileBaselineStore::new(dir.path());
187 let mut main = fixture();
188 main.line_pct = 80.0;
189 let mut feature = fixture();
190 feature.line_pct = 60.0;
191 store.save("main", &main).unwrap();
192 store.save("feature/x", &feature).unwrap();
193 let m = store.load("main", "my-crate").unwrap().unwrap();
194 let f = store.load("feature/x", "my-crate").unwrap().unwrap();
195 assert_eq!(m.line_pct, 80.0);
196 assert_eq!(f.line_pct, 60.0);
197 }
198
199 #[test]
200 fn path_for_matches_layout() {
201 let store = JsonFileBaselineStore::new("/tmp/x");
202 let p = store.path_for("main", "my-crate");
203 assert!(p.ends_with("main/my-crate.json"));
204 }
205
206 #[test]
207 fn load_rejects_corrupt_json() {
208 let dir = tempfile::tempdir().unwrap();
209 let store = JsonFileBaselineStore::new(dir.path());
210 let p = store.path_for("main", "my-crate");
212 fs::create_dir_all(p.parent().unwrap()).unwrap();
213 fs::write(&p, "not json").unwrap();
214 let err = store.load("main", "my-crate").err().unwrap();
215 assert!(err.to_string().to_lowercase().contains("expected"));
217 }
218}