Skip to main content

differential_engine/
store.rs

1//! Filesystem adapters: the mirror of `gitio` for everything that is not git.
2//!
3//! Implementations of the persistence ports, and the only place in the engine
4//! outside `gitio` and `llm` that touches `std::fs` or the process
5//! environment (ADR 0020). Path *policy* — where a cache or a review lives —
6//! is domain and stays in `plan`; this module only reads and writes there.
7
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::EngineError;
14use crate::plan;
15use crate::ports;
16use crate::review_state::{Finding, ReviewState};
17
18fn io_err(path: &Path, source: std::io::Error) -> EngineError {
19    EngineError::Cache {
20        path: path.display().to_string(),
21        source,
22    }
23}
24
25/// Write `body` to `path` so a reader never sees half of it.
26///
27/// Every write in this module goes through here, because `ports::ReviewStore`
28/// promises that a torn write costs at most the last action. A plain
29/// whole-file write does not keep that promise: `save_findings` rewrites the
30/// entire file, so an interrupted write loses EVERY note rather than the newest
31/// one, and the truncated file it leaves is what the next open has to parse.
32///
33/// The temporary file is created in the destination's own directory, so the
34/// rename that publishes it is atomic. `sync_all` runs first: without it the
35/// rename can land ahead of the bytes and publish a file of zeroes.
36fn write_atomic(path: &Path, body: &[u8]) -> Result<(), EngineError> {
37    let dir = path.parent().unwrap_or_else(|| Path::new("."));
38    let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(|e| io_err(dir, e))?;
39    tmp.write_all(body).map_err(|e| io_err(path, e))?;
40    tmp.as_file().sync_all().map_err(|e| io_err(path, e))?;
41    tmp.persist(path).map_err(|e| io_err(path, e.error))?;
42    Ok(())
43}
44
45// ------------------------------------------------------------ grouping cache
46
47#[derive(Serialize, Deserialize)]
48struct Entry {
49    response: String,
50}
51
52/// The on-disk grouping cache (ADR 0009).
53///
54/// Disabling is a state of this one type rather than an `Option` in a domain
55/// signature: `--no-cache` must not put a branch back into the grouping stage,
56/// nor force `None::<&FsGroupingCache>` turbofishes at every call site.
57pub struct FsGroupingCache {
58    dir: Option<PathBuf>,
59}
60
61impl FsGroupingCache {
62    /// The repo's conventional cache directory.
63    pub fn for_repo<L: ports::RepoLayout>(layout: &L) -> Result<Self, EngineError> {
64        Ok(FsGroupingCache {
65            dir: Some(plan::grouping_cache_dir(&layout.common_dir()?)),
66        })
67    }
68
69    pub fn at(dir: PathBuf) -> Self {
70        FsGroupingCache { dir: Some(dir) }
71    }
72
73    /// `--no-cache`: reads miss, writes are dropped.
74    pub fn disabled() -> Self {
75        FsGroupingCache { dir: None }
76    }
77
78    fn entry_path(dir: &Path, key: &str) -> PathBuf {
79        dir.join(format!("{key}.json"))
80    }
81}
82
83impl ports::GroupingCache for FsGroupingCache {
84    fn get(&self, key: &str) -> Result<Option<String>, EngineError> {
85        let Some(dir) = &self.dir else {
86            return Ok(None);
87        };
88        let path = Self::entry_path(dir, key);
89        match std::fs::read_to_string(&path) {
90            Ok(text) => {
91                let entry: Entry = serde_json::from_str(&text).map_err(|e| EngineError::Cache {
92                    path: path.display().to_string(),
93                    source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
94                })?;
95                Ok(Some(entry.response))
96            }
97            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
98            Err(e) => Err(io_err(&path, e)),
99        }
100    }
101
102    fn put(&self, key: &str, response: &str) -> Result<(), EngineError> {
103        let Some(dir) = &self.dir else {
104            return Ok(());
105        };
106        std::fs::create_dir_all(dir).map_err(|e| io_err(dir, e))?;
107        let body = serde_json::to_string(&Entry {
108            response: response.to_string(),
109        })
110        .expect("string serialises");
111        let path = Self::entry_path(dir, key);
112        write_atomic(&path, body.as_bytes())
113    }
114}
115
116/// What the regenerable cache currently holds, or what clearing it removed.
117pub struct CacheUsage {
118    pub groupings: usize,
119    pub documents: usize,
120    pub bytes: u64,
121}
122
123impl CacheUsage {
124    pub fn is_empty(&self) -> bool {
125        self.groupings == 0 && self.documents == 0
126    }
127}
128
129/// Measure the regenerable cache without touching it.
130pub fn cache_usage<L: ports::RepoLayout>(layout: &L) -> Result<CacheUsage, EngineError> {
131    let common = layout.common_dir()?;
132    let (groupings, g_bytes) = count(&plan::grouping_cache_dir(&common))?;
133    let (documents, d_bytes) = count(&plan::artefact_dir(&common))?;
134    Ok(CacheUsage {
135        groupings,
136        documents,
137        bytes: g_bytes + d_bytes,
138    })
139}
140
141/// Delete the regenerable cache, returning what was removed.
142///
143/// **It removes `plan::cache_dir` and nothing else.** Reviews live in a sibling
144/// tree, so findings are out of reach by construction rather than by this
145/// function being careful — see the note on `plan::cache_dir`.
146///
147/// An absent directory is success with an empty result: clearing a cache that
148/// is already clear is not an error.
149///
150/// The count is taken before the delete, so a grouped run writing an entry in
151/// between would have that entry removed without being counted. Deliberately
152/// unlocked: the window is one syscall wide, the consequence is a report short
153/// by one on a machine running two of its own commands at once, and a lock
154/// would be a durable cost for a transient cosmetic one.
155pub fn clear_cache<L: ports::RepoLayout>(layout: &L) -> Result<CacheUsage, EngineError> {
156    let usage = cache_usage(layout)?;
157    let dir = plan::cache_dir(&layout.common_dir()?);
158    match std::fs::remove_dir_all(&dir) {
159        Ok(()) => Ok(usage),
160        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(usage),
161        Err(e) => Err(io_err(&dir, e)),
162    }
163}
164
165/// Entries and total bytes in one cache directory. An absent directory is zero.
166fn count(dir: &Path) -> Result<(usize, u64), EngineError> {
167    let entries = match std::fs::read_dir(dir) {
168        Ok(e) => e,
169        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((0, 0)),
170        Err(e) => return Err(io_err(dir, e)),
171    };
172    let mut n = 0usize;
173    let mut bytes = 0u64;
174    for entry in entries {
175        let entry = entry.map_err(|e| io_err(dir, e))?;
176        let meta = entry.metadata().map_err(|e| io_err(&entry.path(), e))?;
177        if meta.is_file() {
178            n += 1;
179            bytes += meta.len();
180        }
181    }
182    Ok((n, bytes))
183}
184
185// ---------------------------------------------------------------- artefact
186
187/// Where the pre-group document is left for the model to read (ADR 0022).
188///
189/// Disabling mirrors `FsGroupingCache`: `--no-cache` must not put a branch back
190/// into the grouping stage. With no directory the document goes to a temporary
191/// file instead of being skipped — the model needs a path either way, and only
192/// the survival of that path across runs is what caching buys.
193pub struct FsArtefactStore {
194    dir: Option<PathBuf>,
195}
196
197impl FsArtefactStore {
198    pub fn for_repo<L: ports::RepoLayout>(layout: &L) -> Result<Self, EngineError> {
199        Ok(FsArtefactStore {
200            dir: Some(plan::artefact_dir(&layout.common_dir()?)),
201        })
202    }
203
204    /// `--no-cache`: written under the temporary directory, not kept.
205    pub fn disabled() -> Self {
206        FsArtefactStore { dir: None }
207    }
208}
209
210impl ports::ArtefactStore for FsArtefactStore {
211    fn make_readable(&self, key: &str, json: &str) -> Result<PathBuf, EngineError> {
212        let dir = self
213            .dir
214            .clone()
215            .unwrap_or_else(|| std::env::temp_dir().join("differential"));
216        std::fs::create_dir_all(&dir).map_err(|e| io_err(&dir, e))?;
217        let path = dir.join(format!("{key}.json"));
218        write_atomic(&path, json.as_bytes())?;
219        Ok(path)
220    }
221}
222
223// ------------------------------------------------------------- review store
224
225/// One review's sidecar directory (ADR 0013).
226pub struct FsReviewStore {
227    dir: PathBuf,
228}
229
230impl FsReviewStore {
231    /// Open the review with this id.
232    ///
233    /// The id is `review_identity::resolve`'s answer, not this adapter's: which
234    /// review a spelling opens is domain policy, and it can be another
235    /// spelling's review.
236    pub fn for_review<L: ports::RepoLayout>(layout: &L, id: &str) -> Result<Self, EngineError> {
237        Self::at(plan::review_dir(&layout.common_dir()?, id))
238    }
239
240    /// Test/tooling entry: open at an explicit directory.
241    pub fn at(dir: PathBuf) -> Result<Self, EngineError> {
242        std::fs::create_dir_all(dir.join("plans")).map_err(|e| io_err(&dir, e))?;
243        Ok(FsReviewStore { dir })
244    }
245}
246
247impl ports::ReviewStore for FsReviewStore {
248    fn save_plan(&self, hash: &str, json: &str) -> Result<(), EngineError> {
249        let path = self.dir.join("plans").join(format!("{hash}.json"));
250        // Content-addressed and immutable: re-saving the same hash is a no-op.
251        if !path.exists() {
252            write_atomic(&path, json.as_bytes())?;
253        }
254        let current = self.dir.join("current");
255        write_atomic(&current, hash.as_bytes())
256    }
257
258    fn load_state(&self) -> Result<ReviewState, EngineError> {
259        let path = self.dir.join("state.json");
260        match std::fs::read_to_string(&path) {
261            Ok(text) => serde_json::from_str(&text).map_err(|e| EngineError::Cache {
262                path: path.display().to_string(),
263                source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
264            }),
265            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ReviewState::default()),
266            Err(e) => Err(io_err(&path, e)),
267        }
268    }
269
270    fn save_state(&self, state: &ReviewState) -> Result<(), EngineError> {
271        let path = self.dir.join("state.json");
272        let text = serde_json::to_string_pretty(state).expect("state serialises");
273        write_atomic(&path, text.as_bytes())
274    }
275
276    fn load_findings(&self) -> Result<Vec<Finding>, EngineError> {
277        let path = self.dir.join("findings.jsonl");
278        let text = match std::fs::read_to_string(&path) {
279            Ok(t) => t,
280            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
281            Err(e) => return Err(io_err(&path, e)),
282        };
283        let mut out = Vec::new();
284        for (n, line) in text.lines().enumerate() {
285            if line.trim().is_empty() {
286                continue;
287            }
288            let f: Finding = serde_json::from_str(line).map_err(|e| EngineError::Cache {
289                path: format!("{}:{}", path.display(), n + 1),
290                source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
291            })?;
292            out.push(f);
293        }
294        Ok(out)
295    }
296
297    fn save_findings(&self, findings: &[Finding]) -> Result<(), EngineError> {
298        let path = self.dir.join("findings.jsonl");
299        let mut text = String::new();
300        for f in findings {
301            text.push_str(&serde_json::to_string(f).expect("serialises"));
302            text.push('\n');
303        }
304        write_atomic(&path, text.as_bytes())
305    }
306}
307
308// ------------------------------------------------------------ config source
309
310/// Config files from the real filesystem, with the user directory resolved by
311/// platform convention.
312pub struct OsConfigSource;
313
314impl ports::ConfigSource for OsConfigSource {
315    fn user_config_dir(&self) -> Option<PathBuf> {
316        use etcetera::BaseStrategy;
317        let strategy = etcetera::choose_base_strategy().ok()?;
318        Some(strategy.config_dir())
319    }
320
321    fn read(&self, path: &Path) -> Result<Option<String>, EngineError> {
322        match std::fs::read_to_string(path) {
323            Ok(text) => Ok(Some(text)),
324            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
325            Err(e) => Err(EngineError::Config {
326                path: path.display().to_string(),
327                msg: e.to_string(),
328            }),
329        }
330    }
331
332    fn read_required(&self, path: &Path) -> Result<String, EngineError> {
333        // Deliberately its own `std::fs` call rather than unwrapping `read`'s
334        // `None`: the error text for an explicit-but-missing path is part of
335        // the CLI contract and must come from where it always came from.
336        std::fs::read_to_string(path).map_err(|e| EngineError::Config {
337            path: path.display().to_string(),
338            msg: e.to_string(),
339        })
340    }
341}
342
343// --------------------------------------------------------- review catalogue
344
345/// The reviews directory, read as a catalogue.
346///
347/// Holds the common dir rather than a `RepoLayout`, so the one call that can
348/// fail happens at construction and every later read is infallible policy.
349pub struct FsReviewCatalogue {
350    common_dir: PathBuf,
351}
352
353/// The on-disk form of `ports::ReviewIdentity`. Additive by the same rule as
354/// the rest of the sidecar: every field defaults, so a file written by a later
355/// version still loads. A named session records `name` and no endpoints; a
356/// range records the endpoints and no name.
357#[derive(Serialize, Deserialize)]
358struct IdentityFile {
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    name: Option<String>,
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    base: Option<String>,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    head_spec: Option<String>,
365}
366
367impl IdentityFile {
368    fn read(&self) -> Option<ports::ReviewIdentity> {
369        match (&self.name, &self.base, &self.head_spec) {
370            (Some(name), _, _) => Some(ports::ReviewIdentity::Named(name.clone())),
371            (None, Some(base), Some(head_spec)) => Some(ports::ReviewIdentity::Range {
372                base: base.clone(),
373                head_spec: head_spec.clone(),
374            }),
375            // Half a record answers nothing: recognisable, not adoptable.
376            _ => None,
377        }
378    }
379
380    fn of(identity: &ports::ReviewIdentity) -> Self {
381        match identity {
382            ports::ReviewIdentity::Named(name) => IdentityFile {
383                name: Some(name.clone()),
384                base: None,
385                head_spec: None,
386            },
387            ports::ReviewIdentity::Range { base, head_spec } => IdentityFile {
388                name: None,
389                base: Some(base.clone()),
390                head_spec: Some(head_spec.clone()),
391            },
392        }
393    }
394}
395
396impl FsReviewCatalogue {
397    pub fn new<L: ports::RepoLayout>(layout: &L) -> Result<Self, EngineError> {
398        Ok(FsReviewCatalogue {
399            common_dir: layout.common_dir()?,
400        })
401    }
402
403    /// Test/tooling entry: the git common dir directly.
404    pub fn at(common_dir: PathBuf) -> Self {
405        FsReviewCatalogue { common_dir }
406    }
407}
408
409impl ports::ReviewCatalogue for FsReviewCatalogue {
410    fn filed_reviews(&self) -> Result<Vec<ports::FiledReview>, EngineError> {
411        let root = plan::reviews_dir(&self.common_dir);
412        // Never reviewed anything here yet. Not an error: an empty catalogue
413        // is the correct answer, and the directory appears on the first open.
414        let Ok(entries) = std::fs::read_dir(&root) else {
415            return Ok(Vec::new());
416        };
417        let mut out = Vec::new();
418        for entry in entries {
419            let entry = entry.map_err(|e| io_err(&root, e))?;
420            if !entry.path().is_dir() {
421                continue;
422            }
423            let Some(id) = entry.file_name().to_str().map(str::to_string) else {
424                continue;
425            };
426            // A redirect is a pointer, not a review: listing it would let a
427            // third spelling adopt the pointer instead of its target.
428            if plan::alias_path(&self.common_dir, &id).exists() {
429                continue;
430            }
431            let opened_as = match std::fs::read(plan::identity_path(&self.common_dir, &id)) {
432                Ok(bytes) => serde_json::from_slice::<IdentityFile>(&bytes)
433                    .ok()
434                    .and_then(|f| f.read()),
435                Err(_) => None,
436            };
437            out.push(ports::FiledReview { id, opened_as });
438        }
439        // Stable order, so a scan that finds two equally good candidates
440        // cannot answer differently on two machines.
441        out.sort_by(|a, b| a.id.cmp(&b.id));
442        Ok(out)
443    }
444
445    fn alias_of(&self, id: &str) -> Result<Option<String>, EngineError> {
446        let path = plan::alias_path(&self.common_dir, id);
447        match std::fs::read_to_string(&path) {
448            Ok(s) => Ok(Some(s.trim().to_string()).filter(|s| !s.is_empty())),
449            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
450            Err(e) => Err(io_err(&path, e)),
451        }
452    }
453
454    fn file_alias(&self, from: &str, to: &str) -> Result<(), EngineError> {
455        let dir = plan::review_dir(&self.common_dir, from);
456        std::fs::create_dir_all(&dir).map_err(|e| io_err(&dir, e))?;
457        let path = plan::alias_path(&self.common_dir, from);
458        write_atomic(&path, to.as_bytes())
459    }
460
461    fn file_identity(
462        &self,
463        id: &str,
464        opened_as: &ports::ReviewIdentity,
465    ) -> Result<(), EngineError> {
466        let dir = plan::review_dir(&self.common_dir, id);
467        std::fs::create_dir_all(&dir).map_err(|e| io_err(&dir, e))?;
468        let path = plan::identity_path(&self.common_dir, id);
469        let body = serde_json::to_string_pretty(&IdentityFile::of(opened_as))
470            .expect("identity serialises");
471        write_atomic(&path, body.as_bytes())
472    }
473}