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::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::EngineError;
13use crate::plan;
14use crate::ports;
15use crate::review_state::{Finding, ReviewState};
16
17fn io_err(path: &Path, source: std::io::Error) -> EngineError {
18    EngineError::Cache {
19        path: path.display().to_string(),
20        source,
21    }
22}
23
24// ------------------------------------------------------------ grouping cache
25
26#[derive(Serialize, Deserialize)]
27struct Entry {
28    response: String,
29}
30
31/// The on-disk grouping cache (ADR 0009).
32///
33/// Disabling is a state of this one type rather than an `Option` in a domain
34/// signature: `--no-cache` must not put a branch back into the grouping stage,
35/// nor force `None::<&FsGroupingCache>` turbofishes at every call site.
36pub struct FsGroupingCache {
37    dir: Option<PathBuf>,
38}
39
40impl FsGroupingCache {
41    /// The repo's conventional cache directory.
42    pub fn for_repo<L: ports::RepoLayout>(layout: &L) -> Result<Self, EngineError> {
43        Ok(FsGroupingCache {
44            dir: Some(plan::grouping_cache_dir(&layout.common_dir()?)),
45        })
46    }
47
48    pub fn at(dir: PathBuf) -> Self {
49        FsGroupingCache { dir: Some(dir) }
50    }
51
52    /// `--no-cache`: reads miss, writes are dropped.
53    pub fn disabled() -> Self {
54        FsGroupingCache { dir: None }
55    }
56
57    fn entry_path(dir: &Path, key: &str) -> PathBuf {
58        dir.join(format!("{key}.json"))
59    }
60}
61
62impl ports::GroupingCache for FsGroupingCache {
63    fn get(&self, key: &str) -> Result<Option<String>, EngineError> {
64        let Some(dir) = &self.dir else {
65            return Ok(None);
66        };
67        let path = Self::entry_path(dir, key);
68        match std::fs::read_to_string(&path) {
69            Ok(text) => {
70                let entry: Entry = serde_json::from_str(&text).map_err(|e| EngineError::Cache {
71                    path: path.display().to_string(),
72                    source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
73                })?;
74                Ok(Some(entry.response))
75            }
76            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
77            Err(e) => Err(io_err(&path, e)),
78        }
79    }
80
81    fn put(&self, key: &str, response: &str) -> Result<(), EngineError> {
82        let Some(dir) = &self.dir else {
83            return Ok(());
84        };
85        std::fs::create_dir_all(dir).map_err(|e| io_err(dir, e))?;
86        let body = serde_json::to_string(&Entry {
87            response: response.to_string(),
88        })
89        .expect("string serialises");
90        let path = Self::entry_path(dir, key);
91        std::fs::write(&path, body).map_err(|e| io_err(&path, e))
92    }
93}
94
95// ------------------------------------------------------------- review store
96
97/// One review's sidecar directory (ADR 0013).
98pub struct FsReviewStore {
99    dir: PathBuf,
100}
101
102impl FsReviewStore {
103    pub fn for_review<L: ports::RepoLayout>(
104        layout: &L,
105        base_sha: &str,
106        head_spec: &str,
107    ) -> Result<Self, EngineError> {
108        let dir = plan::review_dir(&layout.common_dir()?, &plan::review_id(base_sha, head_spec));
109        Self::at(dir)
110    }
111
112    /// Test/tooling entry: open at an explicit directory.
113    pub fn at(dir: PathBuf) -> Result<Self, EngineError> {
114        std::fs::create_dir_all(dir.join("plans")).map_err(|e| io_err(&dir, e))?;
115        Ok(FsReviewStore { dir })
116    }
117
118    pub fn dir(&self) -> &Path {
119        &self.dir
120    }
121}
122
123impl ports::ReviewStore for FsReviewStore {
124    fn save_plan(&self, hash: &str, json: &str) -> Result<(), EngineError> {
125        let path = self.dir.join("plans").join(format!("{hash}.json"));
126        // Content-addressed and immutable: re-saving the same hash is a no-op.
127        if !path.exists() {
128            std::fs::write(&path, json).map_err(|e| io_err(&path, e))?;
129        }
130        let current = self.dir.join("current");
131        std::fs::write(&current, hash).map_err(|e| io_err(&current, e))
132    }
133
134    fn load_state(&self) -> Result<ReviewState, EngineError> {
135        let path = self.dir.join("state.json");
136        match std::fs::read_to_string(&path) {
137            Ok(text) => serde_json::from_str(&text).map_err(|e| EngineError::Cache {
138                path: path.display().to_string(),
139                source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
140            }),
141            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ReviewState::default()),
142            Err(e) => Err(io_err(&path, e)),
143        }
144    }
145
146    fn save_state(&self, state: &ReviewState) -> Result<(), EngineError> {
147        let path = self.dir.join("state.json");
148        let text = serde_json::to_string_pretty(state).expect("state serialises");
149        std::fs::write(&path, text).map_err(|e| io_err(&path, e))
150    }
151
152    fn load_findings(&self) -> Result<Vec<Finding>, EngineError> {
153        let path = self.dir.join("findings.jsonl");
154        let text = match std::fs::read_to_string(&path) {
155            Ok(t) => t,
156            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
157            Err(e) => return Err(io_err(&path, e)),
158        };
159        let mut out = Vec::new();
160        for (n, line) in text.lines().enumerate() {
161            if line.trim().is_empty() {
162                continue;
163            }
164            let f: Finding = serde_json::from_str(line).map_err(|e| EngineError::Cache {
165                path: format!("{}:{}", path.display(), n + 1),
166                source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
167            })?;
168            out.push(f);
169        }
170        Ok(out)
171    }
172
173    fn save_findings(&self, findings: &[Finding]) -> Result<(), EngineError> {
174        let path = self.dir.join("findings.jsonl");
175        let mut text = String::new();
176        for f in findings {
177            text.push_str(&serde_json::to_string(f).expect("serialises"));
178            text.push('\n');
179        }
180        std::fs::write(&path, text).map_err(|e| io_err(&path, e))
181    }
182}
183
184// ------------------------------------------------------------ config source
185
186/// Config files from the real filesystem, with the user directory resolved by
187/// platform convention.
188pub struct OsConfigSource;
189
190impl ports::ConfigSource for OsConfigSource {
191    fn user_config_dir(&self) -> Option<PathBuf> {
192        use etcetera::BaseStrategy;
193        let strategy = etcetera::choose_base_strategy().ok()?;
194        Some(strategy.config_dir())
195    }
196
197    fn read(&self, path: &Path) -> Result<Option<String>, EngineError> {
198        match std::fs::read_to_string(path) {
199            Ok(text) => Ok(Some(text)),
200            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
201            Err(e) => Err(EngineError::Config {
202                path: path.display().to_string(),
203                msg: e.to_string(),
204            }),
205        }
206    }
207
208    fn read_required(&self, path: &Path) -> Result<String, EngineError> {
209        // Deliberately its own `std::fs` call rather than unwrapping `read`'s
210        // `None`: the error text for an explicit-but-missing path is part of
211        // the CLI contract and must come from where it always came from.
212        std::fs::read_to_string(path).map_err(|e| EngineError::Config {
213            path: path.display().to_string(),
214            msg: e.to_string(),
215        })
216    }
217}