Skip to main content

differential_engine/plan/
ids.rs

1//! Hunk identity, and a validated index over a plan document.
2
3use std::collections::HashMap;
4
5use crate::EngineError;
6use crate::schema;
7
8/// A canonical hunk, in memory.
9///
10/// The wire form is `h<N>`, where N indexes `doc.hunks` — frozen since schema
11/// v1 and unchanged by this type. `HunkId` is only ever the parsed form: it
12/// never crosses a serde boundary, so the contract stays exactly as
13/// `spec/json-contract.md` describes it while callers stop re-parsing strings.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub struct HunkId(usize);
16
17impl HunkId {
18    pub const fn from_index(index: usize) -> Self {
19        HunkId(index)
20    }
21
22    pub const fn index(self) -> usize {
23        self.0
24    }
25
26    /// Parse the wire form.
27    ///
28    /// Fallible on purpose. A malformed id means the document contradicts its
29    /// own contract, which is impossible for a document this process just
30    /// produced and possible for one read back from a review store — so the
31    /// caller gets an error to propagate instead of a panic.
32    pub fn parse(s: &str) -> Result<Self, EngineError> {
33        s.strip_prefix('h')
34            .and_then(|n| n.parse().ok())
35            .map(HunkId)
36            .ok_or_else(|| {
37                EngineError::PlanIntegrity(format!("malformed hunk id {s:?}; expected h<N>"))
38            })
39    }
40}
41
42impl std::fmt::Display for HunkId {
43    /// Writes the wire form, so `format!("{id}")` round-trips through `parse`.
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        write!(f, "h{}", self.0)
46    }
47}
48
49/// A plan document's id lookups, built and validated once.
50///
51/// Transient by construction: it borrows the document, so it is built where it
52/// is used and dropped there. That is already what every hand-rolled copy of
53/// this map did — the difference is that the ids are checked on the way in, so
54/// every accessor below is total and none of them can panic.
55pub struct PlanIndex<'d> {
56    doc: &'d schema::PlanDocument,
57    class_by_id: HashMap<&'d str, &'d schema::ClassEntry>,
58}
59
60impl<'d> PlanIndex<'d> {
61    /// Build, validating every id the document refers to: hunk ids parse and
62    /// are in range, class ids referenced by groups exist.
63    ///
64    /// Before this existed the two renderers disagreed about a broken
65    /// document — `crates/stack` panicked on an unresolvable class reference
66    /// and the TUI silently dropped it, so a corrupt store showed a short
67    /// group with no indication anything was missing.
68    pub fn build(doc: &'d schema::PlanDocument) -> Result<Self, EngineError> {
69        let class_by_id: HashMap<&str, &schema::ClassEntry> =
70            doc.classes.iter().map(|c| (c.id.as_str(), c)).collect();
71
72        let n = doc.hunks.len();
73        let check = |hid: &str| -> Result<(), EngineError> {
74            let h = HunkId::parse(hid)?;
75            if h.index() >= n {
76                return Err(EngineError::PlanIntegrity(format!(
77                    "hunk id {hid} is out of range: the document has {n} hunks"
78                )));
79            }
80            Ok(())
81        };
82
83        for c in &doc.classes {
84            check(&c.exemplar)?;
85            for hid in &c.hunk_ids {
86                check(hid)?;
87            }
88        }
89        for f in &doc.files {
90            for hid in &f.hunk_ids {
91                check(hid)?;
92            }
93        }
94        for g in doc.groups.iter().flatten() {
95            for cid in &g.class_ids {
96                if !class_by_id.contains_key(cid.as_str()) {
97                    return Err(EngineError::PlanIntegrity(format!(
98                        "group {} references class {cid}, which the document does not define",
99                        g.id
100                    )));
101                }
102            }
103        }
104
105        Ok(PlanIndex { doc, class_by_id })
106    }
107
108    pub fn doc(&self) -> &'d schema::PlanDocument {
109        self.doc
110    }
111
112    /// The document's groups, or an empty slice for a core-only document.
113    pub fn groups(&self) -> &'d [schema::Group] {
114        self.doc.groups.as_deref().unwrap_or(&[])
115    }
116
117    /// Total: `build` proved every referenced class id resolves.
118    pub fn class(&self, id: &str) -> &'d schema::ClassEntry {
119        self.class_by_id[id]
120    }
121
122    /// Total: `build` proved every hunk id is in range.
123    pub fn hunk(&self, h: HunkId) -> &'d schema::HunkEntry {
124        &self.doc.hunks[h.index()]
125    }
126
127    pub fn exemplar(&self, class_id: &str) -> HunkId {
128        self.parsed(&self.class(class_id).exemplar)
129    }
130
131    pub fn class_hunks(&self, class_id: &str) -> Vec<HunkId> {
132        self.class(class_id)
133            .hunk_ids
134            .iter()
135            .map(|h| self.parsed(h))
136            .collect()
137    }
138
139    /// Group members in class order — the order both renderers already emit.
140    pub fn group_hunks(&self, group: &schema::Group) -> Vec<HunkId> {
141        group
142            .class_ids
143            .iter()
144            .flat_map(|c| self.class_hunks(c))
145            .collect()
146    }
147
148    pub fn file_hunks(&self, file: &schema::FileEntry) -> Vec<HunkId> {
149        file.hunk_ids.iter().map(|h| self.parsed(h)).collect()
150    }
151
152    /// Infallible re-parse of an id `build` already validated.
153    fn parsed(&self, hid: &str) -> HunkId {
154        HunkId::parse(hid).expect("PlanIndex::build validated every id in the document")
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn hunk_ids_round_trip_through_the_wire_form() {
164        for n in [0usize, 1, 42, 1234] {
165            let id = HunkId::from_index(n);
166            assert_eq!(id.to_string(), format!("h{n}"));
167            assert_eq!(HunkId::parse(&id.to_string()).unwrap(), id);
168        }
169    }
170
171    /// Both renderers used to answer a broken document differently — the stack
172    /// panicked, the TUI dropped the reference and rendered a short group with
173    /// no sign anything was missing. One error, raised once, replaces both.
174    #[test]
175    fn build_rejects_a_document_that_contradicts_itself() {
176        use crate::plan::test_support::{doc_with, group};
177
178        // `doc_with` synthesizes a hunk for every id mentioned, so the range
179        // has to be broken deliberately after the fact.
180        let mut out_of_range = doc_with(&[("C0", &["h0", "h9"], "h0")], &[]);
181        out_of_range.hunks.truncate(1);
182        assert!(
183            matches!(
184                PlanIndex::build(&out_of_range),
185                Err(EngineError::PlanIntegrity(_))
186            ),
187            "a member id past the end of hunks must not reach an accessor"
188        );
189
190        let mut missing_class = doc_with(&[("C0", &["h0"], "h0")], &[]);
191        missing_class.groups = Some(vec![group("g0", schema::Effort::Focus, &["C0", "C7"])]);
192        assert!(matches!(
193            PlanIndex::build(&missing_class),
194            Err(EngineError::PlanIntegrity(_))
195        ));
196
197        let bad_exemplar = doc_with(&[("C0", &["h0"], "nope")], &[]);
198        assert!(matches!(
199            PlanIndex::build(&bad_exemplar),
200            Err(EngineError::PlanIntegrity(_))
201        ));
202    }
203
204    /// Every accessor is total once `build` returns, which is what lets the
205    /// call sites drop their `expect`s.
206    #[test]
207    fn accessors_are_total_after_a_successful_build() {
208        use crate::plan::test_support::{doc_with, group};
209
210        let mut doc = doc_with(
211            &[("C0", &["h0", "h1"], "h0"), ("C1", &["h2"], "h2")],
212            &[("src/a.rs", &["h0", "h1"])],
213        );
214        doc.groups = Some(vec![group("g0", schema::Effort::Focus, &["C0", "C1"])]);
215        let index = PlanIndex::build(&doc).unwrap();
216
217        assert_eq!(index.exemplar("C0"), HunkId::from_index(0));
218        assert_eq!(index.class_hunks("C1"), [HunkId::from_index(2)]);
219        assert_eq!(index.hunk(HunkId::from_index(1)).id, "h1");
220        assert_eq!(index.group_hunks(&index.groups()[0]).len(), 3);
221        assert_eq!(index.file_hunks(&doc.files[0]).len(), 2);
222    }
223
224    /// A core-only document has no groups; that is a state, not a failure.
225    #[test]
226    fn an_ungrouped_document_indexes_with_no_groups() {
227        let doc = crate::plan::test_support::doc_with(&[("C0", &["h0"], "h0")], &[]);
228        let index = PlanIndex::build(&doc).unwrap();
229        assert!(index.groups().is_empty());
230    }
231
232    #[test]
233    fn malformed_hunk_ids_are_rejected_not_panicked_on() {
234        // The last two are why this is not `hid[1..].parse()`: slicing a
235        // multi-byte first character panics, and a bare "h" slices to "".
236        for bad in ["", "x0", "h", "h-1", "hfoo", "0", "é0"] {
237            let err = HunkId::parse(bad).unwrap_err();
238            assert!(
239                matches!(err, EngineError::PlanIntegrity(_)),
240                "{bad:?} produced {err:?}"
241            );
242        }
243    }
244}