differential-engine 0.13.0

Core engine: git io, diff parsing, byte-exact apply, shape classes, invariants
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
//! A renderer-agnostic projection of one plan document.
//!
//! The arithmetic every reviewer surface needs — group totals, file totals,
//! resolved dependency edges, reviewed-mark keys — computed once, in the
//! domain. It lived in the TUI's constructor, which is why the stack had to
//! re-derive its own half and why the two drifted.

use std::collections::HashMap;

use crate::EngineError;
use crate::plan::ids::{HunkId, PlanIndex};
use crate::plan::{LineCounts, effort_name};
use crate::schema;

/// One resolved `depends_on` edge.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dependency {
    pub id: String,
    pub label: String,
    /// The dependency appears **later** in the plan.
    ///
    /// Which means the two groups depend on each other and the topological
    /// sort had to break the cycle. The plan says so rather than quietly
    /// presenting an order it could not honour.
    pub unsatisfied: bool,
    /// The symbols that produced the edge — why the dependency exists.
    pub via: Vec<String>,
    /// Why the sort could not honour it, when it could not. `unsatisfied` says
    /// that it happened; this says whether the cycle is in the change or only
    /// in the grouping.
    pub cycle: Option<schema::Cycle>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupView {
    pub id: String,
    pub label: String,
    /// The model's prose, carried so a renderer needs no second handle on the
    /// raw group. Between them the two renderers print these and nothing else
    /// of the document's text: the stack's commit body is both, the TUI's
    /// group header is the description alone.
    pub description: String,
    pub reason: String,
    pub effort: schema::Effort,
    pub role: Option<schema::Role>,
    pub class_ids: Vec<String>,
    /// Members in class order.
    pub hunks: Vec<HunkId>,
    /// Distinct paths touched. A rename counts twice, because the canonical
    /// view is `--no-renames`; zero-hunk changes contribute nothing.
    pub n_files: usize,
    pub counts: LineCounts,
    pub depends_on: Vec<Dependency>,
    /// The audit's back-fill: classes the model omitted, recovered by the
    /// coverage audit and read last (ADR 0001, invariant 5).
    pub unclassified: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileView {
    pub path: String,
    /// Canonical hunks, file order.
    pub hunks: Vec<HunkId>,
    pub counts: LineCounts,
}

/// The projection. Owned, not borrowing the document, so a session can hold
/// both without becoming self-referential.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewView {
    pub groups: Vec<GroupView>,
    /// Every file in the document, document order — including the zero-hunk
    /// binary, submodule and mode-only changes the group view cannot surface.
    pub files: Vec<FileView>,
    group_of_hunk: HashMap<HunkId, usize>,
    /// Every hunk's own counts, document order — see `counts`.
    counts_of_hunk: Vec<LineCounts>,
    hunk_by_digest: HashMap<String, HunkId>,
    digest_of_hunk: HashMap<HunkId, String>,
    classes: HashMap<String, ClassMembers>,
}

/// One shape class, resolved: which hunk stands for it and which it holds.
///
/// Carried by the projection so a renderer never needs `PlanIndex`. The TUI
/// rebuilt one on every keypress to answer exactly these two questions, and a
/// `PlanIndex` borrows the document — which is why it could not be kept.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClassMembers {
    /// The hunk a skim reader is shown for this class.
    pub exemplar: HunkId,
    /// Every member, in document order.
    pub hunks: Vec<HunkId>,
}

/// Whether a set of hunks — a group's, a file's, a directory's — reads as done.
///
/// Every hunk marked, and at least one hunk to mark: a binary file or a
/// gitlink carries no hunks, and "all of nothing" must not light it up as
/// reviewed. Four rows in the TUI each re-derived that guard by hand.
pub fn all_reviewed<'a>(
    hunks: impl IntoIterator<Item = &'a HunkId>,
    reviewed: &std::collections::HashSet<usize>,
) -> bool {
    let mut any = false;
    for h in hunks {
        if !reviewed.contains(&h.index()) {
            return false;
        }
        any = true;
    }
    any
}

