test_better_matchers/diff.rs
1//! Line-oriented diff rendering, behind the default `diff` feature.
2//!
3//! This module produces the *structured, uncolored* diff text that lands in a
4//! [`Mismatch`](crate::Mismatch)'s `diff` field. Color is applied later, and
5//! only by `test-better-core`'s renderer: `matchers` never emits ANSI escapes.
6//!
7//! The output is a unified-style diff: each line is prefixed with ` ` for
8//! unchanged context, `-` for a line present in `expected` but not `actual`,
9//! and `+` for a line present in `actual` but not `expected`. The renderer in
10//! `core` keys its red/green coloring off exactly those markers.
11
12use similar::{ChangeTag, TextDiff};
13
14/// Renders a line-oriented diff between `expected` and `actual`.
15///
16/// The result has no trailing newline, so it composes when the renderer
17/// indents each line.
18///
19/// ```
20/// use test_better_core::TestResult;
21/// use test_better_matchers::{diff_lines, eq, check};
22///
23/// fn main() -> TestResult {
24/// let diff = diff_lines("a\nb\nc", "a\nB\nc");
25/// check!(diff).satisfies(eq(" a\n-b\n+B\n c".to_string()))?;
26/// Ok(())
27/// }
28/// ```
29#[must_use]
30pub fn diff_lines(expected: &str, actual: &str) -> String {
31 let diff = TextDiff::from_lines(expected, actual);
32 let mut out = String::new();
33 for change in diff.iter_all_changes() {
34 let marker = match change.tag() {
35 ChangeTag::Delete => '-',
36 ChangeTag::Insert => '+',
37 ChangeTag::Equal => ' ',
38 };
39 out.push(marker);
40 let value = change.value();
41 out.push_str(value);
42 // `from_lines` keeps line endings, but the final line may lack one.
43 if !value.ends_with('\n') {
44 out.push('\n');
45 }
46 }
47 // Drop the trailing newline so the diff composes cleanly when indented.
48 if out.ends_with('\n') {
49 out.pop();
50 }
51 out
52}
53
54#[cfg(test)]
55mod tests {
56 use test_better_core::TestResult;
57
58 use super::*;
59 use crate::{check, eq, is_false};
60
61 #[test]
62 fn equal_input_is_all_context_lines() -> TestResult {
63 check!(diff_lines("one\ntwo", "one\ntwo")).satisfies(eq(" one\n two".to_string()))?;
64 Ok(())
65 }
66
67 #[test]
68 fn a_changed_line_becomes_a_delete_then_an_insert() -> TestResult {
69 let diff = diff_lines("keep\nold\nkeep", "keep\nnew\nkeep");
70 check!(diff).satisfies(eq(" keep\n-old\n+new\n keep".to_string()))?;
71 Ok(())
72 }
73
74 #[test]
75 fn has_no_trailing_newline() -> TestResult {
76 let diff = diff_lines("a\n", "b\n");
77 check!(diff.ends_with('\n')).satisfies(is_false())?;
78 Ok(())
79 }
80}