Skip to main content

moonlight_core/compare/
mod.rs

1mod 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;