impl ReviewView {
    /// Project a document, validating it on the way through.
    ///
    /// Needs no store and therefore no port: reviewed-mark keys are a pure
    /// function of the hunk digests the document already carries, which is
    /// why `ReviewSession` can stop computing its own copy of them.
    pub fn project(doc: &schema::PlanDocument) -> Result<Self, EngineError> {
        let index = PlanIndex::build(doc)?;

        let groups = index.groups();
        let label_of: HashMap<&str, &str> = groups
            .iter()
            .map(|g| (g.id.as_str(), g.label.as_str()))
            .collect();
        let rank_of: HashMap<&str, usize> = groups
            .iter()
            .enumerate()
            .map(|(i, g)| (g.id.as_str(), i))
            .collect();

        // The back-fill group is assembled last and the ordering stage keeps
        // it trailing, so its position identifies it. Positional — but
        // positional in ONE place, instead of once per renderer.
        let backfilled = doc.audit.classes_missing.unwrap_or(0) > 0;

        let projected: Vec<GroupView> = groups
            .iter()
            .enumerate()
            .map(|(rank, g)| {
                let hunks = index.group_hunks(g);
                let files: std::collections::HashSet<&str> =
                    hunks.iter().map(|&h| index.hunk(h).file.as_str()).collect();
                GroupView {
                    id: g.id.clone(),
                    label: g.label.clone(),
                    description: g.description.clone(),
                    reason: g.reason.clone(),
                    effort: g.effort,
                    role: g.role,
                    class_ids: g.class_ids.clone(),
                    n_files: files.len(),
                    counts: hunks
                        .iter()
                        .map(|&h| LineCounts::of_hunk(index.hunk(h)))
                        .sum(),
                    depends_on: g
                        .depends_on
                        .iter()
                        .map(|e| Dependency {
                            label: label_of
                                .get(e.on.as_str())
                                .map(|l| (*l).to_string())
                                .unwrap_or_else(|| e.on.clone()),
                            unsatisfied: rank_of.get(e.on.as_str()).copied().unwrap_or(0) > rank,
                            id: e.on.clone(),
                            via: e.via.clone(),
                            cycle: e.cycle,
                        })
                        .collect(),
                    unclassified: backfilled && rank + 1 == groups.len(),
                    hunks,
                }
            })
            .collect();

        let mut group_of_hunk = HashMap::new();
        for (i, g) in projected.iter().enumerate() {
            for &h in &g.hunks {
                group_of_hunk.insert(h, i);
            }
        }

        let files: Vec<FileView> = doc
            .files
            .iter()
            .map(|f| {
                let hunks = index.file_hunks(f);
                FileView {
                    path: f.path.clone(),
                    counts: hunks
                        .iter()
                        .map(|&h| LineCounts::of_hunk(index.hunk(h)))
                        .sum(),
                    hunks,
                }
            })
            .collect();

        // One `LineCounts` per hunk, so any SUBSET of the document can be
        // sized without the projection holding the document. Group totals and
        // file totals are two such subsets; the group's part of a file is the
        // third, and it is the one no field can carry.
        let counts_of_hunk: Vec<LineCounts> = doc.hunks.iter().map(LineCounts::of_hunk).collect();

        let hunk_by_digest = doc
            .hunks
            .iter()
            .enumerate()
            .map(|(i, h)| (h.digest.clone(), HunkId::from_index(i)))
            .collect();
        let digest_of_hunk = doc
            .hunks
            .iter()
            .enumerate()
            .map(|(i, h)| (HunkId::from_index(i), h.digest.clone()))
            .collect();

        let classes = doc
            .classes
            .iter()
            .map(|c| {
                (
                    c.id.clone(),
                    ClassMembers {
                        exemplar: index.exemplar(&c.id),
                        hunks: index.class_hunks(&c.id),
                    },
                )
            })
            .collect();

        Ok(ReviewView {
            groups: projected,
            files,
            group_of_hunk,
            counts_of_hunk,
            hunk_by_digest,
            digest_of_hunk,
            classes,
        })
    }

    pub fn group_position(&self, id: &str) -> Option<usize> {
        self.groups.iter().position(|g| g.id == id)
    }

    /// The group owning a hunk, via its class.
    ///
    /// `None` only for a hunk whose class is in no group — impossible after
    /// the coverage audit, but the type says so rather than a comment.
    pub fn group_of_hunk(&self, hunk: HunkId) -> Option<&GroupView> {
        self.group_of_hunk.get(&hunk).map(|&i| &self.groups[i])
    }

    /// One group's part of one file: the hunks it has there, file order.
    ///
    /// The question a count beside a file answers while the reader is standing
    /// inside a group — the group map and the diff pane's file list both ask
    /// it, and both used to print `files[file].counts` instead, which is a
    /// number about the rest of the file as much as about what is on screen.
    ///
    /// A hunk another group owns is not this group's, however a widened window
    /// came to draw it: the answer is a fact about the plan, so it does not
    /// move under `z`.
    pub fn hunks_in(&self, group: usize, file: usize) -> Vec<HunkId> {
        let Some(f) = self.files.get(file) else {
            return Vec::new();
        };
        f.hunks
            .iter()
            .filter(|h| self.group_of_hunk.get(h) == Some(&group))
            .copied()
            .collect()
    }

