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, PartialEq, Eq, 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    /// Where this is, as a reader writes it: `src/app.rs:47-52`.
115    pub fn at(&self) -> String {
116        format!("{}:{}", self.file, self.line_span())
117    }
118
119    /// The lines this annotates, as a reader writes them: `47`, or `47-52`.
120    ///
121    /// One place decides it, because `end_line` is `0` on a record written
122    /// before ranges existed and every consumer would otherwise have to know
123    /// that.
124    pub fn line_span(&self) -> String {
125        if self.end_line > self.line {
126            format!("{}-{}", self.line, self.end_line)
127        } else {
128            self.line.to_string()
129        }
130    }
131
132    /// Recompute the line numbers from the offset the anchor really carries.
133    ///
134    /// Clamped at 1, never at 0: a line number is 1-based, and an offset that
135    /// would put one above the top of the file is a broken anchor, not line
136    /// zero.
137    fn resolve(&mut self, start: u32) {
138        let at = i64::from(start) + i64::from(self.offset);
139        self.line = at.max(1).min(i64::from(u32::MAX)) as u32;
140        self.end_line = self.line.saturating_add(self.span);
141    }
142}
143
144/// The lines a reviewer pointed at, in file coordinates.
145///
146/// An observation, not a decision: a renderer reports what its cursor was on,
147/// and the engine turns it into an anchor — which side, how far into the hunk,
148/// how many lines, and what text to re-find it by. `None` at the call site
149/// means the whole hunk, which is what a finding filed from its header
150/// annotates.
151#[derive(Debug, Clone)]
152pub struct Lines {
153    /// "old" | "new"
154    pub side: String,
155    pub start: u32,
156    pub end: u32,
157    pub start_text: String,
158    pub end_text: String,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct Finding {
163    pub id: String,
164    /// Unix seconds.
165    pub created: u64,
166    pub body: String,
167    pub status: FindingStatus,
168    /// Reattached by content match rather than exact digest.
169    #[serde(default)]
170    pub moved: bool,
171    pub plan_hash: String,
172    pub anchor: Anchor,
173    /// The forge thread this answers, when the note is a reply drafted under
174    /// one. A reply needs no line to publish; it needs its thread.
175    #[serde(default)]
176    pub reply_to: Option<String>,
177    /// Where this landed on the forge, once published. `Some` is what keeps a
178    /// second publish from sending it again, and what hides it behind the
179    /// fetched thread it became.
180    #[serde(default)]
181    pub upstream: Option<Upstream>,
182}
183
184/// A published finding's address on the forge.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186pub struct Upstream {
187    pub thread: String,
188    pub comment: String,
189}
190
191impl Finding {
192    pub fn new(created: u64, body: String, plan_hash: String, anchor: Anchor) -> Self {
193        let mut h = Sha1::new();
194        h.update(anchor.hunk_digest.as_bytes());
195        h.update(created.to_le_bytes());
196        h.update(body.as_bytes());
197        Finding {
198            id: hex::encode(h.finalize())[..12].to_string(),
199            created,
200            body,
201            status: FindingStatus::Open,
202            moved: false,
203            plan_hash,
204            anchor,
205            reply_to: None,
206            upstream: None,
207        }
208    }
209}
210
211/// Wall-clock seconds. The one reader is `ReviewSession::add_finding`, which
212/// passes the value into `Finding::new` — so the constructor stays a pure
213/// function of its arguments and the ids it produces are reproducible.
214///
215/// Not a `Clock` port: one reader, `SystemTime` is std rather than a project
216/// adapter, and nothing tests timestamps. That fails the bar for a new
217/// abstraction, and would cost `ReviewSession` a second type parameter.
218pub fn now_unix() -> u64 {
219    std::time::SystemTime::now()
220        .duration_since(std::time::UNIX_EPOCH)
221        .map(|d| d.as_secs())
222        .unwrap_or(0)
223}
224
225/// Fix a finding onto the hunk that now carries it: refresh its line from the
226/// hunk's start, record the plan it was re-read against, and revive it.
227///
228/// Both match paths in `reanchor` end here, and they differed in one bit —
229/// whether the note MOVED to a different hunk — so they were written out
230/// twice, five lines each. A note that reattaches always comes back from
231/// `Orphaned`: it was orphaned because nothing carried it, and something does
232/// now.
233fn reattach(f: &mut Finding, old_start: u32, new_start: u32, plan_hash: &str, moved: bool) {
234    let start = f.anchor.hunk_start(old_start, new_start);
235    f.anchor.resolve(start);
236    f.plan_hash = plan_hash.to_string();
237    f.moved = moved;
238    if f.status == FindingStatus::Orphaned {
239        f.status = FindingStatus::Open;
240    }
241}
242
243/// Re-anchor findings onto a (possibly regenerated) plan. Never drops:
244/// exact digest → reattach (position refreshed); same-file content match →
245/// reattach flagged `moved`; otherwise `orphaned` (revived automatically if a
246/// later plan matches again).
247pub fn reanchor(
248    findings: &mut [Finding],
249    doc: &schema::PlanDocument,
250    view: &DiffView,
251    plan_hash: &str,
252) {
253    for f in findings.iter_mut() {
254        if f.plan_hash == plan_hash {
255            continue; // written against this exact plan
256        }
257        // 1. Exact digest. The content is identical, so the offset holds and
258        //    only the hunk's position in the file has to be re-read.
259        if let Some(h) = doc.hunks.iter().find(|h| h.digest == f.anchor.hunk_digest) {
260            f.anchor.file = h.file.clone();
261            reattach(f, h.old_start, h.new_start, plan_hash, false);
262            continue;
263        }
264        // 2. Same-file content match on the anchored line text. The hunk is
265        //    not the one this was written against, so the offset is re-found
266        //    from where the text now sits inside it — the anchor's own side
267        //    first, since a line can appear on both.
268        let text = f.anchor.line_text.as_bytes();
269        let at = |lines: &[Vec<u8>]| lines.iter().position(|l| l == text);
270        let matched = (!text.is_empty())
271            .then(|| {
272                view.hunks.iter().enumerate().find(|(_, h)| {
273                    let file = view.file_of(h);
274                    file.path == f.anchor.file.as_bytes()
275                        && (at(&h.added).is_some() || at(&h.removed).is_some())
276                })
277            })
278            .flatten();
279        if let Some((hi, h)) = matched {
280            f.anchor.hunk_digest = doc.hunks[hi].digest.clone();
281            // An offset is a position in ONE side's numbering, so the side it
282            // was found on is the side it now belongs to. Keeping the old side
283            // while taking the fallback's index paired one side's offset with
284            // the other side's start, and the note landed on an unrelated line
285            // wherever `old_start` and `new_start` had diverged — silently,
286            // and reported as a clean re-anchor.
287            let own = if f.anchor.side == "old" { "old" } else { "new" };
288            let lines_of = |side: &str| if side == "old" { &h.removed } else { &h.added };
289            let found = at(lines_of(own))
290                .map(|p| (own, p))
291                .or_else(|| at(&h.added).map(|p| ("new", p)))
292                .or_else(|| at(&h.removed).map(|p| ("old", p)));
293            let (side, offset) = found.unwrap_or((own, 0));
294            f.anchor.side = side.to_string();
295            f.anchor.offset = offset as i32;
296            reattach(f, h.old_start, h.new_start, plan_hash, true);
297        } else {
298            f.status = FindingStatus::Orphaned;
299        }
300    }
301}