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 }),
168 seen_stops: seen,
169 };
170 save_source(
171 repo_root,
172 &ReviewSource::Walkthrough { id: walkthrough.id },
173 &split,
174 )?;
175 }
176 Ok(true)
177}
178
179fn read_session(
183 repo_root: &Path,
184 path: &Path,
185) -> Result<Option<(ReviewSource, Session)>, StoreError> {
186 match fs::read_to_string(path) {
187 Ok(raw) => {
188 let on_disk: OnDisk = serde_json::from_str(&raw)
189 .map_err(|e| StoreError::Corrupt(path.to_path_buf(), e))?;
190 let origin = on_disk.source.unwrap_or(ReviewSource::WorkingTree);
191 let mut session = on_disk.session;
192 if !matches!(origin, ReviewSource::Walkthrough { .. })
193 && split_embedded_walkthroughs(repo_root, &raw, &mut session)?
194 {
195 save_source(repo_root, &origin, &session)?;
196 }
197 Ok(Some((origin, session)))
198 }
199 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
200 Err(err) => Err(err.into()),
201 }
202}
203
204pub fn load_source(repo_root: &Path, source: &ReviewSource) -> Result<Session, StoreError> {
208 if let Some((_, session)) = read_session(repo_root, &source_path(repo_root, source))? {
209 return Ok(session);
210 }
211 if matches!(source, ReviewSource::WorkingTree)
212 && let Some((_, session)) = read_session(repo_root, &legacy_path(repo_root))?
213 {
214 return Ok(session);
215 }
216 Ok(Session::default())
217}
218
219pub fn delete_source(repo_root: &Path, source: &ReviewSource) -> Result<(), StoreError> {
222 match fs::remove_file(source_path(repo_root, source)) {
223 Ok(()) => Ok(()),
224 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
225 Err(err) => Err(err.into()),
226 }
227}
228
229pub fn save_source(
232 repo_root: &Path,
233 source: &ReviewSource,
234 session: &Session,
235) -> Result<(), StoreError> {
236 let dir = reviews_dir(repo_root);
237 fs::create_dir_all(&dir)?;
238 let gitignore = repo_root.join(DIR).join(".gitignore");
239 if !gitignore.exists() {
240 fs::write(&gitignore, "*\n")?;
241 }
242 let on_disk = OnDisk {
243 version: 1,
244 source: Some(source.clone()),
245 session: session.clone(),
246 };
247 let json = serde_json::to_string_pretty(&on_disk).map_err(std::io::Error::other)?;
248 let mut tmp = tempfile::NamedTempFile::new_in(&dir)?;
249 tmp.write_all(json.as_bytes())?;
250 tmp.persist(source_path(repo_root, source))
251 .map_err(|e| StoreError::Io(e.error))?;
252 if matches!(source, ReviewSource::WorkingTree) {
253 let legacy = legacy_path(repo_root);
254 if legacy.exists() {
255 fs::remove_file(legacy)?;
256 }
257 }
258 Ok(())
259}
260
261pub fn load_all(repo_root: &Path) -> Result<LoadedReviews, StoreError> {
269 let mut reviews: Vec<(ReviewSource, Session)> = Vec::new();
270 let mut corrupt: Vec<PathBuf> = Vec::new();
271 let dir = reviews_dir(repo_root);
272 match fs::read_dir(&dir) {
273 Ok(entries) => {
274 let paths: Vec<PathBuf> = entries
275 .filter_map(|entry| entry.ok().map(|e| e.path()))
276 .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
277 .collect();
278 for path in paths {
279 match read_session(repo_root, &path) {
280 Ok(Some(pair)) => reviews.push(pair),
281 Ok(None) => {}
282 Err(StoreError::Corrupt(path, _)) => corrupt.push(path),
283 Err(err) => return Err(err),
284 }
285 }
286 }
287 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
288 Err(err) => return Err(err.into()),
289 }
290 if !reviews
291 .iter()
292 .any(|(s, _)| matches!(s, ReviewSource::WorkingTree))
293 && let Some((source, session)) = read_session(repo_root, &legacy_path(repo_root))?
294 {
295 reviews.push((source, session));
296 }
297 reviews.sort_by_key(|(source, _)| source.key());
298 Ok((reviews, corrupt))
299}
300
301pub fn load(repo_root: &Path) -> Result<Session, StoreError> {
303 load_source(repo_root, &ReviewSource::WorkingTree)
304}
305
306pub fn save(repo_root: &Path, session: &Session) -> Result<(), StoreError> {
308 save_source(repo_root, &ReviewSource::WorkingTree, session)
309}
310
311#[cfg(test)]
312mod tests {
313 use crate::test_support::anchor;
314
315 use super::*;
316
317 #[test]
318 fn missing_file_loads_default() {
319 let dir = tempfile::tempdir().expect("tempdir");
320 let s = load(dir.path()).expect("load");
321 assert_eq!(s, Session::default());
322 }
323
324 #[test]
325 fn save_load_round_trip() {
326 let dir = tempfile::tempdir().expect("tempdir");
327 let mut s = Session::default();
328 s.add_comment(anchor("a.txt", Some(1)), "reviewer", "hm");
329 s.mark_viewed("a.txt", "hash-1");
330 save(dir.path(), &s).expect("save");
331 let back = load(dir.path()).expect("load");
332 assert_eq!(s, back);
333 }
334
335 #[test]
336 fn save_writes_gitignore() {
337 let dir = tempfile::tempdir().expect("tempdir");
338 save(dir.path(), &Session::default()).expect("save");
339 let gi = std::fs::read_to_string(dir.path().join(".diffler/.gitignore")).expect("read");
340 assert_eq!(gi, "*\n");
341 }
342
343 #[test]
344 fn corrupt_file_is_an_error_not_a_reset() {
345 let dir = tempfile::tempdir().expect("tempdir");
346 std::fs::create_dir_all(dir.path().join(".diffler/reviews")).expect("mkdir");
347 std::fs::write(
348 dir.path().join(".diffler/reviews/working.json"),
349 "{not json",
350 )
351 .expect("write");
352 assert!(matches!(load(dir.path()), Err(StoreError::Corrupt(..))));
353 }
354
355 #[test]
356 fn sources_persist_independently() {
357 let dir = tempfile::tempdir().expect("tempdir");
358 let mut work = Session::default();
359 work.mark_viewed("a.txt", "h-work");
360 let mut commit = Session::default();
361 commit.mark_viewed("a.txt", "h-commit");
362
363 save_source(dir.path(), &ReviewSource::WorkingTree, &work).expect("save work");
364 save_source(dir.path(), &ReviewSource::commit("abc"), &commit).expect("save commit");
365
366 assert_eq!(load(dir.path()).expect("load work"), work);
367 assert_eq!(
368 load_source(dir.path(), &ReviewSource::commit("abc")).expect("load commit"),
369 commit
370 );
371 assert!(
373 load_source(dir.path(), &ReviewSource::commit("abc"))
374 .expect("load")
375 .is_viewed("a.txt", "h-commit")
376 );
377 assert!(
378 !load(dir.path())
379 .expect("load")
380 .is_viewed("a.txt", "h-commit")
381 );
382 }
383
384 #[test]
385 fn legacy_file_migrates_to_working_on_save() {
386 let dir = tempfile::tempdir().expect("tempdir");
387 std::fs::create_dir_all(dir.path().join(".diffler")).expect("mkdir");
388 let legacy = dir.path().join(".diffler/session.json");
389 std::fs::write(
390 &legacy,
391 r#"{"version":1,"comments":[],"viewed":{"a.txt":"h-legacy"}}"#,
392 )
393 .expect("write legacy");
394
395 let loaded = load(dir.path()).expect("load");
397 assert!(loaded.is_viewed("a.txt", "h-legacy"));
398
399 save(dir.path(), &loaded).expect("save");
401 assert!(!legacy.exists(), "legacy file removed after migration");
402 assert!(dir.path().join(".diffler/reviews/working.json").exists());
403 assert_eq!(load(dir.path()).expect("reload"), loaded);
404 }
405
406 #[test]
412 fn a_review_file_with_the_old_walkthroughs_list_splits_each_one_out() {
413 let dir = tempfile::tempdir().expect("tempdir");
414 std::fs::create_dir_all(dir.path().join(".diffler/reviews")).expect("mkdir");
415 std::fs::write(
416 dir.path().join(".diffler/reviews/working.json"),
417 r#"{"version":1,"comments":[
418 {"id":"stop-0","author":"agent","anchor":{"file":"a.txt","line":1},"title":"first","anchor_ref":"a.txt:1","body":"why","status":"open","at":1},
419 {"id":"human-0","author":"reviewer","anchor":{"file":"a.txt","line":1},"body":"a reply","status":"open","at":1},
420 {"id":"unrelated","author":"reviewer","anchor":{"file":"b.txt","line":1},"body":"unrelated","status":"open","at":1}
421 ],"viewed":{"a.txt":"h"},"walkthroughs":[
422 {"id":"w1","title":"tour","author":"agent","at":1,"stops":["stop-0"],"comments":["stop-0"]}
423 ]}"#,
424 )
425 .expect("write");
426
427 let working = load(dir.path()).expect("load working");
428 assert!(working.walkthrough.is_none());
429 let ids: Vec<&str> = working.comments.iter().map(|c| c.id.as_str()).collect();
430 assert_eq!(ids, ["unrelated"], "only the working session's own comment");
431 assert_eq!(
432 working.viewed.get("a.txt").map(String::as_str),
433 Some("h"),
434 "file-viewed marks are the working tree's own and stay"
435 );
436
437 let split = load_source(dir.path(), &ReviewSource::walkthrough("w1")).expect("load w1");
438 let walkthrough = split.walkthrough.expect("walkthrough");
439 assert_eq!(walkthrough.id, "w1");
440 assert_eq!(walkthrough.stops, ["stop-0".to_owned()]);
441 let ids: Vec<&str> = split.comments.iter().map(|c| c.id.as_str()).collect();
442 assert_eq!(
443 ids.into_iter().collect::<std::collections::BTreeSet<_>>(),
444 ["stop-0", "human-0"].into_iter().collect(),
445 "the stop and the human reply in its region both moved"
446 );
447
448 let reloaded = load(dir.path()).expect("reload");
450 assert!(reloaded.walkthrough.is_none());
451 }
452
453 #[test]
456 fn a_review_file_with_the_old_singular_walkthrough_key_splits_it_out() {
457 let dir = tempfile::tempdir().expect("tempdir");
458 std::fs::create_dir_all(dir.path().join(".diffler/reviews")).expect("mkdir");
459 std::fs::write(
460 dir.path().join(".diffler/reviews/working.json"),
461 r#"{"version":1,"comments":[
462 {"id":"stop-0","author":"agent","anchor":{"file":"a.txt","line":1},"title":"first","anchor_ref":"a.txt:1","body":"why","status":"open","at":1}
463 ],"viewed":{},"walkthrough":{"id":"w1","title":"tour","author":"agent","at":1,"stops":["stop-0"],"comments":["stop-0"]}}"#,
464 )
465 .expect("write");
466
467 let working = load(dir.path()).expect("load working");
468 assert!(working.comments.is_empty());
469 let split = load_source(dir.path(), &ReviewSource::walkthrough("w1")).expect("load w1");
470 assert_eq!(split.walkthrough.expect("walkthrough").id, "w1");
471 assert_eq!(split.comments.len(), 1);
472 }
473
474 #[test]
475 fn delete_source_removes_the_file_and_a_missing_one_is_not_an_error() {
476 let dir = tempfile::tempdir().expect("tempdir");
477 let source = ReviewSource::walkthrough("w1");
478 save_source(dir.path(), &source, &Session::default()).expect("save");
479 assert!(
480 load_all(dir.path())
481 .expect("load_all")
482 .0
483 .iter()
484 .any(|(s, _)| *s == source)
485 );
486
487 delete_source(dir.path(), &source).expect("delete");
488 assert!(
489 !load_all(dir.path())
490 .expect("load_all")
491 .0
492 .iter()
493 .any(|(s, _)| *s == source)
494 );
495 delete_source(dir.path(), &source).expect("delete missing is a no-op");
496 }
497
498 #[test]
499 fn load_all_returns_every_source_sorted_by_key() {
500 let dir = tempfile::tempdir().expect("tempdir");
501 save_source(dir.path(), &ReviewSource::WorkingTree, &Session::default()).expect("w");
502 save_source(
503 dir.path(),
504 &ReviewSource::commit("bbb"),
505 &Session::default(),
506 )
507 .expect("c");
508 save_source(
509 dir.path(),
510 &ReviewSource::commit("aaa"),
511 &Session::default(),
512 )
513 .expect("c");
514
515 let (all, corrupt) = load_all(dir.path()).expect("load_all");
516 let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
517 assert_eq!(keys, ["commit-aaa", "commit-bbb", "working"]);
518 assert!(corrupt.is_empty());
519 }
520
521 #[test]
525 fn load_all_skips_a_corrupt_file_and_names_it() {
526 let dir = tempfile::tempdir().expect("tempdir");
527 save_source(dir.path(), &ReviewSource::WorkingTree, &Session::default()).expect("w");
528 save_source(
529 dir.path(),
530 &ReviewSource::commit("abc"),
531 &Session::default(),
532 )
533 .expect("c");
534 let bad = dir.path().join(".diffler/reviews/walkthrough-broken.json");
535 std::fs::write(&bad, "{not json").expect("write corrupt file");
536
537 let (all, corrupt) = load_all(dir.path()).expect("load_all");
538 let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
539 assert_eq!(keys, ["commit-abc", "working"], "the good files still load");
540 assert_eq!(corrupt, vec![bad]);
541 }
542
543 #[test]
544 fn load_all_includes_legacy_working_before_migration() {
545 let dir = tempfile::tempdir().expect("tempdir");
546 std::fs::create_dir_all(dir.path().join(".diffler")).expect("mkdir");
547 std::fs::write(
548 dir.path().join(".diffler/session.json"),
549 r#"{"version":1,"comments":[],"viewed":{"a.txt":"h"}}"#,
550 )
551 .expect("write legacy");
552 save_source(
553 dir.path(),
554 &ReviewSource::commit("abc"),
555 &Session::default(),
556 )
557 .expect("c");
558
559 let (all, _corrupt) = load_all(dir.path()).expect("load_all");
560 let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
561 assert_eq!(keys, ["commit-abc", "working"]);
562 }
563}