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    hunk_by_digest: HashMap<String, HunkId>,
77    digest_of_hunk: HashMap<HunkId, String>,
78    classes: HashMap<String, ClassMembers>,
79}
80
81/// One shape class, resolved: which hunk stands for it and which it holds.
82///
83/// Carried by the projection so a renderer never needs `PlanIndex`. The TUI
84/// rebuilt one on every keypress to answer exactly these two questions, and a
85/// `PlanIndex` borrows the document — which is why it could not be kept.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ClassMembers {
88    /// The hunk a skim reader is shown for this class.
89    pub exemplar: HunkId,
90    /// Every member, in document order.
91    pub hunks: Vec<HunkId>,
92}
93
94/// Whether a set of hunks — a group's, a file's, a directory's — reads as done.
95///
96/// Every hunk marked, and at least one hunk to mark: a binary file or a
97/// gitlink carries no hunks, and "all of nothing" must not light it up as
98/// reviewed. Four rows in the TUI each re-derived that guard by hand.
99pub 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    /// Project a document, validating it on the way through.
115    ///
116    /// Needs no store and therefore no port: reviewed-mark keys are a pure
117    /// function of the hunk digests the document already carries, which is
118    /// why `ReviewSession` can stop computing its own copy of them.
119    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        // The back-fill group is assembled last and the ordering stage keeps
134        // it trailing, so its position identifies it. Positional — but
135        // positional in ONE place, instead of once per renderer.
136        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    /// The group owning a hunk, via its class.
243    ///
244    /// `None` only for a hunk whose class is in no group — impossible after
245    /// the coverage audit, but the type says so rather than a comment.
246    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    /// Findings anchor on digests, which survive regeneration where positional
251    /// ids do not.
252    pub fn hunk_by_digest(&self, digest: &str) -> Option<HunkId> {
253        self.hunk_by_digest.get(digest).copied()
254    }
255
256    /// A hunk's exact content digest — the reviewed-mark key.
257    pub fn digest(&self, hunk: HunkId) -> &str {
258        &self.digest_of_hunk[&hunk]
259    }
260
261    /// Every hunk whose digest is marked.
262    ///
263    /// Walks the hunks rather than the marks, because a digest is content and
264    /// content repeats: two byte-identical hunks carry one key, and marking
265    /// either marks both. `hunk_by_digest` can only name one of them.
266    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    /// How many hunks of THIS document are marked.
274    ///
275    /// A count, not a set. The status bar prints this number every frame, and
276    /// reaching it through `hunks_marked` allocated a whole `HashSet` to ask
277    /// for its length — twice over, because the caller then rebuilt it as a
278    /// set of indices.
279    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    /// One class's exemplar and members.
294    ///
295    /// Total for any id a group names: `PlanIndex::build` proved every one of
296    /// them resolves before this projection was made.
297    pub fn class(&self, id: &str) -> &ClassMembers {
298        &self.classes[id]
299    }
300
301    /// The tier's domain name, or `unclassified` for the audit back-fill.
302    ///
303    /// One answer for both renderers: the stack has always labelled the
304    /// back-fill distinctly and the TUI has always shown it as an ordinary
305    /// focus group, which is the same document described two ways.
306    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        // A binary file or a gitlink has no hunks: "all of nothing" is not done.
333        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        // The fixture's hunks are +2/-1 each.
359        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        // One key per hunk, not one per class: two hunks of the same class
369        // carry different keys, so changing one cannot unmark the other.
370        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        // g0 (rank 0) depends on g1 (rank 1): the order could not honour it.
379        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    /// The asymmetry this projection exists to remove: one flag, so a renderer
415    /// cannot decide for itself that a back-filled group is ordinary.
416    #[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}