1use std::collections::{BTreeMap, BTreeSet};
16
17use serde::Serialize;
18
19use crate::detect::{Finding, FindingType, Severity};
20use crate::report::{PerEndpointIoOps, Report, Warning};
21
22type IdentityKey = (
30 FindingType,
31 String,
32 Option<(String, String)>,
33 String,
34 String,
35);
36
37fn identity_of(finding: &Finding, with_grouping: bool) -> IdentityKey {
38 (
39 finding.finding_type.clone(),
40 finding.service.clone(),
41 with_grouping
44 .then(|| {
45 finding
46 .effective_grouping()
47 .map(|g| (g.key.to_string(), g.value.to_string()))
48 })
49 .flatten(),
50 finding.source_endpoint.clone(),
51 finding.pattern.template.clone(),
52 )
53}
54
55fn grouping_is_comparable(before: &[Finding], after: &[Finding]) -> bool {
61 before.iter().any(|f| f.effective_grouping().is_some())
62 || after.iter().all(|f| f.effective_grouping().is_none())
63}
64
65#[derive(Debug, Clone, Serialize)]
70pub struct DiffReport {
71 pub new_findings: Vec<Finding>,
73 pub resolved_findings: Vec<Finding>,
75 pub severity_changes: Vec<SeverityChange>,
78 pub endpoint_metric_deltas: Vec<EndpointDelta>,
81 #[serde(skip_serializing_if = "Vec::is_empty")]
85 pub warning_details: Vec<Warning>,
86}
87
88#[derive(Debug, Clone, Serialize)]
90pub struct SeverityChange {
91 pub finding: Finding,
93 pub before_severity: Severity,
94 pub after_severity: Severity,
95}
96
97impl SeverityChange {
98 #[must_use]
105 pub fn is_regression(&self) -> bool {
106 self.after_severity < self.before_severity
107 }
108}
109
110#[derive(Debug, Clone, Serialize)]
112pub struct EndpointDelta {
113 pub service: String,
114 pub endpoint: String,
115 pub before_io_ops: usize,
116 pub after_io_ops: usize,
117 pub delta: i64,
119}
120
121#[must_use]
129pub fn diff_runs(before: &Report, after: &Report) -> DiffReport {
130 let with_grouping = grouping_is_comparable(&before.findings, &after.findings);
131 let before_map = build_identity_map(&before.findings, with_grouping);
132 let after_map = build_identity_map(&after.findings, with_grouping);
133
134 let mut new_findings: Vec<Finding> = Vec::new();
135 let mut resolved_findings: Vec<Finding> = Vec::new();
136 let mut severity_changes: Vec<SeverityChange> = Vec::new();
137
138 for (key, after_finding) in &after_map {
139 match before_map.get(key) {
140 None => new_findings.push(after_finding.clone()),
141 Some(before_finding) if before_finding.severity != after_finding.severity => {
142 severity_changes.push(SeverityChange {
143 finding: after_finding.clone(),
144 before_severity: before_finding.severity.clone(),
145 after_severity: after_finding.severity.clone(),
146 });
147 }
148 Some(_) => {}
149 }
150 }
151 for (key, before_finding) in &before_map {
152 if !after_map.contains_key(key) {
153 resolved_findings.push(before_finding.clone());
154 }
155 }
156
157 crate::detect::sort_findings(&mut new_findings);
161 crate::detect::sort_findings(&mut resolved_findings);
162 severity_changes.sort_by(|a, b| {
166 b.is_regression()
167 .cmp(&a.is_regression())
168 .then_with(|| a.finding.finding_type.cmp(&b.finding.finding_type))
169 .then_with(|| a.finding.service.cmp(&b.finding.service))
170 .then_with(|| a.finding.source_endpoint.cmp(&b.finding.source_endpoint))
171 .then_with(|| a.finding.pattern.template.cmp(&b.finding.pattern.template))
172 });
173
174 let endpoint_metric_deltas =
175 diff_per_endpoint_io_ops(&before.per_endpoint_io_ops, &after.per_endpoint_io_ops);
176
177 DiffReport {
178 new_findings,
179 resolved_findings,
180 severity_changes,
181 endpoint_metric_deltas,
182 warning_details: after.warning_details.clone(),
183 }
184}
185
186fn build_identity_map(findings: &[Finding], with_grouping: bool) -> BTreeMap<IdentityKey, Finding> {
204 let mut map: BTreeMap<IdentityKey, Finding> = BTreeMap::new();
205 for finding in findings {
206 let key = identity_of(finding, with_grouping);
207 match map.get_mut(&key) {
208 None => {
209 map.insert(key, finding.clone());
210 }
211 Some(existing) => {
212 if finding.severity < existing.severity {
215 let summed = existing
216 .pattern
217 .occurrences
218 .saturating_add(finding.pattern.occurrences);
219 *existing = finding.clone();
220 existing.pattern.occurrences = summed;
221 } else {
222 existing.pattern.occurrences = existing
223 .pattern
224 .occurrences
225 .saturating_add(finding.pattern.occurrences);
226 }
227 }
228 }
229 }
230 map
231}
232
233fn diff_per_endpoint_io_ops(
239 before: &[PerEndpointIoOps],
240 after: &[PerEndpointIoOps],
241) -> Vec<EndpointDelta> {
242 let mut before_map: BTreeMap<(&str, &str), usize> = BTreeMap::new();
243 for entry in before {
244 before_map.insert((&entry.service, &entry.endpoint), entry.io_ops);
245 }
246 let mut after_map: BTreeMap<(&str, &str), usize> = BTreeMap::new();
247 for entry in after {
248 after_map.insert((&entry.service, &entry.endpoint), entry.io_ops);
249 }
250
251 let mut keys: BTreeSet<(&str, &str)> = BTreeSet::new();
252 keys.extend(before_map.keys().copied());
253 keys.extend(after_map.keys().copied());
254
255 let mut deltas: Vec<EndpointDelta> = keys
256 .iter()
257 .filter_map(|(service, endpoint)| {
258 let before_io = before_map.get(&(*service, *endpoint)).copied().unwrap_or(0);
259 let after_io = after_map.get(&(*service, *endpoint)).copied().unwrap_or(0);
260 if before_io == after_io {
261 return None;
262 }
263 let delta = i128::from(after_io as u64) - i128::from(before_io as u64);
268 let delta_i64 = i64::try_from(delta).unwrap_or_else(|_| {
269 tracing::warn!(
270 target: "perf_sentinel::diff",
271 service = %service,
272 endpoint = %endpoint,
273 before_io = before_io,
274 after_io = after_io,
275 "endpoint I/O op delta overflows i64, clamping for output"
276 );
277 if delta > 0 { i64::MAX } else { i64::MIN }
278 });
279 Some(EndpointDelta {
280 service: (*service).to_string(),
281 endpoint: (*endpoint).to_string(),
282 before_io_ops: before_io,
283 after_io_ops: after_io,
284 delta: delta_i64,
285 })
286 })
287 .collect();
288
289 deltas.sort_by(|a, b| {
294 b.delta
295 .cmp(&a.delta)
296 .then_with(|| a.service.cmp(&b.service))
297 .then_with(|| a.endpoint.cmp(&b.endpoint))
298 });
299 deltas
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use crate::detect::{Confidence, Finding, FindingType, Pattern, Severity};
306 use crate::report::{Analysis, GreenSummary, PerEndpointIoOps, QualityGate, Report};
307
308 fn make_report(findings: Vec<Finding>, per_endpoint: Vec<PerEndpointIoOps>) -> Report {
309 Report {
310 analysis: Analysis {
311 duration_ms: 0,
312 events_processed: 0,
313 traces_analyzed: 0,
314 ingest: None,
315 },
316 findings,
317 green_summary: GreenSummary::disabled(0),
318 quality_gate: QualityGate {
319 passed: true,
320 rules: vec![],
321 },
322 per_endpoint_io_ops: per_endpoint,
323 correlations: vec![],
324 embedded_traces: vec![],
325 warnings: vec![],
326 warning_details: vec![],
327 acknowledged_findings: vec![],
328 binary_version: String::new(),
329 detection_config: None,
330 disclosure_waste: None,
331 }
332 }
333
334 fn finding(
335 ft: FindingType,
336 sev: Severity,
337 service: &str,
338 endpoint: &str,
339 template: &str,
340 ) -> Finding {
341 Finding {
342 finding_type: ft,
343 severity: sev,
344 trace_id: "trace-1".to_string(),
345 service: service.to_string(),
346 grouping: Vec::new(),
347 source_endpoint: endpoint.to_string(),
348 pattern: Pattern {
349 template: template.to_string(),
350 occurrences: 6,
351 window_ms: 200,
352 distinct_params: 6,
353 ..Default::default()
354 },
355 suggestion: "batch".to_string(),
356 first_timestamp: "2025-07-10T14:32:01.000Z".to_string(),
357 last_timestamp: "2025-07-10T14:32:01.250Z".to_string(),
358 green_impact: None,
359 confidence: Confidence::default(),
360 classification_method: None,
361 code_location: None,
362 instrumentation_scopes: Vec::new(),
363 suggested_fix: None,
364 signature: String::new(),
365 }
366 }
367
368 #[test]
369 fn identity_separates_the_same_problem_in_two_namespaces() {
370 let mut prod = finding(
371 FindingType::NPlusOneSql,
372 Severity::Warning,
373 "order-svc",
374 "GET /api/orders",
375 "SELECT * FROM t WHERE id = ?",
376 );
377 prod.grouping = crate::test_helpers::k8s_grouping("prod-eu");
378 let mut staging = prod.clone();
379 staging.grouping = crate::test_helpers::k8s_grouping("staging");
380
381 let before = make_report(vec![prod.clone()], vec![]);
382 let after = make_report(vec![prod, staging], vec![]);
383 let report = diff_runs(&before, &after);
384
385 assert_eq!(report.new_findings.len(), 1);
386 assert_eq!(
387 report.new_findings[0].grouping_value(),
388 Some("staging"),
389 "the staging deployment must not fold into the prod row"
390 );
391 }
392
393 #[test]
394 fn identity_separates_equal_values_from_different_grouping_keys() {
395 let mut tenant = finding(
396 FindingType::NPlusOneSql,
397 Severity::Warning,
398 "order-svc",
399 "GET /api/orders",
400 "SELECT * FROM t WHERE id = ?",
401 );
402 tenant.grouping = crate::test_helpers::grouping("tenant.id", "prod");
403 let mut namespace = tenant.clone();
404 namespace.grouping = crate::test_helpers::grouping("k8s.namespace.name", "prod");
405
406 let before = make_report(vec![tenant.clone()], vec![]);
407 let after = make_report(vec![tenant, namespace], vec![]);
408 let report = diff_runs(&before, &after);
409
410 assert_eq!(report.new_findings.len(), 1);
411 assert_eq!(
412 report.new_findings[0]
413 .effective_grouping()
414 .map(|g| g.key.as_ref()),
415 Some("k8s.namespace.name")
416 );
417 }
418
419 #[test]
423 fn a_baseline_without_grouping_does_not_diff_as_a_total_regression() {
424 let legacy = finding(
425 FindingType::NPlusOneSql,
426 Severity::Warning,
427 "order-svc",
428 "GET /api/orders",
429 "SELECT * FROM t WHERE id = ?",
430 );
431 let mut current = legacy.clone();
432 current.grouping = crate::test_helpers::k8s_grouping("prod-eu");
433
434 let report = diff_runs(
435 &make_report(vec![legacy], vec![]),
436 &make_report(vec![current], vec![]),
437 );
438
439 assert!(report.new_findings.is_empty(), "{:?}", report.new_findings);
440 assert!(
441 report.resolved_findings.is_empty(),
442 "{:?}",
443 report.resolved_findings
444 );
445 }
446
447 #[test]
449 fn a_grouped_baseline_still_separates_deployments() {
450 let mut prod = finding(
451 FindingType::NPlusOneSql,
452 Severity::Warning,
453 "order-svc",
454 "GET /api/orders",
455 "SELECT * FROM t WHERE id = ?",
456 );
457 prod.grouping = crate::test_helpers::k8s_grouping("prod-eu");
458 let mut staging = prod.clone();
459 staging.grouping = crate::test_helpers::k8s_grouping("staging");
460
461 let report = diff_runs(
462 &make_report(vec![prod.clone()], vec![]),
463 &make_report(vec![prod, staging], vec![]),
464 );
465
466 assert_eq!(report.new_findings.len(), 1);
467 assert_eq!(report.new_findings[0].grouping_value(), Some("staging"));
468 }
469
470 fn endpoint(service: &str, ep: &str, ops: usize) -> PerEndpointIoOps {
471 PerEndpointIoOps {
472 service: service.to_string(),
473 endpoint: ep.to_string(),
474 io_ops: ops,
475 }
476 }
477
478 #[test]
483 fn diff_carries_the_after_runs_warnings() {
484 let mut before = make_report(vec![], vec![]);
485 before.warning_details = vec![Warning::new("cold_start", "stale baseline warning")];
486 let mut after = make_report(vec![], vec![]);
487 after.warning_details = vec![Warning::new(
488 "unmatched_acknowledgment",
489 "acknowledgment deadbeef matched no finding in this run",
490 )];
491 let diff = diff_runs(&before, &after);
492 assert_eq!(diff.warning_details, after.warning_details);
493 }
494
495 #[test]
496 fn identical_runs_produce_empty_diff() {
497 let f = finding(
498 FindingType::NPlusOneSql,
499 Severity::Warning,
500 "svc",
501 "POST /api",
502 "SELECT *",
503 );
504 let before = make_report(vec![f.clone()], vec![endpoint("svc", "POST /api", 6)]);
505 let after = make_report(vec![f], vec![endpoint("svc", "POST /api", 6)]);
506 let diff = diff_runs(&before, &after);
507 assert!(diff.new_findings.is_empty());
508 assert!(diff.resolved_findings.is_empty());
509 assert!(diff.severity_changes.is_empty());
510 assert!(diff.endpoint_metric_deltas.is_empty());
511 }
512
513 #[test]
514 fn finding_present_only_in_after_is_new() {
515 let before = make_report(vec![], vec![]);
516 let after = make_report(
517 vec![finding(
518 FindingType::NPlusOneSql,
519 Severity::Warning,
520 "svc",
521 "POST /api",
522 "SELECT *",
523 )],
524 vec![],
525 );
526 let diff = diff_runs(&before, &after);
527 assert_eq!(diff.new_findings.len(), 1);
528 assert!(diff.resolved_findings.is_empty());
529 assert!(diff.severity_changes.is_empty());
530 }
531
532 #[test]
533 fn finding_present_only_in_before_is_resolved() {
534 let before = make_report(
535 vec![finding(
536 FindingType::NPlusOneSql,
537 Severity::Warning,
538 "svc",
539 "POST /api",
540 "SELECT *",
541 )],
542 vec![],
543 );
544 let after = make_report(vec![], vec![]);
545 let diff = diff_runs(&before, &after);
546 assert!(diff.new_findings.is_empty());
547 assert_eq!(diff.resolved_findings.len(), 1);
548 assert!(diff.severity_changes.is_empty());
549 }
550
551 #[test]
552 fn same_identity_with_different_severity_is_severity_change() {
553 let f_warn = finding(
554 FindingType::NPlusOneSql,
555 Severity::Warning,
556 "svc",
557 "POST /api",
558 "SELECT *",
559 );
560 let mut f_crit = f_warn.clone();
561 f_crit.severity = Severity::Critical;
562 let before = make_report(vec![f_warn], vec![]);
563 let after = make_report(vec![f_crit], vec![]);
564 let diff = diff_runs(&before, &after);
565 assert!(diff.new_findings.is_empty());
566 assert!(diff.resolved_findings.is_empty());
567 assert_eq!(diff.severity_changes.len(), 1);
568 let change = &diff.severity_changes[0];
569 assert_eq!(change.before_severity, Severity::Warning);
570 assert_eq!(change.after_severity, Severity::Critical);
571 assert!(
572 change.is_regression(),
573 "warning -> critical is a regression"
574 );
575 }
576
577 #[test]
578 fn severity_changes_sorted_regressions_first() {
579 let before = make_report(
581 vec![
582 finding(
583 FindingType::NPlusOneSql,
584 Severity::Warning,
585 "svc-a",
586 "POST /a",
587 "SELECT a",
588 ),
589 finding(
590 FindingType::NPlusOneSql,
591 Severity::Critical,
592 "svc-b",
593 "POST /b",
594 "SELECT b",
595 ),
596 ],
597 vec![],
598 );
599 let after = make_report(
600 vec![
601 finding(
602 FindingType::NPlusOneSql,
603 Severity::Critical,
604 "svc-a",
605 "POST /a",
606 "SELECT a",
607 ),
608 finding(
609 FindingType::NPlusOneSql,
610 Severity::Warning,
611 "svc-b",
612 "POST /b",
613 "SELECT b",
614 ),
615 ],
616 vec![],
617 );
618 let diff = diff_runs(&before, &after);
619 assert_eq!(diff.severity_changes.len(), 2);
620 assert!(
621 diff.severity_changes[0].is_regression(),
622 "regression must come first"
623 );
624 assert!(
625 !diff.severity_changes[1].is_regression(),
626 "improvement must come last"
627 );
628 }
629
630 #[test]
631 fn duplicate_identity_in_one_run_is_collapsed_to_worst_severity() {
632 let before = make_report(
637 vec![finding(
638 FindingType::NPlusOneSql,
639 Severity::Critical,
640 "svc",
641 "POST /api",
642 "SELECT *",
643 )],
644 vec![],
645 );
646 let f_warn = finding(
647 FindingType::NPlusOneSql,
648 Severity::Warning,
649 "svc",
650 "POST /api",
651 "SELECT *",
652 );
653 let mut f_crit = f_warn.clone();
654 f_crit.severity = Severity::Critical;
655 let after = make_report(vec![f_warn, f_crit], vec![]);
656 let diff = diff_runs(&before, &after);
657 assert!(
658 diff.new_findings.is_empty(),
659 "no new findings when identity is shared"
660 );
661 assert!(
662 diff.resolved_findings.is_empty(),
663 "no resolved when identity is shared"
664 );
665 assert!(
666 diff.severity_changes.is_empty(),
667 "worst-severity dedupe should make this a no-op (Critical == Critical)"
668 );
669 }
670
671 #[test]
672 fn endpoint_io_ops_increase_is_a_positive_delta() {
673 let before = make_report(vec![], vec![endpoint("svc", "POST /api/users", 10)]);
674 let after = make_report(vec![], vec![endpoint("svc", "POST /api/users", 20)]);
675 let diff = diff_runs(&before, &after);
676 assert_eq!(diff.endpoint_metric_deltas.len(), 1);
677 let d = &diff.endpoint_metric_deltas[0];
678 assert_eq!(d.service, "svc");
679 assert_eq!(d.endpoint, "POST /api/users");
680 assert_eq!(d.before_io_ops, 10);
681 assert_eq!(d.after_io_ops, 20);
682 assert_eq!(d.delta, 10);
683 }
684
685 #[test]
686 fn endpoint_absent_from_before_is_a_full_addition() {
687 let before = make_report(vec![], vec![]);
688 let after = make_report(vec![], vec![endpoint("svc", "POST /api", 7)]);
689 let diff = diff_runs(&before, &after);
690 assert_eq!(diff.endpoint_metric_deltas.len(), 1);
691 let d = &diff.endpoint_metric_deltas[0];
692 assert_eq!(d.before_io_ops, 0);
693 assert_eq!(d.after_io_ops, 7);
694 assert_eq!(d.delta, 7);
695 }
696
697 #[test]
698 fn endpoint_absent_from_after_is_a_full_removal() {
699 let before = make_report(vec![], vec![endpoint("svc", "POST /api", 5)]);
700 let after = make_report(vec![], vec![]);
701 let diff = diff_runs(&before, &after);
702 assert_eq!(diff.endpoint_metric_deltas.len(), 1);
703 let d = &diff.endpoint_metric_deltas[0];
704 assert_eq!(d.before_io_ops, 5);
705 assert_eq!(d.after_io_ops, 0);
706 assert_eq!(d.delta, -5);
707 }
708
709 #[test]
710 fn endpoint_deltas_sorted_regressions_first() {
711 let before = make_report(
712 vec![],
713 vec![
714 endpoint("svc", "POST /improve", 10),
715 endpoint("svc", "POST /regress", 5),
716 endpoint("svc", "POST /steady", 7),
717 ],
718 );
719 let after = make_report(
720 vec![],
721 vec![
722 endpoint("svc", "POST /improve", 2),
723 endpoint("svc", "POST /regress", 50),
724 endpoint("svc", "POST /steady", 7),
725 ],
726 );
727 let diff = diff_runs(&before, &after);
728 assert_eq!(diff.endpoint_metric_deltas.len(), 2);
729 assert_eq!(diff.endpoint_metric_deltas[0].endpoint, "POST /regress");
730 assert_eq!(diff.endpoint_metric_deltas[0].delta, 45);
731 assert_eq!(diff.endpoint_metric_deltas[1].endpoint, "POST /improve");
732 assert_eq!(diff.endpoint_metric_deltas[1].delta, -8);
733 }
734
735 #[test]
736 fn equal_severity_in_both_runs_is_not_a_severity_change() {
737 let f = finding(
741 FindingType::NPlusOneSql,
742 Severity::Critical,
743 "svc",
744 "POST /api",
745 "SELECT *",
746 );
747 let before = make_report(vec![f.clone()], vec![]);
748 let after = make_report(vec![f], vec![]);
749 let diff = diff_runs(&before, &after);
750 assert!(diff.severity_changes.is_empty());
751 }
752
753 #[test]
754 fn same_identity_different_trace_id_is_treated_as_one_finding() {
755 let mut f_a = finding(
759 FindingType::NPlusOneSql,
760 Severity::Warning,
761 "svc",
762 "POST /api",
763 "SELECT *",
764 );
765 f_a.trace_id = "trace-a".to_string();
766 f_a.pattern.occurrences = 6;
767 let mut f_b = f_a.clone();
768 f_b.trace_id = "trace-b".to_string();
769 f_b.pattern.occurrences = 12;
770
771 let before = make_report(vec![], vec![]);
772 let after = make_report(vec![f_a, f_b], vec![]);
773 let diff = diff_runs(&before, &after);
774 assert_eq!(diff.new_findings.len(), 1, "two duplicates collapse to one");
775 assert_eq!(
776 diff.new_findings[0].pattern.occurrences, 18,
777 "occurrences from both findings sum on collapse"
778 );
779 }
780
781 #[test]
782 fn duplicate_identity_collapse_sums_occurrences() {
783 let mut f_a = finding(
785 FindingType::NPlusOneSql,
786 Severity::Warning,
787 "svc",
788 "POST /api",
789 "SELECT *",
790 );
791 f_a.pattern.occurrences = 6;
792 let mut f_b = f_a.clone();
793 f_b.pattern.occurrences = 60;
794
795 let before = make_report(vec![], vec![]);
796 let after = make_report(vec![f_a, f_b], vec![]);
797 let diff = diff_runs(&before, &after);
798 assert_eq!(diff.new_findings.len(), 1);
799 assert_eq!(diff.new_findings[0].pattern.occurrences, 66);
800 }
801
802 #[test]
803 fn diff_sarif_emits_one_result_per_new_finding() {
804 let f = finding(
807 FindingType::NPlusOneSql,
808 Severity::Warning,
809 "svc",
810 "POST /api",
811 "SELECT *",
812 );
813 let before = make_report(vec![], vec![]);
814 let after = make_report(vec![f], vec![]);
815 let diff = diff_runs(&before, &after);
816 assert_eq!(diff.new_findings.len(), 1);
817 let sarif = crate::report::sarif::findings_to_sarif(&diff.new_findings);
818 assert_eq!(
819 sarif.runs[0].results.len(),
820 diff.new_findings.len(),
821 "SARIF results count must match new_findings count"
822 );
823 }
824}