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}