Skip to main content

zenkey_fleet/model/
examples.rs

1//! "Up to N examples, and the count of what they stand for" — one collector
2//! (RFC 09 §5.1 O6).
3//!
4//! Every judge in this crate names a handful of offenders and then says how
5//! many more there were: the doctor's per-check findings, `field`'s per-path
6//! ones, `expect`'s violations, `cutover`'s leaked keys, `budget`'s example
7//! expansions, `why`'s evidence lines. It was written out by hand at each
8//! site, with a different cap and a slightly different closure, and the deep
9//! review found what that costs: the `qos-observed-mismatch` cap capped the
10//! *candidates* rather than the findings, so violators past the first twenty
11//! judged keys vanished **and** the remainder note under-counted them.
12//!
13//! The bug is structural — a hand-written cap counts whatever it happens to
14//! be looking at — so the fix is one collector that counts everything offered
15//! and keeps the first `cap`. The cap **value** stays each site's own policy;
16//! only the mechanism is shared.
17
18/// A bounded example list that remembers how many it turned away.
19///
20/// `pub(crate)`: it is a judge-internal collector, held in the private
21/// fields of report shapes and never named by any supported signature —
22/// so by the crate-root rule (see `lib.rs`) it has no business being
23/// public at all.
24///
25/// `total` counts every item offered, `len` how many are held: the two are
26/// what makes "… and N more" honest, and neither can drift from the other
27/// because nothing else feeds them.
28#[derive(Debug, Clone)]
29pub(crate) struct Examples<T> {
30    kept: Vec<T>,
31    total: usize,
32    cap: usize,
33}
34
35// `budget` uses `new`/`push_with`/`into_vec`; the rest of the surface is
36// reached only by the `decode`-gated judges (`doctor`, `field`, `expect`).
37// With the feature off they are genuinely dead, which is a fact about the
38// build, not an omission — saying so beats gating six methods one by one.
39#[cfg_attr(not(feature = "decode"), allow(dead_code))]
40impl<T> Examples<T> {
41    /// A collector holding at most `cap` examples.
42    pub fn new(cap: usize) -> Self {
43        Examples {
44            kept: Vec::new(),
45            total: 0,
46            cap,
47        }
48    }
49
50    /// Take up to `cap` items from `iter`, counting the rest.
51    ///
52    /// The whole iterator is consumed — that is where `total` comes from — so
53    /// pass a cheap one. An item that costs an allocation to build belongs in
54    /// [`push_with`](Self::push_with), which does not build it past the cap.
55    pub fn collect(cap: usize, iter: impl IntoIterator<Item = T>) -> Self {
56        let mut ex = Examples::new(cap);
57        for item in iter {
58            ex.push(item);
59        }
60        ex
61    }
62
63    /// Offer one example: counted always, kept while there is room.
64    pub fn push(&mut self, item: T) {
65        self.total += 1;
66        if self.kept.len() < self.cap {
67            self.kept.push(item);
68        }
69    }
70
71    /// Offer one example, building it only if it will be kept.
72    ///
73    /// The same accounting as [`push`](Self::push) — the total counts the
74    /// call, not the closure — for the sites where the example is a
75    /// `format!` over a population that can be large.
76    pub fn push_with(&mut self, make: impl FnOnce() -> T) {
77        self.total += 1;
78        if self.kept.len() < self.cap {
79            self.kept.push(make());
80        }
81    }
82
83    /// Every item offered, kept or not.
84    pub fn total(&self) -> usize {
85        self.total
86    }
87
88    /// How many were held back by the cap.
89    pub fn dropped(&self) -> usize {
90        self.total - self.kept.len()
91    }
92
93    pub fn as_slice(&self) -> &[T] {
94        &self.kept
95    }
96
97    pub fn into_vec(self) -> Vec<T> {
98        self.kept
99    }
100
101    /// The remainder line — `… and {dropped} {tail}` — or `None` when the cap
102    /// never bit. `tail` is the site's own wording, which is why it is a
103    /// parameter: this unifies the arithmetic, not the sentence.
104    pub fn more(&self, tail: &str) -> Option<String> {
105        (self.dropped() > 0).then(|| format!("… and {} {tail}", self.dropped()))
106    }
107}
108
109impl Examples<String> {
110    /// The kept lines, followed by the remainder line when the cap bit.
111    pub fn into_lines(self, tail: &str) -> Vec<String> {
112        let more = self.more(tail);
113        let mut lines = self.into_vec();
114        lines.extend(more);
115        lines
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    /// The bug that motivated the extraction: what is counted must be what
124    /// was *offered*, not what happened to be kept.
125    #[test]
126    fn the_total_counts_everything_offered() {
127        let mut ex = Examples::new(3);
128        for i in 0..10 {
129            ex.push(format!("k{i}"));
130        }
131        assert_eq!(ex.as_slice().len(), 3);
132        assert_eq!(ex.total(), 10);
133        assert_eq!(ex.dropped(), 7);
134        assert_eq!(ex.as_slice()[0], "k0", "the first offered are the kept");
135        assert_eq!(
136            ex.more("more key(s) with the same finding").as_deref(),
137            Some("… and 7 more key(s) with the same finding")
138        );
139    }
140
141    /// Under the cap there is no remainder to name, and nothing is dropped.
142    #[test]
143    fn an_uncapped_run_says_nothing_extra() {
144        let ex = Examples::collect(20, (0..3).map(|i| format!("k{i}")));
145        assert_eq!(ex.total(), 3);
146        assert_eq!(ex.dropped(), 0);
147        assert_eq!(ex.more("more"), None);
148        assert_eq!(ex.into_lines("more").len(), 3);
149    }
150
151    /// `push_with` accounts like `push` but does not build past the cap.
152    #[test]
153    fn push_with_counts_the_call_not_the_closure() {
154        let mut built = 0;
155        let mut ex = Examples::new(2);
156        for _ in 0..5 {
157            ex.push_with(|| {
158                built += 1;
159                String::from("x")
160            });
161        }
162        assert_eq!(built, 2, "only the kept were built");
163        assert_eq!(ex.total(), 5, "all five were counted");
164        assert_eq!(ex.into_lines("more")[2], "… and 3 more");
165    }
166}