Skip to main content

nextest_runner/
redact.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Redact data that varies by system and OS to produce a stable output.
5//!
6//! Used for snapshot testing.
7
8use crate::{
9    helpers::{
10        DurationRounding, FormattedDuration, FormattedHhMmSs, FormattedRelativeDuration,
11        convert_rel_path_to_forward_slash, decimal_char_width,
12    },
13    list::RustBuildMeta,
14};
15use camino::{Utf8Path, Utf8PathBuf};
16use chrono::{DateTime, TimeZone};
17use regex::Regex;
18use std::{
19    collections::BTreeMap,
20    fmt,
21    sync::{Arc, LazyLock},
22    time::Duration,
23};
24
25static CRATE_NAME_HASH_REGEX: LazyLock<Regex> =
26    LazyLock::new(|| Regex::new(r"^([a-zA-Z0-9_-]+)-[a-f0-9]{16}$").unwrap());
27static UNIT_HASH_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-f0-9]{16}$").unwrap());
28static TARGET_DIR_REDACTION: &str = "<target-dir>";
29static BUILD_DIR_REDACTION: &str = "<build-dir>";
30static FILE_COUNT_REDACTION: &str = "<file-count>";
31static DURATION_REDACTION: &str = "<duration>";
32
33// Fixed-width placeholders for store list alignment.
34// These match the original field widths to preserve column alignment.
35
36/// 19 chars, matches `%Y-%m-%d %H:%M:%S` format.
37static TIMESTAMP_REDACTION: &str = "XXXX-XX-XX XX:XX:XX";
38/// 6 chars for numeric portion (e.g. "   123" for KB display).
39static SIZE_REDACTION: &str = "<size>";
40/// Placeholder for redacted version strings.
41static VERSION_REDACTION: &str = "<version>";
42/// Placeholder for redacted relative durations (e.g. "30s ago").
43static RELATIVE_DURATION_REDACTION: &str = "<ago>";
44/// 8 chars, matches `HH:MM:SS` format.
45static HHMMSS_REDACTION: &str = "HH:MM:SS";
46
47/// A helper for redacting data that varies by environment.
48///
49/// This isn't meant to be perfect, and not everything can be redacted yet -- the set of supported
50/// redactions will grow over time.
51#[derive(Clone, Debug)]
52pub struct Redactor {
53    kind: Arc<RedactorKind>,
54}
55
56impl Default for Redactor {
57    fn default() -> Self {
58        Self::noop()
59    }
60}
61
62impl Redactor {
63    /// Creates a new no-op redactor.
64    pub fn noop() -> Self {
65        Self::new_with_kind(RedactorKind::Noop)
66    }
67
68    fn new_with_kind(kind: RedactorKind) -> Self {
69        Self {
70            kind: Arc::new(kind),
71        }
72    }
73
74    /// Creates a new redactor builder that operates on the given build metadata.
75    ///
76    /// This should only be called if redaction is actually needed.
77    pub fn build_active<State>(build_meta: &RustBuildMeta<State>) -> RedactorBuilder {
78        let mut redactions = Vec::new();
79
80        let linked_path_redactions =
81            build_linked_path_redactions(build_meta.linked_paths.keys().map(|p| p.as_ref()));
82
83        // For all linked paths, push both absolute and relative redactions.
84        // Linked paths are relative to the build directory.
85        let linked_path_dir_redaction = if build_meta.build_directory == build_meta.target_directory
86        {
87            TARGET_DIR_REDACTION
88        } else {
89            BUILD_DIR_REDACTION
90        };
91        for (source, replacement) in linked_path_redactions {
92            redactions.push(Redaction::Path {
93                path: build_meta.build_directory.join(&source),
94                replacement: format!("{linked_path_dir_redaction}/{replacement}"),
95            });
96            redactions.push(Redaction::Path {
97                path: source,
98                replacement,
99            });
100        }
101
102        // Also add redactions for the target and build directories. These go
103        // after the linked paths, so that absolute linked paths are redacted
104        // first.
105        if build_meta.build_directory != build_meta.target_directory {
106            redactions.push(Redaction::Path {
107                path: build_meta.build_directory.clone(),
108                replacement: BUILD_DIR_REDACTION.to_string(),
109            });
110        }
111        redactions.push(Redaction::Path {
112            path: build_meta.target_directory.clone(),
113            replacement: TARGET_DIR_REDACTION.to_string(),
114        });
115
116        RedactorBuilder { redactions }
117    }
118
119    /// Redacts a path.
120    pub fn redact_path<'a>(&self, orig: &'a Utf8Path) -> RedactorOutput<&'a Utf8Path> {
121        for redaction in self.kind.iter_redactions() {
122            match redaction {
123                Redaction::Path { path, replacement } => {
124                    if let Ok(suffix) = orig.strip_prefix(path) {
125                        if suffix.as_str().is_empty() {
126                            return RedactorOutput::Redacted(replacement.clone());
127                        } else {
128                            // Always use "/" as the separator, even on Windows, to ensure stable
129                            // output across OSes.
130                            let path = Utf8PathBuf::from(format!("{replacement}/{suffix}"));
131                            return RedactorOutput::Redacted(
132                                convert_rel_path_to_forward_slash(&path).into(),
133                            );
134                        }
135                    }
136                }
137            }
138        }
139
140        RedactorOutput::Unredacted(orig)
141    }
142
143    /// Redacts a file count.
144    pub fn redact_file_count(&self, orig: usize) -> RedactorOutput<usize> {
145        if self.kind.is_active() {
146            RedactorOutput::Redacted(FILE_COUNT_REDACTION.to_string())
147        } else {
148            RedactorOutput::Unredacted(orig)
149        }
150    }
151
152    /// Redacts a duration.
153    pub(crate) fn redact_duration(&self, orig: Duration) -> RedactorOutput<FormattedDuration> {
154        if self.kind.is_active() {
155            RedactorOutput::Redacted(DURATION_REDACTION.to_string())
156        } else {
157            RedactorOutput::Unredacted(FormattedDuration(orig))
158        }
159    }
160
161    /// Redacts an `HH:MM:SS` duration (used for stress test elapsed/remaining
162    /// time).
163    ///
164    /// The placeholder `HH:MM:SS` is 8 characters, matching the width of the
165    /// zero-padded `%02H:%02M:%02S` format.
166    pub(crate) fn redact_hhmmss_duration(
167        &self,
168        duration: Duration,
169        rounding: DurationRounding,
170    ) -> RedactorOutput<FormattedHhMmSs> {
171        if self.kind.is_active() {
172            RedactorOutput::Redacted(HHMMSS_REDACTION.to_string())
173        } else {
174            RedactorOutput::Unredacted(FormattedHhMmSs { duration, rounding })
175        }
176    }
177
178    /// Returns true if this redactor is active (will redact values).
179    pub fn is_active(&self) -> bool {
180        self.kind.is_active()
181    }
182
183    /// Creates a new redactor for snapshot testing, without any path redactions.
184    ///
185    /// This is useful when you need redaction of timestamps, durations, and
186    /// sizes, but don't have a `RustBuildMeta` to build path redactions from.
187    pub fn for_snapshot_testing() -> Self {
188        Self::new_with_kind(RedactorKind::Active {
189            redactions: Vec::new(),
190        })
191    }
192
193    /// Redacts a timestamp for display, producing a fixed-width placeholder.
194    ///
195    /// The placeholder `XXXX-XX-XX XX:XX:XX` is 19 characters, matching the
196    /// width of the `%Y-%m-%d %H:%M:%S` format.
197    pub fn redact_timestamp<Tz>(&self, orig: &DateTime<Tz>) -> RedactorOutput<DisplayTimestamp<Tz>>
198    where
199        Tz: TimeZone + Clone,
200        Tz::Offset: fmt::Display,
201    {
202        if self.kind.is_active() {
203            RedactorOutput::Redacted(TIMESTAMP_REDACTION.to_string())
204        } else {
205            RedactorOutput::Unredacted(DisplayTimestamp(orig.clone()))
206        }
207    }
208
209    /// Redacts a size (in bytes) for display as a human-readable string.
210    ///
211    /// When redacting, produces `<size>` as a placeholder.
212    pub fn redact_size(&self, orig: u64) -> RedactorOutput<SizeDisplay> {
213        if self.kind.is_active() {
214            RedactorOutput::Redacted(SIZE_REDACTION.to_string())
215        } else {
216            RedactorOutput::Unredacted(SizeDisplay(orig))
217        }
218    }
219
220    /// Redacts a version for display.
221    ///
222    /// When redacting, produces `<version>` as a placeholder.
223    pub fn redact_version(&self, orig: &semver::Version) -> String {
224        if self.kind.is_active() {
225            VERSION_REDACTION.to_string()
226        } else {
227            orig.to_string()
228        }
229    }
230
231    /// Redacts a store duration for display, producing a fixed-width placeholder.
232    ///
233    /// The placeholder `<duration>` is 10 characters, matching the width of the
234    /// `{:>9.3}s` format used for durations.
235    pub fn redact_store_duration(&self, orig: Option<f64>) -> RedactorOutput<StoreDurationDisplay> {
236        if self.kind.is_active() {
237            RedactorOutput::Redacted(format!("{:>10}", DURATION_REDACTION))
238        } else {
239            RedactorOutput::Unredacted(StoreDurationDisplay(orig))
240        }
241    }
242
243    /// Redacts a timestamp with timezone for detailed display.
244    ///
245    /// Produces `XXXX-XX-XX XX:XX:XX` when active, otherwise formats as
246    /// `%Y-%m-%d %H:%M:%S %:z`.
247    pub fn redact_detailed_timestamp<Tz>(&self, orig: &DateTime<Tz>) -> String
248    where
249        Tz: TimeZone,
250        Tz::Offset: fmt::Display,
251    {
252        if self.kind.is_active() {
253            TIMESTAMP_REDACTION.to_string()
254        } else {
255            orig.format("%Y-%m-%d %H:%M:%S %:z").to_string()
256        }
257    }
258
259    /// Redacts a duration in seconds for detailed display.
260    ///
261    /// Produces `<duration>` when active, otherwise formats as `{:.3}s`.
262    pub fn redact_detailed_duration(&self, orig: Option<f64>) -> String {
263        if self.kind.is_active() {
264            DURATION_REDACTION.to_string()
265        } else {
266            match orig {
267                Some(secs) => format!("{:.3}s", secs),
268                None => "-".to_string(),
269            }
270        }
271    }
272
273    /// Redacts a relative duration for display (e.g. "30s ago").
274    ///
275    /// Produces `<ago>` when active, otherwise formats the duration.
276    pub(crate) fn redact_relative_duration(
277        &self,
278        orig: Duration,
279    ) -> RedactorOutput<FormattedRelativeDuration> {
280        if self.kind.is_active() {
281            RedactorOutput::Redacted(RELATIVE_DURATION_REDACTION.to_string())
282        } else {
283            RedactorOutput::Unredacted(FormattedRelativeDuration(orig))
284        }
285    }
286
287    /// Redacts CLI args for display.
288    ///
289    /// - The first arg (the exe) is replaced with `[EXE]`
290    /// - Absolute paths in other args are replaced with `[PATH]`
291    pub fn redact_cli_args(&self, args: &[String]) -> String {
292        if !self.kind.is_active() {
293            return shell_words::join(args);
294        }
295
296        let redacted: Vec<_> = args
297            .iter()
298            .enumerate()
299            .map(|(i, arg)| {
300                if i == 0 {
301                    // First arg is always the exe.
302                    "[EXE]".to_string()
303                } else if is_absolute_path(arg) {
304                    "[PATH]".to_string()
305                } else {
306                    arg.clone()
307                }
308            })
309            .collect();
310        shell_words::join(&redacted)
311    }
312
313    /// Redacts env vars for display.
314    ///
315    /// Formats as `K=V` pairs.
316    pub fn redact_env_vars(&self, env_vars: &BTreeMap<String, String>) -> String {
317        let pairs: Vec<_> = env_vars
318            .iter()
319            .map(|(k, v)| {
320                format!(
321                    "{}={}",
322                    shell_words::quote(k),
323                    shell_words::quote(self.redact_env_value(v)),
324                )
325            })
326            .collect();
327        pairs.join(" ")
328    }
329
330    /// Redacts an env var value for display.
331    ///
332    /// Absolute paths are replaced with `[PATH]`.
333    pub fn redact_env_value<'a>(&self, value: &'a str) -> &'a str {
334        if self.kind.is_active() && is_absolute_path(value) {
335            "[PATH]"
336        } else {
337            value
338        }
339    }
340}
341
342/// Returns true if the string looks like an absolute path.
343fn is_absolute_path(s: &str) -> bool {
344    s.starts_with('/') || (s.len() >= 3 && s.chars().nth(1) == Some(':'))
345}
346
347/// Wrapper for timestamps that formats with `%Y-%m-%d %H:%M:%S`.
348#[derive(Clone, Debug)]
349pub struct DisplayTimestamp<Tz: TimeZone>(pub DateTime<Tz>);
350
351impl<Tz: TimeZone> fmt::Display for DisplayTimestamp<Tz>
352where
353    Tz::Offset: fmt::Display,
354{
355    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356        write!(f, "{}", self.0.format("%Y-%m-%d %H:%M:%S"))
357    }
358}
359
360/// Wrapper for store durations that formats as `{:>9.3}s` or `{:>10}` for "-".
361#[derive(Clone, Debug)]
362pub struct StoreDurationDisplay(pub Option<f64>);
363
364impl fmt::Display for StoreDurationDisplay {
365    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366        match self.0 {
367            Some(secs) => write!(f, "{secs:>9.3}s"),
368            None => write!(f, "{:>10}", "-"),
369        }
370    }
371}
372
373/// Wrapper for sizes that formats bytes as a human-readable string (B, KB, MB,
374/// or GB).
375#[derive(Clone, Copy, Debug)]
376pub struct SizeDisplay(pub u64);
377
378impl SizeDisplay {
379    /// Returns the display width of this size when formatted.
380    ///
381    /// This is useful for alignment calculations.
382    pub fn display_width(self) -> usize {
383        let bytes = self.0;
384        if bytes >= 1024 * 1024 * 1024 {
385            // Format: "{:.1} GB" - integer part + "." + 1 decimal + " GB".
386            let gb_val = bytes as f64 / (1024.0 * 1024.0 * 1024.0);
387            decimal_char_width(rounded_1dp_integer_part(gb_val)) + 2 + 3
388        } else if bytes >= 1024 * 1024 {
389            // Format: "{:.1} MB" - integer part + "." + 1 decimal + " MB".
390            let mb_val = bytes as f64 / (1024.0 * 1024.0);
391            decimal_char_width(rounded_1dp_integer_part(mb_val)) + 2 + 3
392        } else if bytes >= 1024 {
393            // Format: "{} KB" - integer + " KB".
394            let kb = bytes / 1024;
395            decimal_char_width(kb) + 3
396        } else {
397            // Format: "{} B" - integer + " B".
398            decimal_char_width(bytes) + 2
399        }
400    }
401}
402
403/// Returns the integer part of a value after rounding to 1 decimal place.
404///
405/// This matches the integer part produced by `{:.1}` formatting: for example,
406/// `rounded_1dp_integer_part(9.95)` returns 10, matching how `{:.1}` formats
407/// it as "10.0".
408fn rounded_1dp_integer_part(val: f64) -> u64 {
409    (val * 10.0).round() as u64 / 10
410}
411
412impl fmt::Display for SizeDisplay {
413    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
414        let bytes = self.0;
415        if bytes >= 1024 * 1024 * 1024 {
416            // Remove 3 from the width since we're adding " GB" at the end.
417            let width = f.width().map(|w| w.saturating_sub(3));
418            match width {
419                Some(w) => {
420                    write!(f, "{:>w$.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
421                }
422                None => write!(f, "{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)),
423            }
424        } else if bytes >= 1024 * 1024 {
425            // Remove 3 from the width since we're adding " MB" at the end.
426            let width = f.width().map(|w| w.saturating_sub(3));
427            match width {
428                Some(w) => write!(f, "{:>w$.1} MB", bytes as f64 / (1024.0 * 1024.0)),
429                None => write!(f, "{:.1} MB", bytes as f64 / (1024.0 * 1024.0)),
430            }
431        } else if bytes >= 1024 {
432            // Remove 3 from the width since we're adding " KB" at the end.
433            let width = f.width().map(|w| w.saturating_sub(3));
434            match width {
435                Some(w) => write!(f, "{:>w$} KB", bytes / 1024),
436                None => write!(f, "{} KB", bytes / 1024),
437            }
438        } else {
439            // Remove 2 from the width since we're adding " B" at the end.
440            let width = f.width().map(|w| w.saturating_sub(2));
441            match width {
442                Some(w) => write!(f, "{bytes:>w$} B"),
443                None => write!(f, "{bytes} B"),
444            }
445        }
446    }
447}
448
449/// A builder for [`Redactor`] instances.
450///
451/// Created with [`Redactor::build_active`].
452#[derive(Debug)]
453pub struct RedactorBuilder {
454    redactions: Vec<Redaction>,
455}
456
457impl RedactorBuilder {
458    /// Adds a new path redaction.
459    pub fn with_path(mut self, path: Utf8PathBuf, replacement: String) -> Self {
460        self.redactions.push(Redaction::Path { path, replacement });
461        self
462    }
463
464    /// Builds the redactor.
465    pub fn build(self) -> Redactor {
466        Redactor::new_with_kind(RedactorKind::Active {
467            redactions: self.redactions,
468        })
469    }
470}
471
472/// The output of a [`Redactor`] operation.
473#[derive(Debug)]
474pub enum RedactorOutput<T> {
475    /// The value was not redacted.
476    Unredacted(T),
477
478    /// The value was redacted.
479    Redacted(String),
480}
481
482impl<T: fmt::Display> fmt::Display for RedactorOutput<T> {
483    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
484        match self {
485            RedactorOutput::Unredacted(value) => value.fmt(f),
486            RedactorOutput::Redacted(replacement) => replacement.fmt(f),
487        }
488    }
489}
490
491#[derive(Debug)]
492enum RedactorKind {
493    Noop,
494    Active {
495        /// The list of redactions to apply.
496        redactions: Vec<Redaction>,
497    },
498}
499
500impl RedactorKind {
501    fn is_active(&self) -> bool {
502        matches!(self, Self::Active { .. })
503    }
504
505    fn iter_redactions(&self) -> impl Iterator<Item = &Redaction> {
506        match self {
507            Self::Active { redactions } => redactions.iter(),
508            Self::Noop => [].iter(),
509        }
510    }
511}
512
513/// An individual redaction to apply.
514#[derive(Debug)]
515enum Redaction {
516    /// Redact a path.
517    Path {
518        /// The path to redact.
519        path: Utf8PathBuf,
520
521        /// The replacement string.
522        replacement: String,
523    },
524}
525
526fn build_linked_path_redactions<'a>(
527    linked_paths: impl Iterator<Item = &'a Utf8Path>,
528) -> BTreeMap<Utf8PathBuf, String> {
529    // The map prevents dups.
530    let mut linked_path_redactions = BTreeMap::new();
531
532    for linked_path in linked_paths {
533        // Linked paths are relative to the build directory, and usually point
534        // inside a build script's output directory. Cargo uses different
535        // conventions depending on the build directory layout:
536        //
537        // * legacy: `<profile>/build/<crate-name>-<hash>/...`
538        // * v2: `<profile>/build/<crate-name>/<hash>/...`
539        //
540        // Both are redacted to `<profile>/build/<crate-name-hash>/...`, so that
541        // snapshots are stable across layouts.
542        let mut source = Utf8PathBuf::new();
543        let mut replacement = ReplacementBuilder::new();
544        let mut components = linked_path.iter().peekable();
545        let mut prev = None;
546
547        while let Some(elem) = components.next() {
548            if let Some(captures) = CRATE_NAME_HASH_REGEX.captures(elem) {
549                let crate_name = captures.get(1).expect("regex had one capture");
550                source.push(elem);
551                replacement.push(&format!("<{}-hash>", crate_name.as_str()));
552                linked_path_redactions.insert(source, replacement.into_string());
553                break;
554            }
555
556            // For v2, require the parent to be `build` before treating
557            // `<elem>/<hash>` as a unit directory. A 16-hex-digit
558            // component is a much weaker signal than the legacy
559            // `<crate-name>-<hash>`, so anchor it to where Cargo actually
560            // produces these.
561            if prev == Some("build")
562                && let Some(hash_dir) = components.peek()
563                && UNIT_HASH_REGEX.is_match(hash_dir)
564            {
565                source.push(elem);
566                source.push(hash_dir);
567                replacement.push(&format!("<{elem}-hash>"));
568                linked_path_redactions.insert(source, replacement.into_string());
569                break;
570            }
571
572            // Not found yet, keep looking. If the path isn't of either form
573            // above, we don't redact it.
574            source.push(elem);
575            replacement.push(elem);
576            prev = Some(elem);
577        }
578    }
579
580    linked_path_redactions
581}
582
583#[derive(Debug)]
584struct ReplacementBuilder {
585    replacement: String,
586}
587
588impl ReplacementBuilder {
589    fn new() -> Self {
590        Self {
591            replacement: String::new(),
592        }
593    }
594
595    fn push(&mut self, s: &str) {
596        if self.replacement.is_empty() {
597            self.replacement.push_str(s);
598        } else {
599            self.replacement.push('/');
600            self.replacement.push_str(s);
601        }
602    }
603
604    fn into_string(self) -> String {
605        self.replacement
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn test_build_linked_path_redactions() {
615        struct Expected {
616            source: &'static str,
617            replacement: &'static str,
618        }
619
620        struct Case {
621            linked_path: &'static str,
622            expected: Option<Expected>,
623            description: &'static str,
624        }
625
626        let cases = [
627            Case {
628                linked_path: "debug/build/cdylib-link-f17768fb3bcd584c/out",
629                expected: Some(Expected {
630                    source: "debug/build/cdylib-link-f17768fb3bcd584c",
631                    replacement: "debug/build/<cdylib-link-hash>",
632                }),
633                description: "legacy layout",
634            },
635            Case {
636                linked_path: "debug/build/cdylib-link/f17768fb3bcd584c/out",
637                expected: Some(Expected {
638                    source: "debug/build/cdylib-link/f17768fb3bcd584c",
639                    replacement: "debug/build/<cdylib-link-hash>",
640                }),
641                description: "build-dir v2 layout",
642            },
643            Case {
644                linked_path: "aarch64-unknown-linux-gnu/debug/build/cdylib-link/f17768fb3bcd584c/out",
645                expected: Some(Expected {
646                    source: "aarch64-unknown-linux-gnu/debug/build/cdylib-link/f17768fb3bcd584c",
647                    replacement: "aarch64-unknown-linux-gnu/debug/build/<cdylib-link-hash>",
648                }),
649                description: "build-dir v2 layout with a target triple",
650            },
651            Case {
652                linked_path: "debug/build/cdylib-link/f17768fb3bcd584c/does-not-exist",
653                expected: Some(Expected {
654                    source: "debug/build/cdylib-link/f17768fb3bcd584c",
655                    replacement: "debug/build/<cdylib-link-hash>",
656                }),
657                description: "v2 layout, linked path that does not exist on disk",
658            },
659            Case {
660                linked_path: "debug/build/f17768fb3bcd584c/out",
661                expected: None,
662                description: "a hash directly under build is not a unit directory",
663            },
664            Case {
665                linked_path: "debug/deps/cdylib-link/f17768fb3bcd584c",
666                expected: None,
667                description: "the v2 shape is only recognized directly under build",
668            },
669            Case {
670                linked_path: "debug/build/cdylib-link/not-a-hash/out",
671                expected: None,
672                description: "v2 shape with a non-hash second component",
673            },
674            Case {
675                linked_path: "/usr/lib",
676                expected: None,
677                description: "a path outside the build directory",
678            },
679        ];
680
681        for case in &cases {
682            let actual =
683                build_linked_path_redactions(std::iter::once(Utf8Path::new(case.linked_path)));
684            let expected: BTreeMap<Utf8PathBuf, String> = case
685                .expected
686                .iter()
687                .map(|expected| {
688                    (
689                        Utf8PathBuf::from(expected.source),
690                        expected.replacement.to_owned(),
691                    )
692                })
693                .collect();
694            assert_eq!(
695                actual, expected,
696                "{}: redactions for linked path {}",
697                case.description, case.linked_path
698            );
699        }
700    }
701
702    #[test]
703    fn test_redact_path() {
704        let abs_path = make_abs_path();
705        let redactor = Redactor::new_with_kind(RedactorKind::Active {
706            redactions: vec![
707                Redaction::Path {
708                    path: "target/debug".into(),
709                    replacement: "<target-debug>".to_string(),
710                },
711                Redaction::Path {
712                    path: "target".into(),
713                    replacement: "<target-dir>".to_string(),
714                },
715                Redaction::Path {
716                    path: abs_path.clone(),
717                    replacement: "<abs-target>".to_string(),
718                },
719            ],
720        });
721
722        let examples: &[(Utf8PathBuf, &str)] = &[
723            ("target/foo".into(), "<target-dir>/foo"),
724            ("target/debug/bar".into(), "<target-debug>/bar"),
725            ("target2/foo".into(), "target2/foo"),
726            (
727                // This will produce "<target-dir>/foo/bar" on Unix and "<target-dir>\\foo\\bar" on
728                // Windows.
729                ["target", "foo", "bar"].iter().collect(),
730                "<target-dir>/foo/bar",
731            ),
732            (abs_path.clone(), "<abs-target>"),
733            (abs_path.join("foo"), "<abs-target>/foo"),
734        ];
735
736        for (orig, expected) in examples {
737            assert_eq!(
738                redactor.redact_path(orig).to_string(),
739                *expected,
740                "redacting {orig:?}"
741            );
742        }
743    }
744
745    #[cfg(unix)]
746    fn make_abs_path() -> Utf8PathBuf {
747        "/path/to/target".into()
748    }
749
750    #[cfg(windows)]
751    fn make_abs_path() -> Utf8PathBuf {
752        "C:\\path\\to\\target".into()
753        // TODO: test with verbatim paths
754    }
755
756    #[test]
757    fn test_size_display() {
758        // Bytes (< 1024).
759        insta::assert_snapshot!(SizeDisplay(0).to_string(), @"0 B");
760        insta::assert_snapshot!(SizeDisplay(512).to_string(), @"512 B");
761        insta::assert_snapshot!(SizeDisplay(1023).to_string(), @"1023 B");
762
763        // Kilobytes (>= 1024, < 1 MB).
764        insta::assert_snapshot!(SizeDisplay(1024).to_string(), @"1 KB");
765        insta::assert_snapshot!(SizeDisplay(1536).to_string(), @"1 KB");
766        insta::assert_snapshot!(SizeDisplay(10 * 1024).to_string(), @"10 KB");
767        insta::assert_snapshot!(SizeDisplay(1024 * 1024 - 1).to_string(), @"1023 KB");
768
769        // Megabytes (>= 1 MB, < 1 GB).
770        insta::assert_snapshot!(SizeDisplay(1024 * 1024).to_string(), @"1.0 MB");
771        insta::assert_snapshot!(SizeDisplay(1024 * 1024 + 512 * 1024).to_string(), @"1.5 MB");
772        insta::assert_snapshot!(SizeDisplay(10 * 1024 * 1024).to_string(), @"10.0 MB");
773        insta::assert_snapshot!(SizeDisplay(1024 * 1024 * 1024 - 1).to_string(), @"1024.0 MB");
774
775        // Gigabytes (>= 1 GB).
776        insta::assert_snapshot!(SizeDisplay(1024 * 1024 * 1024).to_string(), @"1.0 GB");
777        insta::assert_snapshot!(SizeDisplay(4 * 1024 * 1024 * 1024).to_string(), @"4.0 GB");
778
779        // Rounding boundaries: values where {:.1} formatting rounds up to the
780        // next power of 10 (e.g. 9.95 → "10.0"). These verify that
781        // display_width accounts for the extra digit.
782        //
783        // The byte values are computed as ceil(X.X5 * divisor) to land just
784        // above the rounding boundary.
785        insta::assert_snapshot!(SizeDisplay(10433332).to_string(), @"10.0 MB");
786        insta::assert_snapshot!(SizeDisplay(104805172).to_string(), @"100.0 MB");
787        insta::assert_snapshot!(SizeDisplay(1048523572).to_string(), @"1000.0 MB");
788        insta::assert_snapshot!(SizeDisplay(10683731149).to_string(), @"10.0 GB");
789        insta::assert_snapshot!(SizeDisplay(107320495309).to_string(), @"100.0 GB");
790        insta::assert_snapshot!(SizeDisplay(1073688136909).to_string(), @"1000.0 GB");
791
792        // Verify that display_width returns the actual formatted string length.
793        let test_cases = [
794            0,
795            512,
796            1023,
797            1024,
798            1536,
799            10 * 1024,
800            1024 * 1024 - 1,
801            1024 * 1024,
802            1024 * 1024 + 512 * 1024,
803            10 * 1024 * 1024,
804            // MB rounding boundaries.
805            10433332,
806            104805172,
807            1048523572,
808            1024 * 1024 * 1024 - 1,
809            1024 * 1024 * 1024,
810            4 * 1024 * 1024 * 1024,
811            // GB rounding boundaries.
812            10683731149,
813            107320495309,
814            1073688136909,
815        ];
816
817        for bytes in test_cases {
818            let display = SizeDisplay(bytes);
819            let formatted = display.to_string();
820            assert_eq!(
821                display.display_width(),
822                formatted.len(),
823                "display_width matches for {bytes} bytes: formatted as {formatted:?}"
824            );
825        }
826    }
827}