Skip to main content

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    // Avoid 2 letter crate names being merged into hashes in file paths.
231    // e.g. target/debug/build/d1/[HASH]/out
232    subs.insert(
233        "[HASH]",
234        regex!(r"build/[a-z0-9]{2}/(?<redacted>[a-f0-9]{16})"),
235    )
236    .unwrap();
237    subs.insert("[HASH]", regex!(r"/[a-z0-9\-_]+-(?<redacted>[0-9a-f]{16})"))
238        .unwrap();
239    // Match multi-part hashes like `06/b451d0d6f88b1d` used in directory paths
240    subs.insert("[HASH]", regex!(r"/(?<redacted>[a-f0-9]{2}\/[0-9a-f]{14})"))
241        .unwrap();
242    // Match file name hashes like `foo-06b451d0d6f88b1d`
243    subs.insert("[HASH]", regex!(r"[a-z0-9]+-(?<redacted>[a-f0-9]{16})"))
244        .unwrap();
245    // Match path hashes like `../06b451d0d6f88b1d/..` used in directory paths
246    subs.insert("[HASH]", regex!(r"\/(?<redacted>[0-9a-f]{16})\/"))
247        .unwrap();
248    subs.insert(
249        "[AVG_ELAPSED]",
250        regex!(r"(?<redacted>[0-9]+(\.[0-9]+)?) ns/iter"),
251    )
252    .unwrap();
253    subs.insert(
254        "[JITTER]",
255        regex!(r"ns/iter \(\+/- (?<redacted>[0-9]+(\.[0-9]+)?)\)"),
256    )
257    .unwrap();
258
259    // Following 3 subs redact:
260    //   "1719325877.527949100s, 61549498ns after last build at 1719325877.466399602s"
261    //   "1719503592.218193216s, 1h 1s after last build at 1719499991.982681034s"
262    // into "[DIRTY_REASON_NEW_TIME], [DIRTY_REASON_DIFF] after last build at [DIRTY_REASON_OLD_TIME]"
263    subs.insert(
264        "[TIME_DIFF_AFTER_LAST_BUILD]",
265        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)"),
266       )
267       .unwrap();
268}
269
270static MIN_LITERAL_REDACTIONS: &[(&str, &str)] = &[
271    ("[EXE]", std::env::consts::EXE_SUFFIX),
272    ("[BROKEN_PIPE]", "Broken pipe (os error 32)"),
273    ("[BROKEN_PIPE]", "The pipe is being closed. (os error 232)"),
274    // Unix message for an entity was not found
275    ("[NOT_FOUND]", "No such file or directory (os error 2)"),
276    // Windows message for an entity was not found
277    (
278        "[NOT_FOUND]",
279        "The system cannot find the file specified. (os error 2)",
280    ),
281    (
282        "[NOT_FOUND]",
283        "The system cannot find the path specified. (os error 3)",
284    ),
285    ("[NOT_FOUND]", "Access is denied. (os error 5)"),
286    ("[NOT_FOUND]", "program not found"),
287    // Unix message for exit status
288    ("[EXIT_STATUS]", "exit status"),
289    // Windows message for exit status
290    ("[EXIT_STATUS]", "exit code"),
291];
292static E2E_LITERAL_REDACTIONS: &[(&str, &str)] = &[
293    ("[RUNNING]", "     Running"),
294    ("[COMPILING]", "   Compiling"),
295    ("[CHECKING]", "    Checking"),
296    ("[COMPLETED]", "   Completed"),
297    ("[CREATED]", "     Created"),
298    ("[CREATING]", "    Creating"),
299    ("[CREDENTIAL]", "  Credential"),
300    ("[DOWNGRADING]", " Downgrading"),
301    ("[FINISHED]", "    Finished"),
302    ("[ERROR]", "error:"),
303    ("[WARNING]", "warning:"),
304    ("[NOTE]", "note:"),
305    ("[HELP]", "help:"),
306    ("[DOCUMENTING]", " Documenting"),
307    ("[SCRAPING]", "    Scraping"),
308    ("[FRESH]", "       Fresh"),
309    ("[DIRTY]", "       Dirty"),
310    ("[LOCKING]", "     Locking"),
311    ("[UPDATING]", "    Updating"),
312    ("[UPGRADING]", "   Upgrading"),
313    ("[ADDING]", "      Adding"),
314    ("[REMOVING]", "    Removing"),
315    ("[REMOVED]", "     Removed"),
316    ("[UNCHANGED]", "   Unchanged"),
317    ("[DOCTEST]", "   Doc-tests"),
318    ("[PACKAGING]", "   Packaging"),
319    ("[PACKAGED]", "    Packaged"),
320    ("[DOWNLOADING]", " Downloading"),
321    ("[DOWNLOADED]", "  Downloaded"),
322    ("[UPLOADING]", "   Uploading"),
323    ("[UPLOADED]", "    Uploaded"),
324    ("[VERIFYING]", "   Verifying"),
325    ("[ARCHIVING]", "   Archiving"),
326    ("[INSTALLING]", "  Installing"),
327    ("[REPLACING]", "   Replacing"),
328    ("[UNPACKING]", "   Unpacking"),
329    ("[SUMMARY]", "     Summary"),
330    ("[FIXED]", "       Fixed"),
331    ("[FIXING]", "      Fixing"),
332    ("[IGNORED]", "     Ignored"),
333    ("[INSTALLED]", "   Installed"),
334    ("[REPLACED]", "    Replaced"),
335    ("[BUILDING]", "    Building"),
336    ("[LOGIN]", "       Login"),
337    ("[LOGOUT]", "      Logout"),
338    ("[YANK]", "        Yank"),
339    ("[OWNER]", "       Owner"),
340    ("[MIGRATING]", "   Migrating"),
341    ("[EXECUTABLE]", "  Executable"),
342    ("[SKIPPING]", "    Skipping"),
343    ("[WAITING]", "     Waiting"),
344    ("[PUBLISHED]", "   Published"),
345    ("[BLOCKING]", "    Blocking"),
346    ("[GENERATED]", "   Generated"),
347    ("[OPENING]", "     Opening"),
348    ("[MERGING]", "     Merging"),
349];
350
351/// Checks that the given string contains the given contiguous lines
352/// somewhere.
353///
354/// See [Patterns](index.html#patterns) for more information on pattern matching.
355pub(crate) fn match_contains(
356    expected: &str,
357    actual: &str,
358    redactions: &snapbox::Redactions,
359) -> Result<()> {
360    let expected = normalize_expected(expected, redactions);
361    let actual = normalize_actual(actual, redactions);
362    let e: Vec<_> = expected.lines().map(|line| WildStr::new(line)).collect();
363    let a: Vec<_> = actual.lines().collect();
364    if e.len() == 0 {
365        bail!("expected length must not be zero");
366    }
367    for window in a.windows(e.len()) {
368        if e == window {
369            return Ok(());
370        }
371    }
372    bail!(
373        "expected to find:\n\
374         {}\n\n\
375         did not find in output:\n\
376         {}",
377        expected,
378        actual
379    );
380}
381
382/// Checks that the given string does not contain the given contiguous lines
383/// anywhere.
384///
385/// See [Patterns](index.html#patterns) for more information on pattern matching.
386pub(crate) fn match_does_not_contain(
387    expected: &str,
388    actual: &str,
389    redactions: &snapbox::Redactions,
390) -> Result<()> {
391    if match_contains(expected, actual, redactions).is_ok() {
392        bail!(
393            "expected not to find:\n\
394             {}\n\n\
395             but found in output:\n\
396             {}",
397            expected,
398            actual
399        );
400    } else {
401        Ok(())
402    }
403}
404
405/// Checks that the given string has a line that contains the given patterns,
406/// and that line also does not contain the `without` patterns.
407///
408/// See [Patterns](index.html#patterns) for more information on pattern matching.
409///
410/// See [`crate::Execs::with_stderr_line_without`] for an example and cautions
411/// against using.
412pub(crate) fn match_with_without(
413    actual: &str,
414    with: &[String],
415    without: &[String],
416    redactions: &snapbox::Redactions,
417) -> Result<()> {
418    let actual = normalize_actual(actual, redactions);
419    let norm = |s: &String| format!("[..]{}[..]", normalize_expected(s, redactions));
420    let with: Vec<_> = with.iter().map(norm).collect();
421    let without: Vec<_> = without.iter().map(norm).collect();
422    let with_wild: Vec<_> = with.iter().map(|w| WildStr::new(w)).collect();
423    let without_wild: Vec<_> = without.iter().map(|w| WildStr::new(w)).collect();
424
425    let matches: Vec<_> = actual
426        .lines()
427        .filter(|line| with_wild.iter().all(|with| with == line))
428        .filter(|line| !without_wild.iter().any(|without| without == line))
429        .collect();
430    match matches.len() {
431        0 => bail!(
432            "Could not find expected line in output.\n\
433             With contents: {:?}\n\
434             Without contents: {:?}\n\
435             Actual stderr:\n\
436             {}\n",
437            with,
438            without,
439            actual
440        ),
441        1 => Ok(()),
442        _ => bail!(
443            "Found multiple matching lines, but only expected one.\n\
444             With contents: {:?}\n\
445             Without contents: {:?}\n\
446             Matching lines:\n\
447             {}\n",
448            with,
449            without,
450            itertools::join(matches, "\n")
451        ),
452    }
453}
454
455/// Normalizes the output so that it can be compared against the expected value.
456fn normalize_actual(content: &str, redactions: &snapbox::Redactions) -> String {
457    use snapbox::filter::Filter as _;
458    let content = snapbox::filter::FilterPaths.filter(content.into_data());
459    let content = snapbox::filter::FilterNewlines.filter(content);
460    let content = content.render().expect("came in as a String");
461    let content = redactions.redact(&content);
462    content
463}
464
465/// Normalizes the expected string so that it can be compared against the actual output.
466fn normalize_expected(content: &str, redactions: &snapbox::Redactions) -> String {
467    use snapbox::filter::Filter as _;
468    let content = snapbox::filter::FilterPaths.filter(content.into_data());
469    let content = snapbox::filter::FilterNewlines.filter(content);
470    // Remove any conditionally absent redactions like `[EXE]`
471    let content = content.render().expect("came in as a String");
472    let content = redactions.clear_unused(&content);
473    content.into_owned()
474}
475
476/// A single line string that supports `[..]` wildcard matching.
477struct WildStr<'a> {
478    has_meta: bool,
479    line: &'a str,
480}
481
482impl<'a> WildStr<'a> {
483    fn new(line: &'a str) -> WildStr<'a> {
484        WildStr {
485            has_meta: line.contains("[..]"),
486            line,
487        }
488    }
489}
490
491impl PartialEq<&str> for WildStr<'_> {
492    fn eq(&self, other: &&str) -> bool {
493        if self.has_meta {
494            meta_cmp(self.line, other)
495        } else {
496            self.line == *other
497        }
498    }
499}
500
501fn meta_cmp(a: &str, mut b: &str) -> bool {
502    for (i, part) in a.split("[..]").enumerate() {
503        match b.find(part) {
504            Some(j) => {
505                if i == 0 && j != 0 {
506                    return false;
507                }
508                b = &b[j + part.len()..];
509            }
510            None => return false,
511        }
512    }
513    b.is_empty() || a.ends_with("[..]")
514}
515
516impl fmt::Display for WildStr<'_> {
517    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
518        f.write_str(&self.line)
519    }
520}
521
522impl fmt::Debug for WildStr<'_> {
523    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
524        write!(f, "{:?}", self.line)
525    }
526}
527
528pub struct InMemoryDir {
529    files: Vec<(PathBuf, Data)>,
530}
531
532impl InMemoryDir {
533    pub fn paths(&self) -> impl Iterator<Item = &Path> {
534        self.files.iter().map(|(p, _)| p.as_path())
535    }
536
537    #[track_caller]
538    pub fn assert_contains(&self, expected: &Self) {
539        use std::fmt::Write as _;
540        let assert = assert_e2e();
541        let mut errs = String::new();
542        for (path, expected_data) in &expected.files {
543            let actual_data = self
544                .files
545                .iter()
546                .find_map(|(p, d)| (path == p).then(|| d.clone()))
547                .unwrap_or_else(|| Data::new());
548            if let Err(err) =
549                assert.try_eq(Some(&path.display()), actual_data, expected_data.clone())
550            {
551                let _ = write!(&mut errs, "{err}");
552            }
553        }
554        if !errs.is_empty() {
555            panic!("{errs}")
556        }
557    }
558}
559
560impl<P, D> FromIterator<(P, D)> for InMemoryDir
561where
562    P: Into<std::path::PathBuf>,
563    D: IntoData,
564{
565    fn from_iter<I: IntoIterator<Item = (P, D)>>(files: I) -> Self {
566        let files = files
567            .into_iter()
568            .map(|(p, d)| (p.into(), d.into_data()))
569            .collect();
570        Self { files }
571    }
572}
573
574impl<const N: usize, P, D> From<[(P, D); N]> for InMemoryDir
575where
576    P: Into<PathBuf>,
577    D: IntoData,
578{
579    fn from(files: [(P, D); N]) -> Self {
580        let files = files
581            .into_iter()
582            .map(|(p, d)| (p.into(), d.into_data()))
583            .collect();
584        Self { files }
585    }
586}
587
588impl<P, D> From<std::collections::HashMap<P, D>> for InMemoryDir
589where
590    P: Into<PathBuf>,
591    D: IntoData,
592{
593    fn from(files: std::collections::HashMap<P, D>) -> Self {
594        let files = files
595            .into_iter()
596            .map(|(p, d)| (p.into(), d.into_data()))
597            .collect();
598        Self { files }
599    }
600}
601
602impl<P, D> From<std::collections::BTreeMap<P, D>> for InMemoryDir
603where
604    P: Into<PathBuf>,
605    D: IntoData,
606{
607    fn from(files: std::collections::BTreeMap<P, D>) -> Self {
608        let files = files
609            .into_iter()
610            .map(|(p, d)| (p.into(), d.into_data()))
611            .collect();
612        Self { files }
613    }
614}
615
616impl From<()> for InMemoryDir {
617    fn from(_files: ()) -> Self {
618        let files = Vec::new();
619        Self { files }
620    }
621}
622
623/// Create an `impl _ for InMemoryDir` for a generic tuple
624///
625/// Must pass in names for each tuple parameter for
626/// - internal variable name
627/// - `Path` type
628/// - `Data` type
629macro_rules! impl_from_tuple_for_inmemorydir {
630    ($($var:ident $path:ident $data:ident),+) => {
631        impl<$($path: Into<PathBuf>, $data: IntoData),+> From<($(($path, $data)),+ ,)> for InMemoryDir {
632            fn from(files: ($(($path, $data)),+,)) -> Self {
633                let ($($var),+ ,) = files;
634                let files = [$(($var.0.into(), $var.1.into_data())),+];
635                files.into()
636            }
637        }
638    };
639}
640
641/// Extend `impl_from_tuple_for_inmemorydir` to generate for the specified tuple and all smaller
642/// tuples
643macro_rules! impl_from_tuples_for_inmemorydir {
644    ($var1:ident $path1:ident $data1:ident, $($var:ident $path:ident $data:ident),+) => {
645        impl_from_tuples_for_inmemorydir!(__impl $var1 $path1 $data1; $($var $path $data),+);
646    };
647    (__impl $($var:ident $path:ident $data:ident),+; $var1:ident $path1:ident $data1:ident $(,$var2:ident $path2:ident $data2:ident)*) => {
648        impl_from_tuple_for_inmemorydir!($($var $path $data),+);
649        impl_from_tuples_for_inmemorydir!(__impl $($var $path $data),+, $var1 $path1 $data1; $($var2 $path2 $data2),*);
650    };
651    (__impl $($var:ident $path:ident $data:ident),+;) => {
652        impl_from_tuple_for_inmemorydir!($($var $path $data),+);
653    }
654}
655
656// Generate for tuples of size `1..=7`
657impl_from_tuples_for_inmemorydir!(
658    s1 P1 D1,
659    s2 P2 D2,
660    s3 P3 D3,
661    s4 P4 D4,
662    s5 P5 D5,
663    s6 P6 D6,
664    s7 P7 D7
665);
666
667#[cfg(test)]
668mod test {
669    use snapbox::assert_data_eq;
670    use snapbox::prelude::*;
671    use snapbox::str;
672
673    use super::*;
674
675    #[test]
676    fn wild_str_cmp() {
677        for (a, b) in &[
678            ("a b", "a b"),
679            ("a[..]b", "a b"),
680            ("a[..]", "a b"),
681            ("[..]", "a b"),
682            ("[..]b", "a b"),
683        ] {
684            assert_eq!(WildStr::new(a), b);
685        }
686        for (a, b) in &[("[..]b", "c"), ("b", "c"), ("b", "cb")] {
687            assert_ne!(WildStr::new(a), b);
688        }
689    }
690
691    #[test]
692    fn redact_elapsed_time() {
693        let mut subs = snapbox::Redactions::new();
694        add_regex_redactions(&mut subs);
695
696        assert_data_eq!(
697            subs.redact("[FINISHED] `release` profile [optimized] target(s) in 5.5s"),
698            str!["[FINISHED] `release` profile [optimized] target(s) in [ELAPSED]s"].raw()
699        );
700        assert_data_eq!(
701            subs.redact("[FINISHED] `release` profile [optimized] target(s) in 1m 05s"),
702            str!["[FINISHED] `release` profile [optimized] target(s) in [ELAPSED]s"].raw()
703        );
704    }
705}