Skip to main content

allow_report/
summary.rs

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}