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/// One landed off-thread refresh.
35#[derive(Debug)]
36pub struct Refreshed {
37    pub status: StatusModel,
38    pub model: DiffModel,
39    /// The [`ReviewSource::Against`] rev the caller asked about and its
40    /// recomputed diff. The rev rides along so a review swapped while the
41    /// worker ran can ignore an answer meant for the previous one.
42    pub against: Option<(String, Result<DiffModel, VcsError>)>,
43}
44
45pub struct Review {
46    pub repo_root: PathBuf,
47    pub vcs: Box<dyn Vcs>,
48    pub status: StatusModel,
49    /// HEAD vs workdir+index including untracked: the review view. Computed
50    /// lazily on first [`Review::model`] access: the status screen is the
51    /// initial view and needs no working diff up front.
52    model: OnceCell<DiffModel>,
53    /// The working-tree review session, the default view.
54    pub session: Session,
55    /// Lazily-loaded sessions for non-working sources (commits, ranges), keyed
56    /// by [`ReviewSource::key`].
57    sources: HashMap<String, (ReviewSource, Session)>,
58    /// Returned by [`Review::session_for`] for a source that has no review yet.
59    empty: Session,
60}
61
62impl Review {
63    /// Open the git backend, load the persisted session (if any), and compute
64    /// the status sections. The working-tree review diff is deferred until
65    /// first [`Review::model`] access. Diffs carry git's default hunk context.
66    pub fn open(repo_root: &Path) -> Result<Self, ReviewError> {
67        Self::open_with_context(repo_root, crate::git::DEFAULT_CONTEXT_LINES)
68    }
69
70    /// Like [`Review::open`] with a custom number of context lines around
71    /// diff hunks (config key `ui.context_lines`).
72    pub fn open_with_context(repo_root: &Path, context_lines: u32) -> Result<Self, ReviewError> {
73        let vcs: Box<dyn Vcs> = Box::new(GitVcs::open_with_context(repo_root, context_lines)?);
74        let status = vcs.status()?;
75        let session = store::load(repo_root)?;
76        Ok(Self {
77            repo_root: repo_root.to_path_buf(),
78            vcs,
79            status,
80            model: OnceCell::new(),
81            session,
82            sources: HashMap::new(),
83            empty: Session::default(),
84        })
85    }
86
87    /// The working-tree review diff, computed and cached on first access. A
88    /// backend error yields an empty diff rather than panicking; the next
89    /// [`Review::refresh`] gets another chance to compute it.
90    pub fn model(&self) -> &DiffModel {
91        self.model
92            .get_or_init(|| self.vcs.working_tree_diff().unwrap_or_default())
93    }
94
95    /// Mutable view of the working-tree review diff, computing it first if
96    /// needed. The TUI uses this to enrich a file with intra-line emphasis
97    /// just before rendering it.
98    pub fn model_mut(&mut self) -> &mut DiffModel {
99        self.model();
100        #[allow(clippy::expect_used)]
101        self.model.get_mut().expect("model just initialized")
102    }
103
104    /// Recompute status + diff (the watcher calls this on changes) and drop
105    /// viewed marks for files that changed or left the diff.
106    pub fn refresh(&mut self) -> Result<(), ReviewError> {
107        self.status = self.vcs.status()?;
108        let model = self.vcs.working_tree_diff()?;
109        self.install_refresh(self.status.clone(), model);
110        Ok(())
111    }
112
113    /// Compute a refresh on a separate repo handle, so it can run off the UI
114    /// thread; the result is applied later with [`Review::install_refresh`].
115    /// `against` recomputes the open three-dot review in the same pass, since
116    /// it tracks edits and cannot be pinned like a commit's diff.
117    pub fn compute_refresh(
118        repo_root: &Path,
119        context_lines: u32,
120        against: Option<&str>,
121    ) -> Result<Refreshed, ReviewError> {
122        let vcs = GitVcs::open_with_context(repo_root, context_lines)?;
123        let status = vcs.status()?;
124        let model = vcs.working_tree_diff()?;
125        let against = against.map(|rev| (rev.to_owned(), crate::vcs::against_diff(&vcs, rev)));
126        Ok(Refreshed {
127            status,
128            model,
129            against,
130        })
131    }
132
133    /// What the repo's git attributes declare about each of `paths`, for the
134    /// kinds sidebar. One attribute lookup walks the directory chain and the
135    /// global attribute files, so this is real IO per path and belongs on a
136    /// worker; paths the repo says nothing about are left out.
137    pub fn compute_declared(
138        repo_root: &Path,
139        paths: &[String],
140    ) -> Result<HashMap<String, crate::classify::Kind>, ReviewError> {
141        let vcs = GitVcs::open(repo_root)?;
142        Ok(paths
143            .iter()
144            .filter_map(|path| {
145                let rel = Path::new(path);
146                let kind = crate::classify::declared(|name| vcs.attr(rel, name))?;
147                Some((path.clone(), kind))
148            })
149            .collect())
150    }
151
152    /// One file's worktree text and blame, for the file view. Opens its own
153    /// backend so it runs on a worker thread like [`Review::compute_refresh`].
154    /// A file git cannot blame (untracked, or newly staged) still loads: it
155    /// comes back with text and no spans.
156    pub fn compute_file(repo_root: &Path, rel: &str) -> Result<FileSnapshot, ReviewError> {
157        let vcs = GitVcs::open(repo_root)?;
158        let path = Path::new(rel);
159        let content = std::fs::read_to_string(repo_root.join(path)).map_err(VcsError::from)?;
160        Ok(FileSnapshot {
161            path: rel.to_owned(),
162            blame: vcs.blame(path).unwrap_or_default(),
163            content,
164        })
165    }
166
167    /// Swap in freshly computed status + diff and reconcile viewed marks.
168    pub fn install_refresh(&mut self, status: StatusModel, model: DiffModel) {
169        self.status = status;
170        self.session.reconcile(&model);
171        self.model = OnceCell::from(model);
172    }
173
174    pub fn save(&self) -> Result<(), ReviewError> {
175        store::save(&self.repo_root, &self.session)?;
176        Ok(())
177    }
178
179    /// Load a non-working source's session into the cache if not already there.
180    /// Call before reading via [`Review::session_for`] for that source.
181    pub fn ensure_source(&mut self, source: &ReviewSource) -> Result<(), ReviewError> {
182        if matches!(source, ReviewSource::WorkingTree) {
183            return Ok(());
184        }
185        let key = source.key();
186        if !self.sources.contains_key(&key) {
187            let session = store::load_source(&self.repo_root, source)?;
188            self.sources.insert(key, (source.clone(), session));
189        }
190        Ok(())
191    }
192
193    /// The session for a source. The working tree is always present; other
194    /// sources must be [`Review::ensure_source`]d first, else an empty session
195    /// is returned.
196    pub fn session_for(&self, source: &ReviewSource) -> &Session {
197        match source {
198            ReviewSource::WorkingTree => &self.session,
199            other => self
200                .sources
201                .get(&other.key())
202                .map_or(&self.empty, |(_, session)| session),
203        }
204    }
205
206    pub fn session_for_mut(&mut self, source: &ReviewSource) -> &mut Session {
207        match source {
208            ReviewSource::WorkingTree => &mut self.session,
209            other => {
210                &mut self
211                    .sources
212                    .entry(other.key())
213                    .or_insert_with(|| (other.clone(), Session::default()))
214                    .1
215            }
216        }
217    }
218
219    pub fn save_for(&self, source: &ReviewSource) -> Result<(), ReviewError> {
220        store::save_source(&self.repo_root, source, self.session_for(source))?;
221        Ok(())
222    }
223
224    /// Every review across all sources, in-memory state overriding disk, sorted
225    /// by source key. Powers the agent-facing aggregate feed.
226    pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
227        let mut by_key: BTreeMap<String, (ReviewSource, Session)> =
228            store::load_all(&self.repo_root)?
229                .into_iter()
230                .map(|(source, session)| (source.key(), (source, session)))
231                .collect();
232        by_key.insert(
233            ReviewSource::WorkingTree.key(),
234            (ReviewSource::WorkingTree, self.session.clone()),
235        );
236        for (key, (source, session)) in &self.sources {
237            by_key.insert(key.clone(), (source.clone(), session.clone()));
238        }
239        Ok(by_key.into_values().collect())
240    }
241
242    /// Swap a previously computed model back in. Used when a refresh proved
243    /// a no-op (same fingerprint): the old model carries render-time emphasis
244    /// the rebuilt one lacks.
245    pub fn restore_model(&mut self, model: DiffModel) {
246        self.model = OnceCell::from(model);
247    }
248
249    /// Whether the working-tree model has been computed yet.
250    #[cfg(test)]
251    fn model_is_cached(&self) -> bool {
252        self.model.get().is_some()
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use crate::repo;
259
260    use super::*;
261
262    #[allow(clippy::expect_used)]
263    fn write(root: &std::path::Path, rel: &str, content: &str) {
264        std::fs::write(root.join(rel), content).expect("write");
265    }
266
267    #[allow(clippy::expect_used)]
268    fn commit_all(root: &std::path::Path, message: &str) {
269        for args in [&["add", "-A"][..], &["commit", "-q", "-m", message][..]] {
270            let status = std::process::Command::new("git")
271                .arg("-C")
272                .arg(root)
273                .args(args)
274                .env("GIT_AUTHOR_NAME", "t")
275                .env("GIT_AUTHOR_EMAIL", "t@t")
276                .env("GIT_COMMITTER_NAME", "t")
277                .env("GIT_COMMITTER_EMAIL", "t@t")
278                .status()
279                .expect("git");
280            assert!(status.success(), "git {args:?}");
281        }
282    }
283
284    #[allow(clippy::expect_used)]
285    fn init_repo(root: &std::path::Path) {
286        let status = std::process::Command::new("git")
287            .arg("-C")
288            .arg(root)
289            .args(["init", "-q"])
290            .status()
291            .expect("git init");
292        assert!(status.success());
293    }
294
295    #[test]
296    fn open_defers_the_working_model_until_first_access() {
297        let dir = tempfile::tempdir().expect("tempdir");
298        let root = dir.path();
299        init_repo(root);
300        write(root, "a.py", "value = old\n");
301        commit_all(root, "base");
302        write(root, "a.py", "value = new\n");
303
304        let root = repo::discover(root).expect("discover");
305        let review = Review::open(&root).expect("open");
306        // the status sections are computed eagerly; the review model is not
307        assert!(
308            !review.model_is_cached(),
309            "open must not compute the working model"
310        );
311        assert_eq!(review.status.unstaged.files.len(), 1);
312
313        // first access computes it; it matches a fresh working_tree_diff
314        let lazy = review.model().clone();
315        assert!(review.model_is_cached(), "access caches the model");
316        let eager = review.vcs.working_tree_diff().expect("diff");
317        assert_eq!(lazy, eager, "lazy model equals the eager build");
318    }
319
320    #[allow(clippy::expect_used)]
321    fn git(root: &std::path::Path, args: &[&str]) {
322        let status = std::process::Command::new("git")
323            .arg("-C")
324            .arg(root)
325            .args(args)
326            .status()
327            .expect("git");
328        assert!(status.success(), "git {args:?}");
329    }
330
331    #[test]
332    fn against_a_base_branch_shows_committed_and_uncommitted_work() {
333        let dir = tempfile::tempdir().expect("tempdir");
334        let root = dir.path();
335        init_repo(root);
336        git(root, &["symbolic-ref", "HEAD", "refs/heads/main"]);
337        write(root, "base.txt", "base\n");
338        commit_all(root, "base");
339        git(root, &["checkout", "-q", "-b", "feature"]);
340        write(root, "committed.txt", "landed\n");
341        commit_all(root, "feature work");
342        // main moves on after the fork: three-dot keeps it out of the diff
343        git(root, &["checkout", "-q", "main"]);
344        write(root, "elsewhere.txt", "not mine\n");
345        commit_all(root, "base moved on");
346        git(root, &["checkout", "-q", "feature"]);
347        write(root, "dirty.txt", "still editing\n");
348
349        let root = repo::discover(root).expect("discover");
350        let review = Review::open(&root).expect("open");
351        let model = crate::vcs::against_diff(review.vcs.as_ref(), "main").expect("against");
352        let paths: Vec<&str> = model.files.iter().map(|f| f.path.as_str()).collect();
353        assert_eq!(paths, ["committed.txt", "dirty.txt"]);
354    }
355
356    #[test]
357    fn per_source_sessions_persist_independently_and_aggregate() {
358        let dir = tempfile::tempdir().expect("tempdir");
359        let root = dir.path();
360        init_repo(root);
361        write(root, "a.py", "value = old\n");
362        commit_all(root, "base");
363        write(root, "a.py", "value = new\n");
364
365        let root = repo::discover(root).expect("discover");
366        let mut review = Review::open(&root).expect("open");
367
368        let commit = crate::source::ReviewSource::commit("deadbeef");
369        review.ensure_source(&commit).expect("ensure");
370        review
371            .session_for_mut(&commit)
372            .mark_viewed("a.py", "hash-commit");
373        review.save_for(&commit).expect("save commit");
374        review.session.mark_viewed("a.py", "hash-working");
375        review.save().expect("save working");
376
377        // the same path means different things per source
378        assert!(review.session_for(&commit).is_viewed("a.py", "hash-commit"));
379        assert!(!review.session.is_viewed("a.py", "hash-commit"));
380
381        // a fresh open reloads each source from its own file
382        let mut reopened = Review::open(&root).expect("reopen");
383        reopened.ensure_source(&commit).expect("ensure");
384        assert!(
385            reopened
386                .session_for(&commit)
387                .is_viewed("a.py", "hash-commit")
388        );
389        assert!(reopened.session.is_viewed("a.py", "hash-working"));
390
391        let all = reopened.all_reviews().expect("all");
392        let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
393        assert_eq!(keys, ["commit-deadbeef", "working"]);
394    }
395}