1use std::collections::{BTreeMap, BTreeSet};
22
23use serde_json::{Value, json};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Class {
28 Always,
29 Sometimes,
30}
31
32impl Class {
33 fn as_str(self) -> &'static str {
34 match self {
35 Class::Always => "always",
36 Class::Sometimes => "sometimes",
37 }
38 }
39}
40
41#[derive(Debug, Clone, Default, PartialEq)]
43pub struct ProbeLock {
44 pub fields: BTreeMap<String, Class>,
45 pub uncovered: BTreeSet<String>,
47}
48
49#[derive(Debug, Clone, Default, PartialEq)]
51pub struct Baseline {
52 pub probes: BTreeMap<String, ProbeLock>,
53}
54
55#[derive(Debug, Default)]
57pub struct Findings {
58 pub regressed: Vec<String>,
60 pub new_fields: Vec<String>,
62 pub uncovered: Vec<String>,
65}
66
67impl Findings {
68 pub fn is_clean(&self) -> bool {
69 self.regressed.is_empty() && self.new_fields.is_empty() && self.uncovered.is_empty()
70 }
71}
72
73impl Baseline {
74 pub fn parse(text: &str) -> Result<Baseline, String> {
75 let doc: Value =
76 serde_json::from_str(text).map_err(|e| format!("probes.lock is not JSON: {e}"))?;
77 match doc.get("version").and_then(Value::as_u64) {
78 Some(1) => {}
79 other => return Err(format!("probes.lock version {other:?}, expected 1")),
80 }
81 let mut probes = BTreeMap::new();
82 let Some(entries) = doc.get("probes").and_then(Value::as_object) else {
83 return Err("probes.lock has no `probes` table".into());
84 };
85 for (name, entry) in entries {
86 let Some(fields) = entry.get("fields").and_then(Value::as_object) else {
87 return Err(format!("probe `{name}` has no `fields` table"));
88 };
89 let mut lock = ProbeLock::default();
90 for (path, class) in fields {
91 let class = match class.as_str() {
92 Some("always") => Class::Always,
93 Some("sometimes") => Class::Sometimes,
94 other => {
95 return Err(format!(
96 "probe `{name}` field `{path}`: bad class {other:?}"
97 ));
98 }
99 };
100 lock.fields.insert(path.clone(), class);
101 }
102 if let Some(uncovered) = entry.get("uncovered").and_then(Value::as_array) {
103 lock.uncovered = uncovered
104 .iter()
105 .filter_map(Value::as_str)
106 .map(String::from)
107 .collect();
108 }
109 probes.insert(name.clone(), lock);
110 }
111 Ok(Baseline { probes })
112 }
113
114 pub fn render(&self) -> String {
115 let probes: BTreeMap<&str, Value> = self
116 .probes
117 .iter()
118 .map(|(name, lock)| {
119 let fields: BTreeMap<&str, &str> = lock
120 .fields
121 .iter()
122 .map(|(path, class)| (path.as_str(), class.as_str()))
123 .collect();
124 let entry = if lock.uncovered.is_empty() {
125 json!({ "fields": fields })
126 } else {
127 json!({ "fields": fields, "uncovered": lock.uncovered })
128 };
129 (name.as_str(), entry)
130 })
131 .collect();
132 let mut out = serde_json::to_string_pretty(&json!({
133 "version": 1,
134 "probes": probes,
135 }))
136 .expect("baseline is plain JSON");
137 out.push('\n');
138 out
139 }
140
141 pub fn gate(
146 &self,
147 probe: &str,
148 observed: &BTreeMap<String, bool>,
149 pinned_sometimes: &[String],
150 declared: Option<&BTreeSet<String>>,
151 ) -> Findings {
152 let mut findings = Findings::default();
153 let locked = self.probes.get(probe).cloned().unwrap_or_default();
154 for (path, class) in &locked.fields {
155 if *class == Class::Always
156 && !pinned(pinned_sometimes, path)
157 && observed.get(path) != Some(&true)
158 {
159 findings.regressed.push(path.clone());
160 }
161 }
162 for (path, populated) in observed {
163 if *populated && !locked.fields.contains_key(path) {
164 findings.new_fields.push(path.clone());
165 }
166 }
167 if let Some(declared) = declared {
168 for path in declared {
169 if observed.get(path) != Some(&true)
170 && !locked.fields.contains_key(path)
171 && !locked.uncovered.contains(path)
172 && !pinned(pinned_sometimes, path)
173 {
174 findings.uncovered.push(path.clone());
175 }
176 }
177 }
178 findings
179 }
180
181 pub fn accept(
187 &mut self,
188 probe: &str,
189 observed: &BTreeMap<String, bool>,
190 declared: Option<&BTreeSet<String>>,
191 ) {
192 let lock = self.probes.entry(probe.to_string()).or_default();
193 for (path, populated) in observed {
194 match (lock.fields.get(path), populated) {
195 (None, true) => {
196 lock.fields.insert(path.clone(), Class::Always);
197 }
198 (None, false) => {}
199 (Some(Class::Always), false) => {
200 lock.fields.insert(path.clone(), Class::Sometimes);
201 }
202 _ => {}
203 }
204 }
205 let absent: Vec<String> = lock
208 .fields
209 .iter()
210 .filter(|(path, class)| **class == Class::Always && !observed.contains_key(*path))
211 .map(|(path, _)| path.clone())
212 .collect();
213 for path in absent {
214 lock.fields.insert(path, Class::Sometimes);
215 }
216 lock.uncovered = declared
217 .map(|declared| {
218 declared
219 .iter()
220 .filter(|path| !lock.fields.contains_key(*path))
221 .cloned()
222 .collect()
223 })
224 .unwrap_or_default();
225 }
226
227 pub fn retain_probes(&mut self, declared: &[String]) -> Vec<String> {
230 let gone: Vec<String> = self
231 .probes
232 .keys()
233 .filter(|name| !declared.contains(name))
234 .cloned()
235 .collect();
236 for name in &gone {
237 self.probes.remove(name);
238 }
239 gone
240 }
241}
242
243fn pinned(pins: &[String], path: &str) -> bool {
244 pins.iter().any(|pin| {
245 pin == path
246 || pin
247 .strip_suffix('*')
248 .is_some_and(|prefix| path.starts_with(prefix))
249 })
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn observed(pairs: &[(&str, bool)]) -> BTreeMap<String, bool> {
257 pairs
258 .iter()
259 .map(|(path, populated)| (path.to_string(), *populated))
260 .collect()
261 }
262
263 fn declared(paths: &[&str]) -> BTreeSet<String> {
264 paths.iter().map(|p| p.to_string()).collect()
265 }
266
267 #[test]
268 fn lock_round_trips_through_render_and_parse() {
269 let mut baseline = Baseline::default();
270 baseline.accept(
271 "quote",
272 &observed(&[("price", true), ("pe", true)]),
273 Some(&declared(&["price", "pe", "spark"])),
274 );
275 let reparsed = Baseline::parse(&baseline.render()).unwrap();
276 assert_eq!(reparsed, baseline);
277 assert!(baseline.probes["quote"].uncovered.contains("spark"));
278 }
279
280 #[test]
281 fn an_always_field_going_null_regresses() {
282 let mut baseline = Baseline::default();
283 baseline.accept("quote", &observed(&[("pe", true)]), None);
284 let findings = baseline.gate("quote", &observed(&[("pe", false)]), &[], None);
285 assert_eq!(findings.regressed, ["pe"]);
286 }
287
288 #[test]
289 fn accept_demotes_a_flapping_field_for_good() {
290 let mut baseline = Baseline::default();
291 baseline.accept("quote", &observed(&[("preMarket", true)]), None);
292 baseline.accept("quote", &observed(&[("preMarket", false)]), None);
293 baseline.accept("quote", &observed(&[("preMarket", true)]), None);
294 assert_eq!(
295 baseline.probes["quote"].fields["preMarket"],
296 Class::Sometimes
297 );
298 let findings = baseline.gate("quote", &observed(&[("preMarket", false)]), &[], None);
299 assert!(findings.is_clean());
300 }
301
302 #[test]
303 fn a_field_vanishing_entirely_demotes_on_accept_and_regresses_on_gate() {
304 let mut baseline = Baseline::default();
305 baseline.accept("quote", &observed(&[("pe", true)]), None);
306 let findings = baseline.gate("quote", &observed(&[]), &[], None);
307 assert_eq!(findings.regressed, ["pe"]);
308 baseline.accept("quote", &observed(&[]), None);
309 assert_eq!(baseline.probes["quote"].fields["pe"], Class::Sometimes);
310 }
311
312 #[test]
313 fn new_populated_fields_mark_the_lock_stale() {
314 let baseline = Baseline::default();
315 let findings = baseline.gate("quote", &observed(&[("spark", true)]), &[], None);
316 assert_eq!(findings.new_fields, ["spark"]);
317 }
318
319 #[test]
320 fn manifest_pins_silence_the_gate_with_and_without_wildcard() {
321 let mut baseline = Baseline::default();
322 baseline.accept(
323 "quote",
324 &observed(&[("preMarketPrice", true), ("preMarketTime", true)]),
325 None,
326 );
327 let now = observed(&[("preMarketPrice", false), ("preMarketTime", false)]);
328 let findings = baseline.gate("quote", &now, &["preMarket*".to_string()], None);
329 assert!(findings.is_clean());
330 let findings = baseline.gate("quote", &now, &["preMarketPrice".to_string()], None);
331 assert_eq!(findings.regressed, ["preMarketTime"]);
332 }
333
334 #[test]
335 fn gone_probes_are_reported_and_dropped() {
336 let mut baseline = Baseline::default();
337 baseline.accept("quote", &observed(&[("pe", true)]), None);
338 baseline.accept("chart", &observed(&[("meta", true)]), None);
339 let gone = baseline.retain_probes(&["quote".to_string()]);
340 assert_eq!(gone, ["chart"]);
341 assert!(!baseline.probes.contains_key("chart"));
342 }
343
344 #[test]
345 fn a_declared_field_nobody_populates_fails_until_accepted() {
346 let mut baseline = Baseline::default();
347 let spec_fields = declared(&["price", "cvar95"]);
348 let now = observed(&[("price", true)]);
349 baseline.accept("risk", &now, Some(&spec_fields));
350 let findings = baseline.gate("risk", &now, &[], Some(&spec_fields));
351 assert!(findings.is_clean(), "accepted uncovered debt passes");
352
353 let grown = declared(&["price", "cvar95", "omega"]);
354 let findings = baseline.gate("risk", &now, &[], Some(&grown));
355 assert_eq!(findings.uncovered, ["omega"]);
356 }
357
358 #[test]
359 fn covered_fields_drop_off_the_uncovered_list_on_accept() {
360 let mut baseline = Baseline::default();
361 let spec_fields = declared(&["price", "cvar95"]);
362 baseline.accept("risk", &observed(&[("price", true)]), Some(&spec_fields));
363 assert!(baseline.probes["risk"].uncovered.contains("cvar95"));
364 baseline.accept(
365 "risk",
366 &observed(&[("price", true), ("cvar95", true)]),
367 Some(&spec_fields),
368 );
369 assert!(baseline.probes["risk"].uncovered.is_empty());
370 assert_eq!(baseline.probes["risk"].fields["cvar95"], Class::Always);
371 }
372
373 #[test]
374 fn pinned_sometimes_fields_are_not_coverage_findings() {
375 let baseline = Baseline::default();
376 let spec_fields = declared(&["preMarketPrice"]);
377 let findings = baseline.gate(
378 "quote",
379 &observed(&[]),
380 &["preMarket*".to_string()],
381 Some(&spec_fields),
382 );
383 assert!(findings.is_clean());
384 }
385}