Skip to main content

philiprehberger_diff_assert/
lib.rs

1//! # philiprehberger-diff-assert
2//!
3//! Better test assertion diffs with colored inline comparisons.
4//!
5//! Instead of the default `assert_eq!` output that dumps raw `Debug` representations,
6//! `assert_eq_diff!` shows a colored line-by-line diff so you can immediately see
7//! what changed.
8//!
9//! ## Quick start
10//!
11//! ```rust
12//! use philiprehberger_diff_assert::assert_eq_diff;
13//!
14//! let expected = "hello\nworld";
15//! let actual = "hello\nworld";
16//! assert_eq_diff!(expected, actual);
17//! ```
18//!
19//! ## Programmatic use
20//!
21//! ```rust
22//! use philiprehberger_diff_assert::diff_strings;
23//!
24//! let diff = diff_strings("a\nb\nc", "a\nx\nc");
25//! // Returns a formatted diff string (with ANSI color codes)
26//! ```
27//!
28//! ## NO_COLOR support
29//!
30//! If the `NO_COLOR` environment variable is set, all color output is suppressed
31//! automatically. You can also use [`diff_strings_no_color`] explicitly.
32
33use std::fmt;
34
35/// Represents a single line in a diff result.
36#[derive(Debug, Clone, PartialEq, Eq)]
37enum DiffLine<'a> {
38    /// Line present in both inputs.
39    Same(&'a str),
40    /// Line present only in the left (removed).
41    Removed(&'a str),
42    /// Line present only in the right (added).
43    Added(&'a str),
44}
45
46/// Compute the longest common subsequence table for two slices of lines.
47///
48/// Returns a 2D vector where `table[i][j]` is the LCS length of
49/// `left[0..i]` and `right[0..j]`.
50fn lcs_table<'a>(left: &[&'a str], right: &[&'a str]) -> Vec<Vec<usize>> {
51    let m = left.len();
52    let n = right.len();
53    let mut table = vec![vec![0usize; n + 1]; m + 1];
54
55    for i in 1..=m {
56        for j in 1..=n {
57            if left[i - 1] == right[j - 1] {
58                table[i][j] = table[i - 1][j - 1] + 1;
59            } else {
60                table[i][j] = std::cmp::max(table[i - 1][j], table[i][j - 1]);
61            }
62        }
63    }
64
65    table
66}
67
68/// Compute a line-by-line diff between two string slices using LCS.
69fn compute_diff<'a>(left: &'a str, right: &'a str) -> Vec<DiffLine<'a>> {
70    let left_lines: Vec<&str> = left.lines().collect();
71    let right_lines: Vec<&str> = right.lines().collect();
72
73    let table = lcs_table(&left_lines, &right_lines);
74    let mut result = Vec::new();
75
76    let mut i = left_lines.len();
77    let mut j = right_lines.len();
78
79    // Backtrack through the LCS table to build the diff.
80    while i > 0 || j > 0 {
81        if i > 0 && j > 0 && left_lines[i - 1] == right_lines[j - 1] {
82            result.push(DiffLine::Same(left_lines[i - 1]));
83            i -= 1;
84            j -= 1;
85        } else if j > 0 && (i == 0 || table[i][j - 1] >= table[i - 1][j]) {
86            result.push(DiffLine::Added(right_lines[j - 1]));
87            j -= 1;
88        } else {
89            result.push(DiffLine::Removed(left_lines[i - 1]));
90            i -= 1;
91        }
92    }
93
94    result.reverse();
95    result
96}
97
98/// Format diff lines into a string with optional ANSI color codes.
99fn format_diff(diff: &[DiffLine<'_>], use_color: bool) -> String {
100    let mut output = String::new();
101
102    for (idx, line) in diff.iter().enumerate() {
103        if idx > 0 {
104            output.push('\n');
105        }
106        match line {
107            DiffLine::Same(text) => {
108                output.push_str("  ");
109                output.push_str(text);
110            }
111            DiffLine::Removed(text) => {
112                if use_color {
113                    output.push_str("\x1b[31m- ");
114                    output.push_str(text);
115                    output.push_str("\x1b[0m");
116                } else {
117                    output.push_str("- ");
118                    output.push_str(text);
119                }
120            }
121            DiffLine::Added(text) => {
122                if use_color {
123                    output.push_str("\x1b[32m+ ");
124                    output.push_str(text);
125                    output.push_str("\x1b[0m");
126                } else {
127                    output.push_str("+ ");
128                    output.push_str(text);
129                }
130            }
131        }
132    }
133
134    output
135}
136
137/// Returns `true` if colors should be used (i.e., `NO_COLOR` is not set).
138fn should_use_color() -> bool {
139    std::env::var("NO_COLOR").is_err()
140}
141
142/// Returns a formatted line-by-line diff of two strings with ANSI color codes.
143///
144/// Removed lines are shown in red with a `- ` prefix, added lines in green
145/// with a `+ ` prefix, and unchanged lines with a `  ` prefix.
146///
147/// If the `NO_COLOR` environment variable is set, colors are omitted
148/// (equivalent to calling [`diff_strings_no_color`]).
149///
150/// # Examples
151///
152/// ```
153/// use philiprehberger_diff_assert::diff_strings;
154///
155/// let result = diff_strings("hello\nworld", "hello\neveryone");
156/// // result contains a colored diff showing "world" removed and "everyone" added
157/// ```
158pub fn diff_strings(left: &str, right: &str) -> String {
159    let diff = compute_diff(left, right);
160    format_diff(&diff, should_use_color())
161}
162
163/// Returns a formatted line-by-line diff of two strings without ANSI color codes.
164///
165/// This always produces plain text output regardless of the `NO_COLOR` setting.
166///
167/// # Examples
168///
169/// ```
170/// use philiprehberger_diff_assert::diff_strings_no_color;
171///
172/// let result = diff_strings_no_color("a\nb\nc", "a\nx\nc");
173/// assert_eq!(result, "  a\n- b\n+ x\n  c");
174/// ```
175pub fn diff_strings_no_color(left: &str, right: &str) -> String {
176    let diff = compute_diff(left, right);
177    format_diff(&diff, false)
178}
179
180/// Returns a formatted diff of two values using their [`Debug`] representations.
181///
182/// Both values are formatted with `{:#?}` (pretty-printed Debug), then compared
183/// line by line. Color output respects the `NO_COLOR` environment variable.
184///
185/// # Examples
186///
187/// ```
188/// use philiprehberger_diff_assert::diff_debug;
189///
190/// let left = vec![1, 2, 3];
191/// let right = vec![1, 4, 3];
192/// let result = diff_debug(&left, &right);
193/// // Shows a diff of the pretty-printed Debug output
194/// ```
195pub fn diff_debug<T: fmt::Debug>(left: &T, right: &T) -> String {
196    let left_str = format!("{:#?}", left);
197    let right_str = format!("{:#?}", right);
198    diff_strings(&left_str, &right_str)
199}
200
201/// Asserts that two expressions are equal, showing a colored line-by-line diff on failure.
202///
203/// Works like [`assert_eq!`] but instead of dumping raw `Debug` output, it shows
204/// a line-by-line diff with removed lines in red and added lines in green.
205///
206/// Both expressions must implement [`Debug`] and [`PartialEq`].
207///
208/// # Examples
209///
210/// ```
211/// use philiprehberger_diff_assert::assert_eq_diff;
212///
213/// let a = "hello";
214/// let b = "hello";
215/// assert_eq_diff!(a, b);
216/// ```
217///
218/// With a custom message:
219///
220/// ```should_panic
221/// use philiprehberger_diff_assert::assert_eq_diff;
222///
223/// assert_eq_diff!(1, 2, "values should match: {}", "test");
224/// ```
225#[macro_export]
226macro_rules! assert_eq_diff {
227    ($left:expr, $right:expr $(,)?) => {
228        match (&$left, &$right) {
229            (left_val, right_val) => {
230                if !(*left_val == *right_val) {
231                    let left_str = format!("{:#?}", left_val);
232                    let right_str = format!("{:#?}", right_val);
233                    let diff = $crate::diff_strings(&left_str, &right_str);
234                    panic!(
235                        "assertion `left == right` failed\n\n--- left\n+++ right\n\n{}\n",
236                        diff
237                    );
238                }
239            }
240        }
241    };
242    ($left:expr, $right:expr, $($arg:tt)+) => {
243        match (&$left, &$right) {
244            (left_val, right_val) => {
245                if !(*left_val == *right_val) {
246                    let left_str = format!("{:#?}", left_val);
247                    let right_str = format!("{:#?}", right_val);
248                    let diff = $crate::diff_strings(&left_str, &right_str);
249                    panic!(
250                        "assertion `left == right` failed: {}\n\n--- left\n+++ right\n\n{}\n",
251                        format_args!($($arg)+),
252                        diff
253                    );
254                }
255            }
256        }
257    };
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn assert_eq_diff_passes_when_equal() {
266        assert_eq_diff!("hello", "hello");
267    }
268
269    #[test]
270    fn assert_eq_diff_passes_with_equal_structs() {
271        #[derive(Debug, PartialEq)]
272        struct Point {
273            x: i32,
274            y: i32,
275        }
276
277        let a = Point { x: 1, y: 2 };
278        let b = Point { x: 1, y: 2 };
279        assert_eq_diff!(a, b);
280    }
281
282    #[test]
283    #[should_panic(expected = "assertion `left == right` failed")]
284    fn assert_eq_diff_panics_on_inequality() {
285        assert_eq_diff!("hello", "world");
286    }
287
288    #[test]
289    #[should_panic(expected = "custom error message")]
290    fn assert_eq_diff_custom_message() {
291        assert_eq_diff!(1, 2, "custom error message: {}", 42);
292    }
293
294    #[test]
295    fn diff_strings_identical_returns_all_same() {
296        let result = diff_strings_no_color("hello\nworld", "hello\nworld");
297        assert_eq!(result, "  hello\n  world");
298    }
299
300    #[test]
301    fn diff_strings_with_added_lines() {
302        let result = diff_strings_no_color("a\nc", "a\nb\nc");
303        assert_eq!(result, "  a\n+ b\n  c");
304    }
305
306    #[test]
307    fn diff_strings_with_removed_lines() {
308        let result = diff_strings_no_color("a\nb\nc", "a\nc");
309        assert_eq!(result, "  a\n- b\n  c");
310    }
311
312    #[test]
313    fn diff_strings_with_mixed_changes() {
314        let result = diff_strings_no_color("a\nb\nc\nd", "a\nx\nc\ny");
315        assert_eq!(result, "  a\n- b\n+ x\n  c\n- d\n+ y");
316    }
317
318    #[test]
319    fn diff_strings_no_color_has_no_ansi_codes() {
320        let result = diff_strings_no_color("hello", "world");
321        assert!(!result.contains("\x1b["));
322        assert!(result.contains("- hello"));
323        assert!(result.contains("+ world"));
324    }
325
326    #[test]
327    fn diff_debug_with_structs() {
328        #[derive(Debug)]
329        struct Config {
330            name: String,
331            value: i32,
332        }
333
334        let left = Config {
335            name: "alpha".to_string(),
336            value: 10,
337        };
338        let right = Config {
339            name: "beta".to_string(),
340            value: 10,
341        };
342
343        let result = diff_debug(&left, &right);
344        // Should contain something about the name difference
345        assert!(!result.is_empty());
346    }
347
348    #[test]
349    fn diff_strings_empty_inputs() {
350        let result = diff_strings_no_color("", "");
351        assert_eq!(result, "");
352    }
353
354    #[test]
355    fn diff_strings_left_empty() {
356        let result = diff_strings_no_color("", "hello");
357        // Empty string has one empty line, "hello" has one line
358        assert!(result.contains("+ hello"));
359    }
360}