Skip to main content

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 key on class CONTENT (sorted member
5//! digests), and findings anchor on exact hunk digests — so both survive the
6//! head moving and positional ids shifting. 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 and class keys are domain policy and live in `plan`; they
20// are re-exported here because this is where consumers of the store expect to
21// find them, and moving the names would break them for no gain.
22pub use crate::plan::{class_content_key, review_id};
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct ReviewState {
26    /// Class content keys marked reviewed.
27    #[serde(default)]
28    pub reviewed_classes: BTreeSet<String>,
29    /// Resume position: (group id or file path, row offset) in the last-open
30    /// plan — a group id in the semantic view, a file path in the file view.
31    #[serde(default)]
32    pub cursor: Option<(String, usize)>,
33    /// Side-by-side diff layout (default: unified).
34    #[serde(default)]
35    pub split_diff: bool,
36    /// Flattened per-file view instead of semantic groups (default: groups).
37    #[serde(default)]
38    pub file_view: bool,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum FindingStatus {
44    Open,
45    Resolved,
46    Orphaned,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct Anchor {
51    pub file: String,
52    /// "old" | "new"
53    pub side: String,
54    pub line: u32,
55    pub hunk_digest: String,
56    /// The anchored line's text — the fuzzy re-anchor key.
57    #[serde(default)]
58    pub line_text: String,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct Finding {
63    pub id: String,
64    /// Unix seconds.
65    pub created: u64,
66    pub body: String,
67    pub status: FindingStatus,
68    /// Reattached by content match rather than exact digest.
69    #[serde(default)]
70    pub moved: bool,
71    pub plan_hash: String,
72    pub anchor: Anchor,
73}
74
75impl Finding {
76    pub fn new(created: u64, body: String, plan_hash: String, anchor: Anchor) -> Self {
77        let mut h = Sha1::new();
78        h.update(anchor.hunk_digest.as_bytes());
79        h.update(created.to_le_bytes());
80        h.update(body.as_bytes());
81        Finding {
82            id: hex::encode(h.finalize())[..12].to_string(),
83            created,
84            body,
85            status: FindingStatus::Open,
86            moved: false,
87            plan_hash,
88            anchor,
89        }
90    }
91}
92
93/// Wall-clock seconds. The one reader is `ReviewSession::add_finding`, which
94/// passes the value into `Finding::new` — so the constructor stays a pure
95/// function of its arguments and the ids it produces are reproducible.
96///
97/// Not a `Clock` port: one reader, `SystemTime` is std rather than a project
98/// adapter, and nothing tests timestamps. That fails the bar for a new
99/// abstraction, and would cost `ReviewSession` a second type parameter.
100pub fn now_unix() -> u64 {
101    std::time::SystemTime::now()
102        .duration_since(std::time::UNIX_EPOCH)
103        .map(|d| d.as_secs())
104        .unwrap_or(0)
105}
106
107/// Re-anchor findings onto a (possibly regenerated) plan. Never drops:
108/// exact digest → reattach (position refreshed); same-file content match →
109/// reattach flagged `moved`; otherwise `orphaned` (revived automatically if a
110/// later plan matches again).
111pub fn reanchor(
112    findings: &mut [Finding],
113    doc: &schema::PlanDocument,
114    view: &DiffView,
115    plan_hash: &str,
116) {
117    for f in findings.iter_mut() {
118        if f.plan_hash == plan_hash {
119            continue; // written against this exact plan
120        }
121        // 1. Exact digest.
122        if let Some((idx, h)) = doc
123            .hunks
124            .iter()
125            .enumerate()
126            .find(|(_, h)| h.digest == f.anchor.hunk_digest)
127        {
128            let _ = idx;
129            f.anchor.file = h.file.clone();
130            f.anchor.line = if f.anchor.side == "old" {
131                h.old_start.max(1)
132            } else {
133                h.new_start.max(1)
134            };
135            f.plan_hash = plan_hash.to_string();
136            f.moved = false;
137            if f.status == FindingStatus::Orphaned {
138                f.status = FindingStatus::Open;
139            }
140            continue;
141        }
142        // 2. Same-file content match on the anchored line text.
143        let text = f.anchor.line_text.as_bytes();
144        let matched = (!text.is_empty())
145            .then(|| {
146                view.hunks.iter().enumerate().find(|(_, h)| {
147                    let file = view.file_of(h);
148                    file.path == f.anchor.file.as_bytes()
149                        && (h.added.iter().any(|l| l == text)
150                            || h.removed.iter().any(|l| l == text))
151                })
152            })
153            .flatten();
154        if let Some((hi, h)) = matched {
155            f.anchor.hunk_digest = doc.hunks[hi].digest.clone();
156            f.anchor.line = if f.anchor.side == "old" {
157                h.old_start.max(1)
158            } else {
159                h.new_start.max(1)
160            };
161            f.plan_hash = plan_hash.to_string();
162            f.moved = true;
163            if f.status == FindingStatus::Orphaned {
164                f.status = FindingStatus::Open;
165            }
166        } else {
167            f.status = FindingStatus::Orphaned;
168        }
169    }
170}