1use std::collections::{BTreeMap, BTreeSet};
7use std::fs;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11use thiserror::Error;
12
13use crate::session::Session;
14use crate::source::ReviewSource;
15use crate::walkthrough::Walkthrough;
16
17const DIR: &str = ".diffler";
18const REVIEWS: &str = "reviews";
19const LEGACY_FILE: &str = "session.json";
20
21#[derive(Debug, Error)]
22pub enum StoreError {
23 #[error(transparent)]
24 Io(#[from] std::io::Error),
25 #[error("corrupt session file {0}: {1}")]
26 Corrupt(PathBuf, serde_json::Error),
27}
28
29pub type LoadedReviews = (Vec<(ReviewSource, Session)>, Vec<PathBuf>);
32
33#[derive(Debug, serde::Serialize, serde::Deserialize)]
34struct OnDisk {
35 version: u32,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
39 source: Option<ReviewSource>,
40 #[serde(flatten)]
41 session: Session,
42}
43
44fn reviews_dir(repo_root: &Path) -> PathBuf {
45 repo_root.join(DIR).join(REVIEWS)
46}
47
48fn source_path(repo_root: &Path, source: &ReviewSource) -> PathBuf {
49 reviews_dir(repo_root).join(format!("{}.json", source.key()))
50}
51
52fn legacy_path(repo_root: &Path) -> PathBuf {
53 repo_root.join(DIR).join(LEGACY_FILE)
54}
55
56#[derive(Debug, serde::Deserialize)]
58struct LegacyWalkthrough {
59 id: String,
60 title: String,
61 author: String,
62 at: u64,
63 #[serde(default)]
64 stops: Vec<String>,
65 #[serde(default)]
66 comments: Vec<String>,
67 #[serde(default)]
68 skipped: Option<String>,
69}
70
71#[derive(Debug, Default, serde::Deserialize)]
75struct LegacyEmbedded {
76 #[serde(default)]
77 walkthroughs: Vec<LegacyWalkthrough>,
78 #[serde(default, rename = "walkthrough")]
79 singular: Option<LegacyWalkthrough>,
80}
81
82impl LegacyEmbedded {
83 fn into_list(self) -> Vec<LegacyWalkthrough> {
84 if self.walkthroughs.is_empty() {
85 self.singular.into_iter().collect()
86 } else {
87 self.walkthroughs
88 }
89 }
90}
91
92fn split_embedded_walkthroughs(
96 repo_root: &Path,
97 raw: &str,
98 session: &mut Session,
99) -> Result<bool, StoreError> {
100 let legacy: Vec<LegacyWalkthrough> = serde_json::from_str::<LegacyEmbedded>(raw)
101 .unwrap_or_default()
102 .into_list();
103 if legacy.is_empty() {
104 return Ok(false);
105 }
106 for walkthrough in legacy {
107 let mut owned: BTreeSet<String> = walkthrough.comments.iter().cloned().collect();
108 owned.extend(walkthrough.stops.iter().cloned());
109 for stop_id in &walkthrough.stops {
110 let Some(region) = session
111 .comments
112 .iter()
113 .find(|c| c.id == *stop_id)
114 .map(|c| c.anchor.clone())
115 else {
116 continue;
117 };
118 owned.extend(
119 session
120 .comments
121 .iter()
122 .filter(|c| crate::walkthrough::region_contains(®ion, &c.anchor))
123 .map(|c| c.id.clone()),
124 );
125 }
126 let mut moved = Vec::new();
127 session.comments.retain(|c| {
128 if owned.contains(&c.id) {
129 moved.push(c.clone());
130 false
131 } else {
132 true
133 }
134 });
135 let seen: BTreeSet<String> = session
136 .seen_stops
137 .iter()
138 .filter(|id| walkthrough.stops.contains(id))
139 .cloned()
140 .collect();
141 session
142 .seen_stops
143 .retain(|id| !walkthrough.stops.contains(id));
144 let split = Session {
145 comments: moved,
146 viewed: BTreeMap::new(),
147 walkthrough: Some(Walkthrough {
148 id: walkthrough.id.clone(),
149 title: walkthrough.title,
150 author: walkthrough.author,
151 at: walkthrough.at,
152 stops: walkthrough.stops,
153 skipped: walkthrough.skipped,
154 summary: None,
155 rev: None,
156 about: ReviewSource::WorkingTree,
157 }),
158 seen_stops: seen,
159 };
160 save_source(
161 repo_root,
162 &ReviewSource::Walkthrough { id: walkthrough.id },
163 &split,
164 )?;
165 }
166 Ok(true)
167}
168
169fn read_session(
172 repo_root: &Path,
173 path: &Path,
174) -> Result<Option<(ReviewSource, Session)>, StoreError> {
175 match fs::read_to_string(path) {
176 Ok(raw) => {
177 let on_disk: OnDisk = serde_json::from_str(&raw)
178 .map_err(|e| StoreError::Corrupt(path.to_path_buf(), e))?;
179 let origin = on_disk.source.unwrap_or(ReviewSource::WorkingTree);
180 let mut session = on_disk.session;
181 if !matches!(origin, ReviewSource::Walkthrough { .. })
182 && split_embedded_walkthroughs(repo_root, &raw, &mut session)?
183 {
184 save_source(repo_root, &origin, &session)?;
185 }
186 Ok(Some((origin, session)))
187 }
188 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
189 Err(err) => Err(err.into()),
190 }
191}
192
193pub fn load_source(repo_root: &Path, source: &ReviewSource) -> Result<Session, StoreError> {
196 if let Some((_, session)) = read_session(repo_root, &source_path(repo_root, source))? {
197 return Ok(session);
198 }
199 if matches!(source, ReviewSource::WorkingTree)
200 && let Some((_, session)) = read_session(repo_root, &legacy_path(repo_root))?
201 {
202 return Ok(session);
203 }
204 Ok(Session::default())
205}
206
207pub fn delete_source(repo_root: &Path, source: &ReviewSource) -> Result<(), StoreError> {
209 match fs::remove_file(source_path(repo_root, source)) {
210 Ok(()) => Ok(()),
211 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
212 Err(err) => Err(err.into()),
213 }
214}
215
216pub fn ensure_dir(repo_root: &Path) -> std::io::Result<PathBuf> {
218 let dir = repo_root.join(DIR);
219 fs::create_dir_all(&dir)?;
220 let gitignore = dir.join(".gitignore");
221 if !gitignore.exists() {
222 fs::write(&gitignore, "*\n")?;
223 }
224 Ok(dir)
225}
226
227pub fn write_file(repo_root: &Path, name: &str, contents: &str) -> std::io::Result<()> {
228 let dir = ensure_dir(repo_root)?;
229 write_atomic(&dir.join(name), contents)
230}
231
232pub fn write_atomic(path: &Path, contents: &str) -> std::io::Result<()> {
235 let (target, permissions) = match fs::canonicalize(path) {
236 Ok(target) => {
237 let permissions = fs::metadata(&target)?.permissions();
238 (target, Some(permissions))
239 }
240 Err(err) if err.kind() == std::io::ErrorKind::NotFound => (path.to_owned(), None),
241 Err(err) => return Err(err),
242 };
243 let dir = target
244 .parent()
245 .ok_or_else(|| std::io::Error::other(format!("{} has no folder", target.display())))?;
246 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
247 tmp.write_all(contents.as_bytes())?;
248 if let Some(permissions) = permissions {
249 tmp.as_file().set_permissions(permissions)?;
250 }
251 tmp.persist(&target).map_err(|err| err.error)?;
252 Ok(())
253}
254
255pub fn save_source(
257 repo_root: &Path,
258 source: &ReviewSource,
259 session: &Session,
260) -> Result<(), StoreError> {
261 ensure_dir(repo_root)?;
262 let dir = reviews_dir(repo_root);
263 fs::create_dir_all(&dir)?;
264 let on_disk = OnDisk {
265 version: 1,
266 source: Some(source.clone()),
267 session: session.clone(),
268 };
269 let json = serde_json::to_string_pretty(&on_disk).map_err(std::io::Error::other)?;
270 write_atomic(&source_path(repo_root, source), &json)?;
271 if matches!(source, ReviewSource::WorkingTree) {
272 let legacy = legacy_path(repo_root);
273 if legacy.exists() {
274 fs::remove_file(legacy)?;
275 }
276 }
277 Ok(())
278}
279
280pub fn load_all(repo_root: &Path) -> Result<LoadedReviews, StoreError> {
284 let mut reviews: Vec<(ReviewSource, Session)> = Vec::new();
285 let mut corrupt: Vec<PathBuf> = Vec::new();
286 let dir = reviews_dir(repo_root);
287 match fs::read_dir(&dir) {
288 Ok(entries) => {
289 let paths: Vec<PathBuf> = entries
290 .filter_map(|entry| entry.ok().map(|e| e.path()))
291 .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
292 .collect();
293 for path in paths {
294 match read_session(repo_root, &path) {
295 Ok(Some(pair)) => reviews.push(pair),
296 Ok(None) => {}
297 Err(StoreError::Corrupt(path, _)) => corrupt.push(path),
298 Err(err) => return Err(err),
299 }
300 }
301 }
302 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
303 Err(err) => return Err(err.into()),
304 }
305 if !reviews
306 .iter()
307 .any(|(s, _)| matches!(s, ReviewSource::WorkingTree))
308 && let Some((source, session)) = read_session(repo_root, &legacy_path(repo_root))?
309 {
310 reviews.push((source, session));
311 }
312 reviews.sort_by_key(|(source, _)| source.key());
313 Ok((reviews, corrupt))
314}
315
316pub fn load(repo_root: &Path) -> Result<Session, StoreError> {
317 load_source(repo_root, &ReviewSource::WorkingTree)
318}
319
320pub fn save(repo_root: &Path, session: &Session) -> Result<(), StoreError> {
321 save_source(repo_root, &ReviewSource::WorkingTree, session)
322}
323
324#[cfg(test)]
325mod tests {
326 use crate::test_support::anchor;
327
328 use super::*;
329
330 #[test]
331 fn missing_file_loads_default() {
332 let dir = tempfile::tempdir().expect("tempdir");
333 let s = load(dir.path()).expect("load");
334 assert_eq!(s, Session::default());
335 }
336
337 #[test]
338 fn save_load_round_trip() {
339 let dir = tempfile::tempdir().expect("tempdir");
340 let mut s = Session::default();
341 s.add_comment(anchor("a.txt", Some(1)), "reviewer", "hm");
342 s.mark_viewed("a.txt", "hash-1");
343 save(dir.path(), &s).expect("save");
344 let back = load(dir.path()).expect("load");
345 assert_eq!(s, back);
346 }
347
348 #[test]
349 fn save_writes_gitignore() {
350 let dir = tempfile::tempdir().expect("tempdir");
351 save(dir.path(), &Session::default()).expect("save");
352 let gi = std::fs::read_to_string(dir.path().join(".diffler/.gitignore")).expect("read");
353 assert_eq!(gi, "*\n");
354 }
355
356 #[test]
357 #[cfg(unix)]
358 fn an_atomic_write_keeps_the_files_permissions_and_its_symlink() {
359 use std::os::unix::fs::{PermissionsExt, symlink};
360 let dir = tempfile::tempdir().expect("tempdir");
361 let real = dir.path().join("real.toml");
362 std::fs::write(&real, "old").expect("write");
363 std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o644)).expect("chmod");
364 let link = dir.path().join("link.toml");
365 symlink(&real, &link).expect("symlink");
366 write_atomic(&link, "new").expect("write");
367 assert!(
368 link.symlink_metadata()
369 .expect("meta")
370 .file_type()
371 .is_symlink()
372 );
373 assert_eq!(std::fs::read_to_string(&real).expect("read"), "new");
374 let mode = std::fs::metadata(&real).expect("meta").permissions().mode();
375 assert_eq!(mode & 0o777, 0o644);
376 }
377
378 #[test]
379 fn corrupt_file_is_an_error_not_a_reset() {
380 let dir = tempfile::tempdir().expect("tempdir");
381 std::fs::create_dir_all(dir.path().join(".diffler/reviews")).expect("mkdir");
382 std::fs::write(
383 dir.path().join(".diffler/reviews/working.json"),
384 "{not json",
385 )
386 .expect("write");
387 assert!(matches!(load(dir.path()), Err(StoreError::Corrupt(..))));
388 }
389
390 #[test]
391 fn sources_persist_independently() {
392 let dir = tempfile::tempdir().expect("tempdir");
393 let mut work = Session::default();
394 work.mark_viewed("a.txt", "h-work");
395 let mut commit = Session::default();
396 commit.mark_viewed("a.txt", "h-commit");
397
398 save_source(dir.path(), &ReviewSource::WorkingTree, &work).expect("save work");
399 save_source(dir.path(), &ReviewSource::commit("abc"), &commit).expect("save commit");
400
401 assert_eq!(load(dir.path()).expect("load work"), work);
402 assert_eq!(
403 load_source(dir.path(), &ReviewSource::commit("abc")).expect("load commit"),
404 commit
405 );
406 assert!(
407 load_source(dir.path(), &ReviewSource::commit("abc"))
408 .expect("load")
409 .is_viewed("a.txt", "h-commit")
410 );
411 assert!(
412 !load(dir.path())
413 .expect("load")
414 .is_viewed("a.txt", "h-commit")
415 );
416 }
417
418 #[test]
419 fn legacy_file_migrates_to_working_on_save() {
420 let dir = tempfile::tempdir().expect("tempdir");
421 std::fs::create_dir_all(dir.path().join(".diffler")).expect("mkdir");
422 let legacy = dir.path().join(".diffler/session.json");
423 std::fs::write(
424 &legacy,
425 r#"{"version":1,"comments":[],"viewed":{"a.txt":"h-legacy"}}"#,
426 )
427 .expect("write legacy");
428
429 let loaded = load(dir.path()).expect("load");
430 assert!(loaded.is_viewed("a.txt", "h-legacy"));
431
432 save(dir.path(), &loaded).expect("save");
433 assert!(!legacy.exists(), "legacy file removed after migration");
434 assert!(dir.path().join(".diffler/reviews/working.json").exists());
435 assert_eq!(load(dir.path()).expect("reload"), loaded);
436 }
437
438 #[test]
439 fn a_review_file_with_the_old_walkthroughs_list_splits_each_one_out() {
440 let dir = tempfile::tempdir().expect("tempdir");
441 std::fs::create_dir_all(dir.path().join(".diffler/reviews")).expect("mkdir");
442 std::fs::write(
443 dir.path().join(".diffler/reviews/working.json"),
444 r#"{"version":1,"comments":[
445 {"id":"stop-0","author":"agent","anchor":{"file":"a.txt","line":1},"title":"first","anchor_ref":"a.txt:1","body":"why","status":"open","at":1},
446 {"id":"human-0","author":"reviewer","anchor":{"file":"a.txt","line":1},"body":"a reply","status":"open","at":1},
447 {"id":"unrelated","author":"reviewer","anchor":{"file":"b.txt","line":1},"body":"unrelated","status":"open","at":1}
448 ],"viewed":{"a.txt":"h"},"walkthroughs":[
449 {"id":"w1","title":"tour","author":"agent","at":1,"stops":["stop-0"],"comments":["stop-0"]}
450 ]}"#,
451 )
452 .expect("write");
453
454 let working = load(dir.path()).expect("load working");
455 assert!(working.walkthrough.is_none());
456 let ids: Vec<&str> = working.comments.iter().map(|c| c.id.as_str()).collect();
457 assert_eq!(ids, ["unrelated"], "only the working session's own comment");
458 assert_eq!(
459 working.viewed.get("a.txt").map(String::as_str),
460 Some("h"),
461 "file-viewed marks are the working tree's own and stay"
462 );
463
464 let split = load_source(dir.path(), &ReviewSource::walkthrough("w1")).expect("load w1");
465 let walkthrough = split.walkthrough.expect("walkthrough");
466 assert_eq!(walkthrough.id, "w1");
467 assert_eq!(walkthrough.stops, ["stop-0".to_owned()]);
468 let ids: Vec<&str> = split.comments.iter().map(|c| c.id.as_str()).collect();
469 assert_eq!(
470 ids.into_iter().collect::<std::collections::BTreeSet<_>>(),
471 ["stop-0", "human-0"].into_iter().collect(),
472 "the stop and the human reply in its region both moved"
473 );
474
475 let reloaded = load(dir.path()).expect("reload");
476 assert!(reloaded.walkthrough.is_none());
477 }
478
479 #[test]
480 fn a_review_file_with_the_old_singular_walkthrough_key_splits_it_out() {
481 let dir = tempfile::tempdir().expect("tempdir");
482 std::fs::create_dir_all(dir.path().join(".diffler/reviews")).expect("mkdir");
483 std::fs::write(
484 dir.path().join(".diffler/reviews/working.json"),
485 r#"{"version":1,"comments":[
486 {"id":"stop-0","author":"agent","anchor":{"file":"a.txt","line":1},"title":"first","anchor_ref":"a.txt:1","body":"why","status":"open","at":1}
487 ],"viewed":{},"walkthrough":{"id":"w1","title":"tour","author":"agent","at":1,"stops":["stop-0"],"comments":["stop-0"]}}"#,
488 )
489 .expect("write");
490
491 let working = load(dir.path()).expect("load working");
492 assert!(working.comments.is_empty());
493 let split = load_source(dir.path(), &ReviewSource::walkthrough("w1")).expect("load w1");
494 assert_eq!(split.walkthrough.expect("walkthrough").id, "w1");
495 assert_eq!(split.comments.len(), 1);
496 }
497
498 #[test]
499 fn delete_source_removes_the_file_and_a_missing_one_is_not_an_error() {
500 let dir = tempfile::tempdir().expect("tempdir");
501 let source = ReviewSource::walkthrough("w1");
502 save_source(dir.path(), &source, &Session::default()).expect("save");
503 assert!(
504 load_all(dir.path())
505 .expect("load_all")
506 .0
507 .iter()
508 .any(|(s, _)| *s == source)
509 );
510
511 delete_source(dir.path(), &source).expect("delete");
512 assert!(
513 !load_all(dir.path())
514 .expect("load_all")
515 .0
516 .iter()
517 .any(|(s, _)| *s == source)
518 );
519 delete_source(dir.path(), &source).expect("delete missing is a no-op");
520 }
521
522 #[test]
523 fn load_all_returns_every_source_sorted_by_key() {
524 let dir = tempfile::tempdir().expect("tempdir");
525 save_source(dir.path(), &ReviewSource::WorkingTree, &Session::default()).expect("w");
526 save_source(
527 dir.path(),
528 &ReviewSource::commit("bbb"),
529 &Session::default(),
530 )
531 .expect("c");
532 save_source(
533 dir.path(),
534 &ReviewSource::commit("aaa"),
535 &Session::default(),
536 )
537 .expect("c");
538
539 let (all, corrupt) = load_all(dir.path()).expect("load_all");
540 let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
541 assert_eq!(keys, ["commit-aaa", "commit-bbb", "working"]);
542 assert!(corrupt.is_empty());
543 }
544
545 #[test]
546 fn load_all_skips_a_corrupt_file_and_names_it() {
547 let dir = tempfile::tempdir().expect("tempdir");
548 save_source(dir.path(), &ReviewSource::WorkingTree, &Session::default()).expect("w");
549 save_source(
550 dir.path(),
551 &ReviewSource::commit("abc"),
552 &Session::default(),
553 )
554 .expect("c");
555 let bad = dir.path().join(".diffler/reviews/walkthrough-broken.json");
556 std::fs::write(&bad, "{not json").expect("write corrupt file");
557
558 let (all, corrupt) = load_all(dir.path()).expect("load_all");
559 let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
560 assert_eq!(keys, ["commit-abc", "working"], "the good files still load");
561 assert_eq!(corrupt, vec![bad]);
562 }
563
564 #[test]
565 fn load_all_includes_legacy_working_before_migration() {
566 let dir = tempfile::tempdir().expect("tempdir");
567 std::fs::create_dir_all(dir.path().join(".diffler")).expect("mkdir");
568 std::fs::write(
569 dir.path().join(".diffler/session.json"),
570 r#"{"version":1,"comments":[],"viewed":{"a.txt":"h"}}"#,
571 )
572 .expect("write legacy");
573 save_source(
574 dir.path(),
575 &ReviewSource::commit("abc"),
576 &Session::default(),
577 )
578 .expect("c");
579
580 let (all, _corrupt) = load_all(dir.path()).expect("load_all");
581 let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
582 assert_eq!(keys, ["commit-abc", "working"]);
583 }
584}