1use std::cell::OnceCell;
5use std::collections::{BTreeMap, HashMap};
6use std::path::{Path, PathBuf};
7
8use thiserror::Error;
9
10use crate::diffalgo::{DiffAlgorithm, DiffSettings};
11use crate::model::DiffModel;
12use crate::repo;
13use crate::session::Session;
14use crate::source::ReviewSource;
15use crate::store::{self, StoreError};
16use crate::vcs::{StatusModel, Vcs, VcsError};
17
18#[derive(Debug, Error)]
19pub enum ReviewError {
20 #[error(transparent)]
21 Vcs(#[from] VcsError),
22 #[error(transparent)]
23 Store(#[from] StoreError),
24}
25
26#[derive(Debug)]
28pub struct FileSnapshot {
29 pub path: String,
30 pub content: String,
31 pub blame: Vec<crate::vcs::BlameSpan>,
33}
34
35#[derive(Debug, Default)]
38pub struct WalkthroughFiles {
39 pub contents: HashMap<String, String>,
40 pub pin_broken: bool,
45}
46
47#[derive(Debug)]
49pub struct Refreshed {
50 pub status: StatusModel,
51 pub model: DiffModel,
52 pub against: Option<(String, Result<DiffModel, VcsError>)>,
56 pub pinned: Option<Result<DiffModel, VcsError>>,
60}
61
62pub fn pinned_diff(
71 vcs: &dyn Vcs,
72 source: &ReviewSource,
73 pr_head: Option<(&str, &str)>,
74) -> Result<DiffModel, VcsError> {
75 match source {
76 ReviewSource::Commit { oid } => vcs.commit_diff(oid),
77 ReviewSource::Range { oldest, newest } => vcs.range_diff(oldest, newest),
78 ReviewSource::Pr { number } => {
79 let (base, head) = pr_head
80 .ok_or_else(|| VcsError::Rejected(format!("PR #{number} is not resolved")))?;
81 vcs.tree_diff(base, head)
82 }
83 ReviewSource::WorkingTree
84 | ReviewSource::Walkthrough { .. }
85 | ReviewSource::Against { .. } => Ok(DiffModel::default()),
86 }
87}
88
89pub struct Review {
90 pub repo_root: PathBuf,
91 pub vcs: Box<dyn Vcs>,
92 pub status: StatusModel,
93 model: OnceCell<DiffModel>,
97 pub session: Session,
99 sources: HashMap<String, (ReviewSource, Session)>,
102 empty: Session,
104}
105
106impl Review {
107 pub fn open(repo_root: &Path) -> Result<Self, ReviewError> {
111 Self::open_with_settings(repo_root, &DiffSettings::default())
112 }
113
114 pub fn open_with_settings(
118 repo_root: &Path,
119 settings: &DiffSettings,
120 ) -> Result<Self, ReviewError> {
121 let vcs = repo::open_with_settings(repo_root, settings)?;
122 let status = vcs.status()?;
123 let session = store::load(repo_root)?;
124 Ok(Self {
125 repo_root: repo_root.to_path_buf(),
126 vcs,
127 status,
128 model: OnceCell::new(),
129 session,
130 sources: HashMap::new(),
131 empty: Session::default(),
132 })
133 }
134
135 pub fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool) {
138 self.vcs.set_diff_algorithm(algorithm, indent_heuristic);
139 }
140
141 pub fn model(&self) -> &DiffModel {
145 self.model
146 .get_or_init(|| self.vcs.working_tree_diff().unwrap_or_default())
147 }
148
149 pub fn model_mut(&mut self) -> &mut DiffModel {
153 self.model();
154 #[allow(clippy::expect_used)]
155 self.model.get_mut().expect("model just initialized")
156 }
157
158 pub fn refresh(&mut self) -> Result<(), ReviewError> {
161 self.status = self.vcs.status()?;
162 let model = self.vcs.working_tree_diff()?;
163 self.install_refresh(self.status.clone(), model);
164 Ok(())
165 }
166
167 pub fn compute_refresh(
174 repo_root: &Path,
175 settings: &DiffSettings,
176 against: Option<&str>,
177 pinned: Option<(&ReviewSource, Option<(&str, &str)>)>,
178 ) -> Result<Refreshed, ReviewError> {
179 let vcs = repo::open_with_settings(repo_root, settings)?;
180 let status = vcs.status()?;
181 let model = vcs.working_tree_diff()?;
182 let against =
183 against.map(|rev| (rev.to_owned(), crate::vcs::against_diff(vcs.as_ref(), rev)));
184 let pinned = pinned.map(|(source, pr_head)| pinned_diff(vcs.as_ref(), source, pr_head));
185 Ok(Refreshed {
186 status,
187 model,
188 against,
189 pinned,
190 })
191 }
192
193 pub fn compute_declared(
198 repo_root: &Path,
199 paths: &[String],
200 ) -> Result<HashMap<String, crate::classify::Kind>, ReviewError> {
201 let vcs = repo::open(repo_root)?;
202 Ok(paths
203 .iter()
204 .filter_map(|path| {
205 let rel = Path::new(path);
206 let kind = crate::classify::declared(|name| vcs.attr(rel, name))?;
207 Some((path.clone(), kind))
208 })
209 .collect())
210 }
211
212 pub fn compute_walkthrough_files(
223 repo_root: &Path,
224 rev: Option<&str>,
225 files: &[String],
226 ) -> WalkthroughFiles {
227 let vcs = repo::open(repo_root).ok();
228 let pin_broken = match (rev, vcs.as_ref()) {
229 (Some(rev), Some(vcs)) => vcs.resolve(rev).is_err(),
230 (Some(_), None) => true,
231 (None, _) => false,
232 };
233 let rev = (!pin_broken).then_some(rev).flatten();
234 let contents = files
235 .iter()
236 .filter_map(|path| {
237 let pinned = rev.and_then(|rev| vcs.as_ref()?.read_at(rev, path).ok().flatten());
238 let content =
239 pinned.or_else(|| std::fs::read_to_string(repo_root.join(path)).ok())?;
240 Some((path.clone(), content))
241 })
242 .collect();
243 WalkthroughFiles {
244 contents,
245 pin_broken,
246 }
247 }
248
249 pub fn compute_file(repo_root: &Path, rel: &str) -> Result<FileSnapshot, ReviewError> {
254 let vcs = repo::open(repo_root)?;
255 let path = Path::new(rel);
256 let content = std::fs::read_to_string(repo_root.join(path)).map_err(VcsError::from)?;
257 Ok(FileSnapshot {
258 path: rel.to_owned(),
259 blame: vcs.blame(path).unwrap_or_default(),
260 content,
261 })
262 }
263
264 pub fn install_refresh(&mut self, status: StatusModel, model: DiffModel) {
266 self.status = status;
267 self.session.reconcile(&model);
268 self.model = OnceCell::from(model);
269 }
270
271 pub fn save(&self) -> Result<(), ReviewError> {
272 store::save(&self.repo_root, &self.session)?;
273 Ok(())
274 }
275
276 pub fn ensure_source(&mut self, source: &ReviewSource) -> Result<(), ReviewError> {
279 if matches!(source, ReviewSource::WorkingTree) {
280 return Ok(());
281 }
282 let key = source.key();
283 if !self.sources.contains_key(&key) {
284 let session = store::load_source(&self.repo_root, source)?;
285 self.sources.insert(key, (source.clone(), session));
286 }
287 Ok(())
288 }
289
290 pub fn session_for(&self, source: &ReviewSource) -> &Session {
294 match source {
295 ReviewSource::WorkingTree => &self.session,
296 other => self
297 .sources
298 .get(&other.key())
299 .map_or(&self.empty, |(_, session)| session),
300 }
301 }
302
303 pub fn session_for_mut(&mut self, source: &ReviewSource) -> &mut Session {
304 match source {
305 ReviewSource::WorkingTree => &mut self.session,
306 other => {
307 &mut self
308 .sources
309 .entry(other.key())
310 .or_insert_with(|| (other.clone(), Session::default()))
311 .1
312 }
313 }
314 }
315
316 pub fn save_for(&self, source: &ReviewSource) -> Result<(), ReviewError> {
317 store::save_source(&self.repo_root, source, self.session_for(source))?;
318 Ok(())
319 }
320
321 pub fn forget_source(&mut self, source: &ReviewSource) {
325 self.sources.remove(&source.key());
326 }
327
328 pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
333 Ok(self.all_reviews_and_corrupt()?.0)
334 }
335
336 pub fn all_reviews_and_corrupt(&self) -> Result<store::LoadedReviews, ReviewError> {
339 let (loaded, corrupt) = store::load_all(&self.repo_root)?;
340 let mut by_key: BTreeMap<String, (ReviewSource, Session)> = loaded
341 .into_iter()
342 .map(|(source, session)| (source.key(), (source, session)))
343 .collect();
344 by_key.insert(
345 ReviewSource::WorkingTree.key(),
346 (ReviewSource::WorkingTree, self.session.clone()),
347 );
348 for (key, (source, session)) in &self.sources {
349 by_key.insert(key.clone(), (source.clone(), session.clone()));
350 }
351 Ok((by_key.into_values().collect(), corrupt))
352 }
353
354 pub fn restore_model(&mut self, model: DiffModel) {
358 self.model = OnceCell::from(model);
359 }
360
361 #[cfg(test)]
363 fn model_is_cached(&self) -> bool {
364 self.model.get().is_some()
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use crate::repo;
371
372 use super::*;
373
374 #[allow(clippy::expect_used)]
375 fn write(root: &std::path::Path, rel: &str, content: &str) {
376 std::fs::write(root.join(rel), content).expect("write");
377 }
378
379 #[allow(clippy::expect_used)]
380 fn commit_all(root: &std::path::Path, message: &str) {
381 for args in [&["add", "-A"][..], &["commit", "-q", "-m", message][..]] {
382 let status = std::process::Command::new("git")
383 .arg("-C")
384 .arg(root)
385 .args(args)
386 .env("GIT_AUTHOR_NAME", "t")
387 .env("GIT_AUTHOR_EMAIL", "t@t")
388 .env("GIT_COMMITTER_NAME", "t")
389 .env("GIT_COMMITTER_EMAIL", "t@t")
390 .status()
391 .expect("git");
392 assert!(status.success(), "git {args:?}");
393 }
394 }
395
396 #[allow(clippy::expect_used)]
397 fn init_repo(root: &std::path::Path) {
398 let status = std::process::Command::new("git")
399 .arg("-C")
400 .arg(root)
401 .args(["init", "-q"])
402 .status()
403 .expect("git init");
404 assert!(status.success());
405 }
406
407 #[test]
408 fn open_defers_the_working_model_until_first_access() {
409 let dir = tempfile::tempdir().expect("tempdir");
410 let root = dir.path();
411 init_repo(root);
412 write(root, "a.py", "value = old\n");
413 commit_all(root, "base");
414 write(root, "a.py", "value = new\n");
415
416 let root = repo::discover(root).expect("discover");
417 let review = Review::open(&root).expect("open");
418 assert!(
420 !review.model_is_cached(),
421 "open must not compute the working model"
422 );
423 assert_eq!(review.status.unstaged.files.len(), 1);
424
425 let lazy = review.model().clone();
427 assert!(review.model_is_cached(), "access caches the model");
428 let eager = review.vcs.working_tree_diff().expect("diff");
429 assert_eq!(lazy, eager, "lazy model equals the eager build");
430 }
431
432 #[allow(clippy::expect_used)]
433 fn git(root: &std::path::Path, args: &[&str]) {
434 let status = std::process::Command::new("git")
435 .arg("-C")
436 .arg(root)
437 .args(args)
438 .status()
439 .expect("git");
440 assert!(status.success(), "git {args:?}");
441 }
442
443 #[test]
444 fn against_a_base_branch_shows_committed_and_uncommitted_work() {
445 let dir = tempfile::tempdir().expect("tempdir");
446 let root = dir.path();
447 init_repo(root);
448 git(root, &["symbolic-ref", "HEAD", "refs/heads/main"]);
449 write(root, "base.txt", "base\n");
450 commit_all(root, "base");
451 git(root, &["checkout", "-q", "-b", "feature"]);
452 write(root, "committed.txt", "landed\n");
453 commit_all(root, "feature work");
454 git(root, &["checkout", "-q", "main"]);
456 write(root, "elsewhere.txt", "not mine\n");
457 commit_all(root, "base moved on");
458 git(root, &["checkout", "-q", "feature"]);
459 write(root, "dirty.txt", "still editing\n");
460
461 let root = repo::discover(root).expect("discover");
462 let review = Review::open(&root).expect("open");
463 let model = crate::vcs::against_diff(review.vcs.as_ref(), "main").expect("against");
464 let paths: Vec<&str> = model.files.iter().map(|f| f.path.as_str()).collect();
465 assert_eq!(paths, ["committed.txt", "dirty.txt"]);
466 }
467
468 #[test]
469 fn per_source_sessions_persist_independently_and_aggregate() {
470 let dir = tempfile::tempdir().expect("tempdir");
471 let root = dir.path();
472 init_repo(root);
473 write(root, "a.py", "value = old\n");
474 commit_all(root, "base");
475 write(root, "a.py", "value = new\n");
476
477 let root = repo::discover(root).expect("discover");
478 let mut review = Review::open(&root).expect("open");
479
480 let commit = crate::source::ReviewSource::commit("deadbeef");
481 review.ensure_source(&commit).expect("ensure");
482 review
483 .session_for_mut(&commit)
484 .mark_viewed("a.py", "hash-commit");
485 review.save_for(&commit).expect("save commit");
486 review.session.mark_viewed("a.py", "hash-working");
487 review.save().expect("save working");
488
489 assert!(review.session_for(&commit).is_viewed("a.py", "hash-commit"));
491 assert!(!review.session.is_viewed("a.py", "hash-commit"));
492
493 let mut reopened = Review::open(&root).expect("reopen");
495 reopened.ensure_source(&commit).expect("ensure");
496 assert!(
497 reopened
498 .session_for(&commit)
499 .is_viewed("a.py", "hash-commit")
500 );
501 assert!(reopened.session.is_viewed("a.py", "hash-working"));
502
503 let all = reopened.all_reviews().expect("all");
504 let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
505 assert_eq!(keys, ["commit-deadbeef", "working"]);
506 }
507
508 #[test]
512 fn compute_walkthrough_files_reads_the_pinned_revision_and_falls_back_for_the_rest() {
513 let dir = tempfile::tempdir().expect("tempdir");
514 let root = dir.path();
515 init_repo(root);
516 write(root, "a.txt", "old\n");
517 commit_all(root, "base");
518 let root = repo::discover(root).expect("discover");
519 let pinned = Review::open(&root)
520 .expect("open")
521 .vcs
522 .resolve("HEAD")
523 .expect("resolve");
524 write(&root, "a.txt", "new\n");
525 write(&root, "b.txt", "worktree only\n");
526
527 let files = ["a.txt".to_owned(), "b.txt".to_owned()];
528 let read = Review::compute_walkthrough_files(&root, Some(&pinned), &files);
529 assert_eq!(
530 read.contents.get("a.txt").map(String::as_str),
531 Some("old\n"),
532 "reads the pinned revision, not the dirty worktree"
533 );
534 assert_eq!(
535 read.contents.get("b.txt").map(String::as_str),
536 Some("worktree only\n"),
537 "a path the revision never had falls back to the worktree"
538 );
539 assert!(!read.pin_broken, "the pin itself still resolves");
540 }
541
542 #[test]
545 fn compute_walkthrough_files_with_no_revision_reads_the_worktree() {
546 let dir = tempfile::tempdir().expect("tempdir");
547 let root = dir.path();
548 init_repo(root);
549 write(root, "a.txt", "committed\n");
550 commit_all(root, "base");
551 write(root, "a.txt", "edited\n");
552 let root = repo::discover(root).expect("discover");
553
554 let files = ["a.txt".to_owned()];
555 let read = Review::compute_walkthrough_files(&root, None, &files);
556 assert_eq!(
557 read.contents.get("a.txt").map(String::as_str),
558 Some("edited\n")
559 );
560 assert!(
561 !read.pin_broken,
562 "no revision was ever pinned, so nothing is broken"
563 );
564 }
565
566 #[test]
571 fn compute_walkthrough_files_with_an_unresolvable_revision_reports_the_broken_pin() {
572 let dir = tempfile::tempdir().expect("tempdir");
573 let root = dir.path();
574 init_repo(root);
575 write(root, "a.txt", "edited\n");
576 commit_all(root, "base");
577 let root = repo::discover(root).expect("discover");
578
579 let files = ["a.txt".to_owned()];
580 let read = Review::compute_walkthrough_files(
581 &root,
582 Some("0000000000000000000000000000000000dead"),
583 &files,
584 );
585 assert_eq!(
586 read.contents.get("a.txt").map(String::as_str),
587 Some("edited\n"),
588 "still falls back to the worktree"
589 );
590 assert!(read.pin_broken, "the named revision does not resolve");
591 }
592}