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_declared(
138 repo_root: &Path,
139 paths: &[String],
140 ) -> Result<HashMap<String, crate::classify::Kind>, ReviewError> {
141 let vcs = GitVcs::open(repo_root)?;
142 Ok(paths
143 .iter()
144 .filter_map(|path| {
145 let rel = Path::new(path);
146 let kind = crate::classify::declared(|name| vcs.attr(rel, name))?;
147 Some((path.clone(), kind))
148 })
149 .collect())
150 }
151
152 pub fn compute_file(repo_root: &Path, rel: &str) -> Result<FileSnapshot, ReviewError> {
157 let vcs = GitVcs::open(repo_root)?;
158 let path = Path::new(rel);
159 let content = std::fs::read_to_string(repo_root.join(path)).map_err(VcsError::from)?;
160 Ok(FileSnapshot {
161 path: rel.to_owned(),
162 blame: vcs.blame(path).unwrap_or_default(),
163 content,
164 })
165 }
166
167 pub fn install_refresh(&mut self, status: StatusModel, model: DiffModel) {
169 self.status = status;
170 self.session.reconcile(&model);
171 self.model = OnceCell::from(model);
172 }
173
174 pub fn save(&self) -> Result<(), ReviewError> {
175 store::save(&self.repo_root, &self.session)?;
176 Ok(())
177 }
178
179 pub fn ensure_source(&mut self, source: &ReviewSource) -> Result<(), ReviewError> {
182 if matches!(source, ReviewSource::WorkingTree) {
183 return Ok(());
184 }
185 let key = source.key();
186 if !self.sources.contains_key(&key) {
187 let session = store::load_source(&self.repo_root, source)?;
188 self.sources.insert(key, (source.clone(), session));
189 }
190 Ok(())
191 }
192
193 pub fn session_for(&self, source: &ReviewSource) -> &Session {
197 match source {
198 ReviewSource::WorkingTree => &self.session,
199 other => self
200 .sources
201 .get(&other.key())
202 .map_or(&self.empty, |(_, session)| session),
203 }
204 }
205
206 pub fn session_for_mut(&mut self, source: &ReviewSource) -> &mut Session {
207 match source {
208 ReviewSource::WorkingTree => &mut self.session,
209 other => {
210 &mut self
211 .sources
212 .entry(other.key())
213 .or_insert_with(|| (other.clone(), Session::default()))
214 .1
215 }
216 }
217 }
218
219 pub fn save_for(&self, source: &ReviewSource) -> Result<(), ReviewError> {
220 store::save_source(&self.repo_root, source, self.session_for(source))?;
221 Ok(())
222 }
223
224 pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
227 let mut by_key: BTreeMap<String, (ReviewSource, Session)> =
228 store::load_all(&self.repo_root)?
229 .into_iter()
230 .map(|(source, session)| (source.key(), (source, session)))
231 .collect();
232 by_key.insert(
233 ReviewSource::WorkingTree.key(),
234 (ReviewSource::WorkingTree, self.session.clone()),
235 );
236 for (key, (source, session)) in &self.sources {
237 by_key.insert(key.clone(), (source.clone(), session.clone()));
238 }
239 Ok(by_key.into_values().collect())
240 }
241
242 pub fn restore_model(&mut self, model: DiffModel) {
246 self.model = OnceCell::from(model);
247 }
248
249 #[cfg(test)]
251 fn model_is_cached(&self) -> bool {
252 self.model.get().is_some()
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use crate::repo;
259
260 use super::*;
261
262 #[allow(clippy::expect_used)]
263 fn write(root: &std::path::Path, rel: &str, content: &str) {
264 std::fs::write(root.join(rel), content).expect("write");
265 }
266
267 #[allow(clippy::expect_used)]
268 fn commit_all(root: &std::path::Path, message: &str) {
269 for args in [&["add", "-A"][..], &["commit", "-q", "-m", message][..]] {
270 let status = std::process::Command::new("git")
271 .arg("-C")
272 .arg(root)
273 .args(args)
274 .env("GIT_AUTHOR_NAME", "t")
275 .env("GIT_AUTHOR_EMAIL", "t@t")
276 .env("GIT_COMMITTER_NAME", "t")
277 .env("GIT_COMMITTER_EMAIL", "t@t")
278 .status()
279 .expect("git");
280 assert!(status.success(), "git {args:?}");
281 }
282 }
283
284 #[allow(clippy::expect_used)]
285 fn init_repo(root: &std::path::Path) {
286 let status = std::process::Command::new("git")
287 .arg("-C")
288 .arg(root)
289 .args(["init", "-q"])
290 .status()
291 .expect("git init");
292 assert!(status.success());
293 }
294
295 #[test]
296 fn open_defers_the_working_model_until_first_access() {
297 let dir = tempfile::tempdir().expect("tempdir");
298 let root = dir.path();
299 init_repo(root);
300 write(root, "a.py", "value = old\n");
301 commit_all(root, "base");
302 write(root, "a.py", "value = new\n");
303
304 let root = repo::discover(root).expect("discover");
305 let review = Review::open(&root).expect("open");
306 assert!(
308 !review.model_is_cached(),
309 "open must not compute the working model"
310 );
311 assert_eq!(review.status.unstaged.files.len(), 1);
312
313 let lazy = review.model().clone();
315 assert!(review.model_is_cached(), "access caches the model");
316 let eager = review.vcs.working_tree_diff().expect("diff");
317 assert_eq!(lazy, eager, "lazy model equals the eager build");
318 }
319
320 #[allow(clippy::expect_used)]
321 fn git(root: &std::path::Path, args: &[&str]) {
322 let status = std::process::Command::new("git")
323 .arg("-C")
324 .arg(root)
325 .args(args)
326 .status()
327 .expect("git");
328 assert!(status.success(), "git {args:?}");
329 }
330
331 #[test]
332 fn against_a_base_branch_shows_committed_and_uncommitted_work() {
333 let dir = tempfile::tempdir().expect("tempdir");
334 let root = dir.path();
335 init_repo(root);
336 git(root, &["symbolic-ref", "HEAD", "refs/heads/main"]);
337 write(root, "base.txt", "base\n");
338 commit_all(root, "base");
339 git(root, &["checkout", "-q", "-b", "feature"]);
340 write(root, "committed.txt", "landed\n");
341 commit_all(root, "feature work");
342 git(root, &["checkout", "-q", "main"]);
344 write(root, "elsewhere.txt", "not mine\n");
345 commit_all(root, "base moved on");
346 git(root, &["checkout", "-q", "feature"]);
347 write(root, "dirty.txt", "still editing\n");
348
349 let root = repo::discover(root).expect("discover");
350 let review = Review::open(&root).expect("open");
351 let model = crate::vcs::against_diff(review.vcs.as_ref(), "main").expect("against");
352 let paths: Vec<&str> = model.files.iter().map(|f| f.path.as_str()).collect();
353 assert_eq!(paths, ["committed.txt", "dirty.txt"]);
354 }
355
356 #[test]
357 fn per_source_sessions_persist_independently_and_aggregate() {
358 let dir = tempfile::tempdir().expect("tempdir");
359 let root = dir.path();
360 init_repo(root);
361 write(root, "a.py", "value = old\n");
362 commit_all(root, "base");
363 write(root, "a.py", "value = new\n");
364
365 let root = repo::discover(root).expect("discover");
366 let mut review = Review::open(&root).expect("open");
367
368 let commit = crate::source::ReviewSource::commit("deadbeef");
369 review.ensure_source(&commit).expect("ensure");
370 review
371 .session_for_mut(&commit)
372 .mark_viewed("a.py", "hash-commit");
373 review.save_for(&commit).expect("save commit");
374 review.session.mark_viewed("a.py", "hash-working");
375 review.save().expect("save working");
376
377 assert!(review.session_for(&commit).is_viewed("a.py", "hash-commit"));
379 assert!(!review.session.is_viewed("a.py", "hash-commit"));
380
381 let mut reopened = Review::open(&root).expect("reopen");
383 reopened.ensure_source(&commit).expect("ensure");
384 assert!(
385 reopened
386 .session_for(&commit)
387 .is_viewed("a.py", "hash-commit")
388 );
389 assert!(reopened.session.is_viewed("a.py", "hash-working"));
390
391 let all = reopened.all_reviews().expect("all");
392 let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
393 assert_eq!(keys, ["commit-deadbeef", "working"]);
394 }
395}