differential_engine/review_session.rs
1//! An open review: the engine-owned session over one plan document.
2//!
3//! The engine is the backend; renderers are stateless frontends (ADR 0014).
4//! A `ReviewSession` owns the store, the document, the diff view and all
5//! mutable review state — reviewed marks, findings, resume cursor. Every
6//! mutation persists before returning, so a renderer can crash at any point
7//! without losing anything, and never touches the store itself.
8
9use std::collections::HashSet;
10
11use crate::schema;
12
13use crate::EngineError;
14use crate::forge::{self, ForgeError, OwnComment, Published, RemoteThread};
15use crate::model::DiffView;
16use crate::plan;
17use crate::ports::ReviewStore;
18use crate::review_state::{Anchor, Finding, FindingStatus, Lines, ReviewState, Upstream, reanchor};
19
20pub struct ReviewSession<S: ReviewStore> {
21 store: S,
22 doc: schema::PlanDocument,
23 /// Hunk BYTES. Not to be confused with `plan`, which is the document's
24 /// arithmetic — see `view()` and `plan()`.
25 view: DiffView,
26 plan: plan::ReviewView,
27 plan_hash: String,
28 state: ReviewState,
29 findings: Vec<Finding>,
30 /// The forge's threads, placed against THIS plan (ADR 0029). A cache of
31 /// what was last fetched, never the reader's own work.
32 threads: Vec<RemoteThread>,
33 /// The login the forge knows the reader as, once told. What makes a
34 /// comment with no marker and no record theirs.
35 me: Option<String>,
36}
37
38/// What a publish did to this session, for a renderer to say in its own
39/// words.
40#[derive(Debug)]
41pub struct Recorded {
42 /// Findings the refetched threads gave an address that the publish's
43 /// answer had not.
44 pub reconciled: usize,
45 /// Of the findings sent, how many now have an address — from the answer
46 /// or from a marker the refetch carried.
47 pub landed: usize,
48 /// The refetch that failed, when it did. The comments are on the request
49 /// regardless; the next fetch reconciles them.
50 pub refetch_failed: Option<ForgeError>,
51}
52
53impl<S: ReviewStore> ReviewSession<S> {
54 /// Open (or resume) the review identified by `(review_base, head_spec)`:
55 /// persist the plan, load and re-anchor findings, load state.
56 ///
57 /// `review_base`/`head_spec` are the review's IDENTITY, not necessarily
58 /// the diff endpoints: reviewing uncommitted changes keys on the HEAD sha
59 /// plus a stable literal while the synthesized trees churn.
60 pub fn open(store: S, doc: schema::PlanDocument, view: DiffView) -> Result<Self, EngineError> {
61 let json = doc.to_json()?;
62 let plan_hash = plan::plan_hash(&json);
63 store.save_plan(&plan_hash, &json)?;
64 let mut findings = store.load_findings()?;
65 reanchor(&mut findings, &doc, &view, &plan_hash);
66 store.save_findings(&findings)?;
67 let state = store.load_state()?;
68 // Placed afresh on every open: the cache may be from an older plan, and
69 // placement is a pure function of the forge's coordinates and this one.
70 let mut threads = store.load_threads()?;
71 for t in &mut threads {
72 forge::place(&doc, &view, t);
73 }
74
75 // The projection computes the reviewed-mark keys, so the session no
76 // longer derives its own copy of the same arithmetic.
77 let plan = plan::ReviewView::project(&doc)?;
78
79 Ok(ReviewSession {
80 store,
81 doc,
82 view,
83 plan,
84 plan_hash,
85 state,
86 findings,
87 threads,
88 me: None,
89 })
90 }
91
92 // ---------------------------------------------------------------- reads
93
94 pub fn doc(&self) -> &schema::PlanDocument {
95 &self.doc
96 }
97
98 /// The document's projection: groups, files, counts, dependency edges and
99 /// reviewed-mark keys. Renderers read this instead of re-deriving it.
100 pub fn plan(&self) -> &plan::ReviewView {
101 &self.plan
102 }
103
104 pub fn plan_hash(&self) -> &str {
105 &self.plan_hash
106 }
107
108 pub fn findings(&self) -> &[Finding] {
109 &self.findings
110 }
111
112 /// The forge's review threads as last fetched, placed against this plan.
113 pub fn threads(&self) -> &[RemoteThread] {
114 &self.threads
115 }
116
117 pub fn thread(&self, id: &str) -> Option<&RemoteThread> {
118 self.threads.iter().find(|t| t.id == id)
119 }
120
121 /// The login the forge knows the reader as, if the forge has been asked.
122 pub fn me(&self) -> Option<&str> {
123 self.me.as_deref()
124 }
125
126 /// Whether `comment` in `thread` is the reader's, and how to act on it:
127 /// by a linked record — a marker or a publish's recorded address — or by
128 /// author. `None` for anyone else's comment, which is reply-only.
129 pub fn own_comment(&self, thread: &str, comment: &str) -> Option<OwnComment> {
130 let t = self.thread(thread)?;
131 let c = t.comments.iter().find(|c| c.id == comment)?;
132 let linked = self
133 .findings
134 .iter()
135 .find(|f| links(f, &c.id, c.finding.as_deref()));
136 let mine = linked.is_some() || self.me.as_deref() == Some(c.author.as_str());
137 if !mine {
138 return None;
139 }
140 let at = match &t.anchor {
141 Some(a) => a.at(),
142 None => t.path.clone(),
143 };
144 Some(OwnComment {
145 thread: thread.to_string(),
146 comment: comment.to_string(),
147 finding: linked.map(|f| f.id.clone()),
148 body: c.body.clone(),
149 at,
150 })
151 }
152
153 /// The thread's root as the reader's own comment, if it is theirs.
154 pub fn own_root(&self, thread: &str) -> Option<OwnComment> {
155 let root = self.thread(thread)?.root()?.id.clone();
156 self.own_comment(thread, &root)
157 }
158
159 /// A published finding as the comment it became, whether or not its twin
160 /// has been fetched back yet.
161 pub fn own_of_finding(&self, id: &str) -> Option<OwnComment> {
162 let f = self.findings.iter().find(|f| f.id == id)?;
163 let up = f.upstream.as_ref()?;
164 Some(OwnComment {
165 thread: up.thread.clone(),
166 comment: up.comment.clone(),
167 finding: Some(f.id.clone()),
168 body: f.body.clone(),
169 at: f.anchor.at(),
170 })
171 }
172
173 /// Open findings not yet on the request: what `P` would send and what
174 /// `y` copies (ADR 0029).
175 pub fn unpublished(&self) -> impl Iterator<Item = &Finding> {
176 self.findings
177 .iter()
178 .filter(|f| f.status == FindingStatus::Open && f.upstream.is_none())
179 }
180
181 /// Whether a published finding's fetched twin is present, so the renderer
182 /// draws the thread and not the note.
183 pub fn is_twinned(&self, finding: &Finding) -> bool {
184 finding.upstream.is_some() && self.threads.iter().any(|t| t.is_twin_of(finding))
185 }
186
187 /// What a publish would send now, and what it would leave and why.
188 pub fn publish_plan(&self, kind: forge::ForgeKind) -> forge::PublishPlan {
189 forge::publish_plan(&self.doc, &self.findings, &self.threads, kind.line_rule())
190 }
191
192 /// The reviewed-mark key of `hunk` — its exact content digest.
193 pub fn hunk_key(&self, hunk: usize) -> &str {
194 self.plan.digest(plan::HunkId::from_index(hunk))
195 }
196
197 pub fn is_reviewed(&self, hunk_key: &str) -> bool {
198 self.state.reviewed_hunks.contains(hunk_key)
199 }
200
201 /// Marks that land on a hunk of THIS document.
202 ///
203 /// Keys from an earlier plan stay on disk and revive if their content
204 /// comes back, so counting the stored set would count hunks the reader
205 /// cannot see — and could outrun the total the renderer draws it against.
206 pub fn reviewed_count(&self) -> usize {
207 self.plan
208 .count_marked(|digest| self.state.reviewed_hunks.contains(digest))
209 }
210
211 /// Canonical hunk indices marked reviewed (owned — safe to hold while
212 /// borrowing the session elsewhere).
213 pub fn reviewed_hunks(&self) -> HashSet<usize> {
214 self.plan
215 .hunks_marked(|digest| self.state.reviewed_hunks.contains(digest))
216 .into_iter()
217 .map(|h| h.index())
218 .collect()
219 }
220
221 pub fn cursor(&self) -> Option<&(String, usize)> {
222 self.state.cursor.as_ref()
223 }
224
225 /// The reader's recorded layout choice, or `None` if they have not made
226 /// one and the caller should fall back to its configured default.
227 pub fn split_diff(&self) -> Option<bool> {
228 self.state.split_diff
229 }
230
231 pub fn file_view(&self) -> bool {
232 self.state.file_view
233 }
234
235 /// The reader's recorded wrap choice, or `None` if they have not pressed
236 /// `w` on this review.
237 pub fn wrap(&self) -> Option<bool> {
238 self.state.wrap
239 }
240
241 /// The open, unpublished findings as markdown: one `- file:lines: note`
242 /// per line.
243 ///
244 /// The human-readable projection of `findings()`, and domain policy rather
245 /// than a renderer's formatting — the reviewer's `y` and `dfr findings
246 /// --summary` are the same text, so one cannot drift from the other.
247 ///
248 /// Deliberately says nothing about groups. A group is how THIS reviewer
249 /// chose to read the branch, and the summary is pasted somewhere that has
250 /// no idea what `g7` was. A published finding is left out for the same
251 /// reason a group is: it is already where it was going (ADR 0029).
252 pub fn findings_summary(&self) -> String {
253 let mut out = String::new();
254 for f in self.unpublished() {
255 out.push_str(&format!(
256 "- {}:{}: {}\n",
257 f.anchor.file,
258 f.anchor.line_span(),
259 f.body
260 ));
261 }
262 if out.is_empty() {
263 out.push_str("(no open findings)\n");
264 }
265 out
266 }
267
268 // ---------------------------- mutations (each persists before returning)
269
270 /// Toggle the reviewed mark of `hunk` itself. Returns the new mark
271 /// (true = now reviewed).
272 pub fn toggle_reviewed(&mut self, hunk: usize) -> Result<bool, EngineError> {
273 let key = self.plan.digest(plan::HunkId::from_index(hunk)).to_string();
274 let now = self.state.reviewed_hunks.insert(key.clone());
275 if !now {
276 self.state.reviewed_hunks.remove(&key);
277 }
278 self.store.save_state(&self.state)?;
279 Ok(now)
280 }
281
282 /// Mark a whole set of hunks reviewed (or not) in one write.
283 ///
284 /// Set semantics, not toggle: a partially reviewed group resolves to the
285 /// requested state instead of inverting member by member, and the batch
286 /// costs one `save_state` rather than one per hunk.
287 pub fn set_reviewed(&mut self, hunk_keys: &[String], on: bool) -> Result<(), EngineError> {
288 for key in hunk_keys {
289 if on {
290 self.state.reviewed_hunks.insert(key.clone());
291 } else {
292 self.state.reviewed_hunks.remove(key);
293 }
294 }
295 self.store.save_state(&self.state)
296 }
297
298 /// Persist the resume position: (group id or file path, row offset).
299 pub fn save_cursor(&mut self, id: String, row: usize) -> Result<(), EngineError> {
300 self.state.cursor = Some((id, row));
301 self.store.save_state(&self.state)
302 }
303
304 /// Persist the diff layout (unified / side-by-side).
305 pub fn set_split_diff(&mut self, on: bool) -> Result<(), EngineError> {
306 self.state.split_diff = Some(on);
307 self.store.save_state(&self.state)
308 }
309
310 /// Persist the soft-wrap choice.
311 pub fn set_wrap(&mut self, on: bool) -> Result<(), EngineError> {
312 self.state.wrap = Some(on);
313 self.store.save_state(&self.state)
314 }
315
316 /// Persist the left-pane view (semantic groups / flat file list).
317 pub fn set_file_view(&mut self, on: bool) -> Result<(), EngineError> {
318 self.state.file_view = on;
319 self.store.save_state(&self.state)
320 }
321
322 /// Create a finding on `hunk` and persist it.
323 ///
324 /// `lines` is what the reviewer pointed at; `None` anchors the hunk's
325 /// first changed line, which is what a finding filed from its header
326 /// annotates. Either way the anchor is stored as an OFFSET into the hunk,
327 /// so it survives the hunk moving in the file (see `Anchor::offset`).
328 pub fn add_finding(
329 &mut self,
330 hunk: usize,
331 lines: Option<Lines>,
332 body: String,
333 ) -> Result<&Finding, EngineError> {
334 let h = &self.doc.hunks[hunk];
335 let lines = lines.unwrap_or_else(|| {
336 let vh = &self.view.hunks[hunk];
337 let text = vh
338 .added
339 .first()
340 .or(vh.removed.first())
341 .map(|l| String::from_utf8_lossy(l).into_owned())
342 .unwrap_or_default();
343 let new_side = h.new_count > 0;
344 let line = if new_side {
345 h.new_start.max(1)
346 } else {
347 h.old_start.max(1)
348 };
349 Lines {
350 side: if new_side { "new" } else { "old" }.into(),
351 start: line,
352 end: line,
353 start_text: text.clone(),
354 end_text: text,
355 }
356 });
357 let old_side = lines.side == "old";
358 let (start, count) = if old_side {
359 (h.old_start.max(1), h.old_count)
360 } else {
361 (h.new_start.max(1), h.new_count)
362 };
363 let end = lines.end.max(lines.start);
364
365 // The re-anchor key comes from the HUNK's own bytes wherever the line
366 // is one of its changed lines: `reanchor` matches against those bytes,
367 // and a renderer's text has been through tab expansion and trimming on
368 // the way to the screen. Outside the changed lines — a context line the
369 // reader expanded into view — there is nothing in the hunk to read, so
370 // what the renderer saw is what there is.
371 let vh = &self.view.hunks[hunk];
372 let side_lines = if old_side { &vh.removed } else { &vh.added };
373 let raw = |line: u32| -> Option<String> {
374 (line >= start && line < start.saturating_add(count))
375 .then(|| side_lines.get((line - start) as usize))
376 .flatten()
377 .map(|l| String::from_utf8_lossy(l).into_owned())
378 };
379 let line_text = raw(lines.start).unwrap_or(lines.start_text);
380 let end_line_text = raw(end).unwrap_or(lines.end_text);
381
382 let finding = Finding::new(
383 crate::review_state::now_unix(),
384 body,
385 self.plan_hash.clone(),
386 Anchor {
387 file: h.file.clone(),
388 side: lines.side,
389 line: lines.start,
390 end_line: end,
391 // Signed, and never clamped: a note on a context line ABOVE
392 // the hunk sits at a negative offset, and clamping it to zero
393 // silently walked the note down to the hunk's first line on
394 // the next regeneration.
395 offset: (i64::from(lines.start) - i64::from(start)) as i32,
396 span: end - lines.start,
397 hunk_digest: h.digest.clone(),
398 line_text,
399 end_line_text,
400 },
401 );
402 self.findings.push(finding);
403 self.store.save_findings(&self.findings)?;
404 Ok(self.findings.last().expect("just pushed"))
405 }
406
407 /// Rewrite a finding's body in place. Returns whether one was found.
408 ///
409 /// The id is a handle, not a hash of the text: rewriting a note is not
410 /// filing a different one, and the anchor it was written against is the
411 /// thing worth keeping. `plan_hash` stays too — the note still describes
412 /// the plan it was written on.
413 pub fn edit_finding(&mut self, id: &str, body: String) -> Result<bool, EngineError> {
414 let Some(f) = self.findings.iter_mut().find(|f| f.id == id) else {
415 return Ok(false);
416 };
417 f.body = body;
418 self.store.save_findings(&self.findings)?;
419 Ok(true)
420 }
421
422 /// Delete a finding by id. Returns whether anything was removed.
423 pub fn delete_finding(&mut self, id: &str) -> Result<bool, EngineError> {
424 let before = self.findings.len();
425 self.findings.retain(|f| f.id != id);
426 if self.findings.len() == before {
427 return Ok(false);
428 }
429 self.store.save_findings(&self.findings)?;
430 Ok(true)
431 }
432
433 /// Draft a reply under a forge thread: a finding that carries the thread's
434 /// id and sits where the thread does. Nothing reaches the forge until a
435 /// publish sends it (ADR 0029).
436 pub fn add_reply(&mut self, thread_id: &str, body: String) -> Result<&Finding, EngineError> {
437 let Some(thread) = self.threads.iter().find(|t| t.id == thread_id) else {
438 return Err(EngineError::PlanIntegrity(format!(
439 "no thread {thread_id} on this review"
440 )));
441 };
442 // A thread nothing in this plan holds has no row, so a reply under it
443 // would have none either: filed, listed as open, reachable nowhere.
444 // Refused instead; the forge's own page still takes a reply.
445 let Some(anchor) = thread.anchor.clone() else {
446 return Err(EngineError::PlanIntegrity(format!(
447 "thread {thread_id} has no line in this diff; reply on the forge"
448 )));
449 };
450 let mut finding = Finding::new(
451 crate::review_state::now_unix(),
452 body,
453 self.plan_hash.clone(),
454 anchor,
455 );
456 finding.reply_to = Some(thread_id.to_string());
457 self.findings.push(finding);
458 self.store.save_findings(&self.findings)?;
459 Ok(self.findings.last().expect("just pushed"))
460 }
461
462 /// Replace the thread cache with a fresh fetch, placed against this plan.
463 ///
464 /// Then reconcile: a finding with no upstream whose marker a fetched
465 /// comment carries IS published, whatever the publish's answer said, and
466 /// gets its address now. Returns how many were reconciled. This is what
467 /// makes a publish idempotent across a lost answer (ADR 0029).
468 ///
469 /// When the session knows who the reader is (`set_me`), a comment by that
470 /// author with no marker — one sent before markers existed, or written on
471 /// the forge's own page — is matched to an unpublished note on the same
472 /// file and line with the same text, and the two are linked; a reply the
473 /// same way, by thread and text. Weaker than the marker, and enough: the
474 /// same author, place and words.
475 pub fn set_threads(&mut self, mut threads: Vec<RemoteThread>) -> Result<usize, EngineError> {
476 for t in &mut threads {
477 forge::place(&self.doc, &self.view, t);
478 }
479 self.threads = threads;
480 let mut reconciled = 0;
481 // By marker first: exact.
482 for f in self.findings.iter_mut().filter(|f| f.upstream.is_none()) {
483 if let Some((t, c)) = self
484 .threads
485 .iter()
486 .find_map(|t| t.published_here(&f.id).map(|c| (t, c)))
487 {
488 f.upstream = Some(Upstream {
489 thread: t.id.clone(),
490 comment: c.id.clone(),
491 });
492 reconciled += 1;
493 }
494 }
495 // Then by author, place and words, for comments with no marker.
496 reconciled += self.heal_by_author();
497 self.store.save_threads(&self.threads)?;
498 if reconciled > 0 {
499 self.store.save_findings(&self.findings)?;
500 }
501 Ok(reconciled)
502 }
503
504 /// Link a comment by the reader that carries no marker to the note it
505 /// came from: same author, same file, side and line, same words — or,
506 /// for a reply, same thread and words. Returns how many were linked.
507 fn heal_by_author(&mut self) -> usize {
508 let Some(me) = self.me.as_deref() else {
509 return 0;
510 };
511 let same =
512 |a: &str, b: &str| a.replace("\r\n", "\n").trim() == b.replace("\r\n", "\n").trim();
513 let mut linked = 0;
514 for t in &mut self.threads {
515 let Some(anchor) = t.anchor.clone() else {
516 continue;
517 };
518 for (i, c) in t.comments.iter_mut().enumerate() {
519 if c.finding.is_some() || c.author != me {
520 continue;
521 }
522 let hit = self.findings.iter_mut().find(|f| {
523 f.upstream.is_none()
524 && same(&f.body, &c.body)
525 && if i == 0 {
526 f.reply_to.is_none()
527 && f.anchor.file == anchor.file
528 && f.anchor.side == anchor.side
529 && f.anchor.end_line.max(f.anchor.line)
530 == anchor.end_line.max(anchor.line)
531 } else {
532 f.reply_to.as_deref() == Some(t.id.as_str())
533 }
534 });
535 if let Some(f) = hit {
536 f.upstream = Some(Upstream {
537 thread: t.id.clone(),
538 comment: c.id.clone(),
539 });
540 c.finding = Some(f.id.clone());
541 linked += 1;
542 }
543 }
544 }
545 linked
546 }
547
548 /// Tell the session who the reader is on the forge. Asked of the forge
549 /// once per sitting; a comment by this author is the reader's own.
550 pub fn set_me(&mut self, login: String) {
551 self.me = Some(login);
552 }
553
554 /// Mirror a resolve the forge has already accepted. Returns whether the
555 /// thread was known.
556 pub fn set_thread_resolved(&mut self, id: &str, resolved: bool) -> Result<bool, EngineError> {
557 let Some(t) = self.threads.iter_mut().find(|t| t.id == id) else {
558 return Ok(false);
559 };
560 t.resolved = resolved;
561 self.store.save_threads(&self.threads)?;
562 Ok(true)
563 }
564
565 /// A comment of the reader's, rewritten: the forge has already taken the
566 /// new body, so the cached thread follows it, and the record too when a
567 /// finding is linked to the comment — fetched twin or not. Returns whether
568 /// anything was known.
569 pub fn edit_comment(
570 &mut self,
571 thread: &str,
572 comment: &str,
573 body: String,
574 ) -> Result<bool, EngineError> {
575 // The cached comment, when the twin has been fetched.
576 let mut linked = None;
577 let mut known = false;
578 if let Some(c) = self
579 .threads
580 .iter_mut()
581 .find(|t| t.id == thread)
582 .and_then(|t| t.comments.iter_mut().find(|c| c.id == comment))
583 {
584 c.body = body.clone();
585 linked = c.finding.clone();
586 known = true;
587 self.store.save_threads(&self.threads)?;
588 }
589 // The record, when one is linked — fetched twin or not.
590 if let Some(f) = self
591 .findings
592 .iter_mut()
593 .find(|f| links(f, comment, linked.as_deref()))
594 {
595 f.body = body;
596 known = true;
597 self.store.save_findings(&self.findings)?;
598 }
599 Ok(known)
600 }
601
602 /// A comment of the reader's the forge has already deleted: drop it from
603 /// the cache, the thread with it when nothing is left, and the linked
604 /// finding's record — fetched twin or not. Returns whether anything was
605 /// known.
606 pub fn delete_comment(&mut self, thread: &str, comment: &str) -> Result<bool, EngineError> {
607 let mut linked = None;
608 let mut known = false;
609 if let Some(t) = self.threads.iter_mut().find(|t| t.id == thread)
610 && let Some(pos) = t.comments.iter().position(|c| c.id == comment)
611 {
612 linked = t.comments.remove(pos).finding;
613 known = true;
614 self.threads.retain(|t| !t.comments.is_empty());
615 self.store.save_threads(&self.threads)?;
616 }
617 let before = self.findings.len();
618 self.findings
619 .retain(|f| !links(f, comment, linked.as_deref()));
620 if self.findings.len() != before {
621 known = true;
622 self.store.save_findings(&self.findings)?;
623 }
624 Ok(known)
625 }
626
627 /// Record where a publish put each finding, so the next publish sends
628 /// only what is new and the renderer can hide each behind its twin.
629 pub fn mark_published(&mut self, published: &[Published]) -> Result<usize, EngineError> {
630 let mut n = 0;
631 for p in published {
632 if let Some(f) = self.findings.iter_mut().find(|f| f.id == p.finding) {
633 f.upstream = Some(Upstream {
634 thread: p.thread.clone(),
635 comment: p.comment.clone(),
636 });
637 n += 1;
638 }
639 }
640 if n > 0 {
641 self.store.save_findings(&self.findings)?;
642 }
643 Ok(n)
644 }
645
646 /// Record a publish: the answer first, then what the refetched threads
647 /// carry by marker, then how much of THIS batch is now on the request.
648 ///
649 /// The order is the point, and it is written once so the reviewer's `P`
650 /// and `dfr findings --post` cannot count differently. A finding is
651 /// published when either the answer or a marker says so, and the count
652 /// reads the batch's findings afterwards rather than the answer alone: an
653 /// answer can be lost on the way back while the comments stand.
654 pub fn record_publish(
655 &mut self,
656 sent: &[String],
657 published: &[Published],
658 threads: Result<Vec<RemoteThread>, ForgeError>,
659 ) -> Result<Recorded, EngineError> {
660 self.mark_published(published)?;
661 let (reconciled, refetch_failed) = match threads {
662 Ok(threads) => (self.set_threads(threads)?, None),
663 Err(e) => (0, Some(e)),
664 };
665 let landed = sent
666 .iter()
667 .filter(|id| self.own_of_finding(id).is_some())
668 .count();
669 Ok(Recorded {
670 reconciled,
671 landed,
672 refetch_failed,
673 })
674 }
675
676 /// Delete every finding not on the request. Returns how many went.
677 ///
678 /// A published finding is kept: its record is what lets the reader edit or
679 /// delete the comment on the forge, and what stops the next publish from
680 /// sending it again (ADR 0029). Deleting one is `dd`, which asks.
681 ///
682 /// One write, not one per note: the store rewrites the whole file on every
683 /// save, so a loop over `delete_finding` would rewrite it N times to reach
684 /// the same file.
685 pub fn clear_findings(&mut self) -> Result<usize, EngineError> {
686 let before = self.findings.len();
687 self.findings.retain(|f| f.upstream.is_some());
688 let n = before - self.findings.len();
689 if n == 0 {
690 return Ok(0);
691 }
692 self.store.save_findings(&self.findings)?;
693 Ok(n)
694 }
695}
696
697/// Whether `f` is the record of comment `comment`: the comment's marker names
698/// it, or the record's address names the comment. Three places asked this
699/// with three spellings.
700fn links(f: &Finding, comment: &str, marker_finding: Option<&str>) -> bool {
701 marker_finding == Some(f.id.as_str())
702 || f.upstream.as_ref().is_some_and(|u| u.comment == comment)
703}