1use 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
33static TIMESTAMP_REDACTION: &str = "XXXX-XX-XX XX:XX:XX";
38static SIZE_REDACTION: &str = "<size>";
40static VERSION_REDACTION: &str = "<version>";
42static RELATIVE_DURATION_REDACTION: &str = "<ago>";
44static HHMMSS_REDACTION: &str = "HH:MM:SS";
46
47#[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 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 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 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 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 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 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 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 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 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 pub fn is_active(&self) -> bool {
180 self.kind.is_active()
181 }
182
183 pub fn for_snapshot_testing() -> Self {
188 Self::new_with_kind(RedactorKind::Active {
189 redactions: Vec::new(),
190 })
191 }
192
193 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 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 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 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 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 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 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 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 "[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 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 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
342fn is_absolute_path(s: &str) -> bool {
344 s.starts_with('/') || (s.len() >= 3 && s.chars().nth(1) == Some(':'))
345}
346
347#[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#[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#[derive(Clone, Copy, Debug)]
376pub struct SizeDisplay(pub u64);
377
378impl SizeDisplay {
379 pub fn display_width(self) -> usize {
383 let bytes = self.0;
384 if bytes >= 1024 * 1024 * 1024 {
385 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 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 let kb = bytes / 1024;
395 decimal_char_width(kb) + 3
396 } else {
397 decimal_char_width(bytes) + 2
399 }
400 }
401}
402
403fn 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 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 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 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 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#[derive(Debug)]
453pub struct RedactorBuilder {
454 redactions: Vec<Redaction>,
455}
456
457impl RedactorBuilder {
458 pub fn with_path(mut self, path: Utf8PathBuf, replacement: String) -> Self {
460 self.redactions.push(Redaction::Path { path, replacement });
461 self
462 }
463
464 pub fn build(self) -> Redactor {
466 Redactor::new_with_kind(RedactorKind::Active {
467 redactions: self.redactions,
468 })
469 }
470}
471
472#[derive(Debug)]
474pub enum RedactorOutput<T> {
475 Unredacted(T),
477
478 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 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#[derive(Debug)]
515enum Redaction {
516 Path {
518 path: Utf8PathBuf,
520
521 replacement: String,
523 },
524}
525
526fn build_linked_path_redactions<'a>(
527 linked_paths: impl Iterator<Item = &'a Utf8Path>,
528) -> BTreeMap<Utf8PathBuf, String> {
529 let mut linked_path_redactions = BTreeMap::new();
531
532 for linked_path in linked_paths {
533 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 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 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 ["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 }
755
756 #[test]
757 fn test_size_display() {
758 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 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 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 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 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 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 10433332,
806 104805172,
807 1048523572,
808 1024 * 1024 * 1024 - 1,
809 1024 * 1024 * 1024,
810 4 * 1024 * 1024 * 1024,
811 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}