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, Default)]
37pub struct WalkthroughFiles {
38 pub contents: HashMap<String, String>,
39 pub pin_broken: bool,
44}
45
46#[derive(Debug)]
48pub struct Refreshed {
49 pub status: StatusModel,
50 pub model: DiffModel,
51 pub against: Option<(String, Result<DiffModel, VcsError>)>,
55}
56
57pub struct Review {
58 pub repo_root: PathBuf,
59 pub vcs: Box<dyn Vcs>,
60 pub status: StatusModel,
61 model: OnceCell<DiffModel>,
65 pub session: Session,
67 sources: HashMap<String, (ReviewSource, Session)>,
70 empty: Session,
72}
73
74impl Review {
75 pub fn open(repo_root: &Path) -> Result<Self, ReviewError> {
79 Self::open_with_context(repo_root, crate::git::DEFAULT_CONTEXT_LINES)
80 }
81
82 pub fn open_with_context(repo_root: &Path, context_lines: u32) -> Result<Self, ReviewError> {
85 let vcs: Box<dyn Vcs> = Box::new(GitVcs::open_with_context(repo_root, context_lines)?);
86 let status = vcs.status()?;
87 let session = store::load(repo_root)?;
88 Ok(Self {
89 repo_root: repo_root.to_path_buf(),
90 vcs,
91 status,
92 model: OnceCell::new(),
93 session,
94 sources: HashMap::new(),
95 empty: Session::default(),
96 })
97 }
98
99 pub fn model(&self) -> &DiffModel {
103 self.model
104 .get_or_init(|| self.vcs.working_tree_diff().unwrap_or_default())
105 }
106
107 pub fn model_mut(&mut self) -> &mut DiffModel {
111 self.model();
112 #[allow(clippy::expect_used)]
113 self.model.get_mut().expect("model just initialized")
114 }
115
116 pub fn refresh(&mut self) -> Result<(), ReviewError> {
119 self.status = self.vcs.status()?;
120 let model = self.vcs.working_tree_diff()?;
121 self.install_refresh(self.status.clone(), model);
122 Ok(())
123 }
124
125 pub fn compute_refresh(
130 repo_root: &Path,
131 context_lines: u32,
132 against: Option<&str>,
133 ) -> Result<Refreshed, ReviewError> {
134 let vcs = GitVcs::open_with_context(repo_root, context_lines)?;
135 let status = vcs.status()?;
136 let model = vcs.working_tree_diff()?;
137 let against = against.map(|rev| (rev.to_owned(), crate::vcs::against_diff(&vcs, rev)));
138 Ok(Refreshed {
139 status,
140 model,
141 against,
142 })
143 }
144
145 pub fn compute_declared(
150 repo_root: &Path,
151 paths: &[String],
152 ) -> Result<HashMap<String, crate::classify::Kind>, ReviewError> {
153 let vcs = GitVcs::open(repo_root)?;
154 Ok(paths
155 .iter()
156 .filter_map(|path| {
157 let rel = Path::new(path);
158 let kind = crate::classify::declared(|name| vcs.attr(rel, name))?;
159 Some((path.clone(), kind))
160 })
161 .collect())
162 }
163
164 pub fn compute_walkthrough_files(
175 repo_root: &Path,
176 rev: Option<&str>,
177 files: &[String],
178 ) -> WalkthroughFiles {
179 let vcs = GitVcs::open(repo_root).ok();
180 let pin_broken = match (rev, vcs.as_ref()) {
181 (Some(rev), Some(vcs)) => vcs.resolve(rev).is_err(),
182 (Some(_), None) => true,
183 (None, _) => false,
184 };
185 let rev = (!pin_broken).then_some(rev).flatten();
186 let contents = files
187 .iter()
188 .filter_map(|path| {
189 let pinned = rev.and_then(|rev| vcs.as_ref()?.read_at(rev, path).ok().flatten());
190 let content =
191 pinned.or_else(|| std::fs::read_to_string(repo_root.join(path)).ok())?;
192 Some((path.clone(), content))
193 })
194 .collect();
195 WalkthroughFiles {
196 contents,
197 pin_broken,
198 }
199 }
200
201 pub fn compute_file(repo_root: &Path, rel: &str) -> Result<FileSnapshot, ReviewError> {
206 let vcs = GitVcs::open(repo_root)?;
207 let path = Path::new(rel);
208 let content = std::fs::read_to_string(repo_root.join(path)).map_err(VcsError::from)?;
209 Ok(FileSnapshot {
210 path: rel.to_owned(),
211 blame: vcs.blame(path).unwrap_or_default(),
212 content,
213 })
214 }
215
216 pub fn install_refresh(&mut self, status: StatusModel, model: DiffModel) {
218 self.status = status;
219 self.session.reconcile(&model);
220 self.model = OnceCell::from(model);
221 }
222
223 pub fn save(&self) -> Result<(), ReviewError> {
224 store::save(&self.repo_root, &self.session)?;
225 Ok(())
226 }
227
228 pub fn ensure_source(&mut self, source: &ReviewSource) -> Result<(), ReviewError> {
231 if matches!(source, ReviewSource::WorkingTree) {
232 return Ok(());
233 }
234 let key = source.key();
235 if !self.sources.contains_key(&key) {
236 let session = store::load_source(&self.repo_root, source)?;
237 self.sources.insert(key, (source.clone(), session));
238 }
239 Ok(())
240 }
241
242 pub fn session_for(&self, source: &ReviewSource) -> &Session {
246 match source {
247 ReviewSource::WorkingTree => &self.session,
248 other => self
249 .sources
250 .get(&other.key())
251 .map_or(&self.empty, |(_, session)| session),
252 }
253 }
254
255 pub fn session_for_mut(&mut self, source: &ReviewSource) -> &mut Session {
256 match source {
257 ReviewSource::WorkingTree => &mut self.session,
258 other => {
259 &mut self
260 .sources
261 .entry(other.key())
262 .or_insert_with(|| (other.clone(), Session::default()))
263 .1
264 }
265 }
266 }
267
268 pub fn save_for(&self, source: &ReviewSource) -> Result<(), ReviewError> {
269 store::save_source(&self.repo_root, source, self.session_for(source))?;
270 Ok(())
271 }
272
273 pub fn forget_source(&mut self, source: &ReviewSource) {
277 self.sources.remove(&source.key());
278 }
279
280 pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
285 Ok(self.all_reviews_and_corrupt()?.0)
286 }
287
288 pub fn all_reviews_and_corrupt(&self) -> Result<store::LoadedReviews, ReviewError> {
291 let (loaded, corrupt) = store::load_all(&self.repo_root)?;
292 let mut by_key: BTreeMap<String, (ReviewSource, Session)> = loaded
293 .into_iter()
294 .map(|(source, session)| (source.key(), (source, session)))
295 .collect();
296 by_key.insert(
297 ReviewSource::WorkingTree.key(),
298 (ReviewSource::WorkingTree, self.session.clone()),
299 );
300 for (key, (source, session)) in &self.sources {
301 by_key.insert(key.clone(), (source.clone(), session.clone()));
302 }
303 Ok((by_key.into_values().collect(), corrupt))
304 }
305
306 pub fn restore_model(&mut self, model: DiffModel) {
310 self.model = OnceCell::from(model);
311 }
312
313 #[cfg(test)]
315 fn model_is_cached(&self) -> bool {
316 self.model.get().is_some()
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use crate::repo;
323
324 use super::*;
325
326 #[allow(clippy::expect_used)]
327 fn write(root: &std::path::Path, rel: &str, content: &str) {
328 std::fs::write(root.join(rel), content).expect("write");
329 }
330
331 #[allow(clippy::expect_used)]
332 fn commit_all(root: &std::path::Path, message: &str) {
333 for args in [&["add", "-A"][..], &["commit", "-q", "-m", message][..]] {
334 let status = std::process::Command::new("git")
335 .arg("-C")
336 .arg(root)
337 .args(args)
338 .env("GIT_AUTHOR_NAME", "t")
339 .env("GIT_AUTHOR_EMAIL", "t@t")
340 .env("GIT_COMMITTER_NAME", "t")
341 .env("GIT_COMMITTER_EMAIL", "t@t")
342 .status()
343 .expect("git");
344 assert!(status.success(), "git {args:?}");
345 }
346 }
347
348 #[allow(clippy::expect_used)]
349 fn init_repo(root: &std::path::Path) {
350 let status = std::process::Command::new("git")
351 .arg("-C")
352 .arg(root)
353 .args(["init", "-q"])
354 .status()
355 .expect("git init");
356 assert!(status.success());
357 }
358
359 #[test]
360 fn open_defers_the_working_model_until_first_access() {
361 let dir = tempfile::tempdir().expect("tempdir");
362 let root = dir.path();
363 init_repo(root);
364 write(root, "a.py", "value = old\n");
365 commit_all(root, "base");
366 write(root, "a.py", "value = new\n");
367
368 let root = repo::discover(root).expect("discover");
369 let review = Review::open(&root).expect("open");
370 assert!(
372 !review.model_is_cached(),
373 "open must not compute the working model"
374 );
375 assert_eq!(review.status.unstaged.files.len(), 1);
376
377 let lazy = review.model().clone();
379 assert!(review.model_is_cached(), "access caches the model");
380 let eager = review.vcs.working_tree_diff().expect("diff");
381 assert_eq!(lazy, eager, "lazy model equals the eager build");
382 }
383
384 #[allow(clippy::expect_used)]
385 fn git(root: &std::path::Path, args: &[&str]) {
386 let status = std::process::Command::new("git")
387 .arg("-C")
388 .arg(root)
389 .args(args)
390 .status()
391 .expect("git");
392 assert!(status.success(), "git {args:?}");
393 }
394
395 #[test]
396 fn against_a_base_branch_shows_committed_and_uncommitted_work() {
397 let dir = tempfile::tempdir().expect("tempdir");
398 let root = dir.path();
399 init_repo(root);
400 git(root, &["symbolic-ref", "HEAD", "refs/heads/main"]);
401 write(root, "base.txt", "base\n");
402 commit_all(root, "base");
403 git(root, &["checkout", "-q", "-b", "feature"]);
404 write(root, "committed.txt", "landed\n");
405 commit_all(root, "feature work");
406 git(root, &["checkout", "-q", "main"]);
408 write(root, "elsewhere.txt", "not mine\n");
409 commit_all(root, "base moved on");
410 git(root, &["checkout", "-q", "feature"]);
411 write(root, "dirty.txt", "still editing\n");
412
413 let root = repo::discover(root).expect("discover");
414 let review = Review::open(&root).expect("open");
415 let model = crate::vcs::against_diff(review.vcs.as_ref(), "main").expect("against");
416 let paths: Vec<&str> = model.files.iter().map(|f| f.path.as_str()).collect();
417 assert_eq!(paths, ["committed.txt", "dirty.txt"]);
418 }
419
420 #[test]
421 fn per_source_sessions_persist_independently_and_aggregate() {
422 let dir = tempfile::tempdir().expect("tempdir");
423 let root = dir.path();
424 init_repo(root);
425 write(root, "a.py", "value = old\n");
426 commit_all(root, "base");
427 write(root, "a.py", "value = new\n");
428
429 let root = repo::discover(root).expect("discover");
430 let mut review = Review::open(&root).expect("open");
431
432 let commit = crate::source::ReviewSource::commit("deadbeef");
433 review.ensure_source(&commit).expect("ensure");
434 review
435 .session_for_mut(&commit)
436 .mark_viewed("a.py", "hash-commit");
437 review.save_for(&commit).expect("save commit");
438 review.session.mark_viewed("a.py", "hash-working");
439 review.save().expect("save working");
440
441 assert!(review.session_for(&commit).is_viewed("a.py", "hash-commit"));
443 assert!(!review.session.is_viewed("a.py", "hash-commit"));
444
445 let mut reopened = Review::open(&root).expect("reopen");
447 reopened.ensure_source(&commit).expect("ensure");
448 assert!(
449 reopened
450 .session_for(&commit)
451 .is_viewed("a.py", "hash-commit")
452 );
453 assert!(reopened.session.is_viewed("a.py", "hash-working"));
454
455 let all = reopened.all_reviews().expect("all");
456 let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
457 assert_eq!(keys, ["commit-deadbeef", "working"]);
458 }
459
460 #[test]
464 fn compute_walkthrough_files_reads_the_pinned_revision_and_falls_back_for_the_rest() {
465 let dir = tempfile::tempdir().expect("tempdir");
466 let root = dir.path();
467 init_repo(root);
468 write(root, "a.txt", "old\n");
469 commit_all(root, "base");
470 let root = repo::discover(root).expect("discover");
471 let pinned = Review::open(&root)
472 .expect("open")
473 .vcs
474 .resolve("HEAD")
475 .expect("resolve");
476 write(&root, "a.txt", "new\n");
477 write(&root, "b.txt", "worktree only\n");
478
479 let files = ["a.txt".to_owned(), "b.txt".to_owned()];
480 let read = Review::compute_walkthrough_files(&root, Some(&pinned), &files);
481 assert_eq!(
482 read.contents.get("a.txt").map(String::as_str),
483 Some("old\n"),
484 "reads the pinned revision, not the dirty worktree"
485 );
486 assert_eq!(
487 read.contents.get("b.txt").map(String::as_str),
488 Some("worktree only\n"),
489 "a path the revision never had falls back to the worktree"
490 );
491 assert!(!read.pin_broken, "the pin itself still resolves");
492 }
493
494 #[test]
497 fn compute_walkthrough_files_with_no_revision_reads_the_worktree() {
498 let dir = tempfile::tempdir().expect("tempdir");
499 let root = dir.path();
500 init_repo(root);
501 write(root, "a.txt", "committed\n");
502 commit_all(root, "base");
503 write(root, "a.txt", "edited\n");
504 let root = repo::discover(root).expect("discover");
505
506 let files = ["a.txt".to_owned()];
507 let read = Review::compute_walkthrough_files(&root, None, &files);
508 assert_eq!(
509 read.contents.get("a.txt").map(String::as_str),
510 Some("edited\n")
511 );
512 assert!(
513 !read.pin_broken,
514 "no revision was ever pinned, so nothing is broken"
515 );
516 }
517
518 #[test]
523 fn compute_walkthrough_files_with_an_unresolvable_revision_reports_the_broken_pin() {
524 let dir = tempfile::tempdir().expect("tempdir");
525 let root = dir.path();
526 init_repo(root);
527 write(root, "a.txt", "edited\n");
528 commit_all(root, "base");
529 let root = repo::discover(root).expect("discover");
530
531 let files = ["a.txt".to_owned()];
532 let read = Review::compute_walkthrough_files(
533 &root,
534 Some("0000000000000000000000000000000000dead"),
535 &files,
536 );
537 assert_eq!(
538 read.contents.get("a.txt").map(String::as_str),
539 Some("edited\n"),
540 "still falls back to the worktree"
541 );
542 assert!(read.pin_broken, "the named revision does not resolve");
543 }
544}