Skip to main content

dev_coverage/
baseline.rs

1//! Persisted coverage baselines.
2//!
3//! A [`Baseline`] captures the three headline percentages
4//! (`line_pct`, `function_pct`, `region_pct`) for a single subject so
5//! the next run can diff against it. Baselines are stored under a
6//! caller-chosen *scope* — typically a git SHA, a branch name, or the
7//! literal `"latest"` — via any [`BaselineStore`] implementation.
8//!
9//! [`JsonFileBaselineStore`] is the default file-system backend. It
10//! writes one JSON file per `(scope, name)` pair using write-temp-rename
11//! semantics so partial writes never corrupt a comparison.
12
13use std::fs;
14use std::io;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19/// Persisted coverage baseline for a single subject.
20///
21/// # Example
22///
23/// ```
24/// use dev_coverage::Baseline;
25///
26/// let b = Baseline {
27///     name: "my-crate".into(),
28///     line_pct: 87.5,
29///     function_pct: 90.0,
30///     region_pct: 80.0,
31/// };
32/// assert_eq!(b.name, "my-crate");
33/// ```
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct Baseline {
36    /// Subject name. Matches the `name` on the [`CoverageResult`] this
37    /// baseline was derived from.
38    ///
39    /// [`CoverageResult`]: crate::CoverageResult
40    pub name: String,
41    /// Line coverage percentage at baseline time.
42    pub line_pct: f64,
43    /// Function coverage percentage at baseline time.
44    pub function_pct: f64,
45    /// Region coverage percentage at baseline time.
46    pub region_pct: f64,
47}
48
49/// Storage backend for [`Baseline`] values.
50///
51/// Implementations MUST treat [`load`](Self::load) as tolerant of
52/// missing data (return `Ok(None)`) and SHOULD make
53/// [`save`](Self::save) atomic — partial writes that survive a crash
54/// corrupt future comparisons.
55///
56/// `scope` is a free-form key the caller uses to namespace baselines.
57/// Implementations MUST treat `(scope, name)` as the identity of a
58/// baseline.
59pub trait BaselineStore {
60    /// Load a baseline if one exists for `(scope, name)`.
61    fn load(&self, scope: &str, name: &str) -> io::Result<Option<Baseline>>;
62
63    /// Persist a baseline atomically under the given scope.
64    fn save(&self, scope: &str, baseline: &Baseline) -> io::Result<()>;
65}
66
67/// Filesystem-backed JSON baseline store.
68///
69/// Keys baselines as `<root>/<scope>/<name>.json`. Save uses
70/// write-temp-rename to remain atomic on the same filesystem.
71///
72/// # Example
73///
74/// ```
75/// use dev_coverage::{Baseline, BaselineStore, JsonFileBaselineStore};
76///
77/// let dir = tempfile::tempdir().unwrap();
78/// let store = JsonFileBaselineStore::new(dir.path());
79///
80/// let b = Baseline {
81///     name: "my-crate".into(),
82///     line_pct: 85.0,
83///     function_pct: 90.0,
84///     region_pct: 80.0,
85/// };
86/// store.save("main", &b).unwrap();
87/// let back = store.load("main", "my-crate").unwrap().unwrap();
88/// assert_eq!(back, b);
89/// ```
90#[derive(Debug, Clone)]
91pub struct JsonFileBaselineStore {
92    root: PathBuf,
93}
94
95impl JsonFileBaselineStore {
96    /// Build a store rooted at `path`. Subdirectories per scope are
97    /// created lazily on `save`.
98    pub fn new(root: impl Into<PathBuf>) -> Self {
99        Self { root: root.into() }
100    }
101
102    /// Path that would be used to persist `(scope, name)`.
103    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        // On Windows, rename will fail if the target exists; replace by
114        // removing first when present.
115        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        // Write garbage at the expected path.
211        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        // Just confirm it surfaces an error rather than panicking.
216        assert!(err.to_string().to_lowercase().contains("expected"));
217    }
218}