moonlight_core/compare/
mod.rs1mod capture;
2mod diff;
3
4use crate::{Classification, ComparisonSummary, DiffEntry};
5use std::collections::HashSet;
6
7pub use capture::{
8 capture_body, capture_body_with_redactions, capture_headers, is_hop_by_hop_header,
9};
10pub use diff::CapturedTarget;
11
12#[derive(Debug, Clone)]
13pub struct CompareConfig {
14 pub ignored_json_paths: HashSet<String>,
15 pub redact_json_paths: HashSet<String>,
16 pub ignored_headers: HashSet<String>,
17 pub ignore_stderr: bool,
18}
19
20impl CompareConfig {
21 pub fn new(
22 ignored_json_paths: &[String],
23 ignored_headers: &[String],
24 ignore_stderr: bool,
25 ) -> Self {
26 Self::new_with_redactions(ignored_json_paths, &[], ignored_headers, ignore_stderr)
27 }
28
29 pub fn new_with_redactions(
30 ignored_json_paths: &[String],
31 redact_json_paths: &[String],
32 ignored_headers: &[String],
33 ignore_stderr: bool,
34 ) -> Self {
35 Self {
36 ignored_json_paths: ignored_json_paths.iter().cloned().collect(),
37 redact_json_paths: redact_json_paths.iter().cloned().collect(),
38 ignored_headers: ignored_headers
39 .iter()
40 .map(|value| value.to_ascii_lowercase())
41 .collect(),
42 ignore_stderr,
43 }
44 }
45}
46
47pub fn compare_targets(
48 primary: &CapturedTarget,
49 candidate: &CapturedTarget,
50 secondary: Option<&CapturedTarget>,
51 config: &CompareConfig,
52) -> ComparisonSummary {
53 let raw_candidate_diffs =
54 diff::diff_pair(primary, candidate, diff::TargetRole::Candidate, config);
55 let reference_noise = secondary
56 .map(|secondary| diff::diff_pair(primary, secondary, diff::TargetRole::Secondary, config))
57 .unwrap_or_default();
58 let noise_filtered_diffs = filter_candidate_diffs(&raw_candidate_diffs, &reference_noise);
59
60 let target_error = primary.observation.error.is_some()
61 || candidate.observation.error.is_some()
62 || secondary
63 .and_then(|target| target.observation.error.as_ref())
64 .is_some();
65
66 let classification = if target_error {
67 Classification::TargetError
68 } else if raw_candidate_diffs.is_empty() && reference_noise.is_empty() {
69 Classification::Match
70 } else if noise_filtered_diffs.is_empty() {
71 Classification::ReferenceNoise
72 } else if !reference_noise.is_empty() {
73 Classification::SuspiciousWithNoise
74 } else {
75 Classification::SuspiciousDifference
76 };
77
78 ComparisonSummary {
79 classification,
80 raw_diff_summary: summarize("candidate", &raw_candidate_diffs),
81 noise_summary: summarize("reference noise", &reference_noise),
82 raw_candidate_diffs,
83 reference_noise,
84 noise_filtered_diffs,
85 }
86}
87
88fn summarize(label: &str, diffs: &[DiffEntry]) -> String {
89 if diffs.is_empty() {
90 format!("no {label} diffs")
91 } else {
92 format!("{label}: {} diff(s)", diffs.len())
93 }
94}
95
96fn filter_candidate_diffs(
97 candidate_diffs: &[DiffEntry],
98 reference_noise: &[DiffEntry],
99) -> Vec<DiffEntry> {
100 candidate_diffs
101 .iter()
102 .filter(|candidate_diff| {
103 let Some(reference_diff) = reference_noise.iter().find(|reference_diff| {
104 reference_diff.kind == candidate_diff.kind
105 && reference_diff.path == candidate_diff.path
106 }) else {
107 return true;
108 };
109
110 candidate_diff.candidate != candidate_diff.primary
111 && candidate_diff.candidate != reference_diff.secondary
112 })
113 .cloned()
114 .collect()
115}
116
117#[cfg(test)]
118mod tests;