1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
//! An open review: the engine-owned session over one plan document.
//!
//! The engine is the backend; renderers are stateless frontends (ADR 0014).
//! A `ReviewSession` owns the store, the document, the diff view and all
//! mutable review state — reviewed marks, findings, resume cursor. Every
//! mutation persists before returning, so a renderer can crash at any point
//! without losing anything, and never touches the store itself.
use std::collections::HashSet;
use crate::schema;
use crate::EngineError;
use crate::model::DiffView;
use crate::plan;
use crate::ports::ReviewStore;
use crate::review_state::{Anchor, Finding, FindingStatus, Lines, ReviewState, reanchor};
pub struct ReviewSession<S: ReviewStore> {
store: S,
doc: schema::PlanDocument,
/// Hunk BYTES. Not to be confused with `plan`, which is the document's
/// arithmetic — see `view()` and `plan()`.
view: DiffView,
plan: plan::ReviewView,
plan_hash: String,
state: ReviewState,
findings: Vec<Finding>,
}
impl<S: ReviewStore> ReviewSession<S> {
/// Open (or resume) the review identified by `(review_base, head_spec)`:
/// persist the plan, load and re-anchor findings, load state.
///
/// `review_base`/`head_spec` are the review's IDENTITY, not necessarily
/// the diff endpoints: reviewing uncommitted changes keys on the HEAD sha
/// plus a stable literal while the synthesized trees churn.
pub fn open(store: S, doc: schema::PlanDocument, view: DiffView) -> Result<Self, EngineError> {
let json = doc.to_json()?;
let plan_hash = plan::plan_hash(&json);
store.save_plan(&plan_hash, &json)?;
let mut findings = store.load_findings()?;
reanchor(&mut findings, &doc, &view, &plan_hash);
store.save_findings(&findings)?;
let state = store.load_state()?;
// The projection computes the reviewed-mark keys, so the session no
// longer derives its own copy of the same arithmetic.
let plan = plan::ReviewView::project(&doc)?;
Ok(ReviewSession {
store,
doc,
view,
plan,
plan_hash,
state,
findings,
})
}
// ---------------------------------------------------------------- reads
pub fn doc(&self) -> &schema::PlanDocument {
&self.doc
}
/// The document's projection: groups, files, counts, dependency edges and
/// reviewed-mark keys. Renderers read this instead of re-deriving it.
pub fn plan(&self) -> &plan::ReviewView {
&self.plan
}
pub fn plan_hash(&self) -> &str {
&self.plan_hash
}
pub fn findings(&self) -> &[Finding] {
&self.findings
}
/// The reviewed-mark key of `hunk` — its exact content digest.
pub fn hunk_key(&self, hunk: usize) -> &str {
self.plan.digest(plan::HunkId::from_index(hunk))
}
pub fn is_reviewed(&self, hunk_key: &str) -> bool {
self.state.reviewed_hunks.contains(hunk_key)
}
/// Marks that land on a hunk of THIS document.
///
/// Keys from an earlier plan stay on disk and revive if their content
/// comes back, so counting the stored set would count hunks the reader
/// cannot see — and could outrun the total the renderer draws it against.
pub fn reviewed_count(&self) -> usize {
self.plan
.count_marked(|digest| self.state.reviewed_hunks.contains(digest))
}
/// Canonical hunk indices marked reviewed (owned — safe to hold while
/// borrowing the session elsewhere).
pub fn reviewed_hunks(&self) -> HashSet<usize> {
self.plan
.hunks_marked(|digest| self.state.reviewed_hunks.contains(digest))
.into_iter()
.map(|h| h.index())
.collect()
}
pub fn cursor(&self) -> Option<&(String, usize)> {
self.state.cursor.as_ref()
}
/// The reader's recorded layout choice, or `None` if they have not made
/// one and the caller should fall back to its configured default.
pub fn split_diff(&self) -> Option<bool> {
self.state.split_diff
}
pub fn file_view(&self) -> bool {
self.state.file_view
}
/// The reader's recorded wrap choice, or `None` if they have not pressed
/// `w` on this review.
pub fn wrap(&self) -> Option<bool> {
self.state.wrap
}
/// The open findings as markdown: one `- file:lines: note` per line.
///
/// The human-readable projection of `findings()`, and domain policy rather
/// than a renderer's formatting — the reviewer's `y` and `dfr findings
/// --summary` are the same text, so one cannot drift from the other.
///
/// Deliberately says nothing about groups. A group is how THIS reviewer
/// chose to read the branch, and the summary is pasted somewhere that has
/// no idea what `g7` was.
pub fn findings_summary(&self) -> String {
let mut out = String::new();
for f in self
.findings
.iter()
.filter(|f| f.status == FindingStatus::Open)
{
out.push_str(&format!(
"- {}:{}: {}\n",
f.anchor.file,
f.anchor.line_span(),
f.body
));
}
if out.is_empty() {
out.push_str("(no open findings)\n");
}
out
}
// ---------------------------- mutations (each persists before returning)
/// Toggle the reviewed mark of `hunk` itself. Returns the new mark
/// (true = now reviewed).
pub fn toggle_reviewed(&mut self, hunk: usize) -> Result<bool, EngineError> {
let key = self.plan.digest(plan::HunkId::from_index(hunk)).to_string();
let now = self.state.reviewed_hunks.insert(key.clone());
if !now {
self.state.reviewed_hunks.remove(&key);
}
self.store.save_state(&self.state)?;
Ok(now)
}
/// Mark a whole set of hunks reviewed (or not) in one write.
///
/// Set semantics, not toggle: a partially reviewed group resolves to the
/// requested state instead of inverting member by member, and the batch
/// costs one `save_state` rather than one per hunk.
pub fn set_reviewed(&mut self, hunk_keys: &[String], on: bool) -> Result<(), EngineError> {
for key in hunk_keys {
if on {
self.state.reviewed_hunks.insert(key.clone());
} else {
self.state.reviewed_hunks.remove(key);
}
}
self.store.save_state(&self.state)
}
/// Persist the resume position: (group id or file path, row offset).
pub fn save_cursor(&mut self, id: String, row: usize) -> Result<(), EngineError> {
self.state.cursor = Some((id, row));
self.store.save_state(&self.state)
}
/// Persist the diff layout (unified / side-by-side).
pub fn set_split_diff(&mut self, on: bool) -> Result<(), EngineError> {
self.state.split_diff = Some(on);
self.store.save_state(&self.state)
}
/// Persist the soft-wrap choice.
pub fn set_wrap(&mut self, on: bool) -> Result<(), EngineError> {
self.state.wrap = Some(on);
self.store.save_state(&self.state)
}
/// Persist the left-pane view (semantic groups / flat file list).
pub fn set_file_view(&mut self, on: bool) -> Result<(), EngineError> {
self.state.file_view = on;
self.store.save_state(&self.state)
}
/// Create a finding on `hunk` and persist it.
///
/// `lines` is what the reviewer pointed at; `None` anchors the hunk's
/// first changed line, which is what a finding filed from its header
/// annotates. Either way the anchor is stored as an OFFSET into the hunk,
/// so it survives the hunk moving in the file (see `Anchor::offset`).
pub fn add_finding(
&mut self,
hunk: usize,
lines: Option<Lines>,
body: String,
) -> Result<&Finding, EngineError> {
let h = &self.doc.hunks[hunk];
let lines = lines.unwrap_or_else(|| {
let vh = &self.view.hunks[hunk];
let text = vh
.added
.first()
.or(vh.removed.first())
.map(|l| String::from_utf8_lossy(l).into_owned())
.unwrap_or_default();
let new_side = h.new_count > 0;
let line = if new_side {
h.new_start.max(1)
} else {
h.old_start.max(1)
};
Lines {
side: if new_side { "new" } else { "old" }.into(),
start: line,
end: line,
start_text: text.clone(),
end_text: text,
}
});
let old_side = lines.side == "old";
let (start, count) = if old_side {
(h.old_start.max(1), h.old_count)
} else {
(h.new_start.max(1), h.new_count)
};
let end = lines.end.max(lines.start);
// The re-anchor key comes from the HUNK's own bytes wherever the line
// is one of its changed lines: `reanchor` matches against those bytes,
// and a renderer's text has been through tab expansion and trimming on
// the way to the screen. Outside the changed lines — a context line the
// reader expanded into view — there is nothing in the hunk to read, so
// what the renderer saw is what there is.
let vh = &self.view.hunks[hunk];
let side_lines = if old_side { &vh.removed } else { &vh.added };
let raw = |line: u32| -> Option<String> {
(line >= start && line < start.saturating_add(count))
.then(|| side_lines.get((line - start) as usize))
.flatten()
.map(|l| String::from_utf8_lossy(l).into_owned())
};
let line_text = raw(lines.start).unwrap_or(lines.start_text);
let end_line_text = raw(end).unwrap_or(lines.end_text);
let finding = Finding::new(
crate::review_state::now_unix(),
body,
self.plan_hash.clone(),
Anchor {
file: h.file.clone(),
side: lines.side,
line: lines.start,
end_line: end,
// Signed, and never clamped: a note on a context line ABOVE
// the hunk sits at a negative offset, and clamping it to zero
// silently walked the note down to the hunk's first line on
// the next regeneration.
offset: (i64::from(lines.start) - i64::from(start)) as i32,
span: end - lines.start,
hunk_digest: h.digest.clone(),
line_text,
end_line_text,
},
);
self.findings.push(finding);
self.store.save_findings(&self.findings)?;
Ok(self.findings.last().expect("just pushed"))
}
/// Rewrite a finding's body in place. Returns whether one was found.
///
/// The id is a handle, not a hash of the text: rewriting a note is not
/// filing a different one, and the anchor it was written against is the
/// thing worth keeping. `plan_hash` stays too — the note still describes
/// the plan it was written on.
pub fn edit_finding(&mut self, id: &str, body: String) -> Result<bool, EngineError> {
let Some(f) = self.findings.iter_mut().find(|f| f.id == id) else {
return Ok(false);
};
f.body = body;
self.store.save_findings(&self.findings)?;
Ok(true)
}
/// Delete a finding by id. Returns whether anything was removed.
pub fn delete_finding(&mut self, id: &str) -> Result<bool, EngineError> {
let before = self.findings.len();
self.findings.retain(|f| f.id != id);
if self.findings.len() == before {
return Ok(false);
}
self.store.save_findings(&self.findings)?;
Ok(true)
}
/// Delete every finding. Returns how many there were.
///
/// One write, not one per note: the store rewrites the whole file on every
/// save, so a loop over `delete_finding` would rewrite it N times to reach
/// the same empty file.
pub fn clear_findings(&mut self) -> Result<usize, EngineError> {
let n = self.findings.len();
if n == 0 {
return Ok(0);
}
self.findings.clear();
self.store.save_findings(&self.findings)?;
Ok(n)
}
}