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}