1use std::fmt::Write;
6
7use super::ExecutionResult;
8
9#[derive(Debug, Clone)]
11#[allow(clippy::struct_excessive_bools)]
12pub struct DiffOptions {
13 pub normalize_whitespace: bool,
15 pub ignore_trailing_whitespace: bool,
17 pub ignore_case: bool,
19 pub float_tolerance: Option<f64>,
21 pub ignore_stderr: bool,
23 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 #[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 #[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 #[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#[derive(Debug, Clone)]
77pub struct DiffResult {
78 pub matches: bool,
80 pub differences: Vec<Difference>,
82}
83
84#[derive(Debug, Clone)]
86pub struct Difference {
87 pub line: usize,
89 pub expected: String,
91 pub actual: String,
93 pub kind: DifferenceKind,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum DifferenceKind {
100 ContentMismatch,
102 MissingLine,
104 ExtraLine,
106 ExitCodeMismatch,
108 StderrMismatch,
110}
111
112#[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 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 let stdout_diffs = diff_strings(&expected.stdout, &actual.stdout, options);
133 differences.extend(stdout_diffs);
134
135 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
150fn 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 }
194 }
195 }
196
197 differences
198}
199
200fn 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 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 if exp == act {
223 return true;
224 }
225
226 if let Some(tolerance) = options.float_tolerance {
228 if floats_equal(&exp, &act, tolerance) {
229 return true;
230 }
231 }
232
233 false
234}
235
236fn normalize_whitespace(s: &str) -> String {
238 s.split_whitespace().collect::<Vec<_>>().join(" ")
239}
240
241fn floats_equal(a: &str, b: &str, tolerance: f64) -> bool {
243 match (a.trim().parse::<f64>(), b.trim().parse::<f64>()) {
245 (Ok(fa), Ok(fb)) => (fa - fb).abs() < tolerance,
246 _ => false,
247 }
248}
249
250#[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(); 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(); 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(); 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); }
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}