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
25pub struct Review {
26 pub repo_root: PathBuf,
27 pub vcs: Box<dyn Vcs>,
28 pub status: StatusModel,
29 model: OnceCell<DiffModel>,
33 pub session: Session,
35 sources: HashMap<String, (ReviewSource, Session)>,
38 empty: Session,
40}
41
42impl Review {
43 pub fn open(repo_root: &Path) -> Result<Self, ReviewError> {
47 Self::open_with_context(repo_root, crate::git::DEFAULT_CONTEXT_LINES)
48 }
49
50 pub fn open_with_context(repo_root: &Path, context_lines: u32) -> Result<Self, ReviewError> {
53 let vcs: Box<dyn Vcs> = Box::new(GitVcs::open_with_context(repo_root, context_lines)?);
54 let status = vcs.status()?;
55 let session = store::load(repo_root)?;
56 Ok(Self {
57 repo_root: repo_root.to_path_buf(),
58 vcs,
59 status,
60 model: OnceCell::new(),
61 session,
62 sources: HashMap::new(),
63 empty: Session::default(),
64 })
65 }
66
67 pub fn model(&self) -> &DiffModel {
71 self.model
72 .get_or_init(|| self.vcs.working_tree_diff().unwrap_or_default())
73 }
74
75 pub fn model_mut(&mut self) -> &mut DiffModel {
79 self.model();
80 #[allow(clippy::expect_used)]
81 self.model.get_mut().expect("model just initialized")
82 }
83
84 pub fn refresh(&mut self) -> Result<(), ReviewError> {
87 self.status = self.vcs.status()?;
88 let model = self.vcs.working_tree_diff()?;
89 self.install_refresh(self.status.clone(), model);
90 Ok(())
91 }
92
93 pub fn compute_refresh(
96 repo_root: &Path,
97 context_lines: u32,
98 ) -> Result<(StatusModel, DiffModel), ReviewError> {
99 let vcs = GitVcs::open_with_context(repo_root, context_lines)?;
100 let status = vcs.status()?;
101 let model = vcs.working_tree_diff()?;
102 Ok((status, model))
103 }
104
105 pub fn install_refresh(&mut self, status: StatusModel, model: DiffModel) {
107 self.status = status;
108 self.session.reconcile(&model);
109 self.model = OnceCell::from(model);
110 }
111
112 pub fn save(&self) -> Result<(), ReviewError> {
113 store::save(&self.repo_root, &self.session)?;
114 Ok(())
115 }
116
117 pub fn ensure_source(&mut self, source: &ReviewSource) -> Result<(), ReviewError> {
120 if matches!(source, ReviewSource::WorkingTree) {
121 return Ok(());
122 }
123 let key = source.key();
124 if !self.sources.contains_key(&key) {
125 let session = store::load_source(&self.repo_root, source)?;
126 self.sources.insert(key, (source.clone(), session));
127 }
128 Ok(())
129 }
130
131 pub fn session_for(&self, source: &ReviewSource) -> &Session {
135 match source {
136 ReviewSource::WorkingTree => &self.session,
137 other => self
138 .sources
139 .get(&other.key())
140 .map_or(&self.empty, |(_, session)| session),
141 }
142 }
143
144 pub fn session_for_mut(&mut self, source: &ReviewSource) -> &mut Session {
145 match source {
146 ReviewSource::WorkingTree => &mut self.session,
147 other => {
148 &mut self
149 .sources
150 .entry(other.key())
151 .or_insert_with(|| (other.clone(), Session::default()))
152 .1
153 }
154 }
155 }
156
157 pub fn save_for(&self, source: &ReviewSource) -> Result<(), ReviewError> {
158 store::save_source(&self.repo_root, source, self.session_for(source))?;
159 Ok(())
160 }
161
162 pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
165 let mut by_key: BTreeMap<String, (ReviewSource, Session)> =
166 store::load_all(&self.repo_root)?
167 .into_iter()
168 .map(|(source, session)| (source.key(), (source, session)))
169 .collect();
170 by_key.insert(
171 ReviewSource::WorkingTree.key(),
172 (ReviewSource::WorkingTree, self.session.clone()),
173 );
174 for (key, (source, session)) in &self.sources {
175 by_key.insert(key.clone(), (source.clone(), session.clone()));
176 }
177 Ok(by_key.into_values().collect())
178 }
179
180 pub fn restore_model(&mut self, model: DiffModel) {
184 self.model = OnceCell::from(model);
185 }
186
187 #[cfg(test)]
189 fn model_is_cached(&self) -> bool {
190 self.model.get().is_some()
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use crate::repo;
197
198 use super::*;
199
200 #[allow(clippy::expect_used)]
201 fn write(root: &std::path::Path, rel: &str, content: &str) {
202 std::fs::write(root.join(rel), content).expect("write");
203 }
204
205 #[allow(clippy::expect_used)]
206 fn commit_all(root: &std::path::Path, message: &str) {
207 for args in [&["add", "-A"][..], &["commit", "-q", "-m", message][..]] {
208 let status = std::process::Command::new("git")
209 .arg("-C")
210 .arg(root)
211 .args(args)
212 .env("GIT_AUTHOR_NAME", "t")
213 .env("GIT_AUTHOR_EMAIL", "t@t")
214 .env("GIT_COMMITTER_NAME", "t")
215 .env("GIT_COMMITTER_EMAIL", "t@t")
216 .status()
217 .expect("git");
218 assert!(status.success(), "git {args:?}");
219 }
220 }
221
222 #[allow(clippy::expect_used)]
223 fn init_repo(root: &std::path::Path) {
224 let status = std::process::Command::new("git")
225 .arg("-C")
226 .arg(root)
227 .args(["init", "-q"])
228 .status()
229 .expect("git init");
230 assert!(status.success());
231 }
232
233 #[test]
234 fn open_defers_the_working_model_until_first_access() {
235 let dir = tempfile::tempdir().expect("tempdir");
236 let root = dir.path();
237 init_repo(root);
238 write(root, "a.py", "value = old\n");
239 commit_all(root, "base");
240 write(root, "a.py", "value = new\n");
241
242 let root = repo::discover(root).expect("discover");
243 let review = Review::open(&root).expect("open");
244 assert!(
246 !review.model_is_cached(),
247 "open must not compute the working model"
248 );
249 assert_eq!(review.status.unstaged.files.len(), 1);
250
251 let lazy = review.model().clone();
253 assert!(review.model_is_cached(), "access caches the model");
254 let eager = review.vcs.working_tree_diff().expect("diff");
255 assert_eq!(lazy, eager, "lazy model equals the eager build");
256 }
257
258 #[test]
259 fn per_source_sessions_persist_independently_and_aggregate() {
260 let dir = tempfile::tempdir().expect("tempdir");
261 let root = dir.path();
262 init_repo(root);
263 write(root, "a.py", "value = old\n");
264 commit_all(root, "base");
265 write(root, "a.py", "value = new\n");
266
267 let root = repo::discover(root).expect("discover");
268 let mut review = Review::open(&root).expect("open");
269
270 let commit = crate::source::ReviewSource::commit("deadbeef");
271 review.ensure_source(&commit).expect("ensure");
272 review
273 .session_for_mut(&commit)
274 .mark_viewed("a.py", "hash-commit");
275 review.save_for(&commit).expect("save commit");
276 review.session.mark_viewed("a.py", "hash-working");
277 review.save().expect("save working");
278
279 assert!(review.session_for(&commit).is_viewed("a.py", "hash-commit"));
281 assert!(!review.session.is_viewed("a.py", "hash-commit"));
282
283 let mut reopened = Review::open(&root).expect("reopen");
285 reopened.ensure_source(&commit).expect("ensure");
286 assert!(
287 reopened
288 .session_for(&commit)
289 .is_viewed("a.py", "hash-commit")
290 );
291 assert!(reopened.session.is_viewed("a.py", "hash-working"));
292
293 let all = reopened.all_reviews().expect("all");
294 let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
295 assert_eq!(keys, ["commit-deadbeef", "working"]);
296 }
297}