Skip to main content

diffler_core/
store.rs

1//! Session persistence: one file per review source under `.diffler/reviews/`,
2//! atomically written, self-gitignored. The legacy single-session file
3//! `.diffler/session.json` is read once and migrated to `reviews/working.json`.
4//! A file written before a walkthrough was a source of its own carries it
5//! embedded (`walkthroughs`, or the older singular `walkthrough`); reading
6//! any such file splits each one out into its own `walkthrough-<id>.json`.
7
8use 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
31/// Every review [`load_all`] found, plus the path of any file it had to skip
32/// because it would not parse.
33pub type LoadedReviews = (Vec<(ReviewSource, Session)>, Vec<PathBuf>);
34
35#[derive(Debug, serde::Serialize, serde::Deserialize)]
36struct OnDisk {
37    version: u32,
38    /// Self-describes the file for `load_all`; lookups go by filename (the
39    /// source key), so the filename is authoritative. Absent in legacy files,
40    /// where the source is implicitly the working tree.
41    #[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/// The pre-source shape of one embedded walkthrough. `comments` was the
60/// owned-id list (every stop and the notes hanging off them); splitting reads
61/// it to find what else to move, then drops the field for good.
62#[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/// The embedded walkthrough(s) a pre-source review file may carry: several
77/// under `walkthroughs`, or one under the older singular `walkthrough`. A
78/// permissive read alongside the real [`OnDisk`] parse, so an unknown key
79/// never fails the load.
80#[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
98/// Split every walkthrough `raw` carries embedded out of `session` into its
99/// own `walkthrough-<id>.json`: its stops, and every comment anchored inside
100/// one of those stops' own regions (a human reply included, since a
101/// walkthrough is now its own world), move with it; the rest of `session`
102/// keeps what is left. Returns whether anything moved, so the caller knows
103/// whether the origin file needs resaving.
104fn 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(&region, &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                // a walkthrough this old predates the field entirely, so its
165                // anchors resolve against the live worktree
166                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
179/// Read one file's session and its own declared source (`WorkingTree` for a
180/// file predating that field). A walkthrough's own file never carries an
181/// embedded one, so splitting only ever runs for every other source.
182fn 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
204/// Load the session for one source. The working tree falls back to the legacy
205/// single-session file when no per-source file exists yet; the file is moved on
206/// the next [`save_source`].
207pub 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
219/// Remove a source's review file entirely, e.g. deleting a walkthrough
220/// deletes its `walkthrough-<id>.json`. A missing file is not an error.
221pub 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
229/// Persist one source's session atomically (temp file then rename). Migrates
230/// the working tree off the legacy file by removing it once the new file lands.
231pub 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
261/// Every persisted review, for aggregating across sources (e.g. the MCP feed),
262/// plus the path of any review file that failed to parse. Ordered by key for
263/// deterministic output. A corrupt file is skipped rather than failing the
264/// whole call, so one bad file never hides every other review; its path
265/// comes back in the second list, for a caller that wants to tell the
266/// reader. The directory listing is taken up front, so a split a file
267/// triggers mid-scan never feeds back into this same call.
268pub 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
301/// Working-tree session, the common case.
302pub fn load(repo_root: &Path) -> Result<Session, StoreError> {
303    load_source(repo_root, &ReviewSource::WorkingTree)
304}
305
306/// Persist the working-tree session.
307pub 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        // a path means different things per source; no collision
372        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        // read falls back to the legacy file
396        let loaded = load(dir.path()).expect("load");
397        assert!(loaded.is_viewed("a.txt", "h-legacy"));
398
399        // saving migrates: new file written, legacy removed
400        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    /// A `working.json` carrying the pre-source `walkthroughs` list splits
407    /// each entry into its own `walkthrough-<id>.json`: a stop's own comment,
408    /// a note the legacy `comments` list names, and a human reply anchored in
409    /// the stop's region all move; a comment untouched by any stop stays
410    /// with the working session.
411    #[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        // idempotent: loading again finds nothing left to split
449        let reloaded = load(dir.path()).expect("reload");
450        assert!(reloaded.walkthrough.is_none());
451    }
452
453    /// The older singular `walkthrough` field (from before a review could
454    /// hold more than one) migrates the same way, as a one-element list.
455    #[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    /// A review file that fails to parse is skipped, not fatal: every other
522    /// review still loads, and the bad file's path comes back so a caller
523    /// can tell the reader.
524    #[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}