1use 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
31static TIMESTAMP_REDACTION: &str = "XXXX-XX-XX XX:XX:XX";
36static SIZE_REDACTION: &str = "<size>";
38static VERSION_REDACTION: &str = "<version>";
40static RELATIVE_DURATION_REDACTION: &str = "<ago>";
42
43#[derive(Clone, Debug)]
48pub struct Redactor {
49 kind: Arc<RedactorKind>,
50}
51
52impl Redactor {
53 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 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 (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 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 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 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 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 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 pub fn is_active(&self) -> bool {
139 self.kind.is_active()
140 }
141
142 pub fn for_snapshot_testing() -> Self {
147 Self::new_with_kind(RedactorKind::Active {
148 redactions: Vec::new(),
149 })
150 }
151
152 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 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 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 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 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 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 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 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 "[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 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 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
301fn is_absolute_path(s: &str) -> bool {
303 s.starts_with('/') || (s.len() >= 3 && s.chars().nth(1) == Some(':'))
304}
305
306#[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#[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#[derive(Clone, Copy, Debug)]
335pub struct SizeDisplay(pub u64);
336
337impl SizeDisplay {
338 pub fn display_width(self) -> usize {
342 let bytes = self.0;
343 if bytes >= 1024 * 1024 * 1024 {
344 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 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 let kb = bytes / 1024;
354 u64_decimal_char_width(kb) + 3
355 } else {
356 u64_decimal_char_width(bytes) + 2
358 }
359 }
360}
361
362fn 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 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 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 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 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#[derive(Debug)]
412pub struct RedactorBuilder {
413 redactions: Vec<Redaction>,
414}
415
416impl RedactorBuilder {
417 pub fn with_path(mut self, path: Utf8PathBuf, replacement: String) -> Self {
419 self.redactions.push(Redaction::Path { path, replacement });
420 self
421 }
422
423 pub fn build(self) -> Redactor {
425 Redactor::new_with_kind(RedactorKind::Active {
426 redactions: self.redactions,
427 })
428 }
429}
430
431#[derive(Debug)]
433pub enum RedactorOutput<T> {
434 Unredacted(T),
436
437 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 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#[derive(Debug)]
474enum Redaction {
475 Path {
477 path: Utf8PathBuf,
479
480 replacement: String,
482 },
483}
484
485fn build_linked_path_redactions<'a>(
486 linked_paths: impl Iterator<Item = &'a Utf8Path>,
487) -> BTreeMap<Utf8PathBuf, String> {
488 let mut linked_path_redactions = BTreeMap::new();
490
491 for linked_path in linked_paths {
492 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 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 source.push(elem);
511 replacement.push(elem);
512 }
513
514 }
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 ["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 }
604
605 #[test]
606 fn test_size_display() {
607 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 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 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 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 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 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 10433332,
655 104805172,
656 1048523572,
657 1024 * 1024 * 1024 - 1,
658 1024 * 1024 * 1024,
659 4 * 1024 * 1024 * 1024,
660 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}