Skip to main content

differential_engine/
review_state.rs

1//! The review-state sidecar store (ADR 0013, spec/persistence.md).
2//!
3//! Regeneration is total; state is a sidecar. Plan documents are immutable and
4//! content-addressed; reviewed marks and findings both key on the exact hunk
5//! digest — so both survive the head moving and positional ids shifting.
6//! Re-anchoring never drops anything:
7//! exact digest match → reattach; same-file content match → reattach flagged
8//! moved; otherwise the finding is orphaned and listed.
9
10use std::collections::BTreeSet;
11
12use serde::{Deserialize, Serialize};
13use sha1::{Digest, Sha1};
14
15use crate::schema;
16
17use crate::model::DiffView;
18
19// Review identity is domain policy and lives in `plan`; it is re-exported
20// here because this is where consumers of the store expect to find it, and
21// moving the name would break them for no gain.
22pub use crate::plan::review_id;
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct ReviewState {
26    /// Hunk digests marked reviewed.
27    ///
28    /// One key per hunk, not one per class. A class key made every mark in a
29    /// class hostage to every other hunk in it: change one hunk of five and
30    /// the four nobody touched went unread again.
31    ///
32    /// A state file written before this field existed records
33    /// `reviewed_classes`, which no longer loads — those keys are class
34    /// hashes and cannot be read as hunk digests. The rest of the file
35    /// (cursor, layout) is unaffected.
36    #[serde(default)]
37    pub reviewed_hunks: BTreeSet<String>,
38    /// Resume position: (group id or file path, row offset) in the last-open
39    /// plan — a group id in the semantic view, a file path in the file view.
40    #[serde(default)]
41    pub cursor: Option<(String, usize)>,
42    /// The reader's diff-layout choice, or `None` if they have not made one.
43    ///
44    /// `None` means "use the configured default" — which is why this is an
45    /// option and not a bool. A state file written before this field existed
46    /// records `false`, and that deserialises to `Some(false)`: a review
47    /// already on disk keeps the layout it had, whatever the config now says.
48    #[serde(default)]
49    pub split_diff: Option<bool>,
50    /// The reader's soft-wrap choice, or `None` if they have not pressed `w`.
51    ///
52    /// An option for the same reason `split_diff` is one: absent means the
53    /// reader has never chosen, and the renderer's own default stands.
54    #[serde(default)]
55    pub wrap: Option<bool>,
56    /// Flattened per-file view instead of semantic groups (default: groups).
57    #[serde(default)]
58    pub file_view: bool,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum FindingStatus {
64    Open,
65    Resolved,
66    Orphaned,
67}
68
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct Anchor {
71    pub file: String,
72    /// "old" | "new"
73    pub side: String,
74    /// First anchored line, in file coordinates. DERIVED — `offset` is what
75    /// survives a regeneration, and this is recomputed from it.
76    pub line: u32,
77    /// Last anchored line. Equal to `line` for a single-line anchor; `0` on a
78    /// record written before ranges existed, which reads as "just `line`".
79    #[serde(default)]
80    pub end_line: u32,
81    /// Lines from the hunk's start to `line`. **Signed**: a reader can
82    /// annotate a context line, and context sits on both sides of a hunk.
83    ///
84    /// This, not `line`, is what the anchor is really made of. The digest
85    /// fixes the hunk's CONTENT, so a hunk that moved in the file still holds
86    /// the same line at the same offset — while its absolute line number did
87    /// not survive the move. A record written before offsets existed has `0`,
88    /// which lands it on the hunk's first line: exactly where it used to.
89    #[serde(default)]
90    pub offset: i32,
91    /// Lines the anchor spans past `offset`. `0` is a single line.
92    #[serde(default)]
93    pub span: u32,
94    pub hunk_digest: String,
95    /// The anchored line's text — the fuzzy re-anchor key.
96    #[serde(default)]
97    pub line_text: String,
98    /// The last anchored line's text, for the same job at the range's far end.
99    #[serde(default)]
100    pub end_line_text: String,
101}
102
103impl Anchor {
104    /// Where the anchor's side of `hunk` begins in the file.
105    fn hunk_start(&self, old_start: u32, new_start: u32) -> u32 {
106        if self.side == "old" {
107            old_start
108        } else {
109            new_start
110        }
111        .max(1)
112    }
113
114    /// The lines this annotates, as a reader writes them: `47`, or `47-52`.
115    ///
116    /// One place decides it, because `end_line` is `0` on a record written
117    /// before ranges existed and every consumer would otherwise have to know
118    /// that.
119    pub fn line_span(&self) -> String {
120        if self.end_line > self.line {
121            format!("{}-{}", self.line, self.end_line)
122        } else {
123            self.line.to_string()
124        }
125    }
126
127    /// Recompute the line numbers from the offset the anchor really carries.
128    ///
129    /// Clamped at 1, never at 0: a line number is 1-based, and an offset that
130    /// would put one above the top of the file is a broken anchor, not line
131    /// zero.
132    fn resolve(&mut self, start: u32) {
133        let at = i64::from(start) + i64::from(self.offset);
134        self.line = at.max(1).min(i64::from(u32::MAX)) as u32;
135        self.end_line = self.line.saturating_add(self.span);
136    }
137}
138
139/// The lines a reviewer pointed at, in file coordinates.
140///
141/// An observation, not a decision: a renderer reports what its cursor was on,
142/// and the engine turns it into an anchor — which side, how far into the hunk,
143/// how many lines, and what text to re-find it by. `None` at the call site
144/// means the whole hunk, which is what a finding filed from its header
145/// annotates.
146#[derive(Debug, Clone)]
147pub struct Lines {
148    /// "old" | "new"
149    pub side: String,
150    pub start: u32,
151    pub end: u32,
152    pub start_text: String,
153    pub end_text: String,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct Finding {
158    pub id: String,
159    /// Unix seconds.
160    pub created: u64,
161    pub body: String,
162    pub status: FindingStatus,
163    /// Reattached by content match rather than exact digest.
164    #[serde(default)]
165    pub moved: bool,
166    pub plan_hash: String,
167    pub anchor: Anchor,
168}
169
170impl Finding {
171    pub fn new(created: u64, body: String, plan_hash: String, anchor: Anchor) -> Self {
172        let mut h = Sha1::new();
173        h.update(anchor.hunk_digest.as_bytes());
174        h.update(created.to_le_bytes());
175        h.update(body.as_bytes());
176        Finding {
177            id: hex::encode(h.finalize())[..12].to_string(),
178            created,
179            body,
180            status: FindingStatus::Open,
181            moved: false,
182            plan_hash,
183            anchor,
184        }
185    }
186}
187
188/// Wall-clock seconds. The one reader is `ReviewSession::add_finding`, which
189/// passes the value into `Finding::new` — so the constructor stays a pure
190/// function of its arguments and the ids it produces are reproducible.
191///
192/// Not a `Clock` port: one reader, `SystemTime` is std rather than a project
193/// adapter, and nothing tests timestamps. That fails the bar for a new
194/// abstraction, and would cost `ReviewSession` a second type parameter.
195pub fn now_unix() -> u64 {
196    std::time::SystemTime::now()
197        .duration_since(std::time::UNIX_EPOCH)
198        .map(|d| d.as_secs())
199        .unwrap_or(0)
200}
201
202/// Fix a finding onto the hunk that now carries it: refresh its line from the
203/// hunk's start, record the plan it was re-read against, and revive it.
204///
205/// Both match paths in `reanchor` end here, and they differed in one bit —
206/// whether the note MOVED to a different hunk — so they were written out
207/// twice, five lines each. A note that reattaches always comes back from
208/// `Orphaned`: it was orphaned because nothing carried it, and something does
209/// now.
210fn reattach(f: &mut Finding, old_start: u32, new_start: u32, plan_hash: &str, moved: bool) {
211    let start = f.anchor.hunk_start(old_start, new_start);
212    f.anchor.resolve(start);
213    f.plan_hash = plan_hash.to_string();
214    f.moved = moved;
215    if f.status == FindingStatus::Orphaned {
216        f.status = FindingStatus::Open;
217    }
218}
219
220/// Re-anchor findings onto a (possibly regenerated) plan. Never drops:
221/// exact digest → reattach (position refreshed); same-file content match →
222/// reattach flagged `moved`; otherwise `orphaned` (revived automatically if a
223/// later plan matches again).
224pub fn reanchor(
225    findings: &mut [Finding],
226    doc: &schema::PlanDocument,
227    view: &DiffView,
228    plan_hash: &str,
229) {
230    for f in findings.iter_mut() {
231        if f.plan_hash == plan_hash {
232            continue; // written against this exact plan
233        }
234        // 1. Exact digest. The content is identical, so the offset holds and
235        //    only the hunk's position in the file has to be re-read.
236        if let Some(h) = doc.hunks.iter().find(|h| h.digest == f.anchor.hunk_digest) {
237            f.anchor.file = h.file.clone();
238            reattach(f, h.old_start, h.new_start, plan_hash, false);
239            continue;
240        }
241        // 2. Same-file content match on the anchored line text. The hunk is
242        //    not the one this was written against, so the offset is re-found
243        //    from where the text now sits inside it — the anchor's own side
244        //    first, since a line can appear on both.
245        let text = f.anchor.line_text.as_bytes();
246        let at = |lines: &[Vec<u8>]| lines.iter().position(|l| l == text);
247        let matched = (!text.is_empty())
248            .then(|| {
249                view.hunks.iter().enumerate().find(|(_, h)| {
250                    let file = view.file_of(h);
251                    file.path == f.anchor.file.as_bytes()
252                        && (at(&h.added).is_some() || at(&h.removed).is_some())
253                })
254            })
255            .flatten();
256        if let Some((hi, h)) = matched {
257            f.anchor.hunk_digest = doc.hunks[hi].digest.clone();
258            // An offset is a position in ONE side's numbering, so the side it
259            // was found on is the side it now belongs to. Keeping the old side
260            // while taking the fallback's index paired one side's offset with
261            // the other side's start, and the note landed on an unrelated line
262            // wherever `old_start` and `new_start` had diverged — silently,
263            // and reported as a clean re-anchor.
264            let own = if f.anchor.side == "old" { "old" } else { "new" };
265            let lines_of = |side: &str| if side == "old" { &h.removed } else { &h.added };
266            let found = at(lines_of(own))
267                .map(|p| (own, p))
268                .or_else(|| at(&h.added).map(|p| ("new", p)))
269                .or_else(|| at(&h.removed).map(|p| ("old", p)));
270            let (side, offset) = found.unwrap_or((own, 0));
271            f.anchor.side = side.to_string();
272            f.anchor.offset = offset as i32;
273            reattach(f, h.old_start, h.new_start, plan_hash, true);
274        } else {
275            f.status = FindingStatus::Orphaned;
276        }
277    }
278}