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