cargo_test_support/
compare.rs

1//! Routines for comparing and diffing output.
2//!
3//! # Deprecated comparisons
4//!
5//! Cargo's tests are in transition from internal-only pattern and normalization routines used in
6//! asserts like [`crate::Execs::with_stdout_contains`] to [`assert_e2e`] and [`assert_ui`].
7//!
8//! ## Patterns
9//!
10//! Many of these functions support special markup to assist with comparing
11//! text that may vary or is otherwise uninteresting for the test at hand. The
12//! supported patterns are:
13//!
14//! - `[..]` is a wildcard that matches 0 or more characters on the same line
15//!   (similar to `.*` in a regex). It is non-greedy.
16//! - `[EXE]` optionally adds `.exe` on Windows (empty string on other
17//!   platforms).
18//! - `[ROOT]` is the path to the test directory's root.
19//! - `[CWD]` is the working directory of the process that was run.
20//! - There is a wide range of substitutions (such as `[COMPILING]` or
21//!   `[WARNING]`) to match cargo's "status" output and allows you to ignore
22//!   the alignment. See the source of `substitute_macros` for a complete list
23//!   of substitutions.
24//! - `[DIRTY-MSVC]` (only when the line starts with it) would be replaced by
25//!   `[DIRTY]` when `cfg(target_env = "msvc")` or the line will be ignored otherwise.
26//!   Tests that work around [issue 7358](https://github.com/rust-lang/cargo/issues/7358)
27//!   can use this to avoid duplicating the `with_stderr` call like:
28//!   `if cfg!(target_env = "msvc") {e.with_stderr("...[DIRTY]...");} else {e.with_stderr("...");}`.
29//!
30//! ## Normalization
31//!
32//! In addition to the patterns described above, the strings are normalized
33//! in such a way to avoid unwanted differences. The normalizations are:
34//!
35//! - Raw tab characters are converted to the string `<tab>`. This is helpful
36//!   so that raw tabs do not need to be written in the expected string, and
37//!   to avoid confusion of tabs vs spaces.
38//! - Backslashes are converted to forward slashes to deal with Windows paths.
39//!   This helps so that all tests can be written assuming forward slashes.
40//!   Other heuristics are applied to try to ensure Windows-style paths aren't
41//!   a problem.
42//! - Carriage returns are removed, which can help when running on Windows.
43
44use crate::cross_compile::try_alternate;
45use crate::paths;
46use crate::rustc_host;
47use anyhow::{Result, bail};
48use snapbox::Data;
49use snapbox::IntoData;
50use std::fmt;
51use std::path::Path;
52use std::path::PathBuf;
53use std::str;
54
55/// This makes it easier to write regex replacements that are guaranteed to only
56/// get compiled once
57macro_rules! regex {
58    ($re:literal $(,)?) => {{
59        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
60        RE.get_or_init(|| regex::Regex::new($re).unwrap())
61    }};
62}
63
64/// Assertion policy for UI tests
65///
66/// This emphasizes showing as much content as possible at the cost of more brittleness
67///
68/// # Snapshots
69///
70/// Updating of snapshots is controlled with the `SNAPSHOTS` environment variable:
71///
72/// - `skip`: do not run the tests
73/// - `ignore`: run the tests but ignore their failure
74/// - `verify`: run the tests
75/// - `overwrite`: update the snapshots based on the output of the tests
76///
77/// # Patterns
78///
79/// - `[..]` is a character wildcard, stopping at line breaks
80/// - `\n...\n` is a multi-line wildcard
81/// - `[EXE]` matches the exe suffix for the current platform
82/// - `[ROOT]` matches [`paths::root()`][crate::paths::root]
83/// - `[ROOTURL]` matches [`paths::root()`][crate::paths::root] as a URL
84///
85/// # Normalization
86///
87/// In addition to the patterns described above, text is normalized
88/// in such a way to avoid unwanted differences. The normalizations are:
89///
90/// - Backslashes are converted to forward slashes to deal with Windows paths.
91///   This helps so that all tests can be written assuming forward slashes.
92///   Other heuristics are applied to try to ensure Windows-style paths aren't
93///   a problem.
94/// - Carriage returns are removed, which can help when running on Windows.
95///
96/// # Example
97///
98/// ```no_run
99/// # use cargo_test_support::compare::assert_e2e;
100/// # use cargo_test_support::file;
101/// # let p = cargo_test_support::project().build();
102/// # let stdout = "";
103/// assert_e2e().eq(stdout, file!["stderr.term.svg"]);
104/// ```
105/// ```console
106/// $ SNAPSHOTS=overwrite cargo test
107/// ```
108pub fn assert_ui() -> snapbox::Assert {
109    let mut subs = snapbox::Redactions::new();
110    subs.extend(MIN_LITERAL_REDACTIONS.into_iter().cloned())
111        .unwrap();
112    add_test_support_redactions(&mut subs);
113    add_regex_redactions(&mut subs);
114
115    snapbox::Assert::new()
116        .action_env(snapbox::assert::DEFAULT_ACTION_ENV)
117        .redact_with(subs)
118}
119
120/// Assertion policy for functional end-to-end tests
121///
122/// This emphasizes showing as much content as possible at the cost of more brittleness
123///
124/// # Snapshots
125///
126/// Updating of snapshots is controlled with the `SNAPSHOTS` environment variable:
127///
128/// - `skip`: do not run the tests
129/// - `ignore`: run the tests but ignore their failure
130/// - `verify`: run the tests
131/// - `overwrite`: update the snapshots based on the output of the tests
132///
133/// # Patterns
134///
135/// - `[..]` is a character wildcard, stopping at line breaks
136/// - `\n...\n` is a multi-line wildcard
137/// - `[EXE]` matches the exe suffix for the current platform
138/// - `[ROOT]` matches [`paths::root()`][crate::paths::root]
139/// - `[ROOTURL]` matches [`paths::root()`][crate::paths::root] as a URL
140///
141/// # Normalization
142///
143/// In addition to the patterns described above, text is normalized
144/// in such a way to avoid unwanted differences. The normalizations are:
145///
146/// - Backslashes are converted to forward slashes to deal with Windows paths.
147///   This helps so that all tests can be written assuming forward slashes.
148///   Other heuristics are applied to try to ensure Windows-style paths aren't
149///   a problem.
150/// - Carriage returns are removed, which can help when running on Windows.
151///
152/// # Example
153///
154/// ```no_run
155/// # use cargo_test_support::compare::assert_e2e;
156/// # use cargo_test_support::str;
157/// # let p = cargo_test_support::project().build();
158/// assert_e2e().eq(p.read_lockfile(), str![]);
159/// ```
160/// ```console
161/// $ SNAPSHOTS=overwrite cargo test
162/// ```
163pub fn assert_e2e() -> snapbox::Assert {
164    let mut subs = snapbox::Redactions::new();
165    subs.extend(MIN_LITERAL_REDACTIONS.into_iter().cloned())
166        .unwrap();
167    subs.extend(E2E_LITERAL_REDACTIONS.into_iter().cloned())
168        .unwrap();
169    add_test_support_redactions(&mut subs);
170    add_regex_redactions(&mut subs);
171
172    snapbox::Assert::new()
173        .action_env(snapbox::assert::DEFAULT_ACTION_ENV)
174        .redact_with(subs)
175}
176
177fn add_test_support_redactions(subs: &mut snapbox::Redactions) {
178    let root = paths::root();
179    // Use `from_file_path` instead of `from_dir_path` so the trailing slash is
180    // put in the users output, rather than hidden in the variable
181    let root_url = url::Url::from_file_path(&root).unwrap().to_string();
182
183    subs.insert("[ROOT]", root).unwrap();
184    subs.insert("[ROOTURL]", root_url).unwrap();
185    subs.insert("[HOST_TARGET]", rustc_host()).unwrap();
186    if let Some(alt_target) = try_alternate() {
187        subs.insert("[ALT_TARGET]", alt_target).unwrap();
188    }
189}
190
191fn add_regex_redactions(subs: &mut snapbox::Redactions) {
192    // For e2e tests
193    subs.insert(
194        "[ELAPSED]",
195        regex!(r"\[FINISHED\].*in (?<redacted>[0-9]+(\.[0-9]+)?(m [0-9]+)?)s"),
196    )
197    .unwrap();
198    // for UI tests
199    subs.insert(
200        "[ELAPSED]",
201        regex!(r"Finished.*in (?<redacted>[0-9]+(\.[0-9]+)?(m [0-9]+)?)s"),
202    )
203    .unwrap();
204    // output from libtest
205    subs.insert(
206        "[ELAPSED]",
207        regex!(r"; finished in (?<redacted>[0-9]+(\.[0-9]+)?(m [0-9]+)?)s"),
208    )
209    .unwrap();
210    subs.insert(
211        "[FILE_NUM]",
212        regex!(r"\[(REMOVED|SUMMARY)\] (?<redacted>[1-9][0-9]*) files"),
213    )
214    .unwrap();
215    subs.insert(
216        "[FILE_SIZE]",
217        regex!(r"(?<redacted>[0-9]+(\.[0-9]+)?([a-zA-Z]i)?)B\s"),
218    )
219    .unwrap();
220    subs.insert(
221        "[HASH]",
222        regex!(r"home/\.cargo/registry/(cache|index|src)/-(?<redacted>[a-z0-9]+)"),
223    )
224    .unwrap();
225    subs.insert(
226        "[HASH]",
227        regex!(r"\.cargo/target/(?<redacted>[0-9a-f]{2}/[0-9a-f]{14})"),
228    )
229    .unwrap();
230    subs.insert("[HASH]", regex!(r"/[a-z0-9\-_]+-(?<redacted>[0-9a-f]{16})"))
231        .unwrap();
232    // Match multi-part hashes like `06/b451d0d6f88b1d` used in directory paths
233    subs.insert("[HASH]", regex!(r"/(?<redacted>[a-f0-9]{2}\/[0-9a-f]{14})"))
234        .unwrap();
235    // Match file name hashes like `foo-06b451d0d6f88b1d`
236    subs.insert("[HASH]", regex!(r"[a-z0-9]+-(?<redacted>[a-f0-9]{16})"))
237        .unwrap();
238    // Match path hashes like `../06b451d0d6f88b1d/..` used in directory paths
239    subs.insert("[HASH]", regex!(r"\/(?<redacted>[0-9a-f]{16})\/"))
240        .unwrap();
241    subs.insert(
242        "[AVG_ELAPSED]",
243        regex!(r"(?<redacted>[0-9]+(\.[0-9]+)?) ns/iter"),
244    )
245    .unwrap();
246    subs.insert(
247        "[JITTER]",
248        regex!(r"ns/iter \(\+/- (?<redacted>[0-9]+(\.[0-9]+)?)\)"),
249    )
250    .unwrap();
251
252    // Following 3 subs redact:
253    //   "1719325877.527949100s, 61549498ns after last build at 1719325877.466399602s"
254    //   "1719503592.218193216s, 1h 1s after last build at 1719499991.982681034s"
255    // into "[DIRTY_REASON_NEW_TIME], [DIRTY_REASON_DIFF] after last build at [DIRTY_REASON_OLD_TIME]"
256    subs.insert(
257        "[TIME_DIFF_AFTER_LAST_BUILD]",
258        regex!(r"(?<redacted>[0-9]+(\.[0-9]+)?s, (\s?[0-9]+(\.[0-9]+)?(s|ns|h))+ after last build at [0-9]+(\.[0-9]+)?s)"),
259       )
260       .unwrap();
261}
262
263static MIN_LITERAL_REDACTIONS: &[(&str, &str)] = &[
264    ("[EXE]", std::env::consts::EXE_SUFFIX),
265    ("[BROKEN_PIPE]", "Broken pipe (os error 32)"),
266    ("[BROKEN_PIPE]", "The pipe is being closed. (os error 232)"),
267    // Unix message for an entity was not found
268    ("[NOT_FOUND]", "No such file or directory (os error 2)"),
269    // Windows message for an entity was not found
270    (
271        "[NOT_FOUND]",
272        "The system cannot find the file specified. (os error 2)",
273    ),
274    (
275        "[NOT_FOUND]",
276        "The system cannot find the path specified. (os error 3)",
277    ),
278    ("[NOT_FOUND]", "Access is denied. (os error 5)"),
279    ("[NOT_FOUND]", "program not found"),
280    // Unix message for exit status
281    ("[EXIT_STATUS]", "exit status"),
282    // Windows message for exit status
283    ("[EXIT_STATUS]", "exit code"),
284];
285static E2E_LITERAL_REDACTIONS: &[(&str, &str)] = &[
286    ("[RUNNING]", "     Running"),
287    ("[COMPILING]", "   Compiling"),
288    ("[CHECKING]", "    Checking"),
289    ("[COMPLETED]", "   Completed"),
290    ("[CREATED]", "     Created"),
291    ("[CREATING]", "    Creating"),
292    ("[CREDENTIAL]", "  Credential"),
293    ("[DOWNGRADING]", " Downgrading"),
294    ("[FINISHED]", "    Finished"),
295    ("[ERROR]", "error:"),
296    ("[WARNING]", "warning:"),
297    ("[NOTE]", "note:"),
298    ("[HELP]", "help:"),
299    ("[DOCUMENTING]", " Documenting"),
300    ("[SCRAPING]", "    Scraping"),
301    ("[FRESH]", "       Fresh"),
302    ("[DIRTY]", "       Dirty"),
303    ("[LOCKING]", "     Locking"),
304    ("[UPDATING]", "    Updating"),
305    ("[UPGRADING]", "   Upgrading"),
306    ("[ADDING]", "      Adding"),
307    ("[REMOVING]", "    Removing"),
308    ("[REMOVED]", "     Removed"),
309    ("[UNCHANGED]", "   Unchanged"),
310    ("[DOCTEST]", "   Doc-tests"),
311    ("[PACKAGING]", "   Packaging"),
312    ("[PACKAGED]", "    Packaged"),
313    ("[DOWNLOADING]", " Downloading"),
314    ("[DOWNLOADED]", "  Downloaded"),
315    ("[UPLOADING]", "   Uploading"),
316    ("[UPLOADED]", "    Uploaded"),
317    ("[VERIFYING]", "   Verifying"),
318    ("[ARCHIVING]", "   Archiving"),
319    ("[INSTALLING]", "  Installing"),
320    ("[REPLACING]", "   Replacing"),
321    ("[UNPACKING]", "   Unpacking"),
322    ("[SUMMARY]", "     Summary"),
323    ("[FIXED]", "       Fixed"),
324    ("[FIXING]", "      Fixing"),
325    ("[IGNORED]", "     Ignored"),
326    ("[INSTALLED]", "   Installed"),
327    ("[REPLACED]", "    Replaced"),
328    ("[BUILDING]", "    Building"),
329    ("[LOGIN]", "       Login"),
330    ("[LOGOUT]", "      Logout"),
331    ("[YANK]", "        Yank"),
332    ("[OWNER]", "       Owner"),
333    ("[MIGRATING]", "   Migrating"),
334    ("[EXECUTABLE]", "  Executable"),
335    ("[SKIPPING]", "    Skipping"),
336    ("[WAITING]", "     Waiting"),
337    ("[PUBLISHED]", "   Published"),
338    ("[BLOCKING]", "    Blocking"),
339    ("[GENERATED]", "   Generated"),
340    ("[OPENING]", "     Opening"),
341];
342
343/// Checks that the given string contains the given contiguous lines
344/// somewhere.
345///
346/// See [Patterns](index.html#patterns) for more information on pattern matching.
347pub(crate) fn match_contains(
348    expected: &str,
349    actual: &str,
350    redactions: &snapbox::Redactions,
351) -> Result<()> {
352    let expected = normalize_expected(expected, redactions);
353    let actual = normalize_actual(actual, redactions);
354    let e: Vec<_> = expected.lines().map(|line| WildStr::new(line)).collect();
355    let a: Vec<_> = actual.lines().collect();
356    if e.len() == 0 {
357        bail!("expected length must not be zero");
358    }
359    for window in a.windows(e.len()) {
360        if e == window {
361            return Ok(());
362        }
363    }
364    bail!(
365        "expected to find:\n\
366         {}\n\n\
367         did not find in output:\n\
368         {}",
369        expected,
370        actual
371    );
372}
373
374/// Checks that the given string does not contain the given contiguous lines
375/// anywhere.
376///
377/// See [Patterns](index.html#patterns) for more information on pattern matching.
378pub(crate) fn match_does_not_contain(
379    expected: &str,
380    actual: &str,
381    redactions: &snapbox::Redactions,
382) -> Result<()> {
383    if match_contains(expected, actual, redactions).is_ok() {
384        bail!(
385            "expected not to find:\n\
386             {}\n\n\
387             but found in output:\n\
388             {}",
389            expected,
390            actual
391        );
392    } else {
393        Ok(())
394    }
395}
396
397/// Checks that the given string has a line that contains the given patterns,
398/// and that line also does not contain the `without` patterns.
399///
400/// See [Patterns](index.html#patterns) for more information on pattern matching.
401///
402/// See [`crate::Execs::with_stderr_line_without`] for an example and cautions
403/// against using.
404pub(crate) fn match_with_without(
405    actual: &str,
406    with: &[String],
407    without: &[String],
408    redactions: &snapbox::Redactions,
409) -> Result<()> {
410    let actual = normalize_actual(actual, redactions);
411    let norm = |s: &String| format!("[..]{}[..]", normalize_expected(s, redactions));
412    let with: Vec<_> = with.iter().map(norm).collect();
413    let without: Vec<_> = without.iter().map(norm).collect();
414    let with_wild: Vec<_> = with.iter().map(|w| WildStr::new(w)).collect();
415    let without_wild: Vec<_> = without.iter().map(|w| WildStr::new(w)).collect();
416
417    let matches: Vec<_> = actual
418        .lines()
419        .filter(|line| with_wild.iter().all(|with| with == line))
420        .filter(|line| !without_wild.iter().any(|without| without == line))
421        .collect();
422    match matches.len() {
423        0 => bail!(
424            "Could not find expected line in output.\n\
425             With contents: {:?}\n\
426             Without contents: {:?}\n\
427             Actual stderr:\n\
428             {}\n",
429            with,
430            without,
431            actual
432        ),
433        1 => Ok(()),
434        _ => bail!(
435            "Found multiple matching lines, but only expected one.\n\
436             With contents: {:?}\n\
437             Without contents: {:?}\n\
438             Matching lines:\n\
439             {}\n",
440            with,
441            without,
442            itertools::join(matches, "\n")
443        ),
444    }
445}
446
447/// Normalizes the output so that it can be compared against the expected value.
448fn normalize_actual(content: &str, redactions: &snapbox::Redactions) -> String {
449    use snapbox::filter::Filter as _;
450    let content = snapbox::filter::FilterPaths.filter(content.into_data());
451    let content = snapbox::filter::FilterNewlines.filter(content);
452    let content = content.render().expect("came in as a String");
453    let content = redactions.redact(&content);
454    content
455}
456
457/// Normalizes the expected string so that it can be compared against the actual output.
458fn normalize_expected(content: &str, redactions: &snapbox::Redactions) -> String {
459    use snapbox::filter::Filter as _;
460    let content = snapbox::filter::FilterPaths.filter(content.into_data());
461    let content = snapbox::filter::FilterNewlines.filter(content);
462    // Remove any conditionally absent redactions like `[EXE]`
463    let content = content.render().expect("came in as a String");
464    let content = redactions.clear_unused(&content);
465    content.into_owned()
466}
467
468/// A single line string that supports `[..]` wildcard matching.
469struct WildStr<'a> {
470    has_meta: bool,
471    line: &'a str,
472}
473
474impl<'a> WildStr<'a> {
475    fn new(line: &'a str) -> WildStr<'a> {
476        WildStr {
477            has_meta: line.contains("[..]"),
478            line,
479        }
480    }
481}
482
483impl PartialEq<&str> for WildStr<'_> {
484    fn eq(&self, other: &&str) -> bool {
485        if self.has_meta {
486            meta_cmp(self.line, other)
487        } else {
488            self.line == *other
489        }
490    }
491}
492
493fn meta_cmp(a: &str, mut b: &str) -> bool {
494    for (i, part) in a.split("[..]").enumerate() {
495        match b.find(part) {
496            Some(j) => {
497                if i == 0 && j != 0 {
498                    return false;
499                }
500                b = &b[j + part.len()..];
501            }
502            None => return false,
503        }
504    }
505    b.is_empty() || a.ends_with("[..]")
506}
507
508impl fmt::Display for WildStr<'_> {
509    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
510        f.write_str(&self.line)
511    }
512}
513
514impl fmt::Debug for WildStr<'_> {
515    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516        write!(f, "{:?}", self.line)
517    }
518}
519
520pub struct InMemoryDir {
521    files: Vec<(PathBuf, Data)>,
522}
523
524impl InMemoryDir {
525    pub fn paths(&self) -> impl Iterator<Item = &Path> {
526        self.files.iter().map(|(p, _)| p.as_path())
527    }
528
529    #[track_caller]
530    pub fn assert_contains(&self, expected: &Self) {
531        use std::fmt::Write as _;
532        let assert = assert_e2e();
533        let mut errs = String::new();
534        for (path, expected_data) in &expected.files {
535            let actual_data = self
536                .files
537                .iter()
538                .find_map(|(p, d)| (path == p).then(|| d.clone()))
539                .unwrap_or_else(|| Data::new());
540            if let Err(err) =
541                assert.try_eq(Some(&path.display()), actual_data, expected_data.clone())
542            {
543                let _ = write!(&mut errs, "{err}");
544            }
545        }
546        if !errs.is_empty() {
547            panic!("{errs}")
548        }
549    }
550}
551
552impl<P, D> FromIterator<(P, D)> for InMemoryDir
553where
554    P: Into<std::path::PathBuf>,
555    D: IntoData,
556{
557    fn from_iter<I: IntoIterator<Item = (P, D)>>(files: I) -> Self {
558        let files = files
559            .into_iter()
560            .map(|(p, d)| (p.into(), d.into_data()))
561            .collect();
562        Self { files }
563    }
564}
565
566impl<const N: usize, P, D> From<[(P, D); N]> for InMemoryDir
567where
568    P: Into<PathBuf>,
569    D: IntoData,
570{
571    fn from(files: [(P, D); N]) -> Self {
572        let files = files
573            .into_iter()
574            .map(|(p, d)| (p.into(), d.into_data()))
575            .collect();
576        Self { files }
577    }
578}
579
580impl<P, D> From<std::collections::HashMap<P, D>> for InMemoryDir
581where
582    P: Into<PathBuf>,
583    D: IntoData,
584{
585    fn from(files: std::collections::HashMap<P, D>) -> Self {
586        let files = files
587            .into_iter()
588            .map(|(p, d)| (p.into(), d.into_data()))
589            .collect();
590        Self { files }
591    }
592}
593
594impl<P, D> From<std::collections::BTreeMap<P, D>> for InMemoryDir
595where
596    P: Into<PathBuf>,
597    D: IntoData,
598{
599    fn from(files: std::collections::BTreeMap<P, D>) -> Self {
600        let files = files
601            .into_iter()
602            .map(|(p, d)| (p.into(), d.into_data()))
603            .collect();
604        Self { files }
605    }
606}
607
608impl From<()> for InMemoryDir {
609    fn from(_files: ()) -> Self {
610        let files = Vec::new();
611        Self { files }
612    }
613}
614
615/// Create an `impl _ for InMemoryDir` for a generic tuple
616///
617/// Must pass in names for each tuple parameter for
618/// - internal variable name
619/// - `Path` type
620/// - `Data` type
621macro_rules! impl_from_tuple_for_inmemorydir {
622    ($($var:ident $path:ident $data:ident),+) => {
623        impl<$($path: Into<PathBuf>, $data: IntoData),+> From<($(($path, $data)),+ ,)> for InMemoryDir {
624            fn from(files: ($(($path, $data)),+,)) -> Self {
625                let ($($var),+ ,) = files;
626                let files = [$(($var.0.into(), $var.1.into_data())),+];
627                files.into()
628            }
629        }
630    };
631}
632
633/// Extend `impl_from_tuple_for_inmemorydir` to generate for the specified tuple and all smaller
634/// tuples
635macro_rules! impl_from_tuples_for_inmemorydir {
636    ($var1:ident $path1:ident $data1:ident, $($var:ident $path:ident $data:ident),+) => {
637        impl_from_tuples_for_inmemorydir!(__impl $var1 $path1 $data1; $($var $path $data),+);
638    };
639    (__impl $($var:ident $path:ident $data:ident),+; $var1:ident $path1:ident $data1:ident $(,$var2:ident $path2:ident $data2:ident)*) => {
640        impl_from_tuple_for_inmemorydir!($($var $path $data),+);
641        impl_from_tuples_for_inmemorydir!(__impl $($var $path $data),+, $var1 $path1 $data1; $($var2 $path2 $data2),*);
642    };
643    (__impl $($var:ident $path:ident $data:ident),+;) => {
644        impl_from_tuple_for_inmemorydir!($($var $path $data),+);
645    }
646}
647
648// Generate for tuples of size `1..=7`
649impl_from_tuples_for_inmemorydir!(
650    s1 P1 D1,
651    s2 P2 D2,
652    s3 P3 D3,
653    s4 P4 D4,
654    s5 P5 D5,
655    s6 P6 D6,
656    s7 P7 D7
657);
658
659#[cfg(test)]
660mod test {
661    use snapbox::assert_data_eq;
662    use snapbox::prelude::*;
663    use snapbox::str;
664
665    use super::*;
666
667    #[test]
668    fn wild_str_cmp() {
669        for (a, b) in &[
670            ("a b", "a b"),
671            ("a[..]b", "a b"),
672            ("a[..]", "a b"),
673            ("[..]", "a b"),
674            ("[..]b", "a b"),
675        ] {
676            assert_eq!(WildStr::new(a), b);
677        }
678        for (a, b) in &[("[..]b", "c"), ("b", "c"), ("b", "cb")] {
679            assert_ne!(WildStr::new(a), b);
680        }
681    }
682
683    #[test]
684    fn redact_elapsed_time() {
685        let mut subs = snapbox::Redactions::new();
686        add_regex_redactions(&mut subs);
687
688        assert_data_eq!(
689            subs.redact("[FINISHED] `release` profile [optimized] target(s) in 5.5s"),
690            str!["[FINISHED] `release` profile [optimized] target(s) in [ELAPSED]s"].raw()
691        );
692        assert_data_eq!(
693            subs.redact("[FINISHED] `release` profile [optimized] target(s) in 1m 05s"),
694            str!["[FINISHED] `release` profile [optimized] target(s) in [ELAPSED]s"].raw()
695        );
696    }
697}