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