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