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::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/// One file as the file view reads it: worktree text plus per-line blame.
26#[derive(Debug)]
27pub struct FileSnapshot {
28    pub path: String,
29    pub content: String,
30    /// Empty when git has nothing to attribute, e.g. an untracked file.
31    pub blame: Vec<crate::vcs::BlameSpan>,
32}
33
34/// The result of [`Review::compute_walkthrough_files`]: every file it could
35/// read, plus whether the revision it was asked to pin to still resolves.
36#[derive(Debug, Default)]
37pub struct WalkthroughFiles {
38    pub contents: HashMap<String, String>,
39    /// A `rev` was named but no longer resolves (a squash, a rebase, a gc):
40    /// every file fell back to the worktree, and the reader is looking at
41    /// live code believing it is pinned. `false` when nothing was pinned at
42    /// all, which is not broken, just untracked.
43    pub pin_broken: bool,
44}
45
46/// One landed off-thread refresh.
47#[derive(Debug)]
48pub struct Refreshed {
49    pub status: StatusModel,
50    pub model: DiffModel,
51    /// The [`ReviewSource::Against`] rev the caller asked about and its
52    /// recomputed diff. The rev rides along so a review swapped while the
53    /// worker ran can ignore an answer meant for the previous one.
54    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    /// HEAD vs workdir+index including untracked: the review view. Computed
62    /// lazily on first [`Review::model`] access: the status screen is the
63    /// initial view and needs no working diff up front.
64    model: OnceCell<DiffModel>,
65    /// The working-tree review session, the default view.
66    pub session: Session,
67    /// Lazily-loaded sessions for non-working sources (commits, ranges), keyed
68    /// by [`ReviewSource::key`].
69    sources: HashMap<String, (ReviewSource, Session)>,
70    /// Returned by [`Review::session_for`] for a source that has no review yet.
71    empty: Session,
72}
73
74impl Review {
75    /// Open the git backend, load the persisted session (if any), and compute
76    /// the status sections. The working-tree review diff is deferred until
77    /// first [`Review::model`] access. Diffs carry git's default hunk context.
78    pub fn open(repo_root: &Path) -> Result<Self, ReviewError> {
79        Self::open_with_context(repo_root, crate::git::DEFAULT_CONTEXT_LINES)
80    }
81
82    /// Like [`Review::open`] with a custom number of context lines around
83    /// diff hunks (config key `ui.context_lines`).
84    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    /// The working-tree review diff, computed and cached on first access. A
100    /// backend error yields an empty diff rather than panicking; the next
101    /// [`Review::refresh`] gets another chance to compute it.
102    pub fn model(&self) -> &DiffModel {
103        self.model
104            .get_or_init(|| self.vcs.working_tree_diff().unwrap_or_default())
105    }
106
107    /// Mutable view of the working-tree review diff, computing it first if
108    /// needed. The TUI uses this to enrich a file with intra-line emphasis
109    /// just before rendering it.
110    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    /// Recompute status + diff (the watcher calls this on changes) and drop
117    /// viewed marks for files that changed or left the diff.
118    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    /// Compute a refresh on a separate repo handle, so it can run off the UI
126    /// thread; the result is applied later with [`Review::install_refresh`].
127    /// `against` recomputes the open three-dot review in the same pass, since
128    /// it tracks edits and cannot be pinned like a commit's diff.
129    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    /// What the repo's git attributes declare about each of `paths`, for the
146    /// kinds sidebar. One attribute lookup walks the directory chain and the
147    /// global attribute files, so this is real IO per path and belongs on a
148    /// worker; paths the repo says nothing about are left out.
149    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    /// Every requested file's content as the walkthrough's own `rev` recorded
165    /// it, falling back to the live worktree for a path that revision has
166    /// none of (or when `rev` is `None`, a walkthrough saved before it was
167    /// tracked). Opens its own backend so it runs on a worker thread like
168    /// [`Review::compute_refresh`]; a path neither the revision nor the
169    /// worktree can produce is left out rather than failing the whole read.
170    /// `rev` itself can also stop resolving (a squash, a rebase, a gc): that
171    /// is distinct from a path merely absent from a revision that still
172    /// resolves, so it comes back as `pin_broken` rather than folding into
173    /// the same silent worktree fallback.
174    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    /// One file's worktree text and blame, for the file view. Opens its own
202    /// backend so it runs on a worker thread like [`Review::compute_refresh`].
203    /// A file git cannot blame (untracked, or newly staged) still loads: it
204    /// comes back with text and no spans.
205    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    /// Swap in freshly computed status + diff and reconcile viewed marks.
217    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    /// Load a non-working source's session into the cache if not already there.
229    /// Call before reading via [`Review::session_for`] for that source.
230    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    /// The session for a source. The working tree is always present; other
243    /// sources must be [`Review::ensure_source`]d first, else an empty session
244    /// is returned.
245    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    /// Forget a non-working source's cached session, after its file is
274    /// deleted from disk (e.g. a walkthrough removed for good), so a later
275    /// access reloads default state rather than serving stale memory.
276    pub fn forget_source(&mut self, source: &ReviewSource) {
277        self.sources.remove(&source.key());
278    }
279
280    /// Every review across all sources, in-memory state overriding disk, sorted
281    /// by source key. Powers the agent-facing aggregate feed. A review file
282    /// that fails to parse is skipped rather than failing the whole call; see
283    /// [`Review::all_reviews_and_corrupt`] for the list of what was skipped.
284    pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
285        Ok(self.all_reviews_and_corrupt()?.0)
286    }
287
288    /// [`Review::all_reviews`] plus the path of every review file that failed
289    /// to parse and was skipped, for a caller that wants to tell the reader.
290    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    /// Swap a previously computed model back in. Used when a refresh proved
307    /// a no-op (same fingerprint): the old model carries render-time emphasis
308    /// the rebuilt one lacks.
309    pub fn restore_model(&mut self, model: DiffModel) {
310        self.model = OnceCell::from(model);
311    }
312
313    /// Whether the working-tree model has been computed yet.
314    #[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        // the status sections are computed eagerly; the review model is not
371        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        // first access computes it; it matches a fresh working_tree_diff
378        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        // main moves on after the fork: three-dot keeps it out of the diff
407        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        // the same path means different things per source
442        assert!(review.session_for(&commit).is_viewed("a.py", "hash-commit"));
443        assert!(!review.session.is_viewed("a.py", "hash-commit"));
444
445        // a fresh open reloads each source from its own file
446        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    /// A walkthrough pinned to a revision reads a file as that revision had
461    /// it, ignoring a dirty worktree, and falls back to the worktree for a
462    /// path the revision never had.
463    #[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    /// No `rev` at all (a walkthrough saved before it was tracked) reads the
495    /// worktree directly.
496    #[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    /// A `rev` that no longer resolves (a squash, a rebase, a gc) is a
519    /// different fact than a path merely absent from a revision that does
520    /// resolve: every file still falls back to the worktree, but `pin_broken`
521    /// says so, so the reader is not shown live code believing it is pinned.
522    #[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}