1use crate::{DiffFindingChange, DiffPolicyChange, DiffPostureSummary};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum DiffNetPosture {
5 Worse,
6 ReviewRequired,
7 Improved,
8 Unchanged,
9}
10
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
12pub(crate) struct DiffEvidenceDeltaSummary {
13 pub(crate) evidence_added: usize,
14 pub(crate) weak_evidence_added: usize,
15 pub(crate) broken_evidence_added: usize,
16 pub(crate) evidence_removed: usize,
17 pub(crate) evidence_removal_failures: usize,
18 pub(crate) evidence_removal_review_items: usize,
19 pub(crate) evidence_removal_improvements: usize,
20 pub(crate) link_added: usize,
21 pub(crate) weak_link_added: usize,
22 pub(crate) broken_link_added: usize,
23 pub(crate) link_removed: usize,
24 pub(crate) link_removal_failures: usize,
25 pub(crate) link_removal_review_items: usize,
26 pub(crate) link_removal_improvements: usize,
27}
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub(crate) struct DiffStructuralDeltaSummary {
31 pub(crate) scope_broadened: usize,
32 pub(crate) scope_changed: usize,
33 pub(crate) scope_narrowed: usize,
34 pub(crate) selector_changed: usize,
35 pub(crate) selector_precision_decreased: usize,
36 pub(crate) selector_precision_increased: usize,
37}
38
39impl DiffNetPosture {
40 pub fn as_str(self) -> &'static str {
41 match self {
42 Self::Worse => "worse",
43 Self::ReviewRequired => "review-required",
44 Self::Improved => "improved",
45 Self::Unchanged => "unchanged",
46 }
47 }
48
49 pub fn reviewer_action(self) -> &'static str {
50 match self {
51 Self::Worse => {
52 "block until failing source exception changes are fixed, narrowed, or receipted."
53 }
54 Self::ReviewRequired => "review the source exception posture change before merging.",
55 Self::Improved => "verify the cleanup was intentional and keep the narrower posture.",
56 Self::Unchanged => "no source exception posture change detected.",
57 }
58 }
59}
60
61pub(crate) fn diff_structural_delta_summary(
62 policy_changes: &[DiffPolicyChange<'_>],
63) -> DiffStructuralDeltaSummary {
64 let mut summary = DiffStructuralDeltaSummary::default();
65 for change in policy_changes {
66 match change.kind {
67 "scope_broadened" => summary.scope_broadened += 1,
68 "scope_changed" => summary.scope_changed += 1,
69 "scope_narrowed" => summary.scope_narrowed += 1,
70 "selector_changed" => summary.selector_changed += 1,
71 "selector_precision_decreased" => summary.selector_precision_decreased += 1,
72 "selector_precision_increased" => summary.selector_precision_increased += 1,
73 _ => {}
74 }
75 }
76 summary
77}
78
79pub(crate) fn diff_evidence_delta_summary(
80 policy_changes: &[DiffPolicyChange<'_>],
81) -> DiffEvidenceDeltaSummary {
82 let mut summary = DiffEvidenceDeltaSummary::default();
83 for change in policy_changes {
84 match change.kind {
85 "evidence_added" => {
86 summary.evidence_added += 1;
87 match change.severity {
88 "review" => summary.weak_evidence_added += 1,
89 "fail" => summary.broken_evidence_added += 1,
90 _ => {}
91 }
92 }
93 "evidence_removed" => {
94 summary.evidence_removed += 1;
95 match change.severity {
96 "fail" => summary.evidence_removal_failures += 1,
97 "review" => summary.evidence_removal_review_items += 1,
98 "improvement" => summary.evidence_removal_improvements += 1,
99 _ => {}
100 }
101 }
102 "link_added" => {
103 summary.link_added += 1;
104 match change.severity {
105 "review" => summary.weak_link_added += 1,
106 "fail" => summary.broken_link_added += 1,
107 _ => {}
108 }
109 }
110 "link_removed" => {
111 summary.link_removed += 1;
112 match change.severity {
113 "fail" => summary.link_removal_failures += 1,
114 "review" => summary.link_removal_review_items += 1,
115 "improvement" => summary.link_removal_improvements += 1,
116 _ => {}
117 }
118 }
119 _ => {}
120 }
121 }
122 summary
123}
124
125pub fn diff_posture_summary(
126 current_failures: usize,
127 finding_changes: &[DiffFindingChange<'_>],
128 policy_changes: &[DiffPolicyChange<'_>],
129) -> DiffPostureSummary {
130 DiffPostureSummary {
131 current_failures,
132 new_findings: finding_changes
133 .iter()
134 .filter(|change| change.change == "new")
135 .count(),
136 removed_findings: finding_changes
137 .iter()
138 .filter(|change| change.change == "removed")
139 .count(),
140 policy_failures: policy_changes
141 .iter()
142 .filter(|change| change.severity == "fail")
143 .count(),
144 policy_review_items: policy_changes
145 .iter()
146 .filter(|change| change.severity == "review")
147 .count(),
148 policy_improvements: policy_changes
149 .iter()
150 .filter(|change| change.severity == "improvement")
151 .count(),
152 }
153}
154
155pub fn diff_net_posture(summary: DiffPostureSummary) -> DiffNetPosture {
156 if summary.current_failures > 0 || summary.policy_failures > 0 {
157 return DiffNetPosture::Worse;
158 }
159 if summary.new_findings > 0 || summary.policy_review_items > 0 {
160 return DiffNetPosture::ReviewRequired;
161 }
162 if summary.removed_findings > 0 || summary.policy_improvements > 0 {
163 return DiffNetPosture::Improved;
164 }
165 DiffNetPosture::Unchanged
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 fn policy_change<'a>(severity: &'a str, kind: &'a str) -> DiffPolicyChange<'a> {
173 DiffPolicyChange {
174 severity,
175 allow_id: "allow-test",
176 kind,
177 message: "policy changed",
178 exception_identity: None,
179 selector_identity: None,
180 selector_precision: None,
181 scope: None,
182 occurrence_limit: None,
183 lifecycle: None,
184 evidence: None,
185 metadata: None,
186 requirement: None,
187 policy_status: None,
188 }
189 }
190
191 fn finding_change<'a>(change: &'a str) -> DiffFindingChange<'a> {
192 DiffFindingChange {
193 change,
194 key: "panic|unwrap|src/lib.rs",
195 kind: "panic",
196 family: Some("unwrap"),
197 path: "src/lib.rs",
198 line: Some(1),
199 column: Some(1),
200 source_package: Some("allow-report"),
201 identity: None,
202 }
203 }
204
205 fn summary(
206 current_failures: usize,
207 new_findings: usize,
208 removed_findings: usize,
209 policy_failures: usize,
210 policy_review_items: usize,
211 policy_improvements: usize,
212 ) -> DiffPostureSummary {
213 DiffPostureSummary {
214 current_failures,
215 new_findings,
216 removed_findings,
217 policy_failures,
218 policy_review_items,
219 policy_improvements,
220 }
221 }
222
223 #[test]
224 fn net_posture_strings_and_reviewer_actions_cover_all_variants() {
225 let cases = [
226 (
227 DiffNetPosture::Worse,
228 "worse",
229 "block until failing source exception changes are fixed, narrowed, or receipted.",
230 ),
231 (
232 DiffNetPosture::ReviewRequired,
233 "review-required",
234 "review the source exception posture change before merging.",
235 ),
236 (
237 DiffNetPosture::Improved,
238 "improved",
239 "verify the cleanup was intentional and keep the narrower posture.",
240 ),
241 (
242 DiffNetPosture::Unchanged,
243 "unchanged",
244 "no source exception posture change detected.",
245 ),
246 ];
247
248 for (posture, as_str, action) in cases {
249 assert_eq!(posture.as_str(), as_str);
250 assert_eq!(posture.reviewer_action(), action);
251 }
252 }
253
254 #[test]
255 fn structural_delta_summary_counts_known_kinds_and_ignores_unknowns() {
256 let changes = [
257 policy_change("fail", "scope_broadened"),
258 policy_change("review", "scope_broadened"),
259 policy_change("review", "scope_changed"),
260 policy_change("improvement", "scope_narrowed"),
261 policy_change("review", "selector_changed"),
262 policy_change("fail", "selector_precision_decreased"),
263 policy_change("improvement", "selector_precision_increased"),
264 policy_change("review", "evidence_added"),
265 ];
266
267 assert_eq!(
268 diff_structural_delta_summary(&changes),
269 DiffStructuralDeltaSummary {
270 scope_broadened: 2,
271 scope_changed: 1,
272 scope_narrowed: 1,
273 selector_changed: 1,
274 selector_precision_decreased: 1,
275 selector_precision_increased: 1,
276 }
277 );
278 }
279
280 #[test]
281 fn evidence_delta_summary_counts_severity_buckets_for_evidence_and_links() {
282 let changes = [
283 policy_change("review", "evidence_added"),
284 policy_change("fail", "evidence_added"),
285 policy_change("improvement", "evidence_added"),
286 policy_change("fail", "evidence_removed"),
287 policy_change("review", "evidence_removed"),
288 policy_change("improvement", "evidence_removed"),
289 policy_change("review", "link_added"),
290 policy_change("fail", "link_added"),
291 policy_change("improvement", "link_added"),
292 policy_change("fail", "link_removed"),
293 policy_change("review", "link_removed"),
294 policy_change("improvement", "link_removed"),
295 policy_change("review", "scope_changed"),
296 ];
297
298 assert_eq!(
299 diff_evidence_delta_summary(&changes),
300 DiffEvidenceDeltaSummary {
301 evidence_added: 3,
302 weak_evidence_added: 1,
303 broken_evidence_added: 1,
304 evidence_removed: 3,
305 evidence_removal_failures: 1,
306 evidence_removal_review_items: 1,
307 evidence_removal_improvements: 1,
308 link_added: 3,
309 weak_link_added: 1,
310 broken_link_added: 1,
311 link_removed: 3,
312 link_removal_failures: 1,
313 link_removal_review_items: 1,
314 link_removal_improvements: 1,
315 }
316 );
317 }
318
319 #[test]
320 fn posture_summary_counts_finding_and_policy_statuses() {
321 let finding_changes = [
322 finding_change("new"),
323 finding_change("new"),
324 finding_change("removed"),
325 finding_change("unchanged"),
326 ];
327 let policy_changes = [
328 policy_change("fail", "scope_broadened"),
329 policy_change("review", "selector_changed"),
330 policy_change("improvement", "scope_narrowed"),
331 policy_change("info", "metadata_changed"),
332 ];
333
334 assert_eq!(
335 diff_posture_summary(7, &finding_changes, &policy_changes),
336 DiffPostureSummary {
337 current_failures: 7,
338 new_findings: 2,
339 removed_findings: 1,
340 policy_failures: 1,
341 policy_review_items: 1,
342 policy_improvements: 1,
343 }
344 );
345 }
346
347 #[test]
348 fn net_posture_prioritizes_failures_then_review_then_improvement() {
349 let cases = [
350 (summary(1, 0, 0, 0, 0, 0), DiffNetPosture::Worse),
351 (summary(0, 0, 0, 1, 1, 1), DiffNetPosture::Worse),
352 (summary(0, 1, 1, 0, 0, 1), DiffNetPosture::ReviewRequired),
353 (summary(0, 0, 1, 0, 1, 1), DiffNetPosture::ReviewRequired),
354 (summary(0, 0, 1, 0, 0, 0), DiffNetPosture::Improved),
355 (summary(0, 0, 0, 0, 0, 1), DiffNetPosture::Improved),
356 (summary(0, 0, 0, 0, 0, 0), DiffNetPosture::Unchanged),
357 ];
358
359 for (summary, expected) in cases {
360 assert_eq!(diff_net_posture(summary), expected);
361 }
362 }
363}