1use std::collections::{BTreeMap, BTreeSet};
19use std::path::Path;
20
21use serde::{Deserialize, Serialize};
22
23pub const STATE_FILENAME: &str = "nudge-state.toml";
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ReviewRecord {
30 pub text_hash: String,
31 pub body_hash: String,
32 pub reviewed: bool,
33}
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct Baseline {
39 pub score: f64,
40 pub tier: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct ThrottleRecord {
48 pub last_fired_epoch: u64,
50 pub last_surfaced_metric: f64,
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
57#[serde(default)]
58pub struct NudgeState {
59 pub edits_since_annotation: usize,
61 pub baseline: Option<Baseline>,
63 pub window_intent_ids: Option<BTreeSet<String>>,
69 pub reviewed: BTreeMap<String, ReviewRecord>,
71 pub proof_reviewed: BTreeMap<String, bool>,
73 pub throttle: BTreeMap<String, ThrottleRecord>,
75}
76
77impl NudgeState {
78 pub fn load(path: &Path) -> Self {
81 match std::fs::read_to_string(path) {
82 Ok(text) => toml::from_str(&text).unwrap_or_default(),
83 Err(_) => Self::default(),
84 }
85 }
86
87 pub fn save(&self, path: &Path) -> std::io::Result<()> {
89 if let Some(parent) = path.parent() {
90 std::fs::create_dir_all(parent)?;
91 }
92 let text = toml::to_string_pretty(self)
93 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
94 let tmp = path.with_extension("toml.tmp");
95 std::fs::write(&tmp, text)?;
96 std::fs::rename(&tmp, path)?;
97 Ok(())
98 }
99
100 pub fn unreviewed_count<'a>(
106 &self,
107 current: impl IntoIterator<Item = (&'a str, &'a str, &'a str)>,
108 ) -> usize {
109 current
110 .into_iter()
111 .filter(|(id, text_hash, body_hash)| !self.is_reviewed(id, text_hash, body_hash))
112 .count()
113 }
114
115 #[aristo::intent(
116 "A review is current only while the annotation's text AND body hashes \
117 still match the snapshot taken when it was reviewed. Editing the \
118 claim or the covered code after review re-opens it (reads as \
119 unreviewed) — a reviewer approved a specific version, not the id \
120 forever. Without the hash check, a post-review edit would keep a \
121 stale 'reviewed' badge and the review backlog would under-count work \
122 that genuinely needs another look.",
123 verify = "test",
124 id = "nudge_review_is_current_only_until_hash_drift"
125 )]
126 pub fn is_reviewed(&self, id: &str, text_hash: &str, body_hash: &str) -> bool {
129 match self.reviewed.get(id) {
130 Some(r) => r.reviewed && r.text_hash == text_hash && r.body_hash == body_hash,
131 None => false,
132 }
133 }
134
135 pub fn mark_reviewed(&mut self, id: &str, text_hash: &str, body_hash: &str) {
138 self.reviewed.insert(
139 id.to_string(),
140 ReviewRecord {
141 text_hash: text_hash.to_string(),
142 body_hash: body_hash.to_string(),
143 reviewed: true,
144 },
145 );
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 fn rec(text: &str, body: &str, reviewed: bool) -> ReviewRecord {
154 ReviewRecord {
155 text_hash: text.into(),
156 body_hash: body.into(),
157 reviewed,
158 }
159 }
160
161 #[test]
162 fn missing_file_loads_default_state() {
163 let dir = tempfile::tempdir().unwrap();
164 let s = NudgeState::load(&dir.path().join("nope.toml"));
165 assert_eq!(s, NudgeState::default());
166 }
167
168 #[test]
169 fn corrupt_file_loads_default_state() {
170 let dir = tempfile::tempdir().unwrap();
171 let p = dir.path().join(STATE_FILENAME);
172 std::fs::write(&p, "this is { not valid toml ][").unwrap();
173 assert_eq!(NudgeState::load(&p), NudgeState::default());
175 }
176
177 #[test]
178 fn save_then_load_round_trips() {
179 let dir = tempfile::tempdir().unwrap();
180 let p = dir.path().join(STATE_FILENAME);
181 let mut s = NudgeState {
182 edits_since_annotation: 4,
183 baseline: Some(Baseline {
184 score: 0.42,
185 tier: "Adept".into(),
186 }),
187 window_intent_ids: Some(BTreeSet::from(["aret_a".to_string(), "aret_b".to_string()])),
188 ..Default::default()
189 };
190 s.mark_reviewed("aret_abc12345", "sha256:t", "sha256:b");
191 s.proof_reviewed.insert("aret_abc12345".into(), true);
192 s.throttle.insert(
193 "review_backlog".into(),
194 ThrottleRecord {
195 last_fired_epoch: 1_700_000_000,
196 last_surfaced_metric: 3.0,
197 },
198 );
199 s.save(&p).unwrap();
200 assert_eq!(NudgeState::load(&p), s);
201 }
202
203 #[test]
204 fn unreviewed_counts_absent_unmarked_and_drifted() {
205 let mut s = NudgeState::default();
206 s.reviewed.insert("reviewed".into(), rec("t1", "b1", true));
207 s.reviewed.insert("unmarked".into(), rec("t2", "b2", false));
208 s.reviewed.insert("drifted".into(), rec("t3", "b3", true));
209
210 let current = vec![
211 ("reviewed", "t1", "b1"), ("unmarked", "t2", "b2"), ("drifted", "tX", "b3"), ("brand_new", "t4", "b4"), ];
216 assert_eq!(s.unreviewed_count(current), 3);
217 }
218
219 #[test]
220 fn body_drift_after_review_reopens_the_intent() {
221 let mut s = NudgeState::default();
222 s.mark_reviewed("x", "sha256:t", "sha256:b");
223 assert!(
224 s.is_reviewed("x", "sha256:t", "sha256:b"),
225 "fresh review holds"
226 );
227 assert!(
228 !s.is_reviewed("x", "sha256:t", "sha256:b2"),
229 "a body edit after review re-opens it"
230 );
231 }
232
233 #[test]
234 fn empty_reviewed_map_means_everything_unreviewed() {
235 let s = NudgeState::default();
236 let current = vec![("a", "t", "b"), ("c", "t", "b")];
237 assert_eq!(s.unreviewed_count(current), 2);
238 }
239}