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
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8
9use thiserror::Error;
10
11use crate::session::Session;
12use crate::source::ReviewSource;
13
14const DIR: &str = ".diffler";
15const REVIEWS: &str = "reviews";
16const LEGACY_FILE: &str = "session.json";
17
18#[derive(Debug, Error)]
19pub enum StoreError {
20    #[error(transparent)]
21    Io(#[from] std::io::Error),
22    #[error("corrupt session file {0}: {1}")]
23    Corrupt(PathBuf, serde_json::Error),
24}
25
26#[derive(Debug, serde::Serialize, serde::Deserialize)]
27struct OnDisk {
28    version: u32,
29    /// Self-describes the file for `load_all`; lookups go by filename (the
30    /// source key), so the filename is authoritative. Absent in legacy files,
31    /// where the source is implicitly the working tree.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    source: Option<ReviewSource>,
34    #[serde(flatten)]
35    session: Session,
36}
37
38fn reviews_dir(repo_root: &Path) -> PathBuf {
39    repo_root.join(DIR).join(REVIEWS)
40}
41
42fn source_path(repo_root: &Path, source: &ReviewSource) -> PathBuf {
43    reviews_dir(repo_root).join(format!("{}.json", source.key()))
44}
45
46fn legacy_path(repo_root: &Path) -> PathBuf {
47    repo_root.join(DIR).join(LEGACY_FILE)
48}
49
50fn read_session(path: &Path) -> Result<Option<Session>, StoreError> {
51    match fs::read_to_string(path) {
52        Ok(raw) => {
53            let on_disk: OnDisk = serde_json::from_str(&raw)
54                .map_err(|e| StoreError::Corrupt(path.to_path_buf(), e))?;
55            Ok(Some(on_disk.session))
56        }
57        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
58        Err(err) => Err(err.into()),
59    }
60}
61
62/// Load the session for one source. The working tree falls back to the legacy
63/// single-session file when no per-source file exists yet; the file is moved on
64/// the next [`save_source`].
65pub fn load_source(repo_root: &Path, source: &ReviewSource) -> Result<Session, StoreError> {
66    if let Some(session) = read_session(&source_path(repo_root, source))? {
67        return Ok(session);
68    }
69    if matches!(source, ReviewSource::WorkingTree)
70        && let Some(session) = read_session(&legacy_path(repo_root))?
71    {
72        return Ok(session);
73    }
74    Ok(Session::default())
75}
76
77/// Persist one source's session atomically (temp file then rename). Migrates
78/// the working tree off the legacy file by removing it once the new file lands.
79pub fn save_source(
80    repo_root: &Path,
81    source: &ReviewSource,
82    session: &Session,
83) -> Result<(), StoreError> {
84    let dir = reviews_dir(repo_root);
85    fs::create_dir_all(&dir)?;
86    let gitignore = repo_root.join(DIR).join(".gitignore");
87    if !gitignore.exists() {
88        fs::write(&gitignore, "*\n")?;
89    }
90    let on_disk = OnDisk {
91        version: 1,
92        source: Some(source.clone()),
93        session: session.clone(),
94    };
95    let json = serde_json::to_string_pretty(&on_disk).map_err(std::io::Error::other)?;
96    let mut tmp = tempfile::NamedTempFile::new_in(&dir)?;
97    tmp.write_all(json.as_bytes())?;
98    tmp.persist(source_path(repo_root, source))
99        .map_err(|e| StoreError::Io(e.error))?;
100    if matches!(source, ReviewSource::WorkingTree) {
101        let legacy = legacy_path(repo_root);
102        if legacy.exists() {
103            fs::remove_file(legacy)?;
104        }
105    }
106    Ok(())
107}
108
109/// Every persisted review, for aggregating across sources (e.g. the MCP feed).
110/// Ordered by key for deterministic output. A corrupt file fails the whole
111/// call rather than silently vanishing.
112pub fn load_all(repo_root: &Path) -> Result<Vec<(ReviewSource, Session)>, StoreError> {
113    let mut reviews: Vec<(ReviewSource, Session)> = Vec::new();
114    let dir = reviews_dir(repo_root);
115    match fs::read_dir(&dir) {
116        Ok(entries) => {
117            for entry in entries {
118                let path = entry?.path();
119                if path.extension().is_none_or(|ext| ext != "json") {
120                    continue;
121                }
122                let raw = fs::read_to_string(&path)?;
123                let on_disk: OnDisk =
124                    serde_json::from_str(&raw).map_err(|e| StoreError::Corrupt(path.clone(), e))?;
125                let source = on_disk.source.unwrap_or(ReviewSource::WorkingTree);
126                reviews.push((source, on_disk.session));
127            }
128        }
129        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
130        Err(err) => return Err(err.into()),
131    }
132    if !reviews
133        .iter()
134        .any(|(s, _)| matches!(s, ReviewSource::WorkingTree))
135        && let Some(session) = read_session(&legacy_path(repo_root))?
136    {
137        reviews.push((ReviewSource::WorkingTree, session));
138    }
139    reviews.sort_by_key(|(source, _)| source.key());
140    Ok(reviews)
141}
142
143/// Working-tree session, the common case.
144pub fn load(repo_root: &Path) -> Result<Session, StoreError> {
145    load_source(repo_root, &ReviewSource::WorkingTree)
146}
147
148/// Persist the working-tree session.
149pub fn save(repo_root: &Path, session: &Session) -> Result<(), StoreError> {
150    save_source(repo_root, &ReviewSource::WorkingTree, session)
151}
152
153#[cfg(test)]
154mod tests {
155    use crate::session::Anchor;
156
157    use super::*;
158
159    fn anchor() -> Anchor {
160        Anchor {
161            file: "a.txt".into(),
162            line: Some(1),
163            line_end: None,
164            on_old_side: false,
165            hunk: None,
166            line_text: None,
167        }
168    }
169
170    #[test]
171    fn missing_file_loads_default() {
172        let dir = tempfile::tempdir().expect("tempdir");
173        let s = load(dir.path()).expect("load");
174        assert_eq!(s, Session::default());
175    }
176
177    #[test]
178    fn save_load_round_trip() {
179        let dir = tempfile::tempdir().expect("tempdir");
180        let mut s = Session::default();
181        s.add_comment("mattf", anchor(), "hm");
182        s.mark_viewed("a.txt", "hash-1");
183        save(dir.path(), &s).expect("save");
184        let back = load(dir.path()).expect("load");
185        assert_eq!(s, back);
186    }
187
188    #[test]
189    fn save_writes_gitignore() {
190        let dir = tempfile::tempdir().expect("tempdir");
191        save(dir.path(), &Session::default()).expect("save");
192        let gi = std::fs::read_to_string(dir.path().join(".diffler/.gitignore")).expect("read");
193        assert_eq!(gi, "*\n");
194    }
195
196    #[test]
197    fn corrupt_file_is_an_error_not_a_reset() {
198        let dir = tempfile::tempdir().expect("tempdir");
199        std::fs::create_dir_all(dir.path().join(".diffler/reviews")).expect("mkdir");
200        std::fs::write(
201            dir.path().join(".diffler/reviews/working.json"),
202            "{not json",
203        )
204        .expect("write");
205        assert!(matches!(load(dir.path()), Err(StoreError::Corrupt(..))));
206    }
207
208    #[test]
209    fn sources_persist_independently() {
210        let dir = tempfile::tempdir().expect("tempdir");
211        let mut work = Session::default();
212        work.mark_viewed("a.txt", "h-work");
213        let mut commit = Session::default();
214        commit.mark_viewed("a.txt", "h-commit");
215
216        save_source(dir.path(), &ReviewSource::WorkingTree, &work).expect("save work");
217        save_source(dir.path(), &ReviewSource::commit("abc"), &commit).expect("save commit");
218
219        assert_eq!(load(dir.path()).expect("load work"), work);
220        assert_eq!(
221            load_source(dir.path(), &ReviewSource::commit("abc")).expect("load commit"),
222            commit
223        );
224        // a path means different things per source; no collision
225        assert!(
226            load_source(dir.path(), &ReviewSource::commit("abc"))
227                .expect("load")
228                .is_viewed("a.txt", "h-commit")
229        );
230        assert!(
231            !load(dir.path())
232                .expect("load")
233                .is_viewed("a.txt", "h-commit")
234        );
235    }
236
237    #[test]
238    fn legacy_file_migrates_to_working_on_save() {
239        let dir = tempfile::tempdir().expect("tempdir");
240        std::fs::create_dir_all(dir.path().join(".diffler")).expect("mkdir");
241        let legacy = dir.path().join(".diffler/session.json");
242        std::fs::write(
243            &legacy,
244            r#"{"version":1,"comments":[],"viewed":{"a.txt":"h-legacy"}}"#,
245        )
246        .expect("write legacy");
247
248        // read falls back to the legacy file
249        let loaded = load(dir.path()).expect("load");
250        assert!(loaded.is_viewed("a.txt", "h-legacy"));
251
252        // saving migrates: new file written, legacy removed
253        save(dir.path(), &loaded).expect("save");
254        assert!(!legacy.exists(), "legacy file removed after migration");
255        assert!(dir.path().join(".diffler/reviews/working.json").exists());
256        assert_eq!(load(dir.path()).expect("reload"), loaded);
257    }
258
259    #[test]
260    fn load_all_returns_every_source_sorted_by_key() {
261        let dir = tempfile::tempdir().expect("tempdir");
262        save_source(dir.path(), &ReviewSource::WorkingTree, &Session::default()).expect("w");
263        save_source(
264            dir.path(),
265            &ReviewSource::commit("bbb"),
266            &Session::default(),
267        )
268        .expect("c");
269        save_source(
270            dir.path(),
271            &ReviewSource::commit("aaa"),
272            &Session::default(),
273        )
274        .expect("c");
275
276        let all = load_all(dir.path()).expect("load_all");
277        let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
278        assert_eq!(keys, ["commit-aaa", "commit-bbb", "working"]);
279    }
280
281    #[test]
282    fn load_all_includes_legacy_working_before_migration() {
283        let dir = tempfile::tempdir().expect("tempdir");
284        std::fs::create_dir_all(dir.path().join(".diffler")).expect("mkdir");
285        std::fs::write(
286            dir.path().join(".diffler/session.json"),
287            r#"{"version":1,"comments":[],"viewed":{"a.txt":"h"}}"#,
288        )
289        .expect("write legacy");
290        save_source(
291            dir.path(),
292            &ReviewSource::commit("abc"),
293            &Session::default(),
294        )
295        .expect("c");
296
297        let all = load_all(dir.path()).expect("load_all");
298        let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
299        assert_eq!(keys, ["commit-abc", "working"]);
300    }
301}