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)]
36pub struct WalkthroughFiles {
37 pub contents: HashMap<String, String>,
38 pub pin_broken: bool,
41}
42
43pub const MAX_PREVIEW_BYTES: u64 = 32 * 1024 * 1024;
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum BinarySide {
47 Bytes(Vec<u8>),
48 TooLarge(u64),
50}
51
52impl BinarySide {
53 fn of(bytes: Vec<u8>) -> Self {
54 let size = bytes.len() as u64;
55 if size > MAX_PREVIEW_BYTES {
56 Self::TooLarge(size)
57 } else {
58 Self::Bytes(bytes)
59 }
60 }
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct BinarySides {
65 pub old: Option<BinarySide>,
66 pub new: Option<BinarySide>,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum ReadFirst {
73 Pin,
74 Worktree,
75}
76
77#[derive(Debug)]
78pub struct Refreshed {
79 pub status: StatusModel,
80 pub model: DiffModel,
81 pub against: Option<(String, Result<DiffModel, VcsError>)>,
84 pub pinned: Option<Result<DiffModel, VcsError>>,
85}
86
87pub fn pinned_diff(
91 vcs: &dyn Vcs,
92 source: &ReviewSource,
93 pr_head: Option<(&str, &str)>,
94) -> Result<DiffModel, VcsError> {
95 match source {
96 ReviewSource::Commit { oid } => vcs.commit_diff(oid),
97 ReviewSource::Range { oldest, newest } => vcs.range_diff(oldest, newest),
98 ReviewSource::Pr { number } => {
99 let (base, head) = pr_head
100 .ok_or_else(|| VcsError::Rejected(format!("PR #{number} is not resolved")))?;
101 vcs.tree_diff(base, head)
102 }
103 ReviewSource::WorkingTree
104 | ReviewSource::Walkthrough { .. }
105 | ReviewSource::Against { .. } => Ok(DiffModel::default()),
106 }
107}
108
109pub struct Review {
110 pub repo_root: PathBuf,
111 pub vcs: Box<dyn Vcs>,
112 pub status: StatusModel,
113 model: OnceCell<DiffModel>,
116 pub session: Session,
118 sources: HashMap<String, (ReviewSource, Session)>,
120 empty: Session,
121}
122
123impl Review {
124 pub fn open(repo_root: &Path) -> Result<Self, ReviewError> {
125 Self::open_with_settings(repo_root, &DiffSettings::default())
126 }
127
128 pub fn open_with_settings(
129 repo_root: &Path,
130 settings: &DiffSettings,
131 ) -> Result<Self, ReviewError> {
132 let vcs = repo::open_with_settings(repo_root, settings)?;
133 let status = vcs.status()?;
134 let session = store::load(repo_root)?;
135 Ok(Self {
136 repo_root: repo_root.to_path_buf(),
137 vcs,
138 status,
139 model: OnceCell::new(),
140 session,
141 sources: HashMap::new(),
142 empty: Session::default(),
143 })
144 }
145
146 pub fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool) {
147 self.vcs.set_diff_algorithm(algorithm, indent_heuristic);
148 }
149
150 pub fn model(&self) -> &DiffModel {
153 self.model
154 .get_or_init(|| self.vcs.working_tree_diff().unwrap_or_default())
155 }
156
157 pub fn model_mut(&mut self) -> &mut DiffModel {
158 self.model();
159 #[allow(clippy::expect_used)]
160 self.model.get_mut().expect("model just initialized")
161 }
162
163 pub fn refresh(&mut self) -> Result<(), ReviewError> {
166 self.status = self.vcs.status()?;
167 let model = self.vcs.working_tree_diff()?;
168 self.install_refresh(self.status.clone(), model);
169 Ok(())
170 }
171
172 pub fn compute_refresh(
176 repo_root: &Path,
177 settings: &DiffSettings,
178 against: Option<&str>,
179 pinned: Option<(&ReviewSource, Option<(&str, &str)>)>,
180 ) -> Result<Refreshed, ReviewError> {
181 let vcs = repo::open_with_settings(repo_root, settings)?;
182 let status = vcs.status()?;
183 let model = vcs.working_tree_diff()?;
184 let against =
185 against.map(|rev| (rev.to_owned(), crate::vcs::against_diff(vcs.as_ref(), rev)));
186 let pinned = pinned.map(|(source, pr_head)| pinned_diff(vcs.as_ref(), source, pr_head));
187 Ok(Refreshed {
188 status,
189 model,
190 against,
191 pinned,
192 })
193 }
194
195 pub fn compute_declared(
199 repo_root: &Path,
200 paths: &[String],
201 ) -> Result<HashMap<String, crate::classify::Kind>, ReviewError> {
202 let vcs = repo::open(repo_root)?;
203 Ok(paths
204 .iter()
205 .filter_map(|path| {
206 let rel = Path::new(path);
207 let kind = crate::classify::declared(|name| vcs.attr(rel, name))?;
208 Some((path.clone(), kind))
209 })
210 .collect())
211 }
212
213 pub fn compute_walkthrough_files(
216 repo_root: &Path,
217 rev: Option<&str>,
218 read_first: ReadFirst,
219 files: &[String],
220 ) -> WalkthroughFiles {
221 let vcs = repo::open(repo_root).ok();
222 let pin_broken = match (rev, vcs.as_ref()) {
223 (Some(rev), Some(vcs)) => vcs.resolve(rev).is_err(),
224 (Some(_), None) => true,
225 (None, _) => false,
226 };
227 let rev = (!pin_broken).then_some(rev).flatten();
228 let contents = files
229 .iter()
230 .filter_map(|path| {
231 let pinned = || rev.and_then(|rev| vcs.as_ref()?.read_at(rev, path).ok().flatten());
232 let worktree = || std::fs::read_to_string(repo_root.join(path)).ok();
233 let content = match read_first {
234 ReadFirst::Pin => pinned().or_else(worktree),
235 ReadFirst::Worktree => worktree().or_else(pinned),
236 }?;
237 Some((path.clone(), content))
238 })
239 .collect();
240 WalkthroughFiles {
241 contents,
242 pin_broken,
243 }
244 }
245
246 pub fn compute_binary_sides(
250 repo_root: &Path,
251 path: &str,
252 blobs: &crate::model::BlobIds,
253 deleted: bool,
254 ) -> BinarySides {
255 let vcs = repo::open(repo_root).ok();
256 let from_blob = |oid: &Option<String>| {
257 let bytes = vcs.as_ref()?.read_blob(oid.as_deref()?).ok()??;
258 Some(BinarySide::of(bytes))
259 };
260 let new = from_blob(&blobs.new).or_else(|| {
261 if deleted {
262 return None;
263 }
264 let full = repo_root.join(path);
265 let size = std::fs::metadata(&full).ok()?.len();
266 if size > MAX_PREVIEW_BYTES {
267 return Some(BinarySide::TooLarge(size));
268 }
269 std::fs::read(full).ok().map(BinarySide::of)
270 });
271 BinarySides {
272 old: from_blob(&blobs.old),
273 new,
274 }
275 }
276
277 pub fn compute_file(repo_root: &Path, rel: &str) -> Result<FileSnapshot, ReviewError> {
280 let vcs = repo::open(repo_root)?;
281 let path = Path::new(rel);
282 let content = std::fs::read_to_string(repo_root.join(path)).map_err(VcsError::from)?;
283 Ok(FileSnapshot {
284 path: rel.to_owned(),
285 blame: vcs.blame(path).unwrap_or_default(),
286 content,
287 })
288 }
289
290 pub fn install_refresh(&mut self, status: StatusModel, model: DiffModel) {
291 self.status = status;
292 self.session.reconcile(&model);
293 self.model = OnceCell::from(model);
294 }
295
296 pub fn save(&self) -> Result<(), ReviewError> {
297 store::save(&self.repo_root, &self.session)?;
298 Ok(())
299 }
300
301 pub fn ensure_source(&mut self, source: &ReviewSource) -> Result<(), ReviewError> {
304 if matches!(source, ReviewSource::WorkingTree) {
305 return Ok(());
306 }
307 let key = source.key();
308 if !self.sources.contains_key(&key) {
309 let session = store::load_source(&self.repo_root, source)?;
310 self.sources.insert(key, (source.clone(), session));
311 }
312 Ok(())
313 }
314
315 pub fn session_for(&self, source: &ReviewSource) -> &Session {
317 match source {
318 ReviewSource::WorkingTree => &self.session,
319 other => self
320 .sources
321 .get(&other.key())
322 .map_or(&self.empty, |(_, session)| session),
323 }
324 }
325
326 pub fn session_for_mut(&mut self, source: &ReviewSource) -> &mut Session {
327 match source {
328 ReviewSource::WorkingTree => &mut self.session,
329 other => {
330 &mut self
331 .sources
332 .entry(other.key())
333 .or_insert_with(|| (other.clone(), Session::default()))
334 .1
335 }
336 }
337 }
338
339 pub fn save_for(&self, source: &ReviewSource) -> Result<(), ReviewError> {
340 store::save_source(&self.repo_root, source, self.session_for(source))?;
341 Ok(())
342 }
343
344 pub fn forget_source(&mut self, source: &ReviewSource) {
347 self.sources.remove(&source.key());
348 }
349
350 pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
353 Ok(self.all_reviews_and_corrupt()?.0)
354 }
355
356 pub fn all_reviews_and_corrupt(&self) -> Result<store::LoadedReviews, ReviewError> {
358 let (loaded, corrupt) = store::load_all(&self.repo_root)?;
359 let mut by_key: BTreeMap<String, (ReviewSource, Session)> = loaded
360 .into_iter()
361 .map(|(source, session)| (source.key(), (source, session)))
362 .collect();
363 by_key.insert(
364 ReviewSource::WorkingTree.key(),
365 (ReviewSource::WorkingTree, self.session.clone()),
366 );
367 for (key, (source, session)) in &self.sources {
368 by_key.insert(key.clone(), (source.clone(), session.clone()));
369 }
370 Ok((by_key.into_values().collect(), corrupt))
371 }
372
373 pub fn restore_model(&mut self, model: DiffModel) {
376 self.model = OnceCell::from(model);
377 }
378
379 #[cfg(test)]
380 fn model_is_cached(&self) -> bool {
381 self.model.get().is_some()
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use crate::repo;
388
389 use super::*;
390
391 #[allow(clippy::expect_used)]
392 fn write(root: &std::path::Path, rel: &str, content: &str) {
393 std::fs::write(root.join(rel), content).expect("write");
394 }
395
396 #[allow(clippy::expect_used)]
397 fn commit_all(root: &std::path::Path, message: &str) {
398 for args in [&["add", "-A"][..], &["commit", "-q", "-m", message][..]] {
399 let status = std::process::Command::new("git")
400 .arg("-C")
401 .arg(root)
402 .args(args)
403 .env("GIT_AUTHOR_NAME", "t")
404 .env("GIT_AUTHOR_EMAIL", "t@t")
405 .env("GIT_COMMITTER_NAME", "t")
406 .env("GIT_COMMITTER_EMAIL", "t@t")
407 .status()
408 .expect("git");
409 assert!(status.success(), "git {args:?}");
410 }
411 }
412
413 #[allow(clippy::expect_used)]
414 fn init_repo(root: &std::path::Path) {
415 let status = std::process::Command::new("git")
416 .arg("-C")
417 .arg(root)
418 .args(["init", "-q"])
419 .status()
420 .expect("git init");
421 assert!(status.success());
422 }
423
424 #[test]
425 fn open_defers_the_working_model_until_first_access() {
426 let dir = tempfile::tempdir().expect("tempdir");
427 let root = dir.path();
428 init_repo(root);
429 write(root, "a.py", "value = old\n");
430 commit_all(root, "base");
431 write(root, "a.py", "value = new\n");
432
433 let root = repo::discover(root).expect("discover");
434 let review = Review::open(&root).expect("open");
435 assert!(
436 !review.model_is_cached(),
437 "open must not compute the working model"
438 );
439 assert_eq!(review.status.unstaged.files.len(), 1);
440
441 let lazy = review.model().clone();
442 assert!(review.model_is_cached(), "access caches the model");
443 let eager = review.vcs.working_tree_diff().expect("diff");
444 assert_eq!(lazy, eager, "lazy model equals the eager build");
445 }
446
447 #[allow(clippy::expect_used)]
448 fn git(root: &std::path::Path, args: &[&str]) {
449 let status = std::process::Command::new("git")
450 .arg("-C")
451 .arg(root)
452 .args(args)
453 .status()
454 .expect("git");
455 assert!(status.success(), "git {args:?}");
456 }
457
458 #[test]
459 fn against_a_base_branch_shows_committed_and_uncommitted_work() {
460 let dir = tempfile::tempdir().expect("tempdir");
461 let root = dir.path();
462 init_repo(root);
463 git(root, &["symbolic-ref", "HEAD", "refs/heads/main"]);
464 write(root, "base.txt", "base\n");
465 commit_all(root, "base");
466 git(root, &["checkout", "-q", "-b", "feature"]);
467 write(root, "committed.txt", "landed\n");
468 commit_all(root, "feature work");
469 git(root, &["checkout", "-q", "main"]);
470 write(root, "elsewhere.txt", "not mine\n");
471 commit_all(root, "base moved on");
472 git(root, &["checkout", "-q", "feature"]);
473 write(root, "dirty.txt", "still editing\n");
474
475 let root = repo::discover(root).expect("discover");
476 let review = Review::open(&root).expect("open");
477 let model = crate::vcs::against_diff(review.vcs.as_ref(), "main").expect("against");
478 let paths: Vec<&str> = model.files.iter().map(|f| f.path.as_str()).collect();
479 assert_eq!(paths, ["committed.txt", "dirty.txt"]);
480 }
481
482 #[test]
483 fn per_source_sessions_persist_independently_and_aggregate() {
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 mut review = Review::open(&root).expect("open");
493
494 let commit = crate::source::ReviewSource::commit("deadbeef");
495 review.ensure_source(&commit).expect("ensure");
496 review
497 .session_for_mut(&commit)
498 .mark_viewed("a.py", "hash-commit");
499 review.save_for(&commit).expect("save commit");
500 review.session.mark_viewed("a.py", "hash-working");
501 review.save().expect("save working");
502
503 assert!(review.session_for(&commit).is_viewed("a.py", "hash-commit"));
504 assert!(!review.session.is_viewed("a.py", "hash-commit"));
505
506 let mut reopened = Review::open(&root).expect("reopen");
507 reopened.ensure_source(&commit).expect("ensure");
508 assert!(
509 reopened
510 .session_for(&commit)
511 .is_viewed("a.py", "hash-commit")
512 );
513 assert!(reopened.session.is_viewed("a.py", "hash-working"));
514
515 let all = reopened.all_reviews().expect("all");
516 let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
517 assert_eq!(keys, ["commit-deadbeef", "working"]);
518 }
519
520 #[test]
521 fn compute_walkthrough_files_reads_the_pinned_revision_and_falls_back_for_the_rest() {
522 let dir = tempfile::tempdir().expect("tempdir");
523 let root = dir.path();
524 init_repo(root);
525 write(root, "a.txt", "old\n");
526 commit_all(root, "base");
527 let root = repo::discover(root).expect("discover");
528 let pinned = Review::open(&root)
529 .expect("open")
530 .vcs
531 .resolve("HEAD")
532 .expect("resolve");
533 write(&root, "a.txt", "new\n");
534 write(&root, "b.txt", "worktree only\n");
535
536 let files = ["a.txt".to_owned(), "b.txt".to_owned()];
537 let read = Review::compute_walkthrough_files(&root, Some(&pinned), ReadFirst::Pin, &files);
538 assert_eq!(
539 read.contents.get("a.txt").map(String::as_str),
540 Some("old\n"),
541 "reads the pinned revision, not the dirty worktree"
542 );
543 assert_eq!(
544 read.contents.get("b.txt").map(String::as_str),
545 Some("worktree only\n"),
546 "a path the revision never had falls back to the worktree"
547 );
548 assert!(!read.pin_broken, "the pin itself still resolves");
549 }
550
551 #[test]
552 fn compute_walkthrough_files_with_no_revision_reads_the_worktree() {
553 let dir = tempfile::tempdir().expect("tempdir");
554 let root = dir.path();
555 init_repo(root);
556 write(root, "a.txt", "committed\n");
557 commit_all(root, "base");
558 write(root, "a.txt", "edited\n");
559 let root = repo::discover(root).expect("discover");
560
561 let files = ["a.txt".to_owned()];
562 let read = Review::compute_walkthrough_files(&root, None, ReadFirst::Pin, &files);
563 assert_eq!(
564 read.contents.get("a.txt").map(String::as_str),
565 Some("edited\n")
566 );
567 assert!(
568 !read.pin_broken,
569 "no revision was ever pinned, so nothing is broken"
570 );
571 }
572
573 #[test]
574 fn compute_walkthrough_files_with_an_unresolvable_revision_reports_the_broken_pin() {
575 let dir = tempfile::tempdir().expect("tempdir");
576 let root = dir.path();
577 init_repo(root);
578 write(root, "a.txt", "edited\n");
579 commit_all(root, "base");
580 let root = repo::discover(root).expect("discover");
581
582 let files = ["a.txt".to_owned()];
583 let read = Review::compute_walkthrough_files(
584 &root,
585 Some("0000000000000000000000000000000000dead"),
586 ReadFirst::Pin,
587 &files,
588 );
589 assert_eq!(
590 read.contents.get("a.txt").map(String::as_str),
591 Some("edited\n"),
592 "still falls back to the worktree"
593 );
594 assert!(read.pin_broken, "the named revision does not resolve");
595 }
596}