1use std::cell::OnceCell;
5use std::collections::{BTreeMap, HashMap};
6use std::path::{Path, PathBuf};
7
8use thiserror::Error;
9
10use crate::git::GitVcs;
11use crate::model::DiffModel;
12use crate::session::Session;
13use crate::source::ReviewSource;
14use crate::store::{self, StoreError};
15use crate::vcs::{StatusModel, Vcs, VcsError};
16
17#[derive(Debug, Error)]
18pub enum ReviewError {
19 #[error(transparent)]
20 Vcs(#[from] VcsError),
21 #[error(transparent)]
22 Store(#[from] StoreError),
23}
24
25#[derive(Debug)]
27pub struct Refreshed {
28 pub status: StatusModel,
29 pub model: DiffModel,
30 pub against: Option<(String, Result<DiffModel, VcsError>)>,
34}
35
36pub struct Review {
37 pub repo_root: PathBuf,
38 pub vcs: Box<dyn Vcs>,
39 pub status: StatusModel,
40 model: OnceCell<DiffModel>,
44 pub session: Session,
46 sources: HashMap<String, (ReviewSource, Session)>,
49 empty: Session,
51}
52
53impl Review {
54 pub fn open(repo_root: &Path) -> Result<Self, ReviewError> {
58 Self::open_with_context(repo_root, crate::git::DEFAULT_CONTEXT_LINES)
59 }
60
61 pub fn open_with_context(repo_root: &Path, context_lines: u32) -> Result<Self, ReviewError> {
64 let vcs: Box<dyn Vcs> = Box::new(GitVcs::open_with_context(repo_root, context_lines)?);
65 let status = vcs.status()?;
66 let session = store::load(repo_root)?;
67 Ok(Self {
68 repo_root: repo_root.to_path_buf(),
69 vcs,
70 status,
71 model: OnceCell::new(),
72 session,
73 sources: HashMap::new(),
74 empty: Session::default(),
75 })
76 }
77
78 pub fn model(&self) -> &DiffModel {
82 self.model
83 .get_or_init(|| self.vcs.working_tree_diff().unwrap_or_default())
84 }
85
86 pub fn model_mut(&mut self) -> &mut DiffModel {
90 self.model();
91 #[allow(clippy::expect_used)]
92 self.model.get_mut().expect("model just initialized")
93 }
94
95 pub fn refresh(&mut self) -> Result<(), ReviewError> {
98 self.status = self.vcs.status()?;
99 let model = self.vcs.working_tree_diff()?;
100 self.install_refresh(self.status.clone(), model);
101 Ok(())
102 }
103
104 pub fn compute_refresh(
109 repo_root: &Path,
110 context_lines: u32,
111 against: Option<&str>,
112 ) -> Result<Refreshed, ReviewError> {
113 let vcs = GitVcs::open_with_context(repo_root, context_lines)?;
114 let status = vcs.status()?;
115 let model = vcs.working_tree_diff()?;
116 let against = against.map(|rev| (rev.to_owned(), crate::vcs::against_diff(&vcs, rev)));
117 Ok(Refreshed {
118 status,
119 model,
120 against,
121 })
122 }
123
124 pub fn install_refresh(&mut self, status: StatusModel, model: DiffModel) {
126 self.status = status;
127 self.session.reconcile(&model);
128 self.model = OnceCell::from(model);
129 }
130
131 pub fn save(&self) -> Result<(), ReviewError> {
132 store::save(&self.repo_root, &self.session)?;
133 Ok(())
134 }
135
136 pub fn ensure_source(&mut self, source: &ReviewSource) -> Result<(), ReviewError> {
139 if matches!(source, ReviewSource::WorkingTree) {
140 return Ok(());
141 }
142 let key = source.key();
143 if !self.sources.contains_key(&key) {
144 let session = store::load_source(&self.repo_root, source)?;
145 self.sources.insert(key, (source.clone(), session));
146 }
147 Ok(())
148 }
149
150 pub fn session_for(&self, source: &ReviewSource) -> &Session {
154 match source {
155 ReviewSource::WorkingTree => &self.session,
156 other => self
157 .sources
158 .get(&other.key())
159 .map_or(&self.empty, |(_, session)| session),
160 }
161 }
162
163 pub fn session_for_mut(&mut self, source: &ReviewSource) -> &mut Session {
164 match source {
165 ReviewSource::WorkingTree => &mut self.session,
166 other => {
167 &mut self
168 .sources
169 .entry(other.key())
170 .or_insert_with(|| (other.clone(), Session::default()))
171 .1
172 }
173 }
174 }
175
176 pub fn save_for(&self, source: &ReviewSource) -> Result<(), ReviewError> {
177 store::save_source(&self.repo_root, source, self.session_for(source))?;
178 Ok(())
179 }
180
181 pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
184 let mut by_key: BTreeMap<String, (ReviewSource, Session)> =
185 store::load_all(&self.repo_root)?
186 .into_iter()
187 .map(|(source, session)| (source.key(), (source, session)))
188 .collect();
189 by_key.insert(
190 ReviewSource::WorkingTree.key(),
191 (ReviewSource::WorkingTree, self.session.clone()),
192 );
193 for (key, (source, session)) in &self.sources {
194 by_key.insert(key.clone(), (source.clone(), session.clone()));
195 }
196 Ok(by_key.into_values().collect())
197 }
198
199 pub fn restore_model(&mut self, model: DiffModel) {
203 self.model = OnceCell::from(model);
204 }
205
206 #[cfg(test)]
208 fn model_is_cached(&self) -> bool {
209 self.model.get().is_some()
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use crate::repo;
216
217 use super::*;
218
219 #[allow(clippy::expect_used)]
220 fn write(root: &std::path::Path, rel: &str, content: &str) {
221 std::fs::write(root.join(rel), content).expect("write");
222 }
223
224 #[allow(clippy::expect_used)]
225 fn commit_all(root: &std::path::Path, message: &str) {
226 for args in [&["add", "-A"][..], &["commit", "-q", "-m", message][..]] {
227 let status = std::process::Command::new("git")
228 .arg("-C")
229 .arg(root)
230 .args(args)
231 .env("GIT_AUTHOR_NAME", "t")
232 .env("GIT_AUTHOR_EMAIL", "t@t")
233 .env("GIT_COMMITTER_NAME", "t")
234 .env("GIT_COMMITTER_EMAIL", "t@t")
235 .status()
236 .expect("git");
237 assert!(status.success(), "git {args:?}");
238 }
239 }
240
241 #[allow(clippy::expect_used)]
242 fn init_repo(root: &std::path::Path) {
243 let status = std::process::Command::new("git")
244 .arg("-C")
245 .arg(root)
246 .args(["init", "-q"])
247 .status()
248 .expect("git init");
249 assert!(status.success());
250 }
251
252 #[test]
253 fn open_defers_the_working_model_until_first_access() {
254 let dir = tempfile::tempdir().expect("tempdir");
255 let root = dir.path();
256 init_repo(root);
257 write(root, "a.py", "value = old\n");
258 commit_all(root, "base");
259 write(root, "a.py", "value = new\n");
260
261 let root = repo::discover(root).expect("discover");
262 let review = Review::open(&root).expect("open");
263 assert!(
265 !review.model_is_cached(),
266 "open must not compute the working model"
267 );
268 assert_eq!(review.status.unstaged.files.len(), 1);
269
270 let lazy = review.model().clone();
272 assert!(review.model_is_cached(), "access caches the model");
273 let eager = review.vcs.working_tree_diff().expect("diff");
274 assert_eq!(lazy, eager, "lazy model equals the eager build");
275 }
276
277 #[allow(clippy::expect_used)]
278 fn git(root: &std::path::Path, args: &[&str]) {
279 let status = std::process::Command::new("git")
280 .arg("-C")
281 .arg(root)
282 .args(args)
283 .status()
284 .expect("git");
285 assert!(status.success(), "git {args:?}");
286 }
287
288 #[test]
289 fn against_a_base_branch_shows_committed_and_uncommitted_work() {
290 let dir = tempfile::tempdir().expect("tempdir");
291 let root = dir.path();
292 init_repo(root);
293 git(root, &["symbolic-ref", "HEAD", "refs/heads/main"]);
294 write(root, "base.txt", "base\n");
295 commit_all(root, "base");
296 git(root, &["checkout", "-q", "-b", "feature"]);
297 write(root, "committed.txt", "landed\n");
298 commit_all(root, "feature work");
299 git(root, &["checkout", "-q", "main"]);
301 write(root, "elsewhere.txt", "not mine\n");
302 commit_all(root, "base moved on");
303 git(root, &["checkout", "-q", "feature"]);
304 write(root, "dirty.txt", "still editing\n");
305
306 let root = repo::discover(root).expect("discover");
307 let review = Review::open(&root).expect("open");
308 let model = crate::vcs::against_diff(review.vcs.as_ref(), "main").expect("against");
309 let paths: Vec<&str> = model.files.iter().map(|f| f.path.as_str()).collect();
310 assert_eq!(paths, ["committed.txt", "dirty.txt"]);
311 }
312
313 #[test]
314 fn per_source_sessions_persist_independently_and_aggregate() {
315 let dir = tempfile::tempdir().expect("tempdir");
316 let root = dir.path();
317 init_repo(root);
318 write(root, "a.py", "value = old\n");
319 commit_all(root, "base");
320 write(root, "a.py", "value = new\n");
321
322 let root = repo::discover(root).expect("discover");
323 let mut review = Review::open(&root).expect("open");
324
325 let commit = crate::source::ReviewSource::commit("deadbeef");
326 review.ensure_source(&commit).expect("ensure");
327 review
328 .session_for_mut(&commit)
329 .mark_viewed("a.py", "hash-commit");
330 review.save_for(&commit).expect("save commit");
331 review.session.mark_viewed("a.py", "hash-working");
332 review.save().expect("save working");
333
334 assert!(review.session_for(&commit).is_viewed("a.py", "hash-commit"));
336 assert!(!review.session.is_viewed("a.py", "hash-commit"));
337
338 let mut reopened = Review::open(&root).expect("reopen");
340 reopened.ensure_source(&commit).expect("ensure");
341 assert!(
342 reopened
343 .session_for(&commit)
344 .is_viewed("a.py", "hash-commit")
345 );
346 assert!(reopened.session.is_viewed("a.py", "hash-working"));
347
348 let all = reopened.all_reviews().expect("all");
349 let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
350 assert_eq!(keys, ["commit-deadbeef", "working"]);
351 }
352}