use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
pub const STATE_FILENAME: &str = "nudge-state.toml";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewRecord {
pub text_hash: String,
pub body_hash: String,
pub reviewed: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Baseline {
pub score: f64,
pub tier: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ThrottleRecord {
pub last_fired_epoch: u64,
pub last_surfaced_metric: f64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct NudgeState {
pub edits_since_annotation: usize,
pub baseline: Option<Baseline>,
pub window_intent_ids: Option<BTreeSet<String>>,
pub reviewed: BTreeMap<String, ReviewRecord>,
pub proof_reviewed: BTreeMap<String, bool>,
pub throttle: BTreeMap<String, ThrottleRecord>,
}
impl NudgeState {
pub fn load(path: &Path) -> Self {
match std::fs::read_to_string(path) {
Ok(text) => toml::from_str(&text).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = toml::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn unreviewed_count<'a>(
&self,
current: impl IntoIterator<Item = (&'a str, &'a str, &'a str)>,
) -> usize {
current
.into_iter()
.filter(|(id, text_hash, body_hash)| !self.is_reviewed(id, text_hash, body_hash))
.count()
}
#[aristo::intent(
"A review is current only while the annotation's text AND body hashes \
still match the snapshot taken when it was reviewed. Editing the \
claim or the covered code after review re-opens it (reads as \
unreviewed) — a reviewer approved a specific version, not the id \
forever. Without the hash check, a post-review edit would keep a \
stale 'reviewed' badge and the review backlog would under-count work \
that genuinely needs another look.",
verify = "test",
id = "nudge_review_is_current_only_until_hash_drift"
)]
pub fn is_reviewed(&self, id: &str, text_hash: &str, body_hash: &str) -> bool {
match self.reviewed.get(id) {
Some(r) => r.reviewed && r.text_hash == text_hash && r.body_hash == body_hash,
None => false,
}
}
pub fn mark_reviewed(&mut self, id: &str, text_hash: &str, body_hash: &str) {
self.reviewed.insert(
id.to_string(),
ReviewRecord {
text_hash: text_hash.to_string(),
body_hash: body_hash.to_string(),
reviewed: true,
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(text: &str, body: &str, reviewed: bool) -> ReviewRecord {
ReviewRecord {
text_hash: text.into(),
body_hash: body.into(),
reviewed,
}
}
#[test]
fn missing_file_loads_default_state() {
let dir = tempfile::tempdir().unwrap();
let s = NudgeState::load(&dir.path().join("nope.toml"));
assert_eq!(s, NudgeState::default());
}
#[test]
fn corrupt_file_loads_default_state() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(STATE_FILENAME);
std::fs::write(&p, "this is { not valid toml ][").unwrap();
assert_eq!(NudgeState::load(&p), NudgeState::default());
}
#[test]
fn save_then_load_round_trips() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(STATE_FILENAME);
let mut s = NudgeState {
edits_since_annotation: 4,
baseline: Some(Baseline {
score: 0.42,
tier: "Adept".into(),
}),
window_intent_ids: Some(BTreeSet::from(["aret_a".to_string(), "aret_b".to_string()])),
..Default::default()
};
s.mark_reviewed("aret_abc12345", "sha256:t", "sha256:b");
s.proof_reviewed.insert("aret_abc12345".into(), true);
s.throttle.insert(
"review_backlog".into(),
ThrottleRecord {
last_fired_epoch: 1_700_000_000,
last_surfaced_metric: 3.0,
},
);
s.save(&p).unwrap();
assert_eq!(NudgeState::load(&p), s);
}
#[test]
fn unreviewed_counts_absent_unmarked_and_drifted() {
let mut s = NudgeState::default();
s.reviewed.insert("reviewed".into(), rec("t1", "b1", true));
s.reviewed.insert("unmarked".into(), rec("t2", "b2", false));
s.reviewed.insert("drifted".into(), rec("t3", "b3", true));
let current = vec![
("reviewed", "t1", "b1"), ("unmarked", "t2", "b2"), ("drifted", "tX", "b3"), ("brand_new", "t4", "b4"), ];
assert_eq!(s.unreviewed_count(current), 3);
}
#[test]
fn body_drift_after_review_reopens_the_intent() {
let mut s = NudgeState::default();
s.mark_reviewed("x", "sha256:t", "sha256:b");
assert!(
s.is_reviewed("x", "sha256:t", "sha256:b"),
"fresh review holds"
);
assert!(
!s.is_reviewed("x", "sha256:t", "sha256:b2"),
"a body edit after review re-opens it"
);
}
#[test]
fn empty_reviewed_map_means_everything_unreviewed() {
let s = NudgeState::default();
let current = vec![("a", "t", "b"), ("c", "t", "b")];
assert_eq!(s.unreviewed_count(current), 2);
}
}