1use crate::ReportContext;
2use allow_core::{AllowConfig, MatchOutcome, MatchStatus};
3use std::collections::{BTreeMap, BTreeSet};
4
5pub(crate) const STATUS_COUNT_ORDER: [MatchStatus; 10] = [
6 MatchStatus::Matched,
7 MatchStatus::New,
8 MatchStatus::Expired,
9 MatchStatus::ReviewDue,
10 MatchStatus::Stale,
11 MatchStatus::Ambiguous,
12 MatchStatus::InvalidSelector,
13 MatchStatus::EvidenceMissing,
14 MatchStatus::MissingRequiredField,
15 MatchStatus::BaselineDebt,
16];
17
18pub(crate) const REVIEW_ITEM_STATUSES: [MatchStatus; 8] = [
19 MatchStatus::New,
20 MatchStatus::Expired,
21 MatchStatus::ReviewDue,
22 MatchStatus::Stale,
23 MatchStatus::Ambiguous,
24 MatchStatus::InvalidSelector,
25 MatchStatus::MissingRequiredField,
26 MatchStatus::EvidenceMissing,
27];
28
29pub(crate) const AUDIT_REVIEW_QUEUE_STATUSES: [MatchStatus; 8] = [
30 MatchStatus::New,
31 MatchStatus::Expired,
32 MatchStatus::Ambiguous,
33 MatchStatus::EvidenceMissing,
34 MatchStatus::MissingRequiredField,
35 MatchStatus::BaselineDebt,
36 MatchStatus::Stale,
37 MatchStatus::ReviewDue,
38];
39
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
41pub struct Summary {
42 pub total: usize,
43 pub by_status: BTreeMap<MatchStatus, usize>,
44}
45
46impl Summary {
47 pub fn from_outcomes(outcomes: &[MatchOutcome]) -> Self {
48 let mut summary = Self {
49 total: outcomes.len(),
50 by_status: BTreeMap::new(),
51 };
52 for outcome in outcomes {
53 *summary.by_status.entry(outcome.status).or_insert(0) += 1;
54 }
55 summary
56 }
57
58 pub fn count(&self, status: MatchStatus) -> usize {
59 *self.by_status.get(&status).unwrap_or(&0)
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub(crate) struct ReviewSignals {
65 pub(crate) baseline_debt: usize,
66 pub(crate) policy_missing_evidence: usize,
67 pub(crate) broken_evidence_links: usize,
68 pub(crate) weak_evidence_references: usize,
69 pub(crate) review_items: usize,
70}
71
72impl ReviewSignals {
73 pub(crate) fn from_summary(summary: &Summary, context: ReportContext<'_>) -> Self {
74 let baseline_debt = baseline_debt_count(summary, context);
75 let policy_missing_evidence = policy_missing_evidence_count(summary, context);
76 let broken_evidence_links = broken_evidence_link_count(context);
77 let weak_evidence_references = weak_evidence_reference_count(context);
78 let review_items = review_item_count_with_baseline(
79 summary,
80 baseline_debt,
81 policy_missing_evidence,
82 broken_evidence_links,
83 weak_evidence_references,
84 );
85 Self {
86 baseline_debt,
87 policy_missing_evidence,
88 broken_evidence_links,
89 weak_evidence_references,
90 review_items,
91 }
92 }
93}
94
95pub(crate) fn render_count_fields_with_policy_context(
96 summary: &Summary,
97 policy_baseline_debt: Option<usize>,
98 policy_missing_evidence: Option<usize>,
99 broken_evidence_links: Option<usize>,
100 weak_evidence_references: Option<usize>,
101 indent: &str,
102) -> String {
103 let include_policy_baseline_debt =
104 policy_baseline_debt.filter(|count| *count > summary.count(MatchStatus::BaselineDebt));
105 let include_policy_missing_evidence = policy_missing_evidence
106 .filter(|count| *count > summary.count(MatchStatus::EvidenceMissing));
107 let include_broken_evidence_links = broken_evidence_links.filter(|count| *count > 0);
108 let include_weak_evidence_references = weak_evidence_references.filter(|count| *count > 0);
109 let optional_fields = [
110 ("policy_baseline_debt", include_policy_baseline_debt),
111 ("policy_missing_evidence", include_policy_missing_evidence),
112 ("broken_evidence_links", include_broken_evidence_links),
113 ("weak_evidence_references", include_weak_evidence_references),
114 ]
115 .into_iter()
116 .filter_map(|(name, value)| value.map(|value| (name, value)))
117 .collect::<Vec<_>>();
118 let mut out = STATUS_COUNT_ORDER
119 .iter()
120 .enumerate()
121 .map(|(idx, status)| {
122 let comma = if idx + 1 == STATUS_COUNT_ORDER.len() && optional_fields.is_empty() {
123 ""
124 } else {
125 ","
126 };
127 format!(
128 "{indent}\"{}\": {}{comma}\n",
129 status.as_str(),
130 summary.count(*status)
131 )
132 })
133 .collect::<String>();
134 for (index, (name, value)) in optional_fields.iter().enumerate() {
135 let comma = if index + 1 == optional_fields.len() {
136 ""
137 } else {
138 ","
139 };
140 out.push_str(&format!("{indent}\"{name}\": {value}{comma}\n"));
141 }
142 out
143}
144
145pub(crate) fn review_item_count_with_baseline(
146 summary: &Summary,
147 baseline_debt: usize,
148 policy_missing_evidence: usize,
149 broken_evidence_links: usize,
150 weak_evidence_references: usize,
151) -> usize {
152 let policy_missing_evidence_extra =
153 policy_missing_evidence.saturating_sub(summary.count(MatchStatus::EvidenceMissing));
154 REVIEW_ITEM_STATUSES
155 .iter()
156 .map(|status| summary.count(*status))
157 .sum::<usize>()
158 + baseline_debt
159 + policy_missing_evidence_extra
160 + broken_evidence_links
161 + weak_evidence_references
162}
163
164pub(crate) fn baseline_debt_count(summary: &Summary, context: ReportContext<'_>) -> usize {
165 context
166 .baseline_debt_entries
167 .unwrap_or_else(|| summary.count(MatchStatus::BaselineDebt))
168}
169
170pub(crate) fn broken_evidence_link_count(context: ReportContext<'_>) -> usize {
171 context.broken_evidence_links.unwrap_or(0)
172}
173
174pub(crate) fn weak_evidence_reference_count(context: ReportContext<'_>) -> usize {
175 context.weak_evidence_references.unwrap_or(0)
176}
177
178pub(crate) fn policy_missing_evidence_count(
179 summary: &Summary,
180 context: ReportContext<'_>,
181) -> usize {
182 context
183 .policy_missing_evidence_entries
184 .unwrap_or_else(|| summary.count(MatchStatus::EvidenceMissing))
185}
186
187pub fn policy_baseline_debt_entries(cfg: &AllowConfig) -> usize {
188 cfg.allow
189 .iter()
190 .filter(|entry| entry.classification == "baseline_debt")
191 .count()
192}
193
194pub fn policy_missing_evidence_entries(cfg: &AllowConfig) -> usize {
195 cfg.allow
196 .iter()
197 .filter(|entry| entry.evidence.is_empty())
198 .count()
199}
200
201pub fn matched_policy_missing_evidence_entries(
202 cfg: &AllowConfig,
203 outcomes: &[MatchOutcome],
204) -> usize {
205 let matched_allow_ids = outcomes
206 .iter()
207 .filter(|outcome| outcome.status == MatchStatus::Matched)
208 .filter_map(|outcome| outcome.allow_id.as_deref())
209 .collect::<BTreeSet<_>>();
210 cfg.allow
211 .iter()
212 .filter(|entry| entry.classification != "baseline_debt")
213 .filter(|entry| entry.evidence.is_empty())
214 .filter(|entry| matched_allow_ids.contains(entry.id.as_str()))
215 .count()
216}
217
218pub(crate) fn audit_review_queue(outcomes: &[MatchOutcome]) -> Vec<&MatchOutcome> {
219 outcomes
220 .iter()
221 .filter(|outcome| outcome.status != MatchStatus::Matched)
222 .take(20)
223 .collect()
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use allow_core::{AllowEntry, FindingKind, Lifecycle, Selector};
230 use std::path::PathBuf;
231
232 fn outcome(status: MatchStatus, allow_id: Option<&str>) -> MatchOutcome {
233 MatchOutcome {
234 status,
235 allow_id: allow_id.map(str::to_string),
236 finding_index: None,
237 message: status.as_str().to_string(),
238 score: 0,
239 }
240 }
241
242 fn entry(id: &str, classification: &str, evidence: &[&str]) -> AllowEntry {
243 AllowEntry {
244 id: id.to_string(),
245 kind: FindingKind::PolicyException,
246 family: None,
247 path: Some(PathBuf::from("src/lib.rs")),
248 glob: None,
249 owner: "owner".to_string(),
250 classification: classification.to_string(),
251 reason: "reason".to_string(),
252 evidence: evidence.iter().map(|item| (*item).to_string()).collect(),
253 links: Vec::new(),
254 occurrence_limit: None,
255 lifecycle: Lifecycle::empty(),
256 selector: Selector::default(),
257 last_seen: None,
258 }
259 }
260
261 #[test]
262 fn summary_counts_each_match_status_and_defaults_missing_statuses_to_zero() {
263 let outcomes = vec![
264 outcome(MatchStatus::Matched, Some("matched")),
265 outcome(MatchStatus::Matched, Some("matched-again")),
266 outcome(MatchStatus::New, Some("new")),
267 outcome(MatchStatus::Expired, Some("expired")),
268 outcome(MatchStatus::ReviewDue, Some("review")),
269 outcome(MatchStatus::Stale, Some("stale")),
270 outcome(MatchStatus::Ambiguous, Some("ambiguous")),
271 outcome(MatchStatus::InvalidSelector, Some("invalid")),
272 outcome(MatchStatus::EvidenceMissing, Some("evidence")),
273 outcome(MatchStatus::MissingRequiredField, Some("missing")),
274 ];
275
276 let summary = Summary::from_outcomes(&outcomes);
277
278 assert_eq!(summary.total, 10);
279 assert_eq!(summary.count(MatchStatus::Matched), 2);
280 assert_eq!(summary.count(MatchStatus::New), 1);
281 assert_eq!(summary.count(MatchStatus::Expired), 1);
282 assert_eq!(summary.count(MatchStatus::ReviewDue), 1);
283 assert_eq!(summary.count(MatchStatus::Stale), 1);
284 assert_eq!(summary.count(MatchStatus::Ambiguous), 1);
285 assert_eq!(summary.count(MatchStatus::InvalidSelector), 1);
286 assert_eq!(summary.count(MatchStatus::EvidenceMissing), 1);
287 assert_eq!(summary.count(MatchStatus::MissingRequiredField), 1);
288 assert_eq!(summary.count(MatchStatus::BaselineDebt), 0);
289 }
290
291 #[test]
292 fn review_signals_combine_summary_counts_and_policy_context() {
293 let summary = Summary::from_outcomes(&[
294 outcome(MatchStatus::New, Some("new")),
295 outcome(MatchStatus::Expired, Some("expired")),
296 outcome(MatchStatus::EvidenceMissing, Some("missing-evidence")),
297 outcome(MatchStatus::BaselineDebt, Some("baseline")),
298 ]);
299 let mut context = ReportContext::source_syntax("git_tracked", None, None, Some(3));
300 context.policy_missing_evidence_entries = Some(5);
301 context.broken_evidence_links = Some(2);
302 context.weak_evidence_references = Some(1);
303
304 let signals = ReviewSignals::from_summary(&summary, context);
305
306 assert_eq!(
307 signals,
308 ReviewSignals {
309 baseline_debt: 3,
310 policy_missing_evidence: 5,
311 broken_evidence_links: 2,
312 weak_evidence_references: 1,
313 review_items: 13,
314 }
315 );
316 assert_eq!(review_item_count_with_baseline(&summary, 3, 5, 2, 1), 13);
317 }
318
319 #[test]
320 fn render_count_fields_lists_statuses_and_policy_context_excess() {
321 let summary = Summary::from_outcomes(&[
322 outcome(MatchStatus::Matched, Some("matched")),
323 outcome(MatchStatus::EvidenceMissing, Some("missing-evidence")),
324 outcome(MatchStatus::BaselineDebt, Some("baseline")),
325 ]);
326
327 let rendered = render_count_fields_with_policy_context(
328 &summary,
329 Some(3),
330 Some(4),
331 Some(2),
332 Some(1),
333 " ",
334 );
335
336 assert!(rendered.contains(" \"matched\": 1,"));
337 assert!(rendered.contains(" \"new\": 0,"));
338 assert!(rendered.contains(" \"evidence_missing\": 1,"));
339 assert!(rendered.contains(" \"baseline_debt\": 1,"));
340 assert!(rendered.contains(" \"policy_baseline_debt\": 3,"));
341 assert!(rendered.contains(" \"policy_missing_evidence\": 4,"));
342 assert!(rendered.contains(" \"broken_evidence_links\": 2,"));
343 assert!(rendered.contains(" \"weak_evidence_references\": 1\n"));
344
345 let without_excess = render_count_fields_with_policy_context(
346 &summary,
347 Some(1),
348 Some(1),
349 Some(0),
350 Some(0),
351 "",
352 );
353 assert!(!without_excess.contains("policy_baseline_debt"));
354 assert!(!without_excess.contains("policy_missing_evidence"));
355 assert!(!without_excess.contains("broken_evidence_links"));
356 assert!(!without_excess.contains("weak_evidence_references"));
357 assert!(without_excess.ends_with("\"baseline_debt\": 1\n"));
358 }
359
360 #[test]
361 fn context_count_helpers_use_context_override_or_summary_fallback() {
362 let summary = Summary::from_outcomes(&[
363 outcome(MatchStatus::EvidenceMissing, Some("missing-evidence")),
364 outcome(MatchStatus::BaselineDebt, Some("baseline")),
365 ]);
366 let mut context = ReportContext::default();
367
368 assert_eq!(baseline_debt_count(&summary, context), 1);
369 assert_eq!(policy_missing_evidence_count(&summary, context), 1);
370 assert_eq!(broken_evidence_link_count(context), 0);
371 assert_eq!(weak_evidence_reference_count(context), 0);
372
373 context.baseline_debt_entries = Some(4);
374 context.policy_missing_evidence_entries = Some(5);
375 context.broken_evidence_links = Some(2);
376 context.weak_evidence_references = Some(3);
377
378 assert_eq!(baseline_debt_count(&summary, context), 4);
379 assert_eq!(policy_missing_evidence_count(&summary, context), 5);
380 assert_eq!(broken_evidence_link_count(context), 2);
381 assert_eq!(weak_evidence_reference_count(context), 3);
382 }
383
384 #[test]
385 fn policy_entry_helpers_count_baseline_and_matched_missing_evidence() {
386 let mut cfg = AllowConfig::empty();
387 cfg.allow.push(entry("matched-missing", "reviewed", &[]));
388 cfg.allow
389 .push(entry("matched-evidenced", "reviewed", &["test:covered"]));
390 cfg.allow.push(entry("stale-missing", "reviewed", &[]));
391 cfg.allow
392 .push(entry("baseline-missing", "baseline_debt", &[]));
393 let outcomes = vec![
394 outcome(MatchStatus::Matched, Some("matched-missing")),
395 outcome(MatchStatus::Matched, Some("matched-evidenced")),
396 outcome(MatchStatus::Stale, Some("stale-missing")),
397 outcome(MatchStatus::Matched, Some("baseline-missing")),
398 outcome(MatchStatus::Matched, None),
399 ];
400
401 assert_eq!(policy_baseline_debt_entries(&cfg), 1);
402 assert_eq!(policy_missing_evidence_entries(&cfg), 3);
403 assert_eq!(matched_policy_missing_evidence_entries(&cfg, &outcomes), 1);
404 }
405
406 #[test]
407 fn audit_review_queue_keeps_first_twenty_non_matched_outcomes() {
408 let mut outcomes = vec![outcome(MatchStatus::Matched, Some("matched"))];
409 outcomes.extend((0..25).map(|index| {
410 let id = format!("new-{index}");
411 outcome(MatchStatus::New, Some(&id))
412 }));
413
414 let queue = audit_review_queue(&outcomes);
415
416 assert_eq!(queue.len(), 20);
417 assert!(queue.iter().all(|item| item.status != MatchStatus::Matched));
418 assert_eq!(queue[0].allow_id.as_deref(), Some("new-0"));
419 assert_eq!(queue[19].allow_id.as_deref(), Some("new-19"));
420 }
421}