Skip to main content

aristo_cli/nudge/
intents.rs

1//! Enumerate authored intents for the reviewed/unreviewed axis (Phase 18 #7).
2//!
3//! One function, [`authored_intents`], is the single source of truth for
4//! "what is a reviewable authored intent" — both the engine's `review_backlog`
5//! signal (via the union function) and the `aristo review` surface read it, so
6//! they can never disagree on the backlog.
7
8use aristo_core::index::{IndexEntry, IndexFile, Status, VerifyLevel};
9
10/// One authored intent, carrying the fields the review surface and the
11/// reviewed map need. The hashes are stringified so they line up with the
12/// `NudgeState` reviewed map (`id → {text_hash, body_hash}`).
13#[derive(Debug, Clone)]
14pub struct AuthoredIntent {
15    pub id: String,
16    pub text: String,
17    pub file: String,
18    pub site: String,
19    pub status: Status,
20    pub verify: VerifyLevel,
21    pub text_hash: String,
22    pub body_hash: String,
23}
24
25#[aristo::intent(
26    "Authored intents are exactly the `IndexEntry::Intent` entries — every \
27     one, including documentation-only `verify = false` intents (still claims \
28     the user authored and may want to review). Assumes are excluded: they \
29     state external invariants, not reviewable claims. This is the SAME set \
30     the engine's review_backlog metric counts, so `aristo review` and the \
31     nudge can never report a different backlog size for the same index.",
32    verify = "test",
33    id = "authored_intents_are_exactly_the_index_intents"
34)]
35/// Every authored intent in the index, in id order (the `BTreeMap` iteration
36/// order — deterministic).
37pub fn authored_intents(index: &IndexFile) -> Vec<AuthoredIntent> {
38    index
39        .entries
40        .iter()
41        .filter_map(|(id, entry)| match entry {
42            IndexEntry::Intent(e) => Some(AuthoredIntent {
43                id: id.as_str().to_string(),
44                text: e.text.clone(),
45                file: e.file.clone(),
46                site: e.site.clone(),
47                status: e.status,
48                verify: e.verify,
49                text_hash: e.text_hash.as_str().to_string(),
50                body_hash: e.body_hash.as_str().to_string(),
51            }),
52            IndexEntry::Assume(_) => None,
53        })
54        .collect()
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use aristo_core::index::{
61        AnnotationId, AnnotationKind, AssumeEntry, BindingState, CoveredRegion, IntentEntry, Meta,
62        Sha256, VerifyMethod,
63    };
64    use std::collections::BTreeMap;
65
66    fn sha(c: char) -> Sha256 {
67        Sha256::parse(&format!("sha256:{}", c.to_string().repeat(64))).unwrap()
68    }
69
70    fn intent(text: &str) -> IndexEntry {
71        IndexEntry::Intent(IntentEntry {
72            text: text.into(),
73            verify: VerifyLevel::Method(VerifyMethod::Test),
74            status: Status::Tested,
75            text_hash: sha('a'),
76            body_hash: sha('b'),
77            file: "src/lib.rs".into(),
78            site: "fn foo".into(),
79            covered_region: CoveredRegion::Function,
80            binding: BindingState::Local,
81            parent: None,
82            last_critiqued_at_text_hash: None,
83            last_critique_finding_count: None,
84        })
85    }
86
87    fn assume(text: &str) -> IndexEntry {
88        IndexEntry::Assume(AssumeEntry {
89            text: text.into(),
90            status: Status::Unknown,
91            text_hash: sha('a'),
92            body_hash: sha('b'),
93            file: "src/lib.rs".into(),
94            site: "fn foo".into(),
95            covered_region: CoveredRegion::Function,
96            linked: None,
97            parent: None,
98        })
99    }
100
101    fn index_with(entries: Vec<(&str, IndexEntry)>) -> IndexFile {
102        let mut idx = IndexFile {
103            meta: Meta {
104                schema_version: 1,
105                generated_by: None,
106                generated_at: None,
107                source_root: None,
108            },
109            entries: BTreeMap::new(),
110        };
111        for (id, e) in entries {
112            idx.entries.insert(AnnotationId::parse(id).unwrap(), e);
113        }
114        idx
115    }
116
117    #[test]
118    fn enumerates_intents_excludes_assumes() {
119        let idx = index_with(vec![
120            ("i_one", intent("first claim")),
121            ("a_one", assume("an external invariant")),
122            ("i_two", intent("second claim")),
123        ]);
124        let got = authored_intents(&idx);
125        let ids: Vec<&str> = got.iter().map(|i| i.id.as_str()).collect();
126        // Sorted by id (BTreeMap order); the assume is dropped.
127        assert_eq!(ids, vec!["i_one", "i_two"]);
128    }
129
130    #[test]
131    fn carries_text_site_and_hashes() {
132        let idx = index_with(vec![("i_one", intent("first claim"))]);
133        let got = authored_intents(&idx);
134        let one = &got[0];
135        assert_eq!(one.text, "first claim");
136        assert_eq!(one.site, "fn foo");
137        assert_eq!(one.text_hash, sha('a').as_str());
138        assert_eq!(one.body_hash, sha('b').as_str());
139        assert_eq!(one.status, Status::Tested);
140    }
141
142    #[test]
143    fn includes_documentation_only_intents() {
144        // verify = false (doc-only) intents are still authored claims; they
145        // must appear so the review backlog matches the engine's count.
146        let mut e = intent("doc only");
147        if let IndexEntry::Intent(ref mut ie) = e {
148            ie.verify = VerifyLevel::Bool(false);
149        }
150        let idx = index_with(vec![("i_doc", e)]);
151        assert_eq!(authored_intents(&idx).len(), 1);
152    }
153
154    // Touch AnnotationKind so the import documents intent-vs-assume scope even
155    // though enumeration matches on IndexEntry directly.
156    #[test]
157    fn annotation_kind_is_intent_or_assume() {
158        assert_ne!(AnnotationKind::Intent, AnnotationKind::Assume);
159    }
160}