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 FileSnapshot {
28 pub path: String,
29 pub content: String,
30 pub blame: Vec<crate::vcs::BlameSpan>,
32}
33
34#[derive(Debug)]
36pub struct Refreshed {
37 pub status: StatusModel,
38 pub model: DiffModel,
39 pub against: Option<(String, Result<DiffModel, VcsError>)>,
43}
44
45pub struct Review {
46 pub repo_root: PathBuf,
47 pub vcs: Box<dyn Vcs>,
48 pub status: StatusModel,
49 model: OnceCell<DiffModel>,
53 pub session: Session,
55 sources: HashMap<String, (ReviewSource, Session)>,
58 empty: Session,
60}
61
62impl Review {
63 pub fn open(repo_root: &Path) -> Result<Self, ReviewError> {
67 Self::open_with_context(repo_root, crate::git::DEFAULT_CONTEXT_LINES)
68 }
69
70 pub fn open_with_context(repo_root: &Path, context_lines: u32) -> Result<Self, ReviewError> {
73 let vcs: Box<dyn Vcs> = Box::new(GitVcs::open_with_context(repo_root, context_lines)?);
74 let status = vcs.status()?;
75 let session = store::load(repo_root)?;
76 Ok(Self {
77 repo_root: repo_root.to_path_buf(),
78 vcs,
79 status,
80 model: OnceCell::new(),
81 session,
82 sources: HashMap::new(),
83 empty: Session::default(),
84 })
85 }
86
87 pub fn model(&self) -> &DiffModel {
91 self.model
92 .get_or_init(|| self.vcs.working_tree_diff().unwrap_or_default())
93 }
94
95 pub fn model_mut(&mut self) -> &mut DiffModel {
99 self.model();
100 #[allow(clippy::expect_used)]
101 self.model.get_mut().expect("model just initialized")
102 }
103
104 pub fn refresh(&mut self) -> Result<(), ReviewError> {
107 self.status = self.vcs.status()?;
108 let model = self.vcs.working_tree_diff()?;
109 self.install_refresh(self.status.clone(), model);
110 Ok(())
111 }
112
113 pub fn compute_refresh(
118 repo_root: &Path,
119 context_lines: u32,
120 against: Option<&str>,
121 ) -> Result<Refreshed, ReviewError> {
122 let vcs = GitVcs::open_with_context(repo_root, context_lines)?;
123 let status = vcs.status()?;
124 let model = vcs.working_tree_diff()?;
125 let against = against.map(|rev| (rev.to_owned(), crate::vcs::against_diff(&vcs, rev)));
126 Ok(Refreshed {
127 status,
128 model,
129 against,
130 })
131 }
132
133 pub fn compute_file(repo_root: &Path, rel: &str) -> Result<FileSnapshot, ReviewError> {
138 let vcs = GitVcs::open(repo_root)?;
139 let path = Path::new(rel);
140 let content = std::fs::read_to_string(repo_root.join(path)).map_err(VcsError::from)?;
141 Ok(FileSnapshot {
142 path: rel.to_owned(),
143 blame: vcs.blame(path).unwrap_or_default(),
144 content,
145 })
146 }
147
148 pub fn install_refresh(&mut self, status: StatusModel, model: DiffModel) {
150 self.status = status;
151 self.session.reconcile(&model);
152 self.model = OnceCell::from(model);
153 }
154
155 pub fn save(&self) -> Result<(), ReviewError> {
156 store::save(&self.repo_root, &self.session)?;
157 Ok(())
158 }
159
160 pub fn ensure_source(&mut self, source: &ReviewSource) -> Result<(), ReviewError> {
163 if matches!(source, ReviewSource::WorkingTree) {
164 return Ok(());
165 }
166 let key = source.key();
167 if !self.sources.contains_key(&key) {
168 let session = store::load_source(&self.repo_root, source)?;
169 self.sources.insert(key, (source.clone(), session));
170 }
171 Ok(())
172 }
173
174 pub fn session_for(&self, source: &ReviewSource) -> &Session {
178 match source {
179 ReviewSource::WorkingTree => &self.session,
180 other => self
181 .sources
182 .get(&other.key())
183 .map_or(&self.empty, |(_, session)| session),
184 }
185 }
186
187 pub fn session_for_mut(&mut self, source: &ReviewSource) -> &mut Session {
188 match source {
189 ReviewSource::WorkingTree => &mut self.session,
190 other => {
191 &mut self
192 .sources
193 .entry(other.key())
194 .or_insert_with(|| (other.clone(), Session::default()))
195 .1
196 }
197 }
198 }
199
200 pub fn save_for(&self, source: &ReviewSource) -> Result<(), ReviewError> {
201 store::save_source(&self.repo_root, source, self.session_for(source))?;
202 Ok(())
203 }
204
205 pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
208 let mut by_key: BTreeMap<String, (ReviewSource, Session)> =
209 store::load_all(&self.repo_root)?
210 .into_iter()
211 .map(|(source, session)| (source.key(), (source, session)))
212 .collect();
213 by_key.insert(
214 ReviewSource::WorkingTree.key(),
215 (ReviewSource::WorkingTree, self.session.clone()),
216 );
217 for (key, (source, session)) in &self.sources {
218 by_key.insert(key.clone(), (source.clone(), session.clone()));
219 }
220 Ok(by_key.into_values().collect())
221 }
222
223 pub fn restore_model(&mut self, model: DiffModel) {
227 self.model = OnceCell::from(model);
228 }
229
230 #[cfg(test)]
232 fn model_is_cached(&self) -> bool {
233 self.model.get().is_some()
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use crate::repo;
240
241 use super::*;
242
243 #[allow(clippy::expect_used)]
244 fn write(root: &std::path::Path, rel: &str, content: &str) {
245 std::fs::write(root.join(rel), content).expect("write");
246 }
247
248 #[allow(clippy::expect_used)]
249 fn commit_all(root: &std::path::Path, message: &str) {
250 for args in [&["add", "-A"][..], &["commit", "-q", "-m", message][..]] {
251 let status = std::process::Command::new("git")
252 .arg("-C")
253 .arg(root)
254 .args(args)
255 .env("GIT_AUTHOR_NAME", "t")
256 .env("GIT_AUTHOR_EMAIL", "t@t")
257 .env("GIT_COMMITTER_NAME", "t")
258 .env("GIT_COMMITTER_EMAIL", "t@t")
259 .status()
260 .expect("git");
261 assert!(status.success(), "git {args:?}");
262 }
263 }
264
265 #[allow(clippy::expect_used)]
266 fn init_repo(root: &std::path::Path) {
267 let status = std::process::Command::new("git")
268 .arg("-C")
269 .arg(root)
270 .args(["init", "-q"])
271 .status()
272 .expect("git init");
273 assert!(status.success());
274 }
275
276 #[test]
277 fn open_defers_the_working_model_until_first_access() {
278 let dir = tempfile::tempdir().expect("tempdir");
279 let root = dir.path();
280 init_repo(root);
281 write(root, "a.py", "value = old\n");
282 commit_all(root, "base");
283 write(root, "a.py", "value = new\n");
284
285 let root = repo::discover(root).expect("discover");
286 let review = Review::open(&root).expect("open");
287 assert!(
289 !review.model_is_cached(),
290 "open must not compute the working model"
291 );
292 assert_eq!(review.status.unstaged.files.len(), 1);
293
294 let lazy = review.model().clone();
296 assert!(review.model_is_cached(), "access caches the model");
297 let eager = review.vcs.working_tree_diff().expect("diff");
298 assert_eq!(lazy, eager, "lazy model equals the eager build");
299 }
300
301 #[allow(clippy::expect_used)]
302 fn git(root: &std::path::Path, args: &[&str]) {
303 let status = std::process::Command::new("git")
304 .arg("-C")
305 .arg(root)
306 .args(args)
307 .status()
308 .expect("git");
309 assert!(status.success(), "git {args:?}");
310 }
311
312 #[test]
313 fn against_a_base_branch_shows_committed_and_uncommitted_work() {
314 let dir = tempfile::tempdir().expect("tempdir");
315 let root = dir.path();
316 init_repo(root);
317 git(root, &["symbolic-ref", "HEAD", "refs/heads/main"]);
318 write(root, "base.txt", "base\n");
319 commit_all(root, "base");
320 git(root, &["checkout", "-q", "-b", "feature"]);
321 write(root, "committed.txt", "landed\n");
322 commit_all(root, "feature work");
323 git(root, &["checkout", "-q", "main"]);
325 write(root, "elsewhere.txt", "not mine\n");
326 commit_all(root, "base moved on");
327 git(root, &["checkout", "-q", "feature"]);
328 write(root, "dirty.txt", "still editing\n");
329
330 let root = repo::discover(root).expect("discover");
331 let review = Review::open(&root).expect("open");
332 let model = crate::vcs::against_diff(review.vcs.as_ref(), "main").expect("against");
333 let paths: Vec<&str> = model.files.iter().map(|f| f.path.as_str()).collect();
334 assert_eq!(paths, ["committed.txt", "dirty.txt"]);
335 }
336
337 #[test]
338 fn per_source_sessions_persist_independently_and_aggregate() {
339 let dir = tempfile::tempdir().expect("tempdir");
340 let root = dir.path();
341 init_repo(root);
342 write(root, "a.py", "value = old\n");
343 commit_all(root, "base");
344 write(root, "a.py", "value = new\n");
345
346 let root = repo::discover(root).expect("discover");
347 let mut review = Review::open(&root).expect("open");
348
349 let commit = crate::source::ReviewSource::commit("deadbeef");
350 review.ensure_source(&commit).expect("ensure");
351 review
352 .session_for_mut(&commit)
353 .mark_viewed("a.py", "hash-commit");
354 review.save_for(&commit).expect("save commit");
355 review.session.mark_viewed("a.py", "hash-working");
356 review.save().expect("save working");
357
358 assert!(review.session_for(&commit).is_viewed("a.py", "hash-commit"));
360 assert!(!review.session.is_viewed("a.py", "hash-commit"));
361
362 let mut reopened = Review::open(&root).expect("reopen");
364 reopened.ensure_source(&commit).expect("ensure");
365 assert!(
366 reopened
367 .session_for(&commit)
368 .is_viewed("a.py", "hash-commit")
369 );
370 assert!(reopened.session.is_viewed("a.py", "hash-working"));
371
372 let all = reopened.all_reviews().expect("all");
373 let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
374 assert_eq!(keys, ["commit-deadbeef", "working"]);
375 }
376}