use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Baseline {
pub name: String,
pub line_pct: f64,
pub function_pct: f64,
pub region_pct: f64,
}
pub trait BaselineStore {
fn load(&self, scope: &str, name: &str) -> io::Result<Option<Baseline>>;
fn save(&self, scope: &str, baseline: &Baseline) -> io::Result<()>;
}
#[derive(Debug, Clone)]
pub struct JsonFileBaselineStore {
root: PathBuf,
}
impl JsonFileBaselineStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn path_for(&self, scope: &str, name: &str) -> PathBuf {
self.root.join(scope).join(format!("{name}.json"))
}
fn write_atomic(target: &Path, contents: &str) -> io::Result<()> {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
let tmp = target.with_extension("json.tmp");
fs::write(&tmp, contents)?;
if target.exists() {
fs::remove_file(target)?;
}
fs::rename(&tmp, target)
}
}
impl BaselineStore for JsonFileBaselineStore {
fn load(&self, scope: &str, name: &str) -> io::Result<Option<Baseline>> {
let path = self.path_for(scope, name);
if !path.exists() {
return Ok(None);
}
let text = fs::read_to_string(&path)?;
let baseline: Baseline = serde_json::from_str(&text).map_err(io::Error::other)?;
Ok(Some(baseline))
}
fn save(&self, scope: &str, baseline: &Baseline) -> io::Result<()> {
let path = self.path_for(scope, &baseline.name);
let serialized = serde_json::to_string_pretty(baseline).map_err(io::Error::other)?;
Self::write_atomic(&path, &serialized)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> Baseline {
Baseline {
name: "my-crate".into(),
line_pct: 87.5,
function_pct: 90.0,
region_pct: 80.0,
}
}
#[test]
fn load_missing_returns_ok_none() {
let dir = tempfile::tempdir().unwrap();
let store = JsonFileBaselineStore::new(dir.path());
let got = store.load("main", "nothing-here").unwrap();
assert!(got.is_none());
}
#[test]
fn save_then_load_round_trips() {
let dir = tempfile::tempdir().unwrap();
let store = JsonFileBaselineStore::new(dir.path());
let b = fixture();
store.save("main", &b).unwrap();
let back = store.load("main", "my-crate").unwrap();
assert_eq!(back, Some(b));
}
#[test]
fn save_overwrites_existing() {
let dir = tempfile::tempdir().unwrap();
let store = JsonFileBaselineStore::new(dir.path());
let mut b = fixture();
store.save("main", &b).unwrap();
b.line_pct = 99.0;
store.save("main", &b).unwrap();
let back = store.load("main", "my-crate").unwrap().unwrap();
assert_eq!(back.line_pct, 99.0);
}
#[test]
fn scopes_are_independent() {
let dir = tempfile::tempdir().unwrap();
let store = JsonFileBaselineStore::new(dir.path());
let mut main = fixture();
main.line_pct = 80.0;
let mut feature = fixture();
feature.line_pct = 60.0;
store.save("main", &main).unwrap();
store.save("feature/x", &feature).unwrap();
let m = store.load("main", "my-crate").unwrap().unwrap();
let f = store.load("feature/x", "my-crate").unwrap().unwrap();
assert_eq!(m.line_pct, 80.0);
assert_eq!(f.line_pct, 60.0);
}
#[test]
fn path_for_matches_layout() {
let store = JsonFileBaselineStore::new("/tmp/x");
let p = store.path_for("main", "my-crate");
assert!(p.ends_with("main/my-crate.json"));
}
#[test]
fn load_rejects_corrupt_json() {
let dir = tempfile::tempdir().unwrap();
let store = JsonFileBaselineStore::new(dir.path());
let p = store.path_for("main", "my-crate");
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, "not json").unwrap();
let err = store.load("main", "my-crate").err().unwrap();
assert!(err.to_string().to_lowercase().contains("expected"));
}
}