Skip to main content

verificar/oracle/
diff.rs

1//! I/O diffing utilities for verification
2//!
3//! Provides flexible output comparison with various normalization options.
4
5use std::fmt::Write;
6
7use super::ExecutionResult;
8
9/// Options for I/O comparison
10#[derive(Debug, Clone)]
11#[allow(clippy::struct_excessive_bools)]
12pub struct DiffOptions {
13    /// Normalize whitespace (collapse multiple spaces, trim lines)
14    pub normalize_whitespace: bool,
15    /// Ignore trailing whitespace on lines
16    pub ignore_trailing_whitespace: bool,
17    /// Ignore case when comparing
18    pub ignore_case: bool,
19    /// Tolerance for floating point comparisons
20    pub float_tolerance: Option<f64>,
21    /// Ignore stderr differences
22    pub ignore_stderr: bool,
23    /// Ignore exit code differences (only compare stdout)
24    pub ignore_exit_code: bool,
25}
26
27impl Default for DiffOptions {
28    fn default() -> Self {
29        Self {
30            normalize_whitespace: false,
31            ignore_trailing_whitespace: true,
32            ignore_case: false,
33            float_tolerance: None,
34            ignore_stderr: true,
35            ignore_exit_code: false,
36        }
37    }
38}
39
40impl DiffOptions {
41    /// Create strict comparison options
42    #[must_use]
43    pub fn strict() -> Self {
44        Self {
45            normalize_whitespace: false,
46            ignore_trailing_whitespace: false,
47            ignore_case: false,
48            float_tolerance: None,
49            ignore_stderr: false,
50            ignore_exit_code: false,
51        }
52    }
53
54    /// Create lenient comparison options
55    #[must_use]
56    pub fn lenient() -> Self {
57        Self {
58            normalize_whitespace: true,
59            ignore_trailing_whitespace: true,
60            ignore_case: false,
61            float_tolerance: Some(1e-9),
62            ignore_stderr: true,
63            ignore_exit_code: true,
64        }
65    }
66
67    /// Set float tolerance
68    #[must_use]
69    pub fn with_float_tolerance(mut self, tolerance: f64) -> Self {
70        self.float_tolerance = Some(tolerance);
71        self
72    }
73}
74
75/// Result of a diff operation
76#[derive(Debug, Clone)]
77pub struct DiffResult {
78    /// Whether the outputs match
79    pub matches: bool,
80    /// Differences found (if any)
81    pub differences: Vec<Difference>,
82}
83
84/// A single difference between expected and actual output
85#[derive(Debug, Clone)]
86pub struct Difference {
87    /// Line number (1-indexed)
88    pub line: usize,
89    /// Expected content
90    pub expected: String,
91    /// Actual content
92    pub actual: String,
93    /// Type of difference
94    pub kind: DifferenceKind,
95}
96
97/// Type of difference
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum DifferenceKind {
100    /// Line content differs
101    ContentMismatch,
102    /// Line missing in actual
103    MissingLine,
104    /// Extra line in actual
105    ExtraLine,
106    /// Exit code differs
107    ExitCodeMismatch,
108    /// Stderr differs
109    StderrMismatch,
110}
111
112/// Compare two execution results
113#[must_use]
114pub fn diff_results(
115    expected: &ExecutionResult,
116    actual: &ExecutionResult,
117    options: &DiffOptions,
118) -> DiffResult {
119    let mut differences = Vec::new();
120
121    // Compare exit codes
122    if !options.ignore_exit_code && expected.exit_code != actual.exit_code {
123        differences.push(Difference {
124            line: 0,
125            expected: expected.exit_code.to_string(),
126            actual: actual.exit_code.to_string(),
127            kind: DifferenceKind::ExitCodeMismatch,
128        });
129    }
130
131    // Compare stdout
132    let stdout_diffs = diff_strings(&expected.stdout, &actual.stdout, options);
133    differences.extend(stdout_diffs);
134
135    // Compare stderr
136    if !options.ignore_stderr {
137        let stderr_diffs = diff_strings(&expected.stderr, &actual.stderr, options);
138        for mut diff in stderr_diffs {
139            diff.kind = DifferenceKind::StderrMismatch;
140            differences.push(diff);
141        }
142    }
143
144    DiffResult {
145        matches: differences.is_empty(),
146        differences,
147    }
148}
149
150/// Compare two strings line by line
151fn diff_strings(expected: &str, actual: &str, options: &DiffOptions) -> Vec<Difference> {
152    let expected_lines: Vec<&str> = expected.lines().collect();
153    let actual_lines: Vec<&str> = actual.lines().collect();
154
155    let mut differences = Vec::new();
156
157    let max_lines = expected_lines.len().max(actual_lines.len());
158
159    for i in 0..max_lines {
160        let exp_line = expected_lines.get(i);
161        let act_line = actual_lines.get(i);
162
163        match (exp_line, act_line) {
164            (Some(exp), Some(act)) => {
165                if !lines_equal(exp, act, options) {
166                    differences.push(Difference {
167                        line: i + 1,
168                        expected: (*exp).to_string(),
169                        actual: (*act).to_string(),
170                        kind: DifferenceKind::ContentMismatch,
171                    });
172                }
173            }
174            (Some(exp), None) => {
175                differences.push(Difference {
176                    line: i + 1,
177                    expected: (*exp).to_string(),
178                    actual: String::new(),
179                    kind: DifferenceKind::MissingLine,
180                });
181            }
182            (None, Some(act)) => {
183                differences.push(Difference {
184                    line: i + 1,
185                    expected: String::new(),
186                    actual: (*act).to_string(),
187                    kind: DifferenceKind::ExtraLine,
188                });
189            }
190            (None, None) => {
191                // This case cannot occur because i < max_lines ensures
192                // at least one of the lines exists
193            }
194        }
195    }
196
197    differences
198}
199
200/// Check if two lines are equal according to options
201fn lines_equal(expected: &str, actual: &str, options: &DiffOptions) -> bool {
202    let mut exp = expected.to_string();
203    let mut act = actual.to_string();
204
205    // Apply normalizations
206    if options.ignore_trailing_whitespace {
207        exp = exp.trim_end().to_string();
208        act = act.trim_end().to_string();
209    }
210
211    if options.normalize_whitespace {
212        exp = normalize_whitespace(&exp);
213        act = normalize_whitespace(&act);
214    }
215
216    if options.ignore_case {
217        exp = exp.to_lowercase();
218        act = act.to_lowercase();
219    }
220
221    // Try direct comparison first
222    if exp == act {
223        return true;
224    }
225
226    // Try float comparison if enabled
227    if let Some(tolerance) = options.float_tolerance {
228        if floats_equal(&exp, &act, tolerance) {
229            return true;
230        }
231    }
232
233    false
234}
235
236/// Normalize whitespace in a string
237fn normalize_whitespace(s: &str) -> String {
238    s.split_whitespace().collect::<Vec<_>>().join(" ")
239}
240
241/// Compare two strings as floats with tolerance
242fn floats_equal(a: &str, b: &str, tolerance: f64) -> bool {
243    // Try to parse both as floats
244    match (a.trim().parse::<f64>(), b.trim().parse::<f64>()) {
245        (Ok(fa), Ok(fb)) => (fa - fb).abs() < tolerance,
246        _ => false,
247    }
248}
249
250/// Format a diff result for display
251#[must_use]
252pub fn format_diff(result: &DiffResult) -> String {
253    if result.matches {
254        return "Outputs match".to_string();
255    }
256
257    let mut output = String::new();
258    let _ = writeln!(output, "Found {} difference(s):", result.differences.len());
259
260    for diff in &result.differences {
261        match diff.kind {
262            DifferenceKind::ContentMismatch => {
263                let _ = writeln!(
264                    output,
265                    "Line {}: expected '{}', got '{}'",
266                    diff.line, diff.expected, diff.actual
267                );
268            }
269            DifferenceKind::MissingLine => {
270                let _ = writeln!(output, "Line {}: missing '{}'", diff.line, diff.expected);
271            }
272            DifferenceKind::ExtraLine => {
273                let _ = writeln!(output, "Line {}: unexpected '{}'", diff.line, diff.actual);
274            }
275            DifferenceKind::ExitCodeMismatch => {
276                let _ = writeln!(
277                    output,
278                    "Exit code: expected {}, got {}",
279                    diff.expected, diff.actual
280                );
281            }
282            DifferenceKind::StderrMismatch => {
283                let _ = writeln!(
284                    output,
285                    "Stderr line {}: expected '{}', got '{}'",
286                    diff.line, diff.expected, diff.actual
287                );
288            }
289        }
290    }
291
292    output
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn make_result(stdout: &str, exit_code: i32) -> ExecutionResult {
300        ExecutionResult {
301            stdout: stdout.to_string(),
302            stderr: String::new(),
303            exit_code,
304            duration_ms: 0,
305        }
306    }
307
308    #[test]
309    fn test_identical_outputs() {
310        let expected = make_result("hello\nworld", 0);
311        let actual = make_result("hello\nworld", 0);
312        let options = DiffOptions::default();
313
314        let result = diff_results(&expected, &actual, &options);
315        assert!(result.matches);
316        assert!(result.differences.is_empty());
317    }
318
319    #[test]
320    fn test_different_outputs() {
321        let expected = make_result("hello", 0);
322        let actual = make_result("world", 0);
323        let options = DiffOptions::default();
324
325        let result = diff_results(&expected, &actual, &options);
326        assert!(!result.matches);
327        assert_eq!(result.differences.len(), 1);
328        assert_eq!(result.differences[0].kind, DifferenceKind::ContentMismatch);
329    }
330
331    #[test]
332    fn test_trailing_whitespace_ignored() {
333        let expected = make_result("hello  ", 0);
334        let actual = make_result("hello", 0);
335        let options = DiffOptions::default(); // ignore_trailing_whitespace = true
336
337        let result = diff_results(&expected, &actual, &options);
338        assert!(result.matches);
339    }
340
341    #[test]
342    fn test_trailing_whitespace_strict() {
343        let expected = make_result("hello  ", 0);
344        let actual = make_result("hello", 0);
345        let options = DiffOptions::strict();
346
347        let result = diff_results(&expected, &actual, &options);
348        assert!(!result.matches);
349    }
350
351    #[test]
352    fn test_float_tolerance() {
353        let expected = make_result("3.14159265", 0);
354        let actual = make_result("3.14159266", 0);
355        let options = DiffOptions::default().with_float_tolerance(1e-6);
356
357        let result = diff_results(&expected, &actual, &options);
358        assert!(result.matches);
359    }
360
361    #[test]
362    fn test_float_no_tolerance() {
363        let expected = make_result("3.14159265", 0);
364        let actual = make_result("3.14159266", 0);
365        let options = DiffOptions::default(); // no float_tolerance
366
367        let result = diff_results(&expected, &actual, &options);
368        assert!(!result.matches);
369    }
370
371    #[test]
372    fn test_missing_line() {
373        let expected = make_result("line1\nline2", 0);
374        let actual = make_result("line1", 0);
375        let options = DiffOptions::default();
376
377        let result = diff_results(&expected, &actual, &options);
378        assert!(!result.matches);
379        assert!(result
380            .differences
381            .iter()
382            .any(|d| d.kind == DifferenceKind::MissingLine));
383    }
384
385    #[test]
386    fn test_extra_line() {
387        let expected = make_result("line1", 0);
388        let actual = make_result("line1\nline2", 0);
389        let options = DiffOptions::default();
390
391        let result = diff_results(&expected, &actual, &options);
392        assert!(!result.matches);
393        assert!(result
394            .differences
395            .iter()
396            .any(|d| d.kind == DifferenceKind::ExtraLine));
397    }
398
399    #[test]
400    fn test_exit_code_mismatch() {
401        let expected = make_result("output", 0);
402        let actual = make_result("output", 1);
403        let options = DiffOptions::default();
404
405        let result = diff_results(&expected, &actual, &options);
406        assert!(!result.matches);
407        assert!(result
408            .differences
409            .iter()
410            .any(|d| d.kind == DifferenceKind::ExitCodeMismatch));
411    }
412
413    #[test]
414    fn test_exit_code_ignored() {
415        let expected = make_result("output", 0);
416        let actual = make_result("output", 1);
417        let options = DiffOptions::lenient();
418
419        let result = diff_results(&expected, &actual, &options);
420        assert!(result.matches);
421    }
422
423    #[test]
424    fn test_normalize_whitespace() {
425        let expected = make_result("hello   world", 0);
426        let actual = make_result("hello world", 0);
427        let mut options = DiffOptions::default();
428        options.normalize_whitespace = true;
429
430        let result = diff_results(&expected, &actual, &options);
431        assert!(result.matches);
432    }
433
434    #[test]
435    fn test_format_diff() {
436        let expected = make_result("hello", 0);
437        let actual = make_result("world", 0);
438        let options = DiffOptions::default();
439
440        let result = diff_results(&expected, &actual, &options);
441        let formatted = format_diff(&result);
442
443        assert!(formatted.contains("difference"));
444        assert!(formatted.contains("hello"));
445        assert!(formatted.contains("world"));
446    }
447
448    #[test]
449    fn test_format_diff_match() {
450        let expected = make_result("hello", 0);
451        let actual = make_result("hello", 0);
452        let options = DiffOptions::default();
453
454        let result = diff_results(&expected, &actual, &options);
455        let formatted = format_diff(&result);
456
457        assert!(formatted.contains("match"));
458    }
459
460    #[test]
461    fn test_format_diff_missing_line() {
462        let expected = make_result("line1\nline2", 0);
463        let actual = make_result("line1", 0);
464        let options = DiffOptions::default();
465
466        let result = diff_results(&expected, &actual, &options);
467        let formatted = format_diff(&result);
468
469        assert!(formatted.contains("missing"));
470    }
471
472    #[test]
473    fn test_format_diff_extra_line() {
474        let expected = make_result("line1", 0);
475        let actual = make_result("line1\nextra", 0);
476        let options = DiffOptions::default();
477
478        let result = diff_results(&expected, &actual, &options);
479        let formatted = format_diff(&result);
480
481        assert!(formatted.contains("unexpected"));
482    }
483
484    #[test]
485    fn test_format_diff_exit_code() {
486        let expected = make_result("output", 0);
487        let actual = make_result("output", 1);
488        let options = DiffOptions::strict();
489
490        let result = diff_results(&expected, &actual, &options);
491        let formatted = format_diff(&result);
492
493        assert!(formatted.contains("Exit code"));
494    }
495
496    #[test]
497    fn test_stderr_mismatch() {
498        let expected = ExecutionResult {
499            stdout: "out".to_string(),
500            stderr: "err1".to_string(),
501            exit_code: 0,
502            duration_ms: 0,
503        };
504        let actual = ExecutionResult {
505            stdout: "out".to_string(),
506            stderr: "err2".to_string(),
507            exit_code: 0,
508            duration_ms: 0,
509        };
510        let options = DiffOptions::strict();
511
512        let result = diff_results(&expected, &actual, &options);
513        assert!(!result.matches);
514        assert!(result
515            .differences
516            .iter()
517            .any(|d| d.kind == DifferenceKind::StderrMismatch));
518    }
519
520    #[test]
521    fn test_format_diff_stderr() {
522        let expected = ExecutionResult {
523            stdout: "out".to_string(),
524            stderr: "err1".to_string(),
525            exit_code: 0,
526            duration_ms: 0,
527        };
528        let actual = ExecutionResult {
529            stdout: "out".to_string(),
530            stderr: "err2".to_string(),
531            exit_code: 0,
532            duration_ms: 0,
533        };
534        let options = DiffOptions::strict();
535
536        let result = diff_results(&expected, &actual, &options);
537        let formatted = format_diff(&result);
538
539        assert!(formatted.contains("Stderr"));
540    }
541
542    #[test]
543    fn test_ignore_case() {
544        let expected = make_result("HELLO", 0);
545        let actual = make_result("hello", 0);
546        let mut options = DiffOptions::default();
547        options.ignore_case = true;
548
549        let result = diff_results(&expected, &actual, &options);
550        assert!(result.matches);
551    }
552
553    #[test]
554    fn test_ignore_case_false() {
555        let expected = make_result("HELLO", 0);
556        let actual = make_result("hello", 0);
557        let options = DiffOptions::default(); // ignore_case = false
558
559        let result = diff_results(&expected, &actual, &options);
560        assert!(!result.matches);
561    }
562
563    #[test]
564    fn test_float_tolerance_non_float() {
565        let expected = make_result("not a float", 0);
566        let actual = make_result("also not", 0);
567        let options = DiffOptions::default().with_float_tolerance(1e-6);
568
569        let result = diff_results(&expected, &actual, &options);
570        assert!(!result.matches); // Can't parse as floats, so mismatch
571    }
572
573    #[test]
574    fn test_diff_options_debug() {
575        let options = DiffOptions::default();
576        let debug = format!("{:?}", options);
577        assert!(debug.contains("DiffOptions"));
578    }
579
580    #[test]
581    fn test_diff_options_clone() {
582        let options = DiffOptions::lenient();
583        let cloned = options.clone();
584        assert_eq!(cloned.normalize_whitespace, options.normalize_whitespace);
585    }
586
587    #[test]
588    fn test_diff_result_debug() {
589        let result = DiffResult {
590            matches: true,
591            differences: vec![],
592        };
593        let debug = format!("{:?}", result);
594        assert!(debug.contains("DiffResult"));
595    }
596
597    #[test]
598    fn test_diff_result_clone() {
599        let result = DiffResult {
600            matches: false,
601            differences: vec![Difference {
602                line: 1,
603                expected: "a".to_string(),
604                actual: "b".to_string(),
605                kind: DifferenceKind::ContentMismatch,
606            }],
607        };
608        let cloned = result.clone();
609        assert_eq!(cloned.matches, result.matches);
610    }
611
612    #[test]
613    fn test_difference_debug() {
614        let diff = Difference {
615            line: 1,
616            expected: "a".to_string(),
617            actual: "b".to_string(),
618            kind: DifferenceKind::ContentMismatch,
619        };
620        let debug = format!("{:?}", diff);
621        assert!(debug.contains("Difference"));
622    }
623
624    #[test]
625    fn test_difference_clone() {
626        let diff = Difference {
627            line: 1,
628            expected: "a".to_string(),
629            actual: "b".to_string(),
630            kind: DifferenceKind::ContentMismatch,
631        };
632        let cloned = diff.clone();
633        assert_eq!(cloned.line, diff.line);
634    }
635
636    #[test]
637    fn test_difference_kind_debug() {
638        let kinds = [
639            DifferenceKind::ContentMismatch,
640            DifferenceKind::MissingLine,
641            DifferenceKind::ExtraLine,
642            DifferenceKind::ExitCodeMismatch,
643            DifferenceKind::StderrMismatch,
644        ];
645        for kind in &kinds {
646            let debug = format!("{:?}", kind);
647            assert!(!debug.is_empty());
648        }
649    }
650
651    #[test]
652    fn test_difference_kind_copy() {
653        let kind = DifferenceKind::ContentMismatch;
654        let copied = kind;
655        assert_eq!(copied, DifferenceKind::ContentMismatch);
656    }
657}