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