1use 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 counts_of_hunk: Vec<LineCounts>,
78 hunk_by_digest: HashMap<String, HunkId>,
79 digest_of_hunk: HashMap<HunkId, String>,
80 classes: HashMap<String, ClassMembers>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct ClassMembers {
90 pub exemplar: HunkId,
92 pub hunks: Vec<HunkId>,
94}
95
96pub fn all_reviewed<'a>(
102 hunks: impl IntoIterator<Item = &'a HunkId>,
103 reviewed: &std::collections::HashSet<usize>,
104) -> bool {
105 let mut any = false;
106 for h in hunks {
107 if !reviewed.contains(&h.index()) {
108 return false;
109 }
110 any = true;
111 }
112 any
113}
114
115impl ReviewView {
116 pub fn project(doc: &schema::PlanDocument) -> Result<Self, EngineError> {
122 let index = PlanIndex::build(doc)?;
123
124 let groups = index.groups();
125 let label_of: HashMap<&str, &str> = groups
126 .iter()
127 .map(|g| (g.id.as_str(), g.label.as_str()))
128 .collect();
129 let rank_of: HashMap<&str, usize> = groups
130 .iter()
131 .enumerate()
132 .map(|(i, g)| (g.id.as_str(), i))
133 .collect();
134
135 let backfilled = doc.audit.classes_missing.unwrap_or(0) > 0;
139
140 let projected: Vec<GroupView> = groups
141 .iter()
142 .enumerate()
143 .map(|(rank, g)| {
144 let hunks = index.group_hunks(g);
145 let files: std::collections::HashSet<&str> =
146 hunks.iter().map(|&h| index.hunk(h).file.as_str()).collect();
147 GroupView {
148 id: g.id.clone(),
149 label: g.label.clone(),
150 description: g.description.clone(),
151 reason: g.reason.clone(),
152 effort: g.effort,
153 role: g.role,
154 class_ids: g.class_ids.clone(),
155 n_files: files.len(),
156 counts: hunks
157 .iter()
158 .map(|&h| LineCounts::of_hunk(index.hunk(h)))
159 .sum(),
160 depends_on: g
161 .depends_on
162 .iter()
163 .map(|e| Dependency {
164 label: label_of
165 .get(e.on.as_str())
166 .map(|l| (*l).to_string())
167 .unwrap_or_else(|| e.on.clone()),
168 unsatisfied: rank_of.get(e.on.as_str()).copied().unwrap_or(0) > rank,
169 id: e.on.clone(),
170 via: e.via.clone(),
171 cycle: e.cycle,
172 })
173 .collect(),
174 unclassified: backfilled && rank + 1 == groups.len(),
175 hunks,
176 }
177 })
178 .collect();
179
180 let mut group_of_hunk = HashMap::new();
181 for (i, g) in projected.iter().enumerate() {
182 for &h in &g.hunks {
183 group_of_hunk.insert(h, i);
184 }
185 }
186
187 let files: Vec<FileView> = doc
188 .files
189 .iter()
190 .map(|f| {
191 let hunks = index.file_hunks(f);
192 FileView {
193 path: f.path.clone(),
194 counts: hunks
195 .iter()
196 .map(|&h| LineCounts::of_hunk(index.hunk(h)))
197 .sum(),
198 hunks,
199 }
200 })
201 .collect();
202
203 let counts_of_hunk: Vec<LineCounts> = doc.hunks.iter().map(LineCounts::of_hunk).collect();
208
209 let hunk_by_digest = doc
210 .hunks
211 .iter()
212 .enumerate()
213 .map(|(i, h)| (h.digest.clone(), HunkId::from_index(i)))
214 .collect();
215 let digest_of_hunk = doc
216 .hunks
217 .iter()
218 .enumerate()
219 .map(|(i, h)| (HunkId::from_index(i), h.digest.clone()))
220 .collect();
221
222 let classes = doc
223 .classes
224 .iter()
225 .map(|c| {
226 (
227 c.id.clone(),
228 ClassMembers {
229 exemplar: index.exemplar(&c.id),
230 hunks: index.class_hunks(&c.id),
231 },
232 )
233 })
234 .collect();
235
236 Ok(ReviewView {
237 groups: projected,
238 files,
239 group_of_hunk,
240 counts_of_hunk,
241 hunk_by_digest,
242 digest_of_hunk,
243 classes,
244 })
245 }
246
247 pub fn group_position(&self, id: &str) -> Option<usize> {
248 self.groups.iter().position(|g| g.id == id)
249 }
250
251 pub fn group_of_hunk(&self, hunk: HunkId) -> Option<&GroupView> {
256 self.group_of_hunk.get(&hunk).map(|&i| &self.groups[i])
257 }
258
259 pub fn hunks_in(&self, group: usize, file: usize) -> Vec<HunkId> {
270 let Some(f) = self.files.get(file) else {
271 return Vec::new();
272 };
273 f.hunks
274 .iter()
275 .filter(|h| self.group_of_hunk.get(h) == Some(&group))
276 .copied()
277 .collect()
278 }
279
280 pub fn counts(&self, hunks: &[HunkId]) -> LineCounts {
287 hunks
288 .iter()
289 .filter_map(|h| self.counts_of_hunk.get(h.index()))
290 .copied()
291 .sum()
292 }
293
294 pub fn hunk_by_digest(&self, digest: &str) -> Option<HunkId> {
297 self.hunk_by_digest.get(digest).copied()
298 }
299
300 pub fn digest(&self, hunk: HunkId) -> &str {
302 &self.digest_of_hunk[&hunk]
303 }
304
305 pub fn hunks_marked<'k>(
311 &self,
312 marked: impl Fn(&str) -> bool + 'k,
313 ) -> std::collections::HashSet<HunkId> {
314 self.each_marked(marked).collect()
315 }
316
317 pub fn count_marked<'k>(&self, marked: impl Fn(&str) -> bool + 'k) -> usize {
324 self.each_marked(marked).count()
325 }
326
327 fn each_marked<'a, 'k: 'a>(
328 &'a self,
329 marked: impl Fn(&str) -> bool + 'k,
330 ) -> impl Iterator<Item = HunkId> + 'a {
331 self.digest_of_hunk
332 .iter()
333 .filter(move |(_, digest)| marked(digest))
334 .map(|(h, _)| *h)
335 }
336
337 pub fn class(&self, id: &str) -> &ClassMembers {
342 &self.classes[id]
343 }
344
345 pub fn tier_name(&self, group: &GroupView) -> &'static str {
351 if group.unclassified {
352 "unclassified"
353 } else {
354 effort_name(group.effort)
355 }
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use crate::plan::test_support::{doc_with, group, hunk_ids};
363
364 #[test]
365 fn all_reviewed_needs_every_hunk_and_at_least_one() {
366 let hunks = [HunkId::from_index(0), HunkId::from_index(1)];
367 let marked = |ids: &[usize]| {
368 ids.iter()
369 .copied()
370 .collect::<std::collections::HashSet<_>>()
371 };
372
373 assert!(all_reviewed(&hunks, &marked(&[0, 1])));
374 assert!(!all_reviewed(&hunks, &marked(&[0])), "one hunk unmarked");
375 assert!(!all_reviewed(&hunks, &marked(&[])), "nothing marked");
376 assert!(!all_reviewed(&[], &marked(&[0, 1])));
378 }
379
380 fn two_group_doc() -> schema::PlanDocument {
381 let mut doc = doc_with(
382 &[("C0", &["h0", "h1"], "h0"), ("C1", &["h2"], "h2")],
383 &[("src/a.rs", &["h0", "h1"]), ("src/b.rs", &["h2"])],
384 );
385 doc.groups = Some(vec![
386 group("g0", schema::Effort::Focus, &["C0"]),
387 group("g1", schema::Effort::Skim, &["C1"]),
388 ]);
389 doc
390 }
391
392 #[test]
393 fn groups_carry_their_totals_and_distinct_file_count() {
394 let doc = two_group_doc();
395 let view = ReviewView::project(&doc).unwrap();
396
397 assert_eq!(hunk_ids(&view.groups[0].hunks), ["h0", "h1"]);
398 assert_eq!(
399 view.groups[0].n_files, 2,
400 "the fixture puts each hunk in its own file"
401 );
402 assert_eq!(view.groups[0].counts, LineCounts { adds: 4, dels: 2 });
404 assert_eq!(view.files[0].counts, LineCounts { adds: 4, dels: 2 });
405 }
406
407 #[test]
412 fn a_group_is_sized_within_one_file() {
413 let mut doc = doc_with(
415 &[("C0", &["h0", "h1"], "h0"), ("C1", &["h2"], "h2")],
416 &[("src/a.rs", &["h0", "h2"]), ("src/b.rs", &["h1"])],
417 );
418 doc.groups = Some(vec![
419 group("g0", schema::Effort::Focus, &["C0"]),
420 group("g1", schema::Effort::Skim, &["C1"]),
421 ]);
422 let view = ReviewView::project(&doc).unwrap();
423
424 assert_eq!(view.files[0].counts, LineCounts { adds: 4, dels: 2 });
426 for (g, id) in [(0, "h0"), (1, "h2")] {
427 let part = view.hunks_in(g, 0);
428 assert_eq!(hunk_ids(&part), [id], "group {g}'s part of src/a.rs");
429 assert_eq!(view.counts(&part), LineCounts { adds: 2, dels: 1 });
430 }
431
432 assert!(view.hunks_in(1, 1).is_empty());
435 assert_eq!(view.counts(&view.hunks_in(1, 1)), LineCounts::default());
436 assert_eq!(view.files[1].counts, LineCounts { adds: 2, dels: 1 });
437 }
438
439 #[test]
440 fn reviewed_keys_are_the_documents_own_hunk_digests() {
441 let doc = two_group_doc();
442 let view = ReviewView::project(&doc).unwrap();
443
444 assert_eq!(view.digest(HunkId::from_index(0)), "digest0");
447 assert_eq!(view.digest(HunkId::from_index(1)), "digest1");
448 assert_eq!(view.digest(HunkId::from_index(2)), "digest2");
449 }
450
451 #[test]
452 fn a_dependency_listed_later_is_flagged_unsatisfied() {
453 let mut doc = two_group_doc();
454 let edge = |on: &str| schema::Edge {
456 on: on.to_string(),
457 via: vec!["Config".to_string()],
458 cycle: Some(schema::Cycle::Artefact),
459 };
460 doc.groups.as_mut().unwrap()[0].depends_on = vec![edge("g1")];
461 doc.groups.as_mut().unwrap()[1].depends_on = vec![edge("g0")];
462 let view = ReviewView::project(&doc).unwrap();
463
464 assert_eq!(
465 view.groups[0].depends_on,
466 [Dependency {
467 via: vec!["Config".to_string()],
468 cycle: Some(schema::Cycle::Artefact),
469 id: "g1".into(),
470 label: "g1 label".into(),
471 unsatisfied: true
472 }]
473 );
474 assert!(
475 !view.groups[1].depends_on[0].unsatisfied,
476 "a dependency earlier in the plan is honoured"
477 );
478 }
479
480 #[test]
481 fn hunks_resolve_to_their_owning_group_and_their_digest() {
482 let doc = two_group_doc();
483 let view = ReviewView::project(&doc).unwrap();
484
485 assert_eq!(view.group_of_hunk(HunkId::from_index(1)).unwrap().id, "g0");
486 assert_eq!(view.hunk_by_digest("digest2"), Some(HunkId::from_index(2)));
487 assert_eq!(view.hunk_by_digest("nope"), None);
488 }
489
490 #[test]
493 fn the_trailing_backfill_group_is_marked_unclassified() {
494 let mut doc = two_group_doc();
495 assert!(
496 ReviewView::project(&doc)
497 .unwrap()
498 .groups
499 .iter()
500 .all(|g| !g.unclassified),
501 "no back-fill recorded in the audit"
502 );
503
504 doc.audit.classes_missing = Some(1);
505 let view = ReviewView::project(&doc).unwrap();
506 assert!(!view.groups[0].unclassified);
507 assert!(
508 view.groups[1].unclassified,
509 "the back-fill is assembled last"
510 );
511 assert_eq!(view.tier_name(&view.groups[1]), "unclassified");
512 assert_eq!(view.tier_name(&view.groups[0]), "focus");
513 }
514}