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                // a walkthrough this old predates the field too, and only
168                // ever described the working tree
169                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
182/// Read one file's session and its own declared source (`WorkingTree` for a
183/// file predating that field). A walkthrough's own file never carries an
184/// embedded one, so splitting only ever runs for every other source.
185fn 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
207/// Load the session for one source. The working tree falls back to the legacy
208/// single-session file when no per-source file exists yet; the file is moved on
209/// the next [`save_source`].
210pub 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
222/// Remove a source's review file entirely, e.g. deleting a walkthrough
223/// deletes its `walkthrough-<id>.json`. A missing file is not an error.
224pub 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
232/// Persist one source's session atomically (temp file then rename). Migrates
233/// the working tree off the legacy file by removing it once the new file lands.
234pub 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
264/// Every persisted review, for aggregating across sources (e.g. the MCP feed),
265/// plus the path of any review file that failed to parse. Ordered by key for
266/// deterministic output. A corrupt file is skipped rather than failing the
267/// whole call, so one bad file never hides every other review; its path
268/// comes back in the second list, for a caller that wants to tell the
269/// reader. The directory listing is taken up front, so a split a file
270/// triggers mid-scan never feeds back into this same call.
271pub 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
304/// Working-tree session, the common case.
305pub fn load(repo_root: &Path) -> Result<Session, StoreError> {
306    load_source(repo_root, &ReviewSource::WorkingTree)
307}
308
309/// Persist the working-tree session.
310pub 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        // a path means different things per source; no collision
375        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        // read falls back to the legacy file
399        let loaded = load(dir.path()).expect("load");
400        assert!(loaded.is_viewed("a.txt", "h-legacy"));
401
402        // saving migrates: new file written, legacy removed
403        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    /// A `working.json` carrying the pre-source `walkthroughs` list splits
410    /// each entry into its own `walkthrough-<id>.json`: a stop's own comment,
411    /// a note the legacy `comments` list names, and a human reply anchored in
412    /// the stop's region all move; a comment untouched by any stop stays
413    /// with the working session.
414    #[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        // idempotent: loading again finds nothing left to split
452        let reloaded = load(dir.path()).expect("reload");
453        assert!(reloaded.walkthrough.is_none());
454    }
455
456    /// The older singular `walkthrough` field (from before a review could
457    /// hold more than one) migrates the same way, as a one-element list.
458    #[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    /// A review file that fails to parse is skipped, not fatal: every other
525    /// review still loads, and the bad file's path comes back so a caller
526    /// can tell the reader.
527    #[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}