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}