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