Skip to main content

differential_engine/plan/
view.rs

1//! A renderer-agnostic projection of one plan document.
2//!
3//! The arithmetic every reviewer surface needs — group totals, file totals,
4//! resolved dependency edges, reviewed-mark keys — computed once, in the
5//! domain. It lived in the TUI's constructor, which is why the stack had to
6//! re-derive its own half and why the two drifted.
7
8use std::collections::HashMap;
9
10use crate::EngineError;
11use crate::plan::ids::{HunkId, PlanIndex};
12use crate::plan::{LineCounts, effort_name};
13use crate::schema;
14
15/// One resolved `depends_on` edge.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Dependency {
18    pub id: String,
19    pub label: String,
20    /// The dependency appears **later** in the plan.
21    ///
22    /// Which means the two groups depend on each other and the topological
23    /// sort had to break the cycle. The plan says so rather than quietly
24    /// presenting an order it could not honour.
25    pub unsatisfied: bool,
26    /// The symbols that produced the edge — why the dependency exists.
27    pub via: Vec<String>,
28    /// Why the sort could not honour it, when it could not. `unsatisfied` says
29    /// that it happened; this says whether the cycle is in the change or only
30    /// in the grouping.
31    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    /// The model's prose, carried so a renderer needs no second handle on the
39    /// raw group. Between them the two renderers print these and nothing else
40    /// of the document's text: the stack's commit body is both, the TUI's
41    /// group header is the description alone.
42    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    /// Members in class order.
48    pub hunks: Vec<HunkId>,
49    /// Distinct paths touched. A rename counts twice, because the canonical
50    /// view is `--no-renames`; zero-hunk changes contribute nothing.
51    pub n_files: usize,
52    pub counts: LineCounts,
53    pub depends_on: Vec<Dependency>,
54    /// The audit's back-fill: classes the model omitted, recovered by the
55    /// coverage audit and read last (ADR 0001, invariant 5).
56    pub unclassified: bool,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct FileView {
61    pub path: String,
62    /// Canonical hunks, file order.
63    pub hunks: Vec<HunkId>,
64    pub counts: LineCounts,
65}
66
67/// The projection. Owned, not borrowing the document, so a session can hold
68/// both without becoming self-referential.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ReviewView {
71    pub groups: Vec<GroupView>,
72    /// Every file in the document, document order — including the zero-hunk
73    /// binary, submodule and mode-only changes the group view cannot surface.
74    pub files: Vec<FileView>,
75    group_of_hunk: HashMap<HunkId, usize>,
76    /// Every hunk's own counts, document order — see `counts`.
77    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/// One shape class, resolved: which hunk stands for it and which it holds.
84///
85/// Carried by the projection so a renderer never needs `PlanIndex`. The TUI
86/// rebuilt one on every keypress to answer exactly these two questions, and a
87/// `PlanIndex` borrows the document — which is why it could not be kept.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct ClassMembers {
90    /// The hunk a skim reader is shown for this class.
91    pub exemplar: HunkId,
92    /// Every member, in document order.
93    pub hunks: Vec<HunkId>,
94}
95
96/// Whether a set of hunks — a group's, a file's, a directory's — reads as done.
97///
98/// Every hunk marked, and at least one hunk to mark: a binary file or a
99/// gitlink carries no hunks, and "all of nothing" must not light it up as
100/// reviewed. Four rows in the TUI each re-derived that guard by hand.
101pub 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    /// Project a document, validating it on the way through.
117    ///
118    /// Needs no store and therefore no port: reviewed-mark keys are a pure
119    /// function of the hunk digests the document already carries, which is
120    /// why `ReviewSession` can stop computing its own copy of them.
121    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        // The back-fill group is assembled last and the ordering stage keeps
136        // it trailing, so its position identifies it. Positional — but
137        // positional in ONE place, instead of once per renderer.
138        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        // One `LineCounts` per hunk, so any SUBSET of the document can be
204        // sized without the projection holding the document. Group totals and
205        // file totals are two such subsets; the group's part of a file is the
206        // third, and it is the one no field can carry.
207        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    /// The group owning a hunk, via its class.
252    ///
253    /// `None` only for a hunk whose class is in no group — impossible after
254    /// the coverage audit, but the type says so rather than a comment.
255    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    /// One group's part of one file: the hunks it has there, file order.
260    ///
261    /// The question a count beside a file answers while the reader is standing
262    /// inside a group — the group map and the diff pane's file list both ask
263    /// it, and both used to print `files[file].counts` instead, which is a
264    /// number about the rest of the file as much as about what is on screen.
265    ///
266    /// A hunk another group owns is not this group's, however a widened window
267    /// came to draw it: the answer is a fact about the plan, so it does not
268    /// move under `z`.
269    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    /// What a set of hunks adds and removes.
281    ///
282    /// `GroupView::counts` and `FileView::counts` are this over two fixed
283    /// sets. Any other set — a group's part of a file, a directory, a
284    /// selection — needs the arithmetic in the domain rather than a renderer
285    /// reaching back into the document for `new_count`/`old_count`.
286    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    /// Findings anchor on digests, which survive regeneration where positional
295    /// ids do not.
296    pub fn hunk_by_digest(&self, digest: &str) -> Option<HunkId> {
297        self.hunk_by_digest.get(digest).copied()
298    }
299
300    /// A hunk's exact content digest — the reviewed-mark key.
301    pub fn digest(&self, hunk: HunkId) -> &str {
302        &self.digest_of_hunk[&hunk]
303    }
304
305    /// Every hunk whose digest is marked.
306    ///
307    /// Walks the hunks rather than the marks, because a digest is content and
308    /// content repeats: two byte-identical hunks carry one key, and marking
309    /// either marks both. `hunk_by_digest` can only name one of them.
310    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    /// How many hunks of THIS document are marked.
318    ///
319    /// A count, not a set. The status bar prints this number every frame, and
320    /// reaching it through `hunks_marked` allocated a whole `HashSet` to ask
321    /// for its length — twice over, because the caller then rebuilt it as a
322    /// set of indices.
323    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    /// One class's exemplar and members.
338    ///
339    /// Total for any id a group names: `PlanIndex::build` proved every one of
340    /// them resolves before this projection was made.
341    pub fn class(&self, id: &str) -> &ClassMembers {
342        &self.classes[id]
343    }
344
345    /// The tier's domain name, or `unclassified` for the audit back-fill.
346    ///
347    /// One answer for both renderers: the stack has always labelled the
348    /// back-fill distinctly and the TUI has always shown it as an ordinary
349    /// focus group, which is the same document described two ways.
350    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        // A binary file or a gitlink has no hunks: "all of nothing" is not done.
377        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        // The fixture's hunks are +2/-1 each.
403        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    /// A count beside a file, printed while the reader is inside one group,
408    /// is about that group's part of the file. Two surfaces asked the question
409    /// and both answered it with `files[i].counts`, which is the rest of the
410    /// file as much as what is on screen.
411    #[test]
412    fn a_group_is_sized_within_one_file() {
413        // `src/a.rs` holds a hunk from each group; `src/b.rs` holds one of g0's.
414        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        // The fixture's hunks are +2/-1 each, so the shared file totals +4/-2.
425        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        // A group that never enters the file has no part of it — and no size,
433        // which is not the same number as the file's.
434        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        // One key per hunk, not one per class: two hunks of the same class
445        // carry different keys, so changing one cannot unmark the other.
446        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        // g0 (rank 0) depends on g1 (rank 1): the order could not honour it.
455        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    /// The asymmetry this projection exists to remove: one flag, so a renderer
491    /// cannot decide for itself that a back-filled group is ordinary.
492    #[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}