1use 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 #[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
62pub 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
77pub 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
109pub 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
143pub fn load(repo_root: &Path) -> Result<Session, StoreError> {
145 load_source(repo_root, &ReviewSource::WorkingTree)
146}
147
148pub 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 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 let loaded = load(dir.path()).expect("load");
250 assert!(loaded.is_viewed("a.txt", "h-legacy"));
251
252 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}