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