Skip to main content

diffler_core/
review.rs

1//! Facade tying the VCS backend, session, and store together: the one
2//! entry point the TUI and MCP layers consume.
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/// The result of [`Review::compute_walkthrough_files`]: every file it could
36/// read, plus whether the revision it was asked to pin to still resolves.
37#[derive(Debug, Default)]
38pub struct WalkthroughFiles {
39    pub contents: HashMap<String, String>,
40    /// A `rev` was named but no longer resolves (a squash, a rebase, a gc):
41    /// every file fell back to the worktree, and the reader is looking at
42    /// live code believing it is pinned. `false` when nothing was pinned at
43    /// all, which is not broken, just untracked.
44    pub pin_broken: bool,
45}
46
47/// One landed off-thread refresh.
48#[derive(Debug)]
49pub struct Refreshed {
50    pub status: StatusModel,
51    pub model: DiffModel,
52    /// The [`ReviewSource::Against`] rev the caller asked about and its
53    /// recomputed diff. The rev rides along so a review swapped while the
54    /// worker ran can ignore an answer meant for the previous one.
55    pub against: Option<(String, Result<DiffModel, VcsError>)>,
56    /// A pinned commit, range, or PR source's freshly recomputed diff, when
57    /// the caller asked [`Review::compute_refresh`] for one (an algorithm
58    /// switch re-diffing whatever source is open).
59    pub pinned: Option<Result<DiffModel, VcsError>>,
60}
61
62/// The freshly fetched diff for a commit, range, or PR review source,
63/// straight from the backend: the one place each variant's vcs call is
64/// made, whether the caller reads it on the UI thread or off it.
65/// `pr_head` is the PR's own `(merge_base, head)`, resolved by the caller
66/// since a PR's range lives in app state; `None` rejects an unresolved PR,
67/// so it is never silently read as unchanged.
68/// `WorkingTree`, `Walkthrough`, and `Against` carry no pinned diff of their
69/// own and read back empty.
70pub 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    /// HEAD vs workdir+index including untracked: the review view. Computed
94    /// lazily on first [`Review::model`] access: the status screen is the
95    /// initial view and needs no working diff up front.
96    model: OnceCell<DiffModel>,
97    /// The working-tree review session, the default view.
98    pub session: Session,
99    /// Lazily-loaded sessions for non-working sources (commits, ranges), keyed
100    /// by [`ReviewSource::key`].
101    sources: HashMap<String, (ReviewSource, Session)>,
102    /// Returned by [`Review::session_for`] for a source that has no review yet.
103    empty: Session,
104}
105
106impl Review {
107    /// Open the git backend at [`DiffSettings::default`], load the persisted
108    /// session (if any), and compute the status sections. The working-tree
109    /// review diff is deferred until first [`Review::model`] access.
110    pub fn open(repo_root: &Path) -> Result<Self, ReviewError> {
111        Self::open_with_settings(repo_root, &DiffSettings::default())
112    }
113
114    /// Like [`Review::open`] with a custom context, line-diff algorithm and
115    /// indent heuristic (config keys `ui.context_lines`, `diff.algorithm`,
116    /// `diff.indent_heuristic`).
117    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    /// Switch the session's line-diff algorithm live, so every diff this
136    /// review's own backend computes afterward uses it.
137    pub fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool) {
138        self.vcs.set_diff_algorithm(algorithm, indent_heuristic);
139    }
140
141    /// The working-tree review diff, computed and cached on first access. A
142    /// backend error yields an empty diff rather than panicking; the next
143    /// [`Review::refresh`] gets another chance to compute it.
144    pub fn model(&self) -> &DiffModel {
145        self.model
146            .get_or_init(|| self.vcs.working_tree_diff().unwrap_or_default())
147    }
148
149    /// Mutable view of the working-tree review diff, computing it first if
150    /// needed. The TUI uses this to enrich a file with intra-line emphasis
151    /// just before rendering it.
152    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    /// Recompute status + diff (the watcher calls this on changes) and drop
159    /// viewed marks for files that changed or left the diff.
160    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    /// Compute a refresh on a separate repo handle, so it can run off the UI
168    /// thread; the result is applied later with [`Review::install_refresh`].
169    /// `against` recomputes the open three-dot review in the same pass, since
170    /// it tracks edits and cannot be pinned like a commit's diff. `pinned`
171    /// additionally recomputes a commit, range, or PR source's diff on this
172    /// same backend (an algorithm switch re-diffing whatever source is open).
173    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    /// What the repo's git attributes declare about each of `paths`, for the
194    /// kinds sidebar. One attribute lookup walks the directory chain and the
195    /// global attribute files, so this is real IO per path and belongs on a
196    /// worker; paths the repo says nothing about are left out.
197    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    /// Every requested file's content as the walkthrough's own `rev` recorded
213    /// it, falling back to the live worktree for a path that revision has
214    /// none of (or when `rev` is `None`, a walkthrough saved before it was
215    /// tracked). Opens its own backend so it runs on a worker thread like
216    /// [`Review::compute_refresh`]; a path neither the revision nor the
217    /// worktree can produce is left out rather than failing the whole read.
218    /// `rev` itself can also stop resolving (a squash, a rebase, a gc): that
219    /// is distinct from a path merely absent from a revision that still
220    /// resolves, so it comes back as `pin_broken` rather than folding into
221    /// the same silent worktree fallback.
222    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    /// One file's worktree text and blame, for the file view. Opens its own
250    /// backend so it runs on a worker thread like [`Review::compute_refresh`].
251    /// A file git cannot blame (untracked, or newly staged) still loads: it
252    /// comes back with text and no spans.
253    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    /// Swap in freshly computed status + diff and reconcile viewed marks.
265    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    /// Load a non-working source's session into the cache if not already there.
277    /// Call before reading via [`Review::session_for`] for that source.
278    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    /// The session for a source. The working tree is always present; other
291    /// sources must be [`Review::ensure_source`]d first, else an empty session
292    /// is returned.
293    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    /// Forget a non-working source's cached session, after its file is
322    /// deleted from disk (e.g. a walkthrough removed for good), so a later
323    /// access reloads default state rather than serving stale memory.
324    pub fn forget_source(&mut self, source: &ReviewSource) {
325        self.sources.remove(&source.key());
326    }
327
328    /// Every review across all sources, in-memory state overriding disk, sorted
329    /// by source key. Powers the agent-facing aggregate feed. A review file
330    /// that fails to parse is skipped rather than failing the whole call; see
331    /// [`Review::all_reviews_and_corrupt`] for the list of what was skipped.
332    pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
333        Ok(self.all_reviews_and_corrupt()?.0)
334    }
335
336    /// [`Review::all_reviews`] plus the path of every review file that failed
337    /// to parse and was skipped, for a caller that wants to tell the reader.
338    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    /// Swap a previously computed model back in. Used when a refresh proved
355    /// a no-op (same fingerprint): the old model carries render-time emphasis
356    /// the rebuilt one lacks.
357    pub fn restore_model(&mut self, model: DiffModel) {
358        self.model = OnceCell::from(model);
359    }
360
361    /// Whether the working-tree model has been computed yet.
362    #[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        // the status sections are computed eagerly; the review model is not
419        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        // first access computes it; it matches a fresh working_tree_diff
426        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        // main moves on after the fork: three-dot keeps it out of the diff
455        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        // the same path means different things per source
490        assert!(review.session_for(&commit).is_viewed("a.py", "hash-commit"));
491        assert!(!review.session.is_viewed("a.py", "hash-commit"));
492
493        // a fresh open reloads each source from its own file
494        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    /// A walkthrough pinned to a revision reads a file as that revision had
509    /// it, ignoring a dirty worktree, and falls back to the worktree for a
510    /// path the revision never had.
511    #[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    /// No `rev` at all (a walkthrough saved before it was tracked) reads the
543    /// worktree directly.
544    #[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    /// A `rev` that no longer resolves (a squash, a rebase, a gc) is a
567    /// different fact than a path merely absent from a revision that does
568    /// resolve: every file still falls back to the worktree, but `pin_broken`
569    /// says so, so the reader is not shown live code believing it is pinned.
570    #[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}