expect_test_bytes/
lib.rs

1//! Copy of [expect-test](https://github.com/rust-analyzer/expect-test), a minimalistic snapshot
2//! testing library, for bytes and binary data.
3//!
4//! # Example
5//!
6//! ```
7//! let actual = b"example\n";
8//!
9//! expect_test_bytes::expect_file!["test_data/example"].assert_eq(actual);
10//! ```
11
12use std::fmt::Write as _;
13use std::path::PathBuf;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::{fmt, fs, io};
16
17const UPDATE_EXPECT_VAR_NAME: &str = if cfg!(test) {
18    "UPDATE_EXPECT_BYTES"
19} else {
20    "UPDATE_EXPECT"
21};
22
23const HELP: &str = "
24You can update all `expect!` tests by running:
25
26    env UPDATE_EXPECT=1 cargo test
27
28To update a single test, place the cursor on `expect` token and use `run` feature of rust-analyzer.
29";
30
31static HELP_PRINTED: AtomicBool = AtomicBool::new(false);
32
33/// Converts `ErrorKind::NotFound` to `Ok(None)`
34fn not_found_to_none<T>(res: io::Result<T>) -> io::Result<Option<T>> {
35    match res {
36        Ok(value) => Ok(Some(value)),
37        Err(ref e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
38        Err(e) => Err(e),
39    }
40}
41
42/// Finds the first index where the elements of `a` and `b` differ.
43///
44/// If the elements don't differ but the number of elements differ, the first index where only one
45/// slice has an element is returned.
46fn first_diff_index(a: &[u8], b: &[u8]) -> Option<usize> {
47    a.iter()
48        .zip(b.iter())
49        .position(|(x, y)| x != y)
50        .or_else(|| (a.len() != b.len()).then(|| a.len().min(b.len())))
51}
52
53const BYTE_WINDOW_HALF_SIZE: usize = 4;
54
55struct ByteWindowDisplay<'a> {
56    data: &'a [u8],
57    diff_idx: usize,
58    is_expected: bool,
59}
60impl fmt::Display for ByteWindowDisplay<'_> {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        let start = self.diff_idx.saturating_sub(BYTE_WINDOW_HALF_SIZE);
63        let end = self.data.len().min(self.diff_idx + BYTE_WINDOW_HALF_SIZE);
64
65        // same as `self.diff_idx.min(BYTE_WINDOW_HALF_SIZE)`
66        let translated_diff_idx = self.diff_idx - start;
67
68        for (i, byte) in self.data[start..=end].iter().enumerate() {
69            if i != 0 {
70                write!(f, " ").unwrap();
71            }
72            if i == translated_diff_idx {
73                let highlight_ansi_code = if self.is_expected { "32" } else { "31" };
74                write!(f, "\x1b[{highlight_ansi_code}m").unwrap();
75            }
76
77            write!(f, "{byte:02x}").unwrap();
78
79            if i == translated_diff_idx {
80                write!(f, "\x1b[0m").unwrap();
81            }
82        }
83
84        write!(f, " {}", CharacterPanel(&self.data[start..=end]))?;
85        Ok(())
86    }
87}
88
89/// <https://github.com/sharkdp/hexyl/blob/9ef7c346dda6320bb5d746810b9e93e1a66e7fc0/src/lib.rs#L30-L32>
90struct CharacterPanel<'a>(&'a [u8]);
91impl fmt::Display for CharacterPanel<'_> {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        for byte in self.0 {
94            let ch = match *byte {
95                0 => '⋄',
96                _ if byte.is_ascii_graphic() => *byte as char,
97                b' ' => ' ',
98                _ if byte.is_ascii_whitespace() => '_',
99                _ if byte.is_ascii() => '•',
100                _ => '×',
101            };
102            f.write_char(ch)?;
103        }
104        Ok(())
105    }
106}
107
108/// Self-updating file.
109///
110/// [`ExpectFile::assert_eq`] updates the file when the `UPDATE_EXPECT` environment variable is
111/// set.
112#[derive(Debug)]
113pub struct ExpectFile {
114    #[doc(hidden)]
115    pub path: PathBuf,
116}
117
118impl ExpectFile {
119    /// Checks whether file's contents are equal to `actual`.
120    ///
121    /// When the `UPDATE_EXPECT` environment variable is set, the file is updated or created with
122    /// the data from `actual`.
123    ///
124    /// # Panics
125    ///
126    /// Will panic when the file's contents don't equal `actual` and `UPDATE_EXPECT` is not set or
127    /// if writing to stdout or updating the file fails.
128    pub fn assert_eq(&self, actual: &[u8]) {
129        if let Err(()) = self.assert_eq_nopanic_imp(actual, &mut io::stdout()) {
130            // Use resume_unwind instead of panic!() to prevent a backtrace, which is unnecessary noise.
131            std::panic::resume_unwind(Box::new(()));
132        }
133    }
134    fn assert_eq_nopanic_imp<W: io::Write>(&self, actual: &[u8], writer: &mut W) -> Result<(), ()> {
135        let expected = not_found_to_none(fs::read(&self.path)).unwrap();
136        if expected.as_deref() == Some(actual) {
137            return Ok(());
138        }
139        if std::env::var_os(UPDATE_EXPECT_VAR_NAME).is_some() {
140            writeln!(
141                writer,
142                "\x1b[1m\x1b[92mupdating\x1b[0m: {}",
143                self.path.display()
144            )
145            .unwrap();
146            fs::write(&self.path, actual).unwrap();
147            return Ok(());
148        }
149        let print_help = if cfg!(test) {
150            true // Tests are run in the same process in arbitrary order
151        } else {
152            !HELP_PRINTED.swap(true, Ordering::SeqCst)
153        };
154        let help = if print_help { HELP } else { "" };
155
156        writeln!(
157            writer,
158            "
159\x1b[1m\x1b[91merror\x1b[97m: expect test failed\x1b[0m
160   \x1b[1m\x1b[34m-->\x1b[0m {location}
161{help}
162\x1b[1mExpect\x1b[0m:
163{expect}
164
165\x1b[1mActual\x1b[0m:
166<binary>
167",
168            location = self.path.display(),
169            expect = if expected.is_some() {
170                "<binary>"
171            } else {
172                "\x1b[1mNot found\x1b[0m"
173            },
174        )
175        .unwrap();
176
177        if let Some(expected) = expected {
178            let diff_idx = first_diff_index(&expected, actual).unwrap_or(0);
179
180            writeln!(
181                writer,
182                "\x1b[1mDiff\x1b[0m:
183Binary files differ at byte {diff_idx:#x}
184
185Expect: {expect}
186Actual: {actual}
187        {offset}\x1b[1m^^\x1b[0m",
188                expect = ByteWindowDisplay {
189                    data: &expected,
190                    diff_idx,
191                    is_expected: true
192                },
193                actual = ByteWindowDisplay {
194                    data: actual,
195                    diff_idx,
196                    is_expected: false
197                },
198                offset = "   ".repeat(diff_idx.min(BYTE_WINDOW_HALF_SIZE)),
199            )
200            .unwrap();
201        }
202
203        Err(())
204    }
205}
206
207/// Creates an instance of [`ExpectFile`] from a relative or absolute path:
208///
209/// ```
210/// # use expect_test_bytes::expect_file;
211/// expect_file!["test_data/example"];
212/// ```
213#[macro_export]
214macro_rules! expect_file {
215    [$path:expr] => {
216        $crate::ExpectFile {
217            path: {
218                let path = ::std::path::Path::new($path);
219                if path.is_absolute() {
220                    path.to_owned()
221                } else {
222                    ::std::path::Path::new(file!()).parent().unwrap().join(path)
223                }
224            },
225        }
226    };
227}
228
229/// Bytes.
230///
231/// Self-updating hasn't been implemented yet.
232#[derive(Debug)]
233pub struct Expect<'a> {
234    #[doc(hidden)]
235    pub position: Position,
236    #[doc(hidden)]
237    pub data: &'a [u8],
238}
239
240/// Position of original `expect!` in the source file.
241#[derive(Debug)]
242pub struct Position {
243    #[doc(hidden)]
244    pub file: &'static str,
245    #[doc(hidden)]
246    pub line: u32,
247    #[doc(hidden)]
248    pub column: u32,
249}
250
251impl fmt::Display for Position {
252    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253        write!(f, "{}:{}:{}", self.file, self.line, self.column)
254    }
255}
256
257/// Creates an instance of [`Expect`] from an expression returning bytes:
258///
259/// ```
260/// # use expect_test_bytes::expect;
261/// expect![[b"example"]];
262/// ```
263#[macro_export]
264macro_rules! expect {
265    [[$data:expr]] => {
266        $crate::Expect {
267            position: $crate::Position {
268                file: file!(),
269                line: line!(),
270                column: column!(),
271            },
272            data: $data,
273        }
274    };
275    [$data:expr] => { $crate::expect![[$data]] };
276    [] => { $crate::expect![[b""]] };
277    [[]] => { $crate::expect![[b""]] };
278}
279
280#[cfg(test)]
281mod tests;