Skip to main content

differential_engine/
review_session.rs

1//! An open review: the engine-owned session over one plan document.
2//!
3//! The engine is the backend; renderers are stateless frontends (ADR 0014).
4//! A `ReviewSession` owns the store, the document, the diff view and all
5//! mutable review state — reviewed marks, findings, resume cursor. Every
6//! mutation persists before returning, so a renderer can crash at any point
7//! without losing anything, and never touches the store itself.
8
9use std::collections::{HashMap, HashSet};
10use std::path::PathBuf;
11
12use differential_schema as schema;
13
14use crate::EngineError;
15use crate::gitio::Repo;
16use crate::model::DiffView;
17use crate::review_state::{Anchor, Finding, ReviewState, ReviewStore, class_content_key, reanchor};
18
19pub struct ReviewSession {
20    store: ReviewStore,
21    doc: schema::PlanDocument,
22    view: DiffView,
23    plan_hash: String,
24    /// class id -> class content key (the reviewed-mark key).
25    class_key: HashMap<String, String>,
26    /// canonical hunk index -> class content key.
27    hunk_key: HashMap<usize, String>,
28    state: ReviewState,
29    findings: Vec<Finding>,
30}
31
32impl ReviewSession {
33    /// Open (or resume) the review identified by `(review_base, head_spec)`:
34    /// persist the plan, load and re-anchor findings, load state.
35    ///
36    /// `review_base`/`head_spec` are the review's IDENTITY, not necessarily
37    /// the diff endpoints: reviewing uncommitted changes keys on the HEAD sha
38    /// plus a stable literal while the synthesized trees churn.
39    pub fn open(
40        repo: &Repo,
41        review_base: &str,
42        head_spec: &str,
43        doc: schema::PlanDocument,
44        view: DiffView,
45    ) -> Result<Self, EngineError> {
46        let store = ReviewStore::open(repo, review_base, head_spec)?;
47        Self::from_store(store, doc, view)
48    }
49
50    /// Test/tooling entry: open at an explicit directory.
51    pub fn open_at(
52        dir: PathBuf,
53        doc: schema::PlanDocument,
54        view: DiffView,
55    ) -> Result<Self, EngineError> {
56        Self::from_store(ReviewStore::open_at(dir)?, doc, view)
57    }
58
59    fn from_store(
60        store: ReviewStore,
61        doc: schema::PlanDocument,
62        view: DiffView,
63    ) -> Result<Self, EngineError> {
64        let plan_hash = store.save_plan(&doc)?;
65        let mut findings = store.load_findings()?;
66        reanchor(&mut findings, &doc, &view, &plan_hash);
67        store.save_findings(&findings)?;
68        let state = store.load_state()?;
69
70        let class_by_id: HashMap<&str, &schema::ClassEntry> =
71            doc.classes.iter().map(|c| (c.id.as_str(), c)).collect();
72        let mut class_key = HashMap::new();
73        let mut hunk_key = HashMap::new();
74        for c in class_by_id.values() {
75            let digests: Vec<String> = c
76                .hunk_ids
77                .iter()
78                .map(|hid| doc.hunks[hunk_index(hid)].digest.clone())
79                .collect();
80            let key = class_content_key(&digests);
81            for hid in &c.hunk_ids {
82                hunk_key.insert(hunk_index(hid), key.clone());
83            }
84            class_key.insert(c.id.clone(), key);
85        }
86
87        Ok(ReviewSession {
88            store,
89            doc,
90            view,
91            plan_hash,
92            class_key,
93            hunk_key,
94            state,
95            findings,
96        })
97    }
98
99    // ---------------------------------------------------------------- reads
100
101    pub fn doc(&self) -> &schema::PlanDocument {
102        &self.doc
103    }
104
105    pub fn view(&self) -> &DiffView {
106        &self.view
107    }
108
109    pub fn plan_hash(&self) -> &str {
110        &self.plan_hash
111    }
112
113    pub fn findings(&self) -> &[Finding] {
114        &self.findings
115    }
116
117    /// Content key for a class id (present for every class in the document).
118    pub fn class_key(&self, class_id: &str) -> &str {
119        &self.class_key[class_id]
120    }
121
122    pub fn is_reviewed(&self, class_key: &str) -> bool {
123        self.state.reviewed_classes.contains(class_key)
124    }
125
126    pub fn reviewed_count(&self) -> usize {
127        self.state.reviewed_classes.len()
128    }
129
130    /// Canonical hunk indices whose class is marked reviewed (owned — safe to
131    /// hold while borrowing the session elsewhere).
132    pub fn reviewed_hunks(&self) -> HashSet<usize> {
133        self.hunk_key
134            .iter()
135            .filter(|(_, key)| self.state.reviewed_classes.contains(*key))
136            .map(|(hi, _)| *hi)
137            .collect()
138    }
139
140    pub fn cursor(&self) -> Option<&(String, usize)> {
141        self.state.cursor.as_ref()
142    }
143
144    pub fn split_diff(&self) -> bool {
145        self.state.split_diff
146    }
147
148    pub fn file_view(&self) -> bool {
149        self.state.file_view
150    }
151
152    // ---------------------------- mutations (each persists before returning)
153
154    /// Toggle the reviewed mark of the class owning `hunk`. Returns the new
155    /// mark (true = now reviewed).
156    pub fn toggle_reviewed(&mut self, hunk: usize) -> Result<bool, EngineError> {
157        let key = self.hunk_key[&hunk].clone();
158        let now = self.state.reviewed_classes.insert(key.clone());
159        if !now {
160            self.state.reviewed_classes.remove(&key);
161        }
162        self.store.save_state(&self.state)?;
163        Ok(now)
164    }
165
166    /// Persist the resume position: (group id or file path, row offset).
167    pub fn save_cursor(&mut self, id: String, row: usize) -> Result<(), EngineError> {
168        self.state.cursor = Some((id, row));
169        self.store.save_state(&self.state)
170    }
171
172    /// Persist the diff layout (unified / side-by-side).
173    pub fn set_split_diff(&mut self, on: bool) -> Result<(), EngineError> {
174        self.state.split_diff = on;
175        self.store.save_state(&self.state)
176    }
177
178    /// Persist the left-pane view (semantic groups / flat file list).
179    pub fn set_file_view(&mut self, on: bool) -> Result<(), EngineError> {
180        self.state.file_view = on;
181        self.store.save_state(&self.state)
182    }
183
184    /// Create a finding anchored on `hunk` and persist it.
185    pub fn add_finding(&mut self, hunk: usize, body: String) -> Result<&Finding, EngineError> {
186        let h = &self.doc.hunks[hunk];
187        let side = if h.new_count > 0 { "new" } else { "old" };
188        let line = if h.new_count > 0 {
189            h.new_start.max(1)
190        } else {
191            h.old_start.max(1)
192        };
193        let vh = &self.view.hunks[hunk];
194        let line_text = vh
195            .added
196            .first()
197            .or(vh.removed.first())
198            .map(|l| String::from_utf8_lossy(l).into_owned())
199            .unwrap_or_default();
200        let finding = Finding::new(
201            body,
202            self.plan_hash.clone(),
203            Anchor {
204                file: h.file.clone(),
205                side: side.into(),
206                line,
207                hunk_digest: h.digest.clone(),
208                line_text,
209            },
210        );
211        self.findings.push(finding);
212        self.store.save_findings(&self.findings)?;
213        Ok(self.findings.last().expect("just pushed"))
214    }
215
216    /// Delete a finding by id. Returns whether anything was removed.
217    pub fn delete_finding(&mut self, id: &str) -> Result<bool, EngineError> {
218        let before = self.findings.len();
219        self.findings.retain(|f| f.id != id);
220        if self.findings.len() == before {
221            return Ok(false);
222        }
223        self.store.save_findings(&self.findings)?;
224        Ok(true)
225    }
226}
227
228/// Hunk ids are `h<N>` where N indexes `doc.hunks`.
229fn hunk_index(hid: &str) -> usize {
230    hid[1..].parse().expect("hunk id of the form h<N>")
231}