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}