Skip to main content

diffler_core/
store.rs

1//! Session persistence: one file per review source under `.diffler/reviews/`.
2//! Legacy `.diffler/session.json` migrates to `reviews/working.json`, and a
3//! walkthrough embedded in an older review file splits into its own
4//! `walkthrough-<id>.json` on load.
5
6use 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
29/// Every review [`load_all`] found, plus the path of each file that would
30/// not parse.
31pub type LoadedReviews = (Vec<(ReviewSource, Session)>, Vec<PathBuf>);
32
33#[derive(Debug, serde::Serialize, serde::Deserialize)]
34struct OnDisk {
35    version: u32,
36    /// For `load_all` only; lookups go by filename. Absent in legacy files,
37    /// which are the working tree.
38    #[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/// The legacy embedded walkthrough. `comments` lists the ids it owns.
57#[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/// Walkthroughs a legacy review file embeds, under `walkthroughs` or the older
72/// singular `walkthrough`. We parse it leniently beside [`OnDisk`] so an
73/// unknown key never fails the load.
74#[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
92/// Move each walkthrough embedded in `raw` out of `session` into its own
93/// `walkthrough-<id>.json`, with its stops and every comment inside their
94/// regions, human ones included. Returns whether anything moved.
95fn 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(&region, &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
169/// One file's session and declared source, splitting out any embedded
170/// walkthrough.
171fn 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
193/// The working tree falls back to the legacy file, which the next
194/// [`save_source`] migrates.
195pub 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
207/// Remove a source's review file. A missing file is fine.
208pub 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
216/// Create `.diffler/` with a `.gitignore` that ignores everything in it.
217pub 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
232/// Replace `path` through a temp file and rename. We write through a symlink
233/// to its target and keep the target's permissions.
234pub 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
255/// Saving the working tree removes the legacy file.
256pub 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
280/// Every persisted review sorted by key, plus each corrupt file's path. We
281/// list the directory up front so a walkthrough split mid-scan stays out of
282/// this call.
283pub 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}