Skip to main content

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}