differential_engine/plan/
ids.rs1use std::collections::HashMap;
4
5use crate::EngineError;
6use crate::schema;
7
8#[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 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 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 write!(f, "h{}", self.0)
46 }
47}
48
49pub 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 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 pub fn groups(&self) -> &'d [schema::Group] {
114 self.doc.groups.as_deref().unwrap_or(&[])
115 }
116
117 pub fn class(&self, id: &str) -> &'d schema::ClassEntry {
119 self.class_by_id[id]
120 }
121
122 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 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 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 #[test]
175 fn build_rejects_a_document_that_contradicts_itself() {
176 use crate::plan::test_support::{doc_with, group};
177
178 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 #[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 #[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 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}