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";
18
19impl Store {
20    pub fn at(repo: &Path) -> Store {
21        Store {
22            root: repo.join(".evolving"),
23        }
24    }
25    pub fn ticks_dir(&self) -> PathBuf {
26        self.root.join("ticks")
27    }
28    pub fn head_path(&self) -> PathBuf {
29        self.root.join("HEAD")
30    }
31    pub fn config_path(&self) -> PathBuf {
32        self.root.join("config")
33    }
34    pub fn exists(&self) -> bool {
35        self.root.exists()
36    }
37
38    /// Create the layout. Returns Ok(true) if created, Ok(false) if it already existed (idempotent).
39    pub fn init(&self) -> std::io::Result<bool> {
40        if self.root.exists() {
41            return Ok(false);
42        }
43        fs::create_dir_all(self.ticks_dir())?;
44        fs::create_dir_all(self.root.join("results").join("receipts"))?;
45        fs::create_dir_all(self.root.join("results").join("state"))?;
46        fs::write(self.head_path(), "")?;
47        fs::write(self.config_path(), DEFAULT_CONFIG)?;
48        Ok(true)
49    }
50
51    /// Write a tick file (pretty JSON; the id is recomputed on verify, not from these bytes) and advance HEAD.
52    pub fn write_tick(&self, t: &Tick) -> std::io::Result<()> {
53        let json = serde_json::to_string_pretty(&full_value(t)).expect("serializable");
54        fs::write(self.ticks_dir().join(&t.id), json)?;
55        fs::write(self.head_path(), &t.id)?;
56        Ok(())
57    }
58
59    /// The current HEAD id ("" if genesis / empty store).
60    pub fn read_head(&self) -> std::io::Result<String> {
61        match std::fs::read_to_string(self.head_path()) {
62            Ok(s) => Ok(s.trim().to_string()),
63            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
64            Err(e) => Err(e),
65        }
66    }
67
68    /// Read one tick (parsed) by id, or None if absent.
69    pub fn read_tick(&self, id: &str) -> std::io::Result<Option<crate::tick::Tick>> {
70        let p = self.ticks_dir().join(id);
71        if !p.is_file() {
72            return Ok(None);
73        }
74        let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&p)?)
75            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
76        crate::tick::from_value(&v)
77            .map(Some)
78            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
79    }
80
81    /// Read every tick file as (filename, raw JSON Value). Order is unspecified.
82    pub fn read_all(&self) -> std::io::Result<Vec<(String, serde_json::Value)>> {
83        let mut out = Vec::new();
84        for entry in fs::read_dir(self.ticks_dir())? {
85            let p = entry?.path();
86            if p.is_file() {
87                let name = p.file_name().unwrap().to_string_lossy().to_string();
88                let text = fs::read_to_string(&p)?;
89                let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
90                    std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{name}: {e}"))
91                })?;
92                out.push((name, v));
93            }
94        }
95        Ok(out)
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::tick::{Ground, Tick};
103
104    fn tmp() -> std::path::PathBuf {
105        use std::sync::atomic::{AtomicU64, Ordering};
106        static N: AtomicU64 = AtomicU64::new(0);
107        let p = std::env::temp_dir().join(format!(
108            "ev-store-test-{}-{}",
109            std::process::id(),
110            N.fetch_add(1, Ordering::Relaxed)
111        ));
112        let _ = std::fs::remove_dir_all(&p);
113        std::fs::create_dir_all(&p).unwrap();
114        p
115    }
116
117    fn a_tick(id: &str, parent: &str) -> Tick {
118        Tick {
119            id: id.into(),
120            parent_id: parent.into(),
121            observe: "o".into(),
122            decision: "d".into(),
123            grounds: vec![Ground {
124                claim: "c".into(),
125                supports: "chosen".into(),
126                check: None,
127            }],
128            status: "live".into(),
129            held_since: "".into(),
130            blame: "Wang Yu".into(),
131        }
132    }
133
134    #[test]
135    fn init_should_create_the_full_store_layout_when_the_store_is_new() {
136        // given: a store rooted at a fresh empty repo
137        let repo = tmp();
138        let s = Store::at(&repo);
139
140        // when: the store is initialized
141        let created = s.init().unwrap();
142
143        // then: it reports creation and the full layout exists on disk
144        assert!(created); // true = created
145        assert!(s.ticks_dir().is_dir());
146        assert!(s.head_path().is_file());
147        assert!(s.config_path().is_file());
148        assert!(repo.join(".evolving/results/receipts").is_dir());
149    }
150
151    #[test]
152    fn init_should_be_a_no_op_when_the_store_already_exists() {
153        // given: a store that has already been initialized
154        let repo = tmp();
155        let s = Store::at(&repo);
156        assert!(s.init().unwrap());
157
158        // when: init is called again
159        let created_again = s.init().unwrap();
160
161        // then: it reports no creation and does not overwrite
162        assert!(!created_again); // false = no-op, did not overwrite
163    }
164
165    #[test]
166    fn write_tick_should_persist_the_tick_and_advance_head_when_a_tick_is_written() {
167        // given: an initialized store and a tick to write
168        let repo = tmp();
169        let s = Store::at(&repo);
170        s.init().unwrap();
171        let t = a_tick("aaaaaaaaaaaa", "");
172
173        // when: the tick is written
174        s.write_tick(&t).unwrap();
175
176        // then: the tick file is persisted, HEAD advances to it, and it is the only tick
177        assert!(s.ticks_dir().join("aaaaaaaaaaaa").is_file());
178        assert_eq!(
179            std::fs::read_to_string(s.head_path()).unwrap(),
180            "aaaaaaaaaaaa"
181        );
182        let all = s.read_all().unwrap();
183        assert_eq!(all.len(), 1);
184        assert_eq!(all[0].0, "aaaaaaaaaaaa");
185    }
186}