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}