differential_engine/plan/
view.rs1use std::collections::HashMap;
9
10use crate::EngineError;
11use crate::plan::ids::{HunkId, PlanIndex};
12use crate::plan::{LineCounts, effort_name};
13use crate::schema;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Dependency {
18 pub id: String,
19 pub label: String,
20 pub unsatisfied: bool,
26 pub via: Vec<String>,
28 pub cycle: Option<schema::Cycle>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct GroupView {
36 pub id: String,
37 pub label: String,
38 pub description: String,
43 pub reason: String,
44 pub effort: schema::Effort,
45 pub role: Option<schema::Role>,
46 pub class_ids: Vec<String>,
47 pub hunks: Vec<HunkId>,
49 pub n_files: usize,
52 pub counts: LineCounts,
53 pub depends_on: Vec<Dependency>,
54 pub unclassified: bool,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct FileView {
61 pub path: String,
62 pub hunks: Vec<HunkId>,
64 pub counts: LineCounts,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ReviewView {
71 pub groups: Vec<GroupView>,
72 pub files: Vec<FileView>,
75 group_of_hunk: HashMap<HunkId, usize>,
76 hunk_by_digest: HashMap<String, HunkId>,
77 digest_of_hunk: HashMap<HunkId, String>,
78 classes: HashMap<String, ClassMembers>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ClassMembers {
88 pub exemplar: HunkId,
90 pub hunks: Vec<HunkId>,
92}
93
94pub fn all_reviewed<'a>(
100 hunks: impl IntoIterator<Item = &'a HunkId>,
101 reviewed: &std::collections::HashSet<usize>,
102) -> bool {
103 let mut any = false;
104 for h in hunks {
105 if !reviewed.contains(&h.index()) {
106 return false;
107 }
108 any = true;
109 }
110 any
111}
112
113impl ReviewView {
114 pub fn project(doc: &schema::PlanDocument) -> Result<Self, EngineError> {
120 let index = PlanIndex::build(doc)?;
121
122 let groups = index.groups();
123 let label_of: HashMap<&str, &str> = groups
124 .iter()
125 .map(|g| (g.id.as_str(), g.label.as_str()))
126 .collect();
127 let rank_of: HashMap<&str, usize> = groups
128 .iter()
129 .enumerate()
130 .map(|(i, g)| (g.id.as_str(), i))
131 .collect();
132
133 let backfilled = doc.audit.classes_missing.unwrap_or(0) > 0;
137
138 let projected: Vec<GroupView> = groups
139 .iter()
140 .enumerate()
141 .map(|(rank, g)| {
142 let hunks = index.group_hunks(g);
143 let files: std::collections::HashSet<&str> =
144 hunks.iter().map(|&h| index.hunk(h).file.as_str()).collect();
145 GroupView {
146 id: g.id.clone(),
147 label: g.label.clone(),
148 description: g.description.clone(),
149 reason: g.reason.clone(),
150 effort: g.effort,
151 role: g.role,
152 class_ids: g.class_ids.clone(),
153 n_files: files.len(),
154 counts: hunks
155 .iter()
156 .map(|&h| LineCounts::of_hunk(index.hunk(h)))
157 .sum(),
158 depends_on: g
159 .depends_on
160 .iter()
161 .map(|e| Dependency {
162 label: label_of
163 .get(e.on.as_str())
164 .map(|l| (*l).to_string())
165 .unwrap_or_else(|| e.on.clone()),
166 unsatisfied: rank_of.get(e.on.as_str()).copied().unwrap_or(0) > rank,
167 id: e.on.clone(),
168 via: e.via.clone(),
169 cycle: e.cycle,
170 })
171 .collect(),
172 unclassified: backfilled && rank + 1 == groups.len(),
173 hunks,
174 }
175 })
176 .collect();
177
178 let mut group_of_hunk = HashMap::new();
179 for (i, g) in projected.iter().enumerate() {
180 for &h in &g.hunks {
181 group_of_hunk.insert(h, i);
182 }
183 }
184
185 let files: Vec<FileView> = doc
186 .files
187 .iter()
188 .map(|f| {
189 let hunks = index.file_hunks(f);
190 FileView {
191 path: f.path.clone(),
192 counts: hunks
193 .iter()
194 .map(|&h| LineCounts::of_hunk(index.hunk(h)))
195 .sum(),
196 hunks,
197 }
198 })
199 .collect();
200
201 let hunk_by_digest = doc
202 .hunks
203 .iter()
204 .enumerate()
205 .map(|(i, h)| (h.digest.clone(), HunkId::from_index(i)))
206 .collect();
207 let digest_of_hunk = doc
208 .hunks
209 .iter()
210 .enumerate()
211 .map(|(i, h)| (HunkId::from_index(i), h.digest.clone()))
212 .collect();
213
214 let classes = doc
215 .classes
216 .iter()
217 .map(|c| {
218 (
219 c.id.clone(),
220 ClassMembers {
221 exemplar: index.exemplar(&c.id),
222 hunks: index.class_hunks(&c.id),
223 },
224 )
225 })
226 .collect();
227
228 Ok(ReviewView {
229 groups: projected,
230 files,
231 group_of_hunk,
232 hunk_by_digest,
233 digest_of_hunk,
234 classes,
235 })
236 }
237
238 pub fn group_position(&self, id: &str) -> Option<usize> {
239 self.groups.iter().position(|g| g.id == id)
240 }
241
242 pub fn group_of_hunk(&self, hunk: HunkId) -> Option<&GroupView> {
247 self.group_of_hunk.get(&hunk).map(|&i| &self.groups[i])
248 }
249
250 pub fn hunk_by_digest(&self, digest: &str) -> Option<HunkId> {
253 self.hunk_by_digest.get(digest).copied()
254 }
255
256 pub fn digest(&self, hunk: HunkId) -> &str {
258 &self.digest_of_hunk[&hunk]
259 }
260
261 pub fn hunks_marked<'k>(
267 &self,
268 marked: impl Fn(&str) -> bool + 'k,
269 ) -> std::collections::HashSet<HunkId> {
270 self.each_marked(marked).collect()
271 }
272
273 pub fn count_marked<'k>(&self, marked: impl Fn(&str) -> bool + 'k) -> usize {
280 self.each_marked(marked).count()
281 }
282
283 fn each_marked<'a, 'k: 'a>(
284 &'a self,
285 marked: impl Fn(&str) -> bool + 'k,
286 ) -> impl Iterator<Item = HunkId> + 'a {
287 self.digest_of_hunk
288 .iter()
289 .filter(move |(_, digest)| marked(digest))
290 .map(|(h, _)| *h)
291 }
292
293 pub fn class(&self, id: &str) -> &ClassMembers {
298 &self.classes[id]
299 }
300
301 pub fn tier_name(&self, group: &GroupView) -> &'static str {
307 if group.unclassified {
308 "unclassified"
309 } else {
310 effort_name(group.effort)
311 }
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::plan::test_support::{doc_with, group, hunk_ids};
319
320 #[test]
321 fn all_reviewed_needs_every_hunk_and_at_least_one() {
322 let hunks = [HunkId::from_index(0), HunkId::from_index(1)];
323 let marked = |ids: &[usize]| {
324 ids.iter()
325 .copied()
326 .collect::<std::collections::HashSet<_>>()
327 };
328
329 assert!(all_reviewed(&hunks, &marked(&[0, 1])));
330 assert!(!all_reviewed(&hunks, &marked(&[0])), "one hunk unmarked");
331 assert!(!all_reviewed(&hunks, &marked(&[])), "nothing marked");
332 assert!(!all_reviewed(&[], &marked(&[0, 1])));
334 }
335
336 fn two_group_doc() -> schema::PlanDocument {
337 let mut doc = doc_with(
338 &[("C0", &["h0", "h1"], "h0"), ("C1", &["h2"], "h2")],
339 &[("src/a.rs", &["h0", "h1"]), ("src/b.rs", &["h2"])],
340 );
341 doc.groups = Some(vec![
342 group("g0", schema::Effort::Focus, &["C0"]),
343 group("g1", schema::Effort::Skim, &["C1"]),
344 ]);
345 doc
346 }
347
348 #[test]
349 fn groups_carry_their_totals_and_distinct_file_count() {
350 let doc = two_group_doc();
351 let view = ReviewView::project(&doc).unwrap();
352
353 assert_eq!(hunk_ids(&view.groups[0].hunks), ["h0", "h1"]);
354 assert_eq!(
355 view.groups[0].n_files, 2,
356 "the fixture puts each hunk in its own file"
357 );
358 assert_eq!(view.groups[0].counts, LineCounts { adds: 4, dels: 2 });
360 assert_eq!(view.files[0].counts, LineCounts { adds: 4, dels: 2 });
361 }
362
363 #[test]
364 fn reviewed_keys_are_the_documents_own_hunk_digests() {
365 let doc = two_group_doc();
366 let view = ReviewView::project(&doc).unwrap();
367
368 assert_eq!(view.digest(HunkId::from_index(0)), "digest0");
371 assert_eq!(view.digest(HunkId::from_index(1)), "digest1");
372 assert_eq!(view.digest(HunkId::from_index(2)), "digest2");
373 }
374
375 #[test]
376 fn a_dependency_listed_later_is_flagged_unsatisfied() {
377 let mut doc = two_group_doc();
378 let edge = |on: &str| schema::Edge {
380 on: on.to_string(),
381 via: vec!["Config".to_string()],
382 cycle: Some(schema::Cycle::Artefact),
383 };
384 doc.groups.as_mut().unwrap()[0].depends_on = vec![edge("g1")];
385 doc.groups.as_mut().unwrap()[1].depends_on = vec![edge("g0")];
386 let view = ReviewView::project(&doc).unwrap();
387
388 assert_eq!(
389 view.groups[0].depends_on,
390 [Dependency {
391 via: vec!["Config".to_string()],
392 cycle: Some(schema::Cycle::Artefact),
393 id: "g1".into(),
394 label: "g1 label".into(),
395 unsatisfied: true
396 }]
397 );
398 assert!(
399 !view.groups[1].depends_on[0].unsatisfied,
400 "a dependency earlier in the plan is honoured"
401 );
402 }
403
404 #[test]
405 fn hunks_resolve_to_their_owning_group_and_their_digest() {
406 let doc = two_group_doc();
407 let view = ReviewView::project(&doc).unwrap();
408
409 assert_eq!(view.group_of_hunk(HunkId::from_index(1)).unwrap().id, "g0");
410 assert_eq!(view.hunk_by_digest("digest2"), Some(HunkId::from_index(2)));
411 assert_eq!(view.hunk_by_digest("nope"), None);
412 }
413
414 #[test]
417 fn the_trailing_backfill_group_is_marked_unclassified() {
418 let mut doc = two_group_doc();
419 assert!(
420 ReviewView::project(&doc)
421 .unwrap()
422 .groups
423 .iter()
424 .all(|g| !g.unclassified),
425 "no back-fill recorded in the audit"
426 );
427
428 doc.audit.classes_missing = Some(1);
429 let view = ReviewView::project(&doc).unwrap();
430 assert!(!view.groups[0].unclassified);
431 assert!(
432 view.groups[1].unclassified,
433 "the back-fill is assembled last"
434 );
435 assert_eq!(view.tier_name(&view.groups[1]), "unclassified");
436 assert_eq!(view.tier_name(&view.groups[0]), "focus");
437 }
438}