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