1use serde_json::Value;
4use similar::{ChangeTag, TextDiff};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
9#[serde(tag = "type")]
10pub enum DifferenceType {
11 Added { path: String, value: String },
13 Removed { path: String, value: String },
15 Changed {
17 path: String,
18 original: String,
19 current: String,
20 },
21 TypeChanged {
23 path: String,
24 original_type: String,
25 current_type: String,
26 },
27}
28
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
31pub struct Difference {
32 pub path: String,
34 pub difference_type: DifferenceType,
36 pub description: String,
38}
39
40impl Difference {
41 pub fn new(path: String, difference_type: DifferenceType) -> Self {
43 let description = match &difference_type {
44 DifferenceType::Added { value, .. } => format!("Added: {}", value),
45 DifferenceType::Removed { value, .. } => format!("Removed: {}", value),
46 DifferenceType::Changed {
47 original, current, ..
48 } => {
49 format!("Changed from '{}' to '{}'", original, current)
50 }
51 DifferenceType::TypeChanged {
52 original_type,
53 current_type,
54 ..
55 } => format!("Type changed from {} to {}", original_type, current_type),
56 };
57
58 Self {
59 path,
60 difference_type,
61 description,
62 }
63 }
64}
65
66#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
68pub struct ComparisonResult {
69 pub matches: bool,
71 pub status_match: bool,
73 pub headers_match: bool,
75 pub body_match: bool,
77 pub differences: Vec<Difference>,
79 pub summary: ComparisonSummary,
81}
82
83#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85pub struct ComparisonSummary {
86 pub total_differences: usize,
87 pub added_fields: usize,
88 pub removed_fields: usize,
89 pub changed_fields: usize,
90 pub type_changes: usize,
91}
92
93impl ComparisonSummary {
94 pub fn from_differences(differences: &[Difference]) -> Self {
95 let mut summary = Self {
96 total_differences: differences.len(),
97 added_fields: 0,
98 removed_fields: 0,
99 changed_fields: 0,
100 type_changes: 0,
101 };
102
103 for diff in differences {
104 match &diff.difference_type {
105 DifferenceType::Added { .. } => summary.added_fields += 1,
106 DifferenceType::Removed { .. } => summary.removed_fields += 1,
107 DifferenceType::Changed { .. } => summary.changed_fields += 1,
108 DifferenceType::TypeChanged { .. } => summary.type_changes += 1,
109 }
110 }
111
112 summary
113 }
114}
115
116pub struct ResponseComparator;
118
119impl ResponseComparator {
120 pub fn compare(
122 original_status: i32,
123 original_headers: &HashMap<String, String>,
124 original_body: &[u8],
125 current_status: i32,
126 current_headers: &HashMap<String, String>,
127 current_body: &[u8],
128 ) -> ComparisonResult {
129 let mut differences = Vec::new();
130
131 let status_match = original_status == current_status;
133 if !status_match {
134 differences.push(Difference::new(
135 "status_code".to_string(),
136 DifferenceType::Changed {
137 path: "status_code".to_string(),
138 original: original_status.to_string(),
139 current: current_status.to_string(),
140 },
141 ));
142 }
143
144 let header_diffs = Self::compare_headers(original_headers, current_headers);
146 let headers_match = header_diffs.is_empty();
147 differences.extend(header_diffs);
148
149 let content_type = original_headers
151 .get("content-type")
152 .or_else(|| current_headers.get("content-type"))
153 .map(|s| s.to_lowercase());
154
155 let body_diffs = Self::compare_bodies(original_body, current_body, content_type.as_deref());
156 let body_match = body_diffs.is_empty();
157 differences.extend(body_diffs);
158
159 let matches = differences.is_empty();
160 let summary = ComparisonSummary::from_differences(&differences);
161
162 ComparisonResult {
163 matches,
164 status_match,
165 headers_match,
166 body_match,
167 differences,
168 summary,
169 }
170 }
171
172 fn compare_headers(
174 original: &HashMap<String, String>,
175 current: &HashMap<String, String>,
176 ) -> Vec<Difference> {
177 let mut differences = Vec::new();
178
179 for (key, original_value) in original {
181 if Self::is_dynamic_header(key) {
183 continue;
184 }
185
186 match current.get(key) {
187 Some(current_value) if current_value != original_value => {
188 differences.push(Difference::new(
189 format!("headers.{}", key),
190 DifferenceType::Changed {
191 path: format!("headers.{}", key),
192 original: original_value.clone(),
193 current: current_value.clone(),
194 },
195 ));
196 }
197 None => {
198 differences.push(Difference::new(
199 format!("headers.{}", key),
200 DifferenceType::Removed {
201 path: format!("headers.{}", key),
202 value: original_value.clone(),
203 },
204 ));
205 }
206 _ => {}
207 }
208 }
209
210 for (key, current_value) in current {
212 if Self::is_dynamic_header(key) {
213 continue;
214 }
215
216 if !original.contains_key(key) {
217 differences.push(Difference::new(
218 format!("headers.{}", key),
219 DifferenceType::Added {
220 path: format!("headers.{}", key),
221 value: current_value.clone(),
222 },
223 ));
224 }
225 }
226
227 differences
228 }
229
230 fn is_dynamic_header(key: &str) -> bool {
232 let key_lower = key.to_lowercase();
233 matches!(
234 key_lower.as_str(),
235 "date" | "x-request-id" | "x-trace-id" | "set-cookie" | "age" | "expires"
236 )
237 }
238
239 fn compare_bodies(
241 original: &[u8],
242 current: &[u8],
243 content_type: Option<&str>,
244 ) -> Vec<Difference> {
245 let is_json = content_type
247 .map(|ct| ct.contains("json"))
248 .unwrap_or_else(|| Self::is_likely_json(original));
249
250 if is_json {
251 Self::compare_json_bodies(original, current)
252 } else {
253 Self::compare_text_bodies(original, current)
254 }
255 }
256
257 fn is_likely_json(data: &[u8]) -> bool {
259 if data.is_empty() {
260 return false;
261 }
262 let first_char = data[0];
263 first_char == b'{' || first_char == b'['
264 }
265
266 fn compare_json_bodies(original: &[u8], current: &[u8]) -> Vec<Difference> {
268 let original_json: Result<Value, _> = serde_json::from_slice(original);
269 let current_json: Result<Value, _> = serde_json::from_slice(current);
270
271 match (original_json, current_json) {
272 (Ok(orig), Ok(curr)) => Self::compare_json_values(&orig, &curr, "body"),
273 _ => {
274 Self::compare_text_bodies(original, current)
276 }
277 }
278 }
279
280 fn compare_json_values(original: &Value, current: &Value, path: &str) -> Vec<Difference> {
282 let mut differences = Vec::new();
283
284 match (original, current) {
285 (Value::Object(orig_map), Value::Object(curr_map)) => {
286 for (key, orig_value) in orig_map {
288 let new_path = format!("{}.{}", path, key);
289 match curr_map.get(key) {
290 Some(curr_value) => {
291 differences.extend(Self::compare_json_values(
292 orig_value, curr_value, &new_path,
293 ));
294 }
295 None => {
296 differences.push(Difference::new(
297 new_path.clone(),
298 DifferenceType::Removed {
299 path: new_path,
300 value: orig_value.to_string(),
301 },
302 ));
303 }
304 }
305 }
306
307 for (key, curr_value) in curr_map {
309 if !orig_map.contains_key(key) {
310 let new_path = format!("{}.{}", path, key);
311 differences.push(Difference::new(
312 new_path.clone(),
313 DifferenceType::Added {
314 path: new_path,
315 value: curr_value.to_string(),
316 },
317 ));
318 }
319 }
320 }
321 (Value::Array(orig_arr), Value::Array(curr_arr)) => {
322 let max_len = orig_arr.len().max(curr_arr.len());
323 for i in 0..max_len {
324 let new_path = format!("{}[{}]", path, i);
325
326 match (orig_arr.get(i), curr_arr.get(i)) {
327 (Some(orig_val), Some(curr_val)) => {
328 differences
329 .extend(Self::compare_json_values(orig_val, curr_val, &new_path));
330 }
331 (Some(orig_val), None) => {
332 differences.push(Difference::new(
333 new_path.clone(),
334 DifferenceType::Removed {
335 path: new_path,
336 value: orig_val.to_string(),
337 },
338 ));
339 }
340 (None, Some(curr_val)) => {
341 differences.push(Difference::new(
342 new_path.clone(),
343 DifferenceType::Added {
344 path: new_path,
345 value: curr_val.to_string(),
346 },
347 ));
348 }
349 (None, None) => unreachable!(),
350 }
351 }
352 }
353 (orig, curr) if orig != curr => {
354 if std::mem::discriminant(orig) != std::mem::discriminant(curr) {
356 differences.push(Difference::new(
357 path.to_string(),
358 DifferenceType::TypeChanged {
359 path: path.to_string(),
360 original_type: Self::json_type_name(orig),
361 current_type: Self::json_type_name(curr),
362 },
363 ));
364 } else {
365 differences.push(Difference::new(
367 path.to_string(),
368 DifferenceType::Changed {
369 path: path.to_string(),
370 original: orig.to_string(),
371 current: curr.to_string(),
372 },
373 ));
374 }
375 }
376 _ => {
377 }
379 }
380
381 differences
382 }
383
384 fn json_type_name(value: &Value) -> String {
386 match value {
387 Value::Null => "null".to_string(),
388 Value::Bool(_) => "boolean".to_string(),
389 Value::Number(_) => "number".to_string(),
390 Value::String(_) => "string".to_string(),
391 Value::Array(_) => "array".to_string(),
392 Value::Object(_) => "object".to_string(),
393 }
394 }
395
396 fn compare_text_bodies(original: &[u8], current: &[u8]) -> Vec<Difference> {
398 let original_str = String::from_utf8_lossy(original);
399 let current_str = String::from_utf8_lossy(current);
400
401 if original_str == current_str {
402 return vec![];
403 }
404
405 let diff = TextDiff::from_lines(&original_str, ¤t_str);
407 let mut differences = Vec::new();
408
409 for (idx, change) in diff.iter_all_changes().enumerate() {
410 let path = format!("body.line_{}", idx);
411 match change.tag() {
412 ChangeTag::Delete => {
413 differences.push(Difference::new(
414 path.clone(),
415 DifferenceType::Removed {
416 path,
417 value: change.to_string().trim_end().to_string(),
418 },
419 ));
420 }
421 ChangeTag::Insert => {
422 differences.push(Difference::new(
423 path.clone(),
424 DifferenceType::Added {
425 path,
426 value: change.to_string().trim_end().to_string(),
427 },
428 ));
429 }
430 ChangeTag::Equal => {
431 }
433 }
434 }
435
436 if differences.len() > 100 {
438 vec![Difference::new(
439 "body".to_string(),
440 DifferenceType::Changed {
441 path: "body".to_string(),
442 original: format!("{} bytes", original.len()),
443 current: format!("{} bytes", current.len()),
444 },
445 )]
446 } else {
447 differences
448 }
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn test_compare_identical_responses() {
458 let headers = HashMap::new();
459 let body = b"test";
460
461 let result = ResponseComparator::compare(200, &headers, body, 200, &headers, body);
462
463 assert!(result.matches);
464 assert!(result.status_match);
465 assert!(result.headers_match);
466 assert!(result.body_match);
467 assert_eq!(result.differences.len(), 0);
468 }
469
470 #[test]
471 fn test_status_code_difference() {
472 let headers = HashMap::new();
473 let body = b"test";
474
475 let result = ResponseComparator::compare(200, &headers, body, 404, &headers, body);
476
477 assert!(!result.matches);
478 assert!(!result.status_match);
479 assert_eq!(result.differences.len(), 1);
480
481 match &result.differences[0].difference_type {
482 DifferenceType::Changed {
483 path,
484 original,
485 current,
486 } => {
487 assert_eq!(path, "status_code");
488 assert_eq!(original, "200");
489 assert_eq!(current, "404");
490 }
491 _ => panic!("Expected Changed difference"),
492 }
493 }
494
495 #[test]
496 fn test_header_differences() {
497 let mut original_headers = HashMap::new();
498 original_headers.insert("content-type".to_string(), "application/json".to_string());
499 original_headers.insert("x-custom".to_string(), "value1".to_string());
500
501 let mut current_headers = HashMap::new();
502 current_headers.insert("content-type".to_string(), "text/plain".to_string());
503 current_headers.insert("x-new".to_string(), "value2".to_string());
504
505 let body = b"";
506
507 let result =
508 ResponseComparator::compare(200, &original_headers, body, 200, ¤t_headers, body);
509
510 assert!(!result.matches);
511 assert!(!result.headers_match);
512
513 assert_eq!(result.differences.len(), 3);
515 }
516
517 #[test]
518 fn test_json_body_differences() {
519 let original = br#"{"name": "John", "age": 30}"#;
520 let current = br#"{"name": "Jane", "age": 30, "city": "NYC"}"#;
521
522 let headers = HashMap::new();
523 let result = ResponseComparator::compare(200, &headers, original, 200, &headers, current);
524
525 assert!(!result.matches);
526 assert!(!result.body_match);
527
528 assert!(result.differences.len() >= 2);
530
531 let name_diff = result.differences.iter().find(|d| d.path == "body.name");
533 assert!(name_diff.is_some());
534 }
535
536 #[test]
537 fn test_json_array_differences() {
538 let original = br#"{"items": [1, 2, 3]}"#;
539 let current = br#"{"items": [1, 2, 3, 4]}"#;
540
541 let headers = HashMap::new();
542 let result = ResponseComparator::compare(200, &headers, original, 200, &headers, current);
543
544 assert!(!result.matches);
545
546 let array_diff = result.differences.iter().find(|d| d.path.contains("items[3]"));
548 assert!(array_diff.is_some());
549 }
550
551 #[test]
552 fn test_json_type_change() {
553 let original = br#"{"value": "123"}"#;
554 let current = br#"{"value": 123}"#;
555
556 let headers = HashMap::new();
557 let result = ResponseComparator::compare(200, &headers, original, 200, &headers, current);
558
559 assert!(!result.matches);
560
561 let type_diff = result
563 .differences
564 .iter()
565 .find(|d| matches!(&d.difference_type, DifferenceType::TypeChanged { .. }));
566 assert!(type_diff.is_some());
567 }
568
569 #[test]
570 fn test_dynamic_headers_ignored() {
571 let mut original_headers = HashMap::new();
572 original_headers.insert("date".to_string(), "Mon, 01 Jan 2024".to_string());
573
574 let mut current_headers = HashMap::new();
575 current_headers.insert("date".to_string(), "Tue, 02 Jan 2024".to_string());
576
577 let body = b"";
578
579 let result =
580 ResponseComparator::compare(200, &original_headers, body, 200, ¤t_headers, body);
581
582 assert!(result.headers_match);
584 }
585
586 #[test]
587 fn test_comparison_summary() {
588 let original = br#"{"a": 1, "b": 2}"#;
589 let current = br#"{"a": 2, "c": 3}"#;
590
591 let headers = HashMap::new();
592 let result = ResponseComparator::compare(200, &headers, original, 200, &headers, current);
593
594 assert_eq!(result.summary.total_differences, 3);
595 assert_eq!(result.summary.changed_fields, 1); assert_eq!(result.summary.removed_fields, 1); assert_eq!(result.summary.added_fields, 1); }
599}