Skip to main content

ev/
store.rs

1//! The .evolving/ store: a committed hashed chain + a non-hashed results cache.
2use crate::tick::{full_value, Tick};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6pub struct Store {
7    pub root: PathBuf, // <repo>/.evolving
8}
9
10const DEFAULT_CONFIG: &str = "schema_version = 1\n\n\
11[runner]\n\
12template = \"pytest {selector}\"\n\
13green_exit_code = 0\n\n\
14[liveness]\n\
15platforms = [\"linux-ci\", \"mac\", \"ship-image\"]\n\
16staleness_days = 7\n\
17not_run_lookback_commits = 20\n\
18staleness_ref = \"live-origin\"\n";
19
20impl Store {
21    pub fn at(repo: &Path) -> Store {
22        Store {
23            root: repo.join(".evolving"),
24        }
25    }
26    pub fn ticks_dir(&self) -> PathBuf {
27        self.root.join("ticks")
28    }
29    pub fn head_path(&self) -> PathBuf {
30        self.root.join("HEAD")
31    }
32    pub fn config_path(&self) -> PathBuf {
33        self.root.join("config")
34    }
35    pub fn exists(&self) -> bool {
36        self.root.exists()
37    }
38
39    /// Create the layout. Returns Ok(true) if created, Ok(false) if it already existed (idempotent).
40    pub fn init(&self) -> std::io::Result<bool> {
41        if self.root.exists() {
42            return Ok(false);
43        }
44        fs::create_dir_all(self.ticks_dir())?;
45        fs::create_dir_all(self.root.join("results").join("receipts"))?;
46        fs::create_dir_all(self.root.join("results").join("state"))?;
47        fs::write(self.head_path(), "")?;
48        fs::write(self.config_path(), DEFAULT_CONFIG)?;
49        Ok(true)
50    }
51
52    /// Write a tick file (pretty JSON; the id is recomputed on verify, not from these bytes) and advance HEAD.
53    pub fn write_tick(&self, t: &Tick) -> std::io::Result<()> {
54        let json = serde_json::to_string_pretty(&full_value(t)).expect("serializable");
55        fs::write(self.ticks_dir().join(&t.id), json)?;
56        fs::write(self.head_path(), &t.id)?;
57        Ok(())
58    }
59
60    /// The current HEAD id ("" if genesis / empty store).
61    pub fn read_head(&self) -> std::io::Result<String> {
62        match std::fs::read_to_string(self.head_path()) {
63            Ok(s) => Ok(s.trim().to_string()),
64            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
65            Err(e) => Err(e),
66        }
67    }
68
69    /// Read one tick (parsed) by id, or None if absent.
70    pub fn read_tick(&self, id: &str) -> std::io::Result<Option<crate::tick::Tick>> {
71        let p = self.ticks_dir().join(id);
72        if !p.is_file() {
73            return Ok(None);
74        }
75        let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&p)?)
76            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
77        crate::tick::from_value(&v)
78            .map(Some)
79            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
80    }
81
82    /// Read every tick file as (filename, raw JSON Value). Order is unspecified.
83    pub fn read_all(&self) -> std::io::Result<Vec<(String, serde_json::Value)>> {
84        let mut out = Vec::new();
85        for entry in fs::read_dir(self.ticks_dir())? {
86            let p = entry?.path();
87            if p.is_file() {
88                let name = p.file_name().unwrap().to_string_lossy().to_string();
89                let text = fs::read_to_string(&p)?;
90                let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
91                    std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{name}: {e}"))
92                })?;
93                out.push((name, v));
94            }
95        }
96        Ok(out)
97    }
98
99    /// The cached live-origin sha (results/origin-sha), or None if absent/empty. No network.
100    pub fn read_origin_sha(&self) -> Option<String> {
101        std::fs::read_to_string(self.root.join("results").join("origin-sha"))
102            .ok()
103            .map(|s| s.trim().to_string())
104            .filter(|s| !s.is_empty())
105    }
106
107    /// Cache the live-origin sha to results/origin-sha (the staleness reference for offline runs).
108    pub fn write_origin_sha(&self, sha: &str) -> std::io::Result<()> {
109        std::fs::write(self.root.join("results").join("origin-sha"), sha)
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::tick::{Ground, Tick};
117
118    fn tmp() -> std::path::PathBuf {
119        use std::sync::atomic::{AtomicU64, Ordering};
120        static N: AtomicU64 = AtomicU64::new(0);
121        let p = std::env::temp_dir().join(format!(
122            "ev-store-test-{}-{}",
123            std::process::id(),
124            N.fetch_add(1, Ordering::Relaxed)
125        ));
126        let _ = std::fs::remove_dir_all(&p);
127        std::fs::create_dir_all(&p).unwrap();
128        p
129    }
130
131    fn a_tick(id: &str, parent: &str) -> Tick {
132        Tick {
133            id: id.into(),
134            parent_id: parent.into(),
135            observe: "o".into(),
136            decision: "d".into(),
137            grounds: vec![Ground {
138                claim: "c".into(),
139                supports: "chosen".into(),
140                check: None,
141            }],
142            status: "live".into(),
143            held_since: "".into(),
144            blame: "Wang Yu".into(),
145            authority: None,
146            jurisdiction: None,
147            source_ref: None,
148            provenance: None,
149        }
150    }
151
152    #[test]
153    fn init_should_create_the_full_store_layout_when_the_store_is_new() {
154        // given: a store rooted at a fresh empty repo
155        let repo = tmp();
156        let s = Store::at(&repo);
157
158        // when: the store is initialized
159        let created = s.init().unwrap();
160
161        // then: it reports creation and the full layout exists on disk
162        assert!(created); // true = created
163        assert!(s.ticks_dir().is_dir());
164        assert!(s.head_path().is_file());
165        assert!(s.config_path().is_file());
166        assert!(repo.join(".evolving/results/receipts").is_dir());
167    }
168
169    #[test]
170    fn init_should_be_a_no_op_when_the_store_already_exists() {
171        // given: a store that has already been initialized
172        let repo = tmp();
173        let s = Store::at(&repo);
174        assert!(s.init().unwrap());
175
176        // when: init is called again
177        let created_again = s.init().unwrap();
178
179        // then: it reports no creation and does not overwrite
180        assert!(!created_again); // false = no-op, did not overwrite
181    }
182
183    #[test]
184    fn write_tick_should_persist_the_tick_and_advance_head_when_a_tick_is_written() {
185        // given: an initialized store and a tick to write
186        let repo = tmp();
187        let s = Store::at(&repo);
188        s.init().unwrap();
189        let t = a_tick("aaaaaaaaaaaa", "");
190
191        // when: the tick is written
192        s.write_tick(&t).unwrap();
193
194        // then: the tick file is persisted, HEAD advances to it, and it is the only tick
195        assert!(s.ticks_dir().join("aaaaaaaaaaaa").is_file());
196        assert_eq!(
197            std::fs::read_to_string(s.head_path()).unwrap(),
198            "aaaaaaaaaaaa"
199        );
200        let all = s.read_all().unwrap();
201        assert_eq!(all.len(), 1);
202        assert_eq!(all[0].0, "aaaaaaaaaaaa");
203    }
204
205    #[test]
206    fn read_origin_sha_should_return_the_trimmed_sha_when_the_cache_file_exists() {
207        // given: an initialized store with a cached origin-sha file
208        let repo = tmp();
209        let s = Store::at(&repo);
210        s.init().unwrap();
211        std::fs::write(
212            s.root.join("results").join("origin-sha"),
213            "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901\n",
214        )
215        .unwrap();
216
217        // when: the cached origin sha is read
218        let sha = s.read_origin_sha();
219
220        // then: it is the trimmed value
221        assert_eq!(
222            sha.as_deref(),
223            Some("d308afac1b2c3d4e5f60718293a4b5c6d7e8f901")
224        );
225    }
226
227    #[test]
228    fn read_origin_sha_should_be_none_when_no_cache_file_exists() {
229        // given: an initialized store with no origin-sha cache
230        let repo = tmp();
231        let s = Store::at(&repo);
232        s.init().unwrap();
233
234        // when: the cached origin sha is read
235        let sha = s.read_origin_sha();
236
237        // then: it is None (no network is consulted)
238        assert!(sha.is_none());
239    }
240}