    /// What a set of hunks adds and removes.
    ///
    /// `GroupView::counts` and `FileView::counts` are this over two fixed
    /// sets. Any other set — a group's part of a file, a directory, a
    /// selection — needs the arithmetic in the domain rather than a renderer
    /// reaching back into the document for `new_count`/`old_count`.
    pub fn counts(&self, hunks: &[HunkId]) -> LineCounts {
        hunks
            .iter()
            .filter_map(|h| self.counts_of_hunk.get(h.index()))
            .copied()
            .sum()
    }

    /// Findings anchor on digests, which survive regeneration where positional
    /// ids do not.
    pub fn hunk_by_digest(&self, digest: &str) -> Option<HunkId> {
        self.hunk_by_digest.get(digest).copied()
    }

    /// A hunk's exact content digest — the reviewed-mark key.
    pub fn digest(&self, hunk: HunkId) -> &str {
        &self.digest_of_hunk[&hunk]
    }

    /// Every hunk whose digest is marked.
    ///
    /// Walks the hunks rather than the marks, because a digest is content and
    /// content repeats: two byte-identical hunks carry one key, and marking
    /// either marks both. `hunk_by_digest` can only name one of them.
    pub fn hunks_marked<'k>(
        &self,
        marked: impl Fn(&str) -> bool + 'k,
    ) -> std::collections::HashSet<HunkId> {
        self.each_marked(marked).collect()
    }

    /// How many hunks of THIS document are marked.
    ///
    /// A count, not a set. The status bar prints this number every frame, and
    /// reaching it through `hunks_marked` allocated a whole `HashSet` to ask
    /// for its length — twice over, because the caller then rebuilt it as a
    /// set of indices.
    pub fn count_marked<'k>(&self, marked: impl Fn(&str) -> bool + 'k) -> usize {
        self.each_marked(marked).count()
    }

    fn each_marked<'a, 'k: 'a>(
        &'a self,
        marked: impl Fn(&str) -> bool + 'k,
    ) -> impl Iterator<Item = HunkId> + 'a {
        self.digest_of_hunk
            .iter()
            .filter(move |(_, digest)| marked(digest))
            .map(|(h, _)| *h)
    }

    /// One class's exemplar and members.
    ///
    /// Total for any id a group names: `PlanIndex::build` proved every one of
    /// them resolves before this projection was made.
    pub fn class(&self, id: &str) -> &ClassMembers {
        &self.classes[id]
    }

    /// The tier's domain name, or `unclassified` for the audit back-fill.
    ///
    /// One answer for both renderers: the stack has always labelled the
    /// back-fill distinctly and the TUI has always shown it as an ordinary
    /// focus group, which is the same document described two ways.
    pub fn tier_name(&self, group: &GroupView) -> &'static str {
        if group.unclassified {
            "unclassified"
        } else {
            effort_name(group.effort)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::plan::test_support::{doc_with, group, hunk_ids};

    #[test]
    fn all_reviewed_needs_every_hunk_and_at_least_one() {
        let hunks = [HunkId::from_index(0), HunkId::from_index(1)];
        let marked = |ids: &[usize]| {
            ids.iter()
                .copied()
                .collect::<std::collections::HashSet<_>>()
        };

        assert!(all_reviewed(&hunks, &marked(&[0, 1])));
        assert!(!all_reviewed(&hunks, &marked(&[0])), "one hunk unmarked");
        assert!(!all_reviewed(&hunks, &marked(&[])), "nothing marked");
        // A binary file or a gitlink has no hunks: "all of nothing" is not done.
        assert!(!all_reviewed(&[], &marked(&[0, 1])));
    }

    fn two_group_doc() -> schema::PlanDocument {
        let mut doc = doc_with(
            &[("C0", &["h0", "h1"], "h0"), ("C1", &["h2"], "h2")],
            &[("src/a.rs", &["h0", "h1"]), ("src/b.rs", &["h2"])],
        );
        doc.groups = Some(vec![
            group("g0", schema::Effort::Focus, &["C0"]),
            group("g1", schema::Effort::Skim, &["C1"]),
        ]);
        doc
    }

    #[test]
    fn groups_carry_their_totals_and_distinct_file_count() {
        let doc = two_group_doc();
        let view = ReviewView::project(&doc).unwrap();

        assert_eq!(hunk_ids(&view.groups[0].hunks), ["h0", "h1"]);
        assert_eq!(
            view.groups[0].n_files, 2,
            "the fixture puts each hunk in its own file"
        );
        // The fixture's hunks are +2/-1 each.
        assert_eq!(view.groups[0].counts, LineCounts { adds: 4, dels: 2 });
        assert_eq!(view.files[0].counts, LineCounts { adds: 4, dels: 2 });
    }

    /// A count beside a file, printed while the reader is inside one group,
    /// is about that group's part of the file. Two surfaces asked the question
    /// and both answered it with `files[i].counts`, which is the rest of the
    /// file as much as what is on screen.
    #[test]
    fn a_group_is_sized_within_one_file() {
        // `src/a.rs` holds a hunk from each group; `src/b.rs` holds one of g0's.
        let mut doc = doc_with(
            &[("C0", &["h0", "h1"], "h0"), ("C1", &["h2"], "h2")],
            &[("src/a.rs", &["h0", "h2"]), ("src/b.rs", &["h1"])],
        );
        doc.groups = Some(vec![
            group("g0", schema::Effort::Focus, &["C0"]),
            group("g1", schema::Effort::Skim, &["C1"]),
        ]);
        let view = ReviewView::project(&doc).unwrap();

        // The fixture's hunks are +2/-1 each, so the shared file totals +4/-2.
        assert_eq!(view.files[0].counts, LineCounts { adds: 4, dels: 2 });
        for (g, id) in [(0, "h0"), (1, "h2")] {
            let part = view.hunks_in(g, 0);
            assert_eq!(hunk_ids(&part), [id], "group {g}'s part of src/a.rs");
            assert_eq!(view.counts(&part), LineCounts { adds: 2, dels: 1 });
        }

        // A group that never enters the file has no part of it — and no size,
        // which is not the same number as the file's.
        assert!(view.hunks_in(1, 1).is_empty());
        assert_eq!(view.counts(&view.hunks_in(1, 1)), LineCounts::default());
        assert_eq!(view.files[1].counts, LineCounts { adds: 2, dels: 1 });
    }

    #[test]
    fn reviewed_keys_are_the_documents_own_hunk_digests() {
        let doc = two_group_doc();
        let view = ReviewView::project(&doc).unwrap();

        // One key per hunk, not one per class: two hunks of the same class
        // carry different keys, so changing one cannot unmark the other.
        assert_eq!(view.digest(HunkId::from_index(0)), "digest0");
        assert_eq!(view.digest(HunkId::from_index(1)), "digest1");
        assert_eq!(view.digest(HunkId::from_index(2)), "digest2");
    }

    #[test]
    fn a_dependency_listed_later_is_flagged_unsatisfied() {
        let mut doc = two_group_doc();
        // g0 (rank 0) depends on g1 (rank 1): the order could not honour it.
        let edge = |on: &str| schema::Edge {
            on: on.to_string(),
            via: vec!["Config".to_string()],
            cycle: Some(schema::Cycle::Artefact),
        };
        doc.groups.as_mut().unwrap()[0].depends_on = vec![edge("g1")];
        doc.groups.as_mut().unwrap()[1].depends_on = vec![edge("g0")];
        let view = ReviewView::project(&doc).unwrap();

        assert_eq!(
            view.groups[0].depends_on,
            [Dependency {
                via: vec!["Config".to_string()],
                cycle: Some(schema::Cycle::Artefact),
                id: "g1".into(),
                label: "g1 label".into(),
                unsatisfied: true
            }]
        );
        assert!(
            !view.groups[1].depends_on[0].unsatisfied,
            "a dependency earlier in the plan is honoured"
        );
    }

    #[test]
    fn hunks_resolve_to_their_owning_group_and_their_digest() {
        let doc = two_group_doc();
        let view = ReviewView::project(&doc).unwrap();

        assert_eq!(view.group_of_hunk(HunkId::from_index(1)).unwrap().id, "g0");
        assert_eq!(view.hunk_by_digest("digest2"), Some(HunkId::from_index(2)));
        assert_eq!(view.hunk_by_digest("nope"), None);
    }

    /// The asymmetry this projection exists to remove: one flag, so a renderer
    /// cannot decide for itself that a back-filled group is ordinary.
    #[test]
    fn the_trailing_backfill_group_is_marked_unclassified() {
        let mut doc = two_group_doc();
        assert!(
            ReviewView::project(&doc)
                .unwrap()
                .groups
                .iter()
                .all(|g| !g.unclassified),
            "no back-fill recorded in the audit"
        );

        doc.audit.classes_missing = Some(1);
        let view = ReviewView::project(&doc).unwrap();
        assert!(!view.groups[0].unclassified);
        assert!(
            view.groups[1].unclassified,
            "the back-fill is assembled last"
        );
        assert_eq!(view.tier_name(&view.groups[1]), "unclassified");
        assert_eq!(view.tier_name(&view.groups[0]), "focus");
    }
}