Skip to main content

moonlight_core/compare/
diff.rs

1use crate::{DiffEntry, DiffKind, TargetObservation};
2use bytes::Bytes;
3use serde_json::Value;
4use std::collections::BTreeSet;
5
6use super::CompareConfig;
7
8#[derive(Debug, Clone)]
9pub struct CapturedTarget {
10    pub observation: TargetObservation,
11    pub body_bytes: Bytes,
12    pub stderr_bytes: Bytes,
13}
14
15#[derive(Debug, Clone, Copy)]
16pub(super) enum TargetRole {
17    Candidate,
18    Secondary,
19}
20
21impl TargetRole {
22    fn label(self) -> &'static str {
23        match self {
24            Self::Candidate => "candidate",
25            Self::Secondary => "secondary",
26        }
27    }
28
29    fn values(self, value: Option<String>) -> (Option<String>, Option<String>) {
30        match self {
31            Self::Candidate => (value, None),
32            Self::Secondary => (None, value),
33        }
34    }
35}
36
37pub(super) fn diff_pair(
38    primary: &CapturedTarget,
39    other: &CapturedTarget,
40    role: TargetRole,
41    config: &CompareConfig,
42) -> Vec<DiffEntry> {
43    let mut diffs = Vec::new();
44    diff_target_errors(primary, other, role, &mut diffs);
45    diff_status(primary, other, role, &mut diffs);
46    diff_headers(primary, other, role, config, &mut diffs);
47    diff_bodies(primary, other, role, config, &mut diffs);
48    diff_stderr(primary, other, role, config, &mut diffs);
49    diffs
50}
51
52fn diff_target_errors(
53    primary: &CapturedTarget,
54    other: &CapturedTarget,
55    role: TargetRole,
56    diffs: &mut Vec<DiffEntry>,
57) {
58    if primary.observation.error != other.observation.error {
59        let (candidate, secondary) = role.values(other.observation.error.clone());
60        diffs.push(DiffEntry {
61            kind: DiffKind::TargetError,
62            path: "$target_error".to_string(),
63            primary: primary.observation.error.clone(),
64            candidate,
65            secondary,
66            message: format!("primary target error differs from {}", role.label()),
67        });
68    }
69}
70
71fn diff_status(
72    primary: &CapturedTarget,
73    other: &CapturedTarget,
74    role: TargetRole,
75    diffs: &mut Vec<DiffEntry>,
76) {
77    if primary.observation.status != other.observation.status {
78        let (candidate, secondary) =
79            role.values(other.observation.status.map(|value| value.to_string()));
80        diffs.push(DiffEntry {
81            kind: DiffKind::Status,
82            path: "$status".to_string(),
83            primary: primary.observation.status.map(|value| value.to_string()),
84            candidate,
85            secondary,
86            message: format!("primary status differs from {}", role.label()),
87        });
88    }
89}
90
91fn diff_headers(
92    primary: &CapturedTarget,
93    other: &CapturedTarget,
94    role: TargetRole,
95    config: &CompareConfig,
96    diffs: &mut Vec<DiffEntry>,
97) {
98    let keys: BTreeSet<String> = primary
99        .observation
100        .headers
101        .keys()
102        .chain(other.observation.headers.keys())
103        .filter(|name| !config.ignored_headers.contains(*name))
104        .cloned()
105        .collect();
106
107    for key in keys {
108        let primary_value = primary.observation.headers.get(&key).cloned();
109        let other_value = other.observation.headers.get(&key).cloned();
110        if primary_value != other_value {
111            let (candidate, secondary) = role.values(other_value);
112            diffs.push(DiffEntry {
113                kind: DiffKind::Header,
114                path: format!("$.headers.{key}"),
115                primary: primary_value,
116                candidate,
117                secondary,
118                message: format!("primary header {key} differs from {}", role.label()),
119            });
120        }
121    }
122}
123
124fn diff_bodies(
125    primary: &CapturedTarget,
126    other: &CapturedTarget,
127    role: TargetRole,
128    config: &CompareConfig,
129    diffs: &mut Vec<DiffEntry>,
130) {
131    if primary.body_bytes == other.body_bytes {
132        return;
133    }
134
135    let primary_json = serde_json::from_slice::<Value>(&primary.body_bytes);
136    let other_json = serde_json::from_slice::<Value>(&other.body_bytes);
137
138    match (primary_json, other_json) {
139        (Ok(primary_json), Ok(other_json)) => {
140            diff_json("$", &primary_json, &other_json, role, config, diffs);
141        }
142        _ => {
143            let primary_text = normalize_text(&primary.body_bytes);
144            let other_text = normalize_text(&other.body_bytes);
145            if primary_text != other_text {
146                let (candidate, secondary) = role.values(Some(other_text));
147                diffs.push(DiffEntry {
148                    kind: DiffKind::Body,
149                    path: "$body".to_string(),
150                    primary: Some(primary_text),
151                    candidate,
152                    secondary,
153                    message: format!("primary body differs from {}", role.label()),
154                });
155            }
156        }
157    }
158}
159
160fn diff_stderr(
161    primary: &CapturedTarget,
162    other: &CapturedTarget,
163    role: TargetRole,
164    config: &CompareConfig,
165    diffs: &mut Vec<DiffEntry>,
166) {
167    if config.ignore_stderr || primary.stderr_bytes == other.stderr_bytes {
168        return;
169    }
170
171    let primary_text = normalize_text(&primary.stderr_bytes);
172    let other_text = normalize_text(&other.stderr_bytes);
173    if primary_text != other_text {
174        let (candidate, secondary) = role.values(Some(other_text));
175        diffs.push(DiffEntry {
176            kind: DiffKind::Stderr,
177            path: "$stderr".to_string(),
178            primary: Some(primary_text),
179            candidate,
180            secondary,
181            message: format!("primary stderr differs from {}", role.label()),
182        });
183    }
184}
185
186fn diff_json(
187    path: &str,
188    primary: &Value,
189    other: &Value,
190    role: TargetRole,
191    config: &CompareConfig,
192    diffs: &mut Vec<DiffEntry>,
193) {
194    if config.ignored_json_paths.contains(path) {
195        return;
196    }
197    if config.redact_json_paths.contains(path) {
198        if primary != other {
199            push_redacted_json_diff(path.to_string(), role, diffs);
200        }
201        return;
202    }
203
204    match (primary, other) {
205        (Value::Object(primary_map), Value::Object(other_map)) => {
206            let keys: BTreeSet<String> = primary_map
207                .keys()
208                .chain(other_map.keys())
209                .cloned()
210                .collect();
211            for key in keys {
212                let child_path = if path == "$" {
213                    format!("$.{key}")
214                } else {
215                    format!("{path}.{key}")
216                };
217                match (primary_map.get(&key), other_map.get(&key)) {
218                    (Some(primary_value), Some(other_value)) => {
219                        diff_json(&child_path, primary_value, other_value, role, config, diffs);
220                    }
221                    (primary_value, other_value) => {
222                        push_json_diff(child_path, primary_value, other_value, role, diffs);
223                    }
224                }
225            }
226        }
227        (Value::Array(primary_items), Value::Array(other_items)) => {
228            let max_len = primary_items.len().max(other_items.len());
229            for index in 0..max_len {
230                let child_path = format!("{path}[{index}]");
231                match (primary_items.get(index), other_items.get(index)) {
232                    (Some(primary_value), Some(other_value)) => {
233                        diff_json(&child_path, primary_value, other_value, role, config, diffs);
234                    }
235                    (primary_value, other_value) => {
236                        push_json_diff(child_path, primary_value, other_value, role, diffs);
237                    }
238                }
239            }
240        }
241        _ if primary == other => {}
242        _ => push_json_diff(path.to_string(), Some(primary), Some(other), role, diffs),
243    }
244}
245
246fn push_json_diff(
247    path: String,
248    primary: Option<&Value>,
249    other: Option<&Value>,
250    role: TargetRole,
251    diffs: &mut Vec<DiffEntry>,
252) {
253    let other_value = other.map(json_preview);
254    let (candidate, secondary) = role.values(other_value);
255    diffs.push(DiffEntry {
256        kind: DiffKind::Body,
257        path: path.clone(),
258        primary: primary.map(json_preview),
259        candidate,
260        secondary,
261        message: format!("primary body value {path} differs from {}", role.label()),
262    });
263}
264
265fn push_redacted_json_diff(path: String, role: TargetRole, diffs: &mut Vec<DiffEntry>) {
266    let redacted = Some("\"[redacted]\"".to_string());
267    let (candidate, secondary) = role.values(redacted.clone());
268    diffs.push(DiffEntry {
269        kind: DiffKind::Body,
270        path: path.clone(),
271        primary: redacted,
272        candidate,
273        secondary,
274        message: format!("primary body value {path} differs from {}", role.label()),
275    });
276}
277
278fn json_preview(value: &Value) -> String {
279    serde_json::to_string(value).unwrap_or_else(|_| "<json>".to_string())
280}
281
282fn normalize_text(body: &[u8]) -> String {
283    String::from_utf8_lossy(body)
284        .lines()
285        .map(str::trim_end)
286        .collect::<Vec<_>>()
287        .join("\n")
288        .trim()
289        .to_string()
290}