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
47pub const MAX_PREVIEW_BYTES: u64 = 32 * 1024 * 1024;
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum BinarySide {
53 Bytes(Vec<u8>),
54 TooLarge(u64),
56}
57
58impl BinarySide {
59 fn of(bytes: Vec<u8>) -> Self {
60 let size = bytes.len() as u64;
61 if size > MAX_PREVIEW_BYTES {
62 Self::TooLarge(size)
63 } else {
64 Self::Bytes(bytes)
65 }
66 }
67}
68
69#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct BinarySides {
72 pub old: Option<BinarySide>,
73 pub new: Option<BinarySide>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ReadFirst {
80 Pin,
82 Worktree,
84}
85
86#[derive(Debug)]
88pub struct Refreshed {
89 pub status: StatusModel,
90 pub model: DiffModel,
91 pub against: Option<(String, Result<DiffModel, VcsError>)>,
95 pub pinned: Option<Result<DiffModel, VcsError>>,
99}
100
101pub fn pinned_diff(
110 vcs: &dyn Vcs,
111 source: &ReviewSource,
112 pr_head: Option<(&str, &str)>,
113) -> Result<DiffModel, VcsError> {
114 match source {
115 ReviewSource::Commit { oid } => vcs.commit_diff(oid),
116 ReviewSource::Range { oldest, newest } => vcs.range_diff(oldest, newest),
117 ReviewSource::Pr { number } => {
118 let (base, head) = pr_head
119 .ok_or_else(|| VcsError::Rejected(format!("PR #{number} is not resolved")))?;
120 vcs.tree_diff(base, head)
121 }
122 ReviewSource::WorkingTree
123 | ReviewSource::Walkthrough { .. }
124 | ReviewSource::Against { .. } => Ok(DiffModel::default()),
125 }
126}
127
128pub struct Review {
129 pub repo_root: PathBuf,
130 pub vcs: Box<dyn Vcs>,
131 pub status: StatusModel,
132 model: OnceCell<DiffModel>,
136 pub session: Session,
138 sources: HashMap<String, (ReviewSource, Session)>,
141 empty: Session,
143}
144
145impl Review {
146 pub fn open(repo_root: &Path) -> Result<Self, ReviewError> {
150 Self::open_with_settings(repo_root, &DiffSettings::default())
151 }
152
153 pub fn open_with_settings(
157 repo_root: &Path,
158 settings: &DiffSettings,
159 ) -> Result<Self, ReviewError> {
160 let vcs = repo::open_with_settings(repo_root, settings)?;
161 let status = vcs.status()?;
162 let session = store::load(repo_root)?;
163 Ok(Self {
164 repo_root: repo_root.to_path_buf(),
165 vcs,
166 status,
167 model: OnceCell::new(),
168 session,
169 sources: HashMap::new(),
170 empty: Session::default(),
171 })
172 }
173
174 pub fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool) {
177 self.vcs.set_diff_algorithm(algorithm, indent_heuristic);
178 }
179
180 pub fn model(&self) -> &DiffModel {
184 self.model
185 .get_or_init(|| self.vcs.working_tree_diff().unwrap_or_default())
186 }
187
188 pub fn model_mut(&mut self) -> &mut DiffModel {
192 self.model();
193 #[allow(clippy::expect_used)]
194 self.model.get_mut().expect("model just initialized")
195 }
196
197 pub fn refresh(&mut self) -> Result<(), ReviewError> {
200 self.status = self.vcs.status()?;
201 let model = self.vcs.working_tree_diff()?;
202 self.install_refresh(self.status.clone(), model);
203 Ok(())
204 }
205
206 pub fn compute_refresh(
213 repo_root: &Path,
214 settings: &DiffSettings,
215 against: Option<&str>,
216 pinned: Option<(&ReviewSource, Option<(&str, &str)>)>,
217 ) -> Result<Refreshed, ReviewError> {
218 let vcs = repo::open_with_settings(repo_root, settings)?;
219 let status = vcs.status()?;
220 let model = vcs.working_tree_diff()?;
221 let against =
222 against.map(|rev| (rev.to_owned(), crate::vcs::against_diff(vcs.as_ref(), rev)));
223 let pinned = pinned.map(|(source, pr_head)| pinned_diff(vcs.as_ref(), source, pr_head));
224 Ok(Refreshed {
225 status,
226 model,
227 against,
228 pinned,
229 })
230 }
231
232 pub fn compute_declared(
237 repo_root: &Path,
238 paths: &[String],
239 ) -> Result<HashMap<String, crate::classify::Kind>, ReviewError> {
240 let vcs = repo::open(repo_root)?;
241 Ok(paths
242 .iter()
243 .filter_map(|path| {
244 let rel = Path::new(path);
245 let kind = crate::classify::declared(|name| vcs.attr(rel, name))?;
246 Some((path.clone(), kind))
247 })
248 .collect())
249 }
250
251 pub fn compute_walkthrough_files(
262 repo_root: &Path,
263 rev: Option<&str>,
264 read_first: ReadFirst,
265 files: &[String],
266 ) -> WalkthroughFiles {
267 let vcs = repo::open(repo_root).ok();
268 let pin_broken = match (rev, vcs.as_ref()) {
269 (Some(rev), Some(vcs)) => vcs.resolve(rev).is_err(),
270 (Some(_), None) => true,
271 (None, _) => false,
272 };
273 let rev = (!pin_broken).then_some(rev).flatten();
274 let contents = files
275 .iter()
276 .filter_map(|path| {
277 let pinned = || rev.and_then(|rev| vcs.as_ref()?.read_at(rev, path).ok().flatten());
278 let worktree = || std::fs::read_to_string(repo_root.join(path)).ok();
279 let content = match read_first {
280 ReadFirst::Pin => pinned().or_else(worktree),
281 ReadFirst::Worktree => worktree().or_else(pinned),
282 }?;
283 Some((path.clone(), content))
284 })
285 .collect();
286 WalkthroughFiles {
287 contents,
288 pin_broken,
289 }
290 }
291
292 pub fn compute_binary_sides(
297 repo_root: &Path,
298 path: &str,
299 blobs: &crate::model::BlobIds,
300 deleted: bool,
301 ) -> BinarySides {
302 let vcs = repo::open(repo_root).ok();
303 let from_blob = |oid: &Option<String>| {
304 let bytes = vcs.as_ref()?.read_blob(oid.as_deref()?).ok()??;
305 Some(BinarySide::of(bytes))
306 };
307 let new = from_blob(&blobs.new).or_else(|| {
308 if deleted {
309 return None;
310 }
311 let full = repo_root.join(path);
312 let size = std::fs::metadata(&full).ok()?.len();
313 if size > MAX_PREVIEW_BYTES {
314 return Some(BinarySide::TooLarge(size));
315 }
316 std::fs::read(full).ok().map(BinarySide::of)
317 });
318 BinarySides {
319 old: from_blob(&blobs.old),
320 new,
321 }
322 }
323
324 pub fn compute_file(repo_root: &Path, rel: &str) -> Result<FileSnapshot, ReviewError> {
329 let vcs = repo::open(repo_root)?;
330 let path = Path::new(rel);
331 let content = std::fs::read_to_string(repo_root.join(path)).map_err(VcsError::from)?;
332 Ok(FileSnapshot {
333 path: rel.to_owned(),
334 blame: vcs.blame(path).unwrap_or_default(),
335 content,
336 })
337 }
338
339 pub fn install_refresh(&mut self, status: StatusModel, model: DiffModel) {
341 self.status = status;
342 self.session.reconcile(&model);
343 self.model = OnceCell::from(model);
344 }
345
346 pub fn save(&self) -> Result<(), ReviewError> {
347 store::save(&self.repo_root, &self.session)?;
348 Ok(())
349 }
350
351 pub fn ensure_source(&mut self, source: &ReviewSource) -> Result<(), ReviewError> {
354 if matches!(source, ReviewSource::WorkingTree) {
355 return Ok(());
356 }
357 let key = source.key();
358 if !self.sources.contains_key(&key) {
359 let session = store::load_source(&self.repo_root, source)?;
360 self.sources.insert(key, (source.clone(), session));
361 }
362 Ok(())
363 }
364
365 pub fn session_for(&self, source: &ReviewSource) -> &Session {
369 match source {
370 ReviewSource::WorkingTree => &self.session,
371 other => self
372 .sources
373 .get(&other.key())
374 .map_or(&self.empty, |(_, session)| session),
375 }
376 }
377
378 pub fn session_for_mut(&mut self, source: &ReviewSource) -> &mut Session {
379 match source {
380 ReviewSource::WorkingTree => &mut self.session,
381 other => {
382 &mut self
383 .sources
384 .entry(other.key())
385 .or_insert_with(|| (other.clone(), Session::default()))
386 .1
387 }
388 }
389 }
390
391 pub fn save_for(&self, source: &ReviewSource) -> Result<(), ReviewError> {
392 store::save_source(&self.repo_root, source, self.session_for(source))?;
393 Ok(())
394 }
395
396 pub fn forget_source(&mut self, source: &ReviewSource) {
400 self.sources.remove(&source.key());
401 }
402
403 pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
408 Ok(self.all_reviews_and_corrupt()?.0)
409 }
410
411 pub fn all_reviews_and_corrupt(&self) -> Result<store::LoadedReviews, ReviewError> {
414 let (loaded, corrupt) = store::load_all(&self.repo_root)?;
415 let mut by_key: BTreeMap<String, (ReviewSource, Session)> = loaded
416 .into_iter()
417 .map(|(source, session)| (source.key(), (source, session)))
418 .collect();
419 by_key.insert(
420 ReviewSource::WorkingTree.key(),
421 (ReviewSource::WorkingTree, self.session.clone()),
422 );
423 for (key, (source, session)) in &self.sources {
424 by_key.insert(key.clone(), (source.clone(), session.clone()));
425 }
426 Ok((by_key.into_values().collect(), corrupt))
427 }
428
429 pub fn restore_model(&mut self, model: DiffModel) {
433 self.model = OnceCell::from(model);
434 }
435
436 #[cfg(test)]
438 fn model_is_cached(&self) -> bool {
439 self.model.get().is_some()
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use crate::repo;
446
447 use super::*;
448
449 #[allow(clippy::expect_used)]
450 fn write(root: &std::path::Path, rel: &str, content: &str) {
451 std::fs::write(root.join(rel), content).expect("write");
452 }
453
454 #[allow(clippy::expect_used)]
455 fn commit_all(root: &std::path::Path, message: &str) {
456 for args in [&["add", "-A"][..], &["commit", "-q", "-m", message][..]] {
457 let status = std::process::Command::new("git")
458 .arg("-C")
459 .arg(root)
460 .args(args)
461 .env("GIT_AUTHOR_NAME", "t")
462 .env("GIT_AUTHOR_EMAIL", "t@t")
463 .env("GIT_COMMITTER_NAME", "t")
464 .env("GIT_COMMITTER_EMAIL", "t@t")
465 .status()
466 .expect("git");
467 assert!(status.success(), "git {args:?}");
468 }
469 }
470
471 #[allow(clippy::expect_used)]
472 fn init_repo(root: &std::path::Path) {
473 let status = std::process::Command::new("git")
474 .arg("-C")
475 .arg(root)
476 .args(["init", "-q"])
477 .status()
478 .expect("git init");
479 assert!(status.success());
480 }
481
482 #[test]
483 fn open_defers_the_working_model_until_first_access() {
484 let dir = tempfile::tempdir().expect("tempdir");
485 let root = dir.path();
486 init_repo(root);
487 write(root, "a.py", "value = old\n");
488 commit_all(root, "base");
489 write(root, "a.py", "value = new\n");
490
491 let root = repo::discover(root).expect("discover");
492 let review = Review::open(&root).expect("open");
493 assert!(
495 !review.model_is_cached(),
496 "open must not compute the working model"
497 );
498 assert_eq!(review.status.unstaged.files.len(), 1);
499
500 let lazy = review.model().clone();
502 assert!(review.model_is_cached(), "access caches the model");
503 let eager = review.vcs.working_tree_diff().expect("diff");
504 assert_eq!(lazy, eager, "lazy model equals the eager build");
505 }
506
507 #[allow(clippy::expect_used)]
508 fn git(root: &std::path::Path, args: &[&str]) {
509 let status = std::process::Command::new("git")
510 .arg("-C")
511 .arg(root)
512 .args(args)
513 .status()
514 .expect("git");
515 assert!(status.success(), "git {args:?}");
516 }
517
518 #[test]
519 fn against_a_base_branch_shows_committed_and_uncommitted_work() {
520 let dir = tempfile::tempdir().expect("tempdir");
521 let root = dir.path();
522 init_repo(root);
523 git(root, &["symbolic-ref", "HEAD", "refs/heads/main"]);
524 write(root, "base.txt", "base\n");
525 commit_all(root, "base");
526 git(root, &["checkout", "-q", "-b", "feature"]);
527 write(root, "committed.txt", "landed\n");
528 commit_all(root, "feature work");
529 git(root, &["checkout", "-q", "main"]);
531 write(root, "elsewhere.txt", "not mine\n");
532 commit_all(root, "base moved on");
533 git(root, &["checkout", "-q", "feature"]);
534 write(root, "dirty.txt", "still editing\n");
535
536 let root = repo::discover(root).expect("discover");
537 let review = Review::open(&root).expect("open");
538 let model = crate::vcs::against_diff(review.vcs.as_ref(), "main").expect("against");
539 let paths: Vec<&str> = model.files.iter().map(|f| f.path.as_str()).collect();
540 assert_eq!(paths, ["committed.txt", "dirty.txt"]);
541 }
542
543 #[test]
544 fn per_source_sessions_persist_independently_and_aggregate() {
545 let dir = tempfile::tempdir().expect("tempdir");
546 let root = dir.path();
547 init_repo(root);
548 write(root, "a.py", "value = old\n");
549 commit_all(root, "base");
550 write(root, "a.py", "value = new\n");
551
552 let root = repo::discover(root).expect("discover");
553 let mut review = Review::open(&root).expect("open");
554
555 let commit = crate::source::ReviewSource::commit("deadbeef");
556 review.ensure_source(&commit).expect("ensure");
557 review
558 .session_for_mut(&commit)
559 .mark_viewed("a.py", "hash-commit");
560 review.save_for(&commit).expect("save commit");
561 review.session.mark_viewed("a.py", "hash-working");
562 review.save().expect("save working");
563
564 assert!(review.session_for(&commit).is_viewed("a.py", "hash-commit"));
566 assert!(!review.session.is_viewed("a.py", "hash-commit"));
567
568 let mut reopened = Review::open(&root).expect("reopen");
570 reopened.ensure_source(&commit).expect("ensure");
571 assert!(
572 reopened
573 .session_for(&commit)
574 .is_viewed("a.py", "hash-commit")
575 );
576 assert!(reopened.session.is_viewed("a.py", "hash-working"));
577
578 let all = reopened.all_reviews().expect("all");
579 let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
580 assert_eq!(keys, ["commit-deadbeef", "working"]);
581 }
582
583 #[test]
587 fn compute_walkthrough_files_reads_the_pinned_revision_and_falls_back_for_the_rest() {
588 let dir = tempfile::tempdir().expect("tempdir");
589 let root = dir.path();
590 init_repo(root);
591 write(root, "a.txt", "old\n");
592 commit_all(root, "base");
593 let root = repo::discover(root).expect("discover");
594 let pinned = Review::open(&root)
595 .expect("open")
596 .vcs
597 .resolve("HEAD")
598 .expect("resolve");
599 write(&root, "a.txt", "new\n");
600 write(&root, "b.txt", "worktree only\n");
601
602 let files = ["a.txt".to_owned(), "b.txt".to_owned()];
603 let read = Review::compute_walkthrough_files(&root, Some(&pinned), ReadFirst::Pin, &files);
604 assert_eq!(
605 read.contents.get("a.txt").map(String::as_str),
606 Some("old\n"),
607 "reads the pinned revision, not the dirty worktree"
608 );
609 assert_eq!(
610 read.contents.get("b.txt").map(String::as_str),
611 Some("worktree only\n"),
612 "a path the revision never had falls back to the worktree"
613 );
614 assert!(!read.pin_broken, "the pin itself still resolves");
615 }
616
617 #[test]
620 fn compute_walkthrough_files_with_no_revision_reads_the_worktree() {
621 let dir = tempfile::tempdir().expect("tempdir");
622 let root = dir.path();
623 init_repo(root);
624 write(root, "a.txt", "committed\n");
625 commit_all(root, "base");
626 write(root, "a.txt", "edited\n");
627 let root = repo::discover(root).expect("discover");
628
629 let files = ["a.txt".to_owned()];
630 let read = Review::compute_walkthrough_files(&root, None, ReadFirst::Pin, &files);
631 assert_eq!(
632 read.contents.get("a.txt").map(String::as_str),
633 Some("edited\n")
634 );
635 assert!(
636 !read.pin_broken,
637 "no revision was ever pinned, so nothing is broken"
638 );
639 }
640
641 #[test]
646 fn compute_walkthrough_files_with_an_unresolvable_revision_reports_the_broken_pin() {
647 let dir = tempfile::tempdir().expect("tempdir");
648 let root = dir.path();
649 init_repo(root);
650 write(root, "a.txt", "edited\n");
651 commit_all(root, "base");
652 let root = repo::discover(root).expect("discover");
653
654 let files = ["a.txt".to_owned()];
655 let read = Review::compute_walkthrough_files(
656 &root,
657 Some("0000000000000000000000000000000000dead"),
658 ReadFirst::Pin,
659 &files,
660 );
661 assert_eq!(
662 read.contents.get("a.txt").map(String::as_str),
663 Some("edited\n"),
664 "still falls back to the worktree"
665 );
666 assert!(read.pin_broken, "the named revision does not resolve");
667 }
668}