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}