1use 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
27fn 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#[derive(Serialize, Deserialize)]
50struct Entry {
51 response: String,
52}
53
54pub struct FsGroupingCache {
60 dir: Option<PathBuf>,
61}
62
63impl FsGroupingCache {
64 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 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
118pub 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
131pub 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
143pub 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
167fn 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
187pub 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 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
225pub struct FsReviewStore {
229 dir: PathBuf,
230}
231
232impl FsReviewStore {
233 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 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 if !path.exists() {
254 write_atomic(&path, json.as_bytes())?;
255 }
256 let current = self.dir.join("current");
257 write_atomic(¤t, 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
295fn 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
317fn 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
327pub 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 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
372pub struct FsReviewCatalogue {
379 common_dir: PathBuf,
380}
381
382#[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 #[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 _ => 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 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 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 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 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}