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    ("[MERGING]", "     Merging"),
342];
343
344/// Checks that the given string contains the given contiguous lines
345/// somewhere.
346///
347/// See [Patterns](index.html#patterns) for more information on pattern matching.
348pub(crate) fn match_contains(
349    expected: &str,
350    actual: &str,
351    redactions: &snapbox::Redactions,
352) -> Result<()> {
353    let expected = normalize_expected(expected, redactions);
354    let actual = normalize_actual(actual, redactions);
355    let e: Vec<_> = expected.lines().map(|line| WildStr::new(line)).collect();
356    let a: Vec<_> = actual.lines().collect();
357    if e.len() == 0 {
358        bail!("expected length must not be zero");
359    }
360    for window in a.windows(e.len()) {
361        if e == window {
362            return Ok(());
363        }
364    }
365    bail!(
366        "expected to find:\n\
367         {}\n\n\
368         did not find in output:\n\
369         {}",
370        expected,
371        actual
372    );
373}
374
375/// Checks that the given string does not contain the given contiguous lines
376/// anywhere.
377///
378/// See [Patterns](index.html#patterns) for more information on pattern matching.
379pub(crate) fn match_does_not_contain(
380    expected: &str,
381    actual: &str,
382    redactions: &snapbox::Redactions,
383) -> Result<()> {
384    if match_contains(expected, actual, redactions).is_ok() {
385        bail!(
386            "expected not to find:\n\
387             {}\n\n\
388             but found in output:\n\
389             {}",
390            expected,
391            actual
392        );
393    } else {
394        Ok(())
395    }
396}
397
398/// Checks that the given string has a line that contains the given patterns,
399/// and that line also does not contain the `without` patterns.
400///
401/// See [Patterns](index.html#patterns) for more information on pattern matching.
402///
403/// See [`crate::Execs::with_stderr_line_without`] for an example and cautions
404/// against using.
405pub(crate) fn match_with_without(
406    actual: &str,
407    with: &[String],
408    without: &[String],
409    redactions: &snapbox::Redactions,
410) -> Result<()> {
411    let actual = normalize_actual(actual, redactions);
412    let norm = |s: &String| format!("[..]{}[..]", normalize_expected(s, redactions));
413    let with: Vec<_> = with.iter().map(norm).collect();
414    let without: Vec<_> = without.iter().map(norm).collect();
415    let with_wild: Vec<_> = with.iter().map(|w| WildStr::new(w)).collect();
416    let without_wild: Vec<_> = without.iter().map(|w| WildStr::new(w)).collect();
417
418    let matches: Vec<_> = actual
419        .lines()
420        .filter(|line| with_wild.iter().all(|with| with == line))
421        .filter(|line| !without_wild.iter().any(|without| without == line))
422        .collect();
423    match matches.len() {
424        0 => bail!(
425            "Could not find expected line in output.\n\
426             With contents: {:?}\n\
427             Without contents: {:?}\n\
428             Actual stderr:\n\
429             {}\n",
430            with,
431            without,
432            actual
433        ),
434        1 => Ok(()),
435        _ => bail!(
436            "Found multiple matching lines, but only expected one.\n\
437             With contents: {:?}\n\
438             Without contents: {:?}\n\
439             Matching lines:\n\
440             {}\n",
441            with,
442            without,
443            itertools::join(matches, "\n")
444        ),
445    }
446}
447
448/// Normalizes the output so that it can be compared against the expected value.
449fn normalize_actual(content: &str, redactions: &snapbox::Redactions) -> String {
450    use snapbox::filter::Filter as _;
451    let content = snapbox::filter::FilterPaths.filter(content.into_data());
452    let content = snapbox::filter::FilterNewlines.filter(content);
453    let content = content.render().expect("came in as a String");
454    let content = redactions.redact(&content);
455    content
456}
457
458/// Normalizes the expected string so that it can be compared against the actual output.
459fn normalize_expected(content: &str, redactions: &snapbox::Redactions) -> String {
460    use snapbox::filter::Filter as _;
461    let content = snapbox::filter::FilterPaths.filter(content.into_data());
462    let content = snapbox::filter::FilterNewlines.filter(content);
463    // Remove any conditionally absent redactions like `[EXE]`
464    let content = content.render().expect("came in as a String");
465    let content = redactions.clear_unused(&content);
466    content.into_owned()
467}
468
469/// A single line string that supports `[..]` wildcard matching.
470struct WildStr<'a> {
471    has_meta: bool,
472    line: &'a str,
473}
474
475impl<'a> WildStr<'a> {
476    fn new(line: &'a str) -> WildStr<'a> {
477        WildStr {
478            has_meta: line.contains("[..]"),
479            line,
480        }
481    }
482}
483
484impl PartialEq<&str> for WildStr<'_> {
485    fn eq(&self, other: &&str) -> bool {
486        if self.has_meta {
487            meta_cmp(self.line, other)
488        } else {
489            self.line == *other
490        }
491    }
492}
493
494fn meta_cmp(a: &str, mut b: &str) -> bool {
495    for (i, part) in a.split("[..]").enumerate() {
496        match b.find(part) {
497            Some(j) => {
498                if i == 0 && j != 0 {
499                    return false;
500                }
501                b = &b[j + part.len()..];
502            }
503            None => return false,
504        }
505    }
506    b.is_empty() || a.ends_with("[..]")
507}
508
509impl fmt::Display for WildStr<'_> {
510    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
511        f.write_str(&self.line)
512    }
513}
514
515impl fmt::Debug for WildStr<'_> {
516    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
517        write!(f, "{:?}", self.line)
518    }
519}
520
521pub struct InMemoryDir {
522    files: Vec<(PathBuf, Data)>,
523}
524
525impl InMemoryDir {
526    pub fn paths(&self) -> impl Iterator<Item = &Path> {
527        self.files.iter().map(|(p, _)| p.as_path())
528    }
529
530    #[track_caller]
531    pub fn assert_contains(&self, expected: &Self) {
532        use std::fmt::Write as _;
533        let assert = assert_e2e();
534        let mut errs = String::new();
535        for (path, expected_data) in &expected.files {
536            let actual_data = self
537                .files
538                .iter()
539                .find_map(|(p, d)| (path == p).then(|| d.clone()))
540                .unwrap_or_else(|| Data::new());
541            if let Err(err) =
542                assert.try_eq(Some(&path.display()), actual_data, expected_data.clone())
543            {
544                let _ = write!(&mut errs, "{err}");
545            }
546        }
547        if !errs.is_empty() {
548            panic!("{errs}")
549        }
550    }
551}
552
553impl<P, D> FromIterator<(P, D)> for InMemoryDir
554where
555    P: Into<std::path::PathBuf>,
556    D: IntoData,
557{
558    fn from_iter<I: IntoIterator<Item = (P, D)>>(files: I) -> Self {
559        let files = files
560            .into_iter()
561            .map(|(p, d)| (p.into(), d.into_data()))
562            .collect();
563        Self { files }
564    }
565}
566
567impl<const N: usize, P, D> From<[(P, D); N]> for InMemoryDir
568where
569    P: Into<PathBuf>,
570    D: IntoData,
571{
572    fn from(files: [(P, D); N]) -> Self {
573        let files = files
574            .into_iter()
575            .map(|(p, d)| (p.into(), d.into_data()))
576            .collect();
577        Self { files }
578    }
579}
580
581impl<P, D> From<std::collections::HashMap<P, D>> for InMemoryDir
582where
583    P: Into<PathBuf>,
584    D: IntoData,
585{
586    fn from(files: std::collections::HashMap<P, D>) -> Self {
587        let files = files
588            .into_iter()
589            .map(|(p, d)| (p.into(), d.into_data()))
590            .collect();
591        Self { files }
592    }
593}
594
595impl<P, D> From<std::collections::BTreeMap<P, D>> for InMemoryDir
596where
597    P: Into<PathBuf>,
598    D: IntoData,
599{
600    fn from(files: std::collections::BTreeMap<P, D>) -> Self {
601        let files = files
602            .into_iter()
603            .map(|(p, d)| (p.into(), d.into_data()))
604            .collect();
605        Self { files }
606    }
607}
608
609impl From<()> for InMemoryDir {
610    fn from(_files: ()) -> Self {
611        let files = Vec::new();
612        Self { files }
613    }
614}
615
616/// Create an `impl _ for InMemoryDir` for a generic tuple
617///
618/// Must pass in names for each tuple parameter for
619/// - internal variable name
620/// - `Path` type
621/// - `Data` type
622macro_rules! impl_from_tuple_for_inmemorydir {
623    ($($var:ident $path:ident $data:ident),+) => {
624        impl<$($path: Into<PathBuf>, $data: IntoData),+> From<($(($path, $data)),+ ,)> for InMemoryDir {
625            fn from(files: ($(($path, $data)),+,)) -> Self {
626                let ($($var),+ ,) = files;
627                let files = [$(($var.0.into(), $var.1.into_data())),+];
628                files.into()
629            }
630        }
631    };
632}
633
634/// Extend `impl_from_tuple_for_inmemorydir` to generate for the specified tuple and all smaller
635/// tuples
636macro_rules! impl_from_tuples_for_inmemorydir {
637    ($var1:ident $path1:ident $data1:ident, $($var:ident $path:ident $data:ident),+) => {
638        impl_from_tuples_for_inmemorydir!(__impl $var1 $path1 $data1; $($var $path $data),+);
639    };
640    (__impl $($var:ident $path:ident $data:ident),+; $var1:ident $path1:ident $data1:ident $(,$var2:ident $path2:ident $data2:ident)*) => {
641        impl_from_tuple_for_inmemorydir!($($var $path $data),+);
642        impl_from_tuples_for_inmemorydir!(__impl $($var $path $data),+, $var1 $path1 $data1; $($var2 $path2 $data2),*);
643    };
644    (__impl $($var:ident $path:ident $data:ident),+;) => {
645        impl_from_tuple_for_inmemorydir!($($var $path $data),+);
646    }
647}
648
649// Generate for tuples of size `1..=7`
650impl_from_tuples_for_inmemorydir!(
651    s1 P1 D1,
652    s2 P2 D2,
653    s3 P3 D3,
654    s4 P4 D4,
655    s5 P5 D5,
656    s6 P6 D6,
657    s7 P7 D7
658);
659
660#[cfg(test)]
661mod test {
662    use snapbox::assert_data_eq;
663    use snapbox::prelude::*;
664    use snapbox::str;
665
666    use super::*;
667
668    #[test]
669    fn wild_str_cmp() {
670        for (a, b) in &[
671            ("a b", "a b"),
672            ("a[..]b", "a b"),
673            ("a[..]", "a b"),
674            ("[..]", "a b"),
675            ("[..]b", "a b"),
676        ] {
677            assert_eq!(WildStr::new(a), b);
678        }
679        for (a, b) in &[("[..]b", "c"), ("b", "c"), ("b", "cb")] {
680            assert_ne!(WildStr::new(a), b);
681        }
682    }
683
684    #[test]
685    fn redact_elapsed_time() {
686        let mut subs = snapbox::Redactions::new();
687        add_regex_redactions(&mut subs);
688
689        assert_data_eq!(
690            subs.redact("[FINISHED] `release` profile [optimized] target(s) in 5.5s"),
691            str!["[FINISHED] `release` profile [optimized] target(s) in [ELAPSED]s"].raw()
692        );
693        assert_data_eq!(
694            subs.redact("[FINISHED] `release` profile [optimized] target(s) in 1m 05s"),
695            str!["[FINISHED] `release` profile [optimized] target(s) in [ELAPSED]s"].raw()
696        );
697    }
698}