Skip to main content

diffler_core/
review.rs

1//! Facade over the VCS backend, session and store, used by the TUI and MCP
2//! layers.
3
4use 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/// One file as the file view reads it: worktree text plus per-line blame.
27#[derive(Debug)]
28pub struct FileSnapshot {
29    pub path: String,
30    pub content: String,
31    /// Empty when git has nothing to attribute, e.g. an untracked file.
32    pub blame: Vec<crate::vcs::BlameSpan>,
33}
34
35#[derive(Debug, Default)]
36pub struct WalkthroughFiles {
37    pub contents: HashMap<String, String>,
38    /// A `rev` was named but no longer resolves (a squash, a rebase, a gc), so
39    /// every file fell back to the worktree. `false` when nothing was pinned.
40    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    /// The side exists but is over [`MAX_PREVIEW_BYTES`]; its size in bytes.
49    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/// Which copy of a walkthrough's file [`Review::compute_walkthrough_files`]
70/// reads first, falling back to the other.
71#[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    /// The [`ReviewSource::Against`] rev asked about and its diff. We carry
82    /// the rev so a review swapped while the worker ran drops the answer.
83    pub against: Option<(String, Result<DiffModel, VcsError>)>,
84    pub pinned: Option<Result<DiffModel, VcsError>>,
85}
86
87/// The diff for a commit, range or PR source. `pr_head` is the PR's
88/// `(merge_base, head)`; `None` rejects the PR so it never reads as
89/// unchanged. Other sources read back empty.
90pub 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    /// Computed on first [`Review::model`] access, since the status screen
114    /// opens first and needs no working diff.
115    model: OnceCell<DiffModel>,
116    /// The working-tree review session.
117    pub session: Session,
118    /// Sessions of every other source, keyed by [`ReviewSource::key`].
119    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    /// The working-tree diff, cached on first access. A backend error caches
151    /// an empty diff until the next [`Review::refresh`].
152    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    /// Recompute status and diff, and drop viewed marks for files that changed
164    /// or left the diff.
165    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    /// A refresh on its own repo handle, for a worker thread; apply it with
173    /// [`Review::install_refresh`]. `against` and `pinned` re-diff the open
174    /// three-dot or pinned source in the same pass.
175    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    /// What the repo's git attributes declare about each of `paths`, leaving
196    /// out paths they say nothing about. Each lookup is real IO, so we run
197    /// this on a worker.
198    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    /// Every requested file's content from the copy `read_first` names, else
214    /// the other. A path neither copy has is left out. Runs on a worker.
215    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    /// Both sides of a binary file, each from its blob, the new side from the
247    /// worktree when the store has no blob for it. We check the size first so
248    /// a side over [`MAX_PREVIEW_BYTES`] never loads into memory.
249    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    /// One file's worktree text and blame, for a worker. A file git cannot
278    /// blame comes back with no spans.
279    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    /// Load a source's session into the cache. Call before
302    /// [`Review::session_for`].
303    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    /// Empty for a source not yet [`Review::ensure_source`]d.
316    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    /// Drop a source's cached session once its file is deleted, so a later
345    /// access reloads it.
346    pub fn forget_source(&mut self, source: &ReviewSource) {
347        self.sources.remove(&source.key());
348    }
349
350    /// Every review across all sources, in-memory state overriding disk,
351    /// sorted by source key. A review file that fails to parse is skipped.
352    pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
353        Ok(self.all_reviews_and_corrupt()?.0)
354    }
355
356    /// [`Review::all_reviews`] plus the path of every review file skipped.
357    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    /// Swap a previous model back in after a no-op refresh, since it carries
374    /// render-time emphasis the rebuilt one lacks.
375    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}