mockforge_recorder/
diff.rs

1//! Request/response diff viewer with content-aware comparison
2
3use serde_json::Value;
4use similar::{ChangeTag, TextDiff};
5use std::collections::HashMap;
6
7/// Difference type
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
9#[serde(tag = "type")]
10pub enum DifferenceType {
11    /// Value added in new response
12    Added { path: String, value: String },
13    /// Value removed from original
14    Removed { path: String, value: String },
15    /// Value changed
16    Changed {
17        path: String,
18        original: String,
19        current: String,
20    },
21    /// Type changed (e.g., string -> number)
22    TypeChanged {
23        path: String,
24        original_type: String,
25        current_type: String,
26    },
27}
28
29/// Difference between original and current response
30#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
31pub struct Difference {
32    /// JSON path or header name
33    pub path: String,
34    /// Type of difference
35    pub difference_type: DifferenceType,
36    /// Human-readable description
37    pub description: String,
38}
39
40impl Difference {
41    /// Create a new difference
42    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/// Result of comparing two responses
67#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
68pub struct ComparisonResult {
69    /// Do responses match exactly?
70    pub matches: bool,
71    /// Status code comparison
72    pub status_match: bool,
73    /// Headers match?
74    pub headers_match: bool,
75    /// Body match?
76    pub body_match: bool,
77    /// List of all differences
78    pub differences: Vec<Difference>,
79    /// Summary statistics
80    pub summary: ComparisonSummary,
81}
82
83/// Summary of comparison
84#[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
116/// Response comparator with content-aware diffing
117pub struct ResponseComparator;
118
119impl ResponseComparator {
120    /// Compare two responses
121    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        // Compare status codes
132        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        // Compare headers
145        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        // Compare bodies based on content type
150        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    /// Compare headers
173    fn compare_headers(
174        original: &HashMap<String, String>,
175        current: &HashMap<String, String>,
176    ) -> Vec<Difference> {
177        let mut differences = Vec::new();
178
179        // Check for removed or changed headers
180        for (key, original_value) in original {
181            // Skip dynamic headers
182            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        // Check for added headers
211        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    /// Check if header is dynamic and should be ignored in comparisons
231    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    /// Compare bodies based on content type
240    fn compare_bodies(
241        original: &[u8],
242        current: &[u8],
243        content_type: Option<&str>,
244    ) -> Vec<Difference> {
245        // Detect content type
246        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    /// Check if bytes are likely JSON
258    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    /// Compare JSON bodies with deep diff
267    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                // Fallback to text comparison if not valid JSON
275                Self::compare_text_bodies(original, current)
276            }
277        }
278    }
279
280    /// Deep compare JSON values
281    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                // Check for removed or changed keys
287                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                // Check for added keys
308                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                // Check for type changes
355                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                    // Same type, different value
366                    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                // Values match, no difference
378            }
379        }
380
381        differences
382    }
383
384    /// Get JSON value type name
385    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    /// Compare text bodies using line-by-line diff
397    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        // Use line-based diff for text
406        let diff = TextDiff::from_lines(&original_str, &current_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                    // No difference for equal lines
432                }
433            }
434        }
435
436        // If too many line diffs, summarize
437        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, &current_headers, body);
509
510        assert!(!result.matches);
511        assert!(!result.headers_match);
512
513        // Should have: content-type changed, x-custom removed, x-new added
514        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        // Should detect: name changed, city added
529        assert!(result.differences.len() >= 2);
530
531        // Check for name change
532        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        // Should detect array item added
547        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        // Should detect type change from string to number
562        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, &current_headers, body);
581
582        // Date header should be ignored
583        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); // a changed
596        assert_eq!(result.summary.removed_fields, 1); // b removed
597        assert_eq!(result.summary.added_fields, 1); // c added
598    }
599}