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 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
32static TIMESTAMP_REDACTION: &str = "XXXX-XX-XX XX:XX:XX";
37static SIZE_REDACTION: &str = "<size>";
39static VERSION_REDACTION: &str = "<version>";
41static RELATIVE_DURATION_REDACTION: &str = "<ago>";
43static HHMMSS_REDACTION: &str = "HH:MM:SS";
45
46#[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 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 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 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 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 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 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 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 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 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 pub fn is_active(&self) -> bool {
179 self.kind.is_active()
180 }
181
182 pub fn for_snapshot_testing() -> Self {
187 Self::new_with_kind(RedactorKind::Active {
188 redactions: Vec::new(),
189 })
190 }
191
192 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 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 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 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 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 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 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 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 "[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 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 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
341fn is_absolute_path(s: &str) -> bool {
343 s.starts_with('/') || (s.len() >= 3 && s.chars().nth(1) == Some(':'))
344}
345
346#[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#[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#[derive(Clone, Copy, Debug)]
375pub struct SizeDisplay(pub u64);
376
377impl SizeDisplay {
378 pub fn display_width(self) -> usize {
382 let bytes = self.0;
383 if bytes >= 1024 * 1024 * 1024 {
384 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 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 let kb = bytes / 1024;
394 decimal_char_width(kb) + 3
395 } else {
396 decimal_char_width(bytes) + 2
398 }
399 }
400}
401
402fn 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 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 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 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 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#[derive(Debug)]
452pub struct RedactorBuilder {
453 redactions: Vec<Redaction>,
454}
455
456impl RedactorBuilder {
457 pub fn with_path(mut self, path: Utf8PathBuf, replacement: String) -> Self {
459 self.redactions.push(Redaction::Path { path, replacement });
460 self
461 }
462
463 pub fn build(self) -> Redactor {
465 Redactor::new_with_kind(RedactorKind::Active {
466 redactions: self.redactions,
467 })
468 }
469}
470
471#[derive(Debug)]
473pub enum RedactorOutput<T> {
474 Unredacted(T),
476
477 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 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#[derive(Debug)]
514enum Redaction {
515 Path {
517 path: Utf8PathBuf,
519
520 replacement: String,
522 },
523}
524
525fn build_linked_path_redactions<'a>(
526 linked_paths: impl Iterator<Item = &'a Utf8Path>,
527) -> BTreeMap<Utf8PathBuf, String> {
528 let mut linked_path_redactions = BTreeMap::new();
530
531 for linked_path in linked_paths {
532 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 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 source.push(elem);
551 replacement.push(elem);
552 }
553
554 }
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 ["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 }
644
645 #[test]
646 fn test_size_display() {
647 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 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 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 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 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 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 10433332,
695 104805172,
696 1048523572,
697 1024 * 1024 * 1024 - 1,
698 1024 * 1024 * 1024,
699 4 * 1024 * 1024 * 1024,
700 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}