Skip to main content

omgbase_store/
doc_store.rs

1//! The doc store seam (`spec/mutate/README.md` §4 step 5, ADR-014): the
2//! write target of a mutation, keyed by repo-relative path. A filesystem
3//! store writes atomically (temp file + rename); an in-memory store backs the
4//! fixture runner (file-CAS and the written bytes stay checkable); a headless
5//! store does nothing.
6
7use std::collections::BTreeMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use crate::error::{Error, Result};
12
13/// Where a mutation's rendered bytes go and where file-CAS reads from. Paths
14/// are repo-relative and canonical (`docs.path`).
15pub trait DocStore {
16    /// Whether a file exists at `path`.
17    fn exists(&self, path: &str) -> bool;
18    /// The bytes at `path`, or `None` when absent.
19    fn read(&self, path: &str) -> Result<Option<String>>;
20    /// Write `bytes` to `path` atomically, creating parent directories.
21    fn write(&mut self, path: &str, bytes: &str) -> Result<()>;
22    /// Rename `from` to `to`, creating the destination's parents.
23    fn rename(&mut self, from: &str, to: &str) -> Result<()>;
24    /// Remove `path` if present.
25    fn remove(&mut self, path: &str) -> Result<()>;
26}
27
28/// An in-memory doc store: path → bytes (the fixture runner's, §9).
29#[derive(Clone, Debug, Default, PartialEq, Eq)]
30pub struct MemDocStore {
31    files: BTreeMap<String, String>,
32}
33
34impl MemDocStore {
35    #[must_use]
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Every file, sorted by path (the `files` projection).
41    #[must_use]
42    pub fn files(&self) -> &BTreeMap<String, String> {
43        &self.files
44    }
45}
46
47impl DocStore for MemDocStore {
48    fn exists(&self, path: &str) -> bool {
49        self.files.contains_key(path)
50    }
51
52    fn read(&self, path: &str) -> Result<Option<String>> {
53        Ok(self.files.get(path).cloned())
54    }
55
56    fn write(&mut self, path: &str, bytes: &str) -> Result<()> {
57        self.files.insert(path.to_owned(), bytes.to_owned());
58        Ok(())
59    }
60
61    fn rename(&mut self, from: &str, to: &str) -> Result<()> {
62        let bytes = self
63            .files
64            .remove(from)
65            .ok_or_else(|| Error::Other(format!("rename: no file at {from}")))?;
66        self.files.insert(to.to_owned(), bytes);
67        Ok(())
68    }
69
70    fn remove(&mut self, path: &str) -> Result<()> {
71        self.files.remove(path);
72        Ok(())
73    }
74}
75
76/// A filesystem doc store rooted at a working tree. Writes go to
77/// `<path>.omgtmp` and are renamed into place.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct FsDocStore {
80    root: PathBuf,
81}
82
83impl FsDocStore {
84    #[must_use]
85    pub fn new(root: impl Into<PathBuf>) -> Self {
86        Self { root: root.into() }
87    }
88
89    /// The working-tree root.
90    #[must_use]
91    pub fn root(&self) -> &Path {
92        &self.root
93    }
94
95    fn abs(&self, path: &str) -> PathBuf {
96        self.root.join(path)
97    }
98}
99
100fn io_err(what: &str, path: &Path, e: std::io::Error) -> Error {
101    Error::Other(format!("{what} {}: {e}", path.display()))
102}
103
104fn ensure_parent(abs: &Path) -> Result<()> {
105    if let Some(dir) = abs.parent().filter(|d| !d.as_os_str().is_empty()) {
106        fs::create_dir_all(dir).map_err(|e| io_err("cannot create", dir, e))?;
107    }
108    Ok(())
109}
110
111impl DocStore for FsDocStore {
112    fn exists(&self, path: &str) -> bool {
113        self.abs(path).exists()
114    }
115
116    fn read(&self, path: &str) -> Result<Option<String>> {
117        let abs = self.abs(path);
118        if !abs.exists() {
119            return Ok(None);
120        }
121        let bytes = fs::read(&abs).map_err(|e| io_err("cannot read", &abs, e))?;
122        Ok(Some(String::from_utf8_lossy(&bytes).into_owned()))
123    }
124
125    fn write(&mut self, path: &str, bytes: &str) -> Result<()> {
126        let abs = self.abs(path);
127        ensure_parent(&abs)?;
128        let mut tmp = abs.clone().into_os_string();
129        tmp.push(".omgtmp");
130        let tmp = PathBuf::from(tmp);
131        fs::write(&tmp, bytes).map_err(|e| io_err("cannot write", &tmp, e))?;
132        fs::rename(&tmp, &abs).map_err(|e| io_err("cannot rename into", &abs, e))
133    }
134
135    fn rename(&mut self, from: &str, to: &str) -> Result<()> {
136        let to_abs = self.abs(to);
137        ensure_parent(&to_abs)?;
138        fs::rename(self.abs(from), &to_abs).map_err(|e| io_err("cannot rename to", &to_abs, e))
139    }
140
141    fn remove(&mut self, path: &str) -> Result<()> {
142        let abs = self.abs(path);
143        if abs.exists() {
144            fs::remove_file(&abs).map_err(|e| io_err("cannot remove", &abs, e))?;
145        }
146        Ok(())
147    }
148}
149
150/// A headless doc store: no working tree, every operation a no-op, nothing
151/// to CAS against.
152#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
153pub struct NullDocStore;
154
155impl DocStore for NullDocStore {
156    fn exists(&self, _path: &str) -> bool {
157        false
158    }
159
160    fn read(&self, _path: &str) -> Result<Option<String>> {
161        Ok(None)
162    }
163
164    fn write(&mut self, _path: &str, _bytes: &str) -> Result<()> {
165        Ok(())
166    }
167
168    fn rename(&mut self, _from: &str, _to: &str) -> Result<()> {
169        Ok(())
170    }
171
172    fn remove(&mut self, _path: &str) -> Result<()> {
173        Ok(())
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn mem_store_round_trips_and_renames() {
183        let mut m = MemDocStore::new();
184        assert!(!m.exists("a.md"));
185        m.write("a.md", "x\n").unwrap();
186        assert_eq!(m.read("a.md").unwrap().as_deref(), Some("x\n"));
187        m.rename("a.md", "dir/b.md").unwrap();
188        assert!(!m.exists("a.md") && m.exists("dir/b.md"));
189        assert!(m.rename("nope", "x").is_err());
190        m.remove("dir/b.md").unwrap();
191        m.remove("dir/b.md").unwrap();
192        assert!(m.files().is_empty());
193    }
194
195    #[test]
196    fn fs_store_writes_atomically_into_nested_dirs() {
197        let dir = std::env::temp_dir().join(format!(
198            "omgbase-fs-store-{}-{}",
199            std::process::id(),
200            std::time::SystemTime::now()
201                .duration_since(std::time::UNIX_EPOCH)
202                .map(|d| d.as_nanos())
203                .unwrap_or(0)
204        ));
205        let mut s = FsDocStore::new(&dir);
206        assert_eq!(s.root(), dir.as_path());
207        s.write("a/b/c.md", "hello\n").unwrap();
208        assert!(s.exists("a/b/c.md"));
209        assert!(!dir.join("a/b/c.md.omgtmp").exists());
210        assert_eq!(s.read("a/b/c.md").unwrap().as_deref(), Some("hello\n"));
211        s.rename("a/b/c.md", "d/e.md").unwrap();
212        assert!(!s.exists("a/b/c.md"));
213        assert_eq!(s.read("d/e.md").unwrap().as_deref(), Some("hello\n"));
214        s.remove("d/e.md").unwrap();
215        assert_eq!(s.read("d/e.md").unwrap(), None);
216        let _ = fs::remove_dir_all(&dir);
217    }
218
219    #[test]
220    fn null_store_does_nothing() {
221        let mut n = NullDocStore;
222        n.write("a.md", "x").unwrap();
223        assert!(!n.exists("a.md"));
224        assert_eq!(n.read("a.md").unwrap(), None);
225        n.rename("a.md", "b.md").unwrap();
226        n.remove("b.md").unwrap();
227    }
228}