1use 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
25fn 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#[derive(Serialize, Deserialize)]
48struct Entry {
49 response: String,
50}
51
52pub struct FsGroupingCache {
58 dir: Option<PathBuf>,
59}
60
61impl FsGroupingCache {
62 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 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
116pub 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
129pub 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
141pub 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
165fn 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
185pub 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 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
223pub struct FsReviewStore {
227 dir: PathBuf,
228}
229
230impl FsReviewStore {
231 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 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 if !path.exists() {
252 write_atomic(&path, json.as_bytes())?;
253 }
254 let current = self.dir.join("current");
255 write_atomic(¤t, 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
308pub 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 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
343pub struct FsReviewCatalogue {
350 common_dir: PathBuf,
351}
352
353#[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 _ => 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 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 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 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 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}