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