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)]
334pub struct SizeDisplay(pub u64);
335
336impl SizeDisplay {
337 pub fn display_width(self) -> usize {
341 let bytes = self.0;
342 if bytes >= 1024 * 1024 {
343 let mb_int = bytes / (1024 * 1024);
345 u64_decimal_char_width(mb_int) + 2 + 3
346 } else if bytes >= 1024 {
347 let kb = bytes / 1024;
349 u64_decimal_char_width(kb) + 3
350 } else {
351 u64_decimal_char_width(bytes) + 2
353 }
354 }
355}
356
357impl fmt::Display for SizeDisplay {
358 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359 let bytes = self.0;
360 if bytes >= 1024 * 1024 {
361 let width = f.width().map(|w| w.saturating_sub(3));
363 match width {
364 Some(w) => write!(f, "{:>w$.1} MB", bytes as f64 / (1024.0 * 1024.0)),
365 None => write!(f, "{:.1} MB", bytes as f64 / (1024.0 * 1024.0)),
366 }
367 } else if bytes >= 1024 {
368 let width = f.width().map(|w| w.saturating_sub(3));
370 match width {
371 Some(w) => write!(f, "{:>w$} KB", bytes / 1024),
372 None => write!(f, "{} KB", bytes / 1024),
373 }
374 } else {
375 let width = f.width().map(|w| w.saturating_sub(2));
377 match width {
378 Some(w) => write!(f, "{bytes:>w$} B"),
379 None => write!(f, "{bytes} B"),
380 }
381 }
382 }
383}
384
385#[derive(Debug)]
389pub struct RedactorBuilder {
390 redactions: Vec<Redaction>,
391}
392
393impl RedactorBuilder {
394 pub fn with_path(mut self, path: Utf8PathBuf, replacement: String) -> Self {
396 self.redactions.push(Redaction::Path { path, replacement });
397 self
398 }
399
400 pub fn build(self) -> Redactor {
402 Redactor::new_with_kind(RedactorKind::Active {
403 redactions: self.redactions,
404 })
405 }
406}
407
408#[derive(Debug)]
410pub enum RedactorOutput<T> {
411 Unredacted(T),
413
414 Redacted(String),
416}
417
418impl<T: fmt::Display> fmt::Display for RedactorOutput<T> {
419 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420 match self {
421 RedactorOutput::Unredacted(value) => value.fmt(f),
422 RedactorOutput::Redacted(replacement) => replacement.fmt(f),
423 }
424 }
425}
426
427#[derive(Debug)]
428enum RedactorKind {
429 Noop,
430 Active {
431 redactions: Vec<Redaction>,
433 },
434}
435
436impl RedactorKind {
437 fn is_active(&self) -> bool {
438 matches!(self, Self::Active { .. })
439 }
440
441 fn iter_redactions(&self) -> impl Iterator<Item = &Redaction> {
442 match self {
443 Self::Active { redactions } => redactions.iter(),
444 Self::Noop => [].iter(),
445 }
446 }
447}
448
449#[derive(Debug)]
451enum Redaction {
452 Path {
454 path: Utf8PathBuf,
456
457 replacement: String,
459 },
460}
461
462fn build_linked_path_redactions<'a>(
463 linked_paths: impl Iterator<Item = &'a Utf8Path>,
464) -> BTreeMap<Utf8PathBuf, String> {
465 let mut linked_path_redactions = BTreeMap::new();
467
468 for linked_path in linked_paths {
469 let mut source = Utf8PathBuf::new();
475 let mut replacement = ReplacementBuilder::new();
476
477 for elem in linked_path {
478 if let Some(captures) = CRATE_NAME_HASH_REGEX.captures(elem) {
479 let crate_name = captures.get(1).expect("regex had one capture");
481 source.push(elem);
482 replacement.push(&format!("<{}-hash>", crate_name.as_str()));
483 linked_path_redactions.insert(source, replacement.into_string());
484 break;
485 } else {
486 source.push(elem);
488 replacement.push(elem);
489 }
490
491 }
493 }
494
495 linked_path_redactions
496}
497
498#[derive(Debug)]
499struct ReplacementBuilder {
500 replacement: String,
501}
502
503impl ReplacementBuilder {
504 fn new() -> Self {
505 Self {
506 replacement: String::new(),
507 }
508 }
509
510 fn push(&mut self, s: &str) {
511 if self.replacement.is_empty() {
512 self.replacement.push_str(s);
513 } else {
514 self.replacement.push('/');
515 self.replacement.push_str(s);
516 }
517 }
518
519 fn into_string(self) -> String {
520 self.replacement
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 #[test]
529 fn test_redact_path() {
530 let abs_path = make_abs_path();
531 let redactor = Redactor::new_with_kind(RedactorKind::Active {
532 redactions: vec![
533 Redaction::Path {
534 path: "target/debug".into(),
535 replacement: "<target-debug>".to_string(),
536 },
537 Redaction::Path {
538 path: "target".into(),
539 replacement: "<target-dir>".to_string(),
540 },
541 Redaction::Path {
542 path: abs_path.clone(),
543 replacement: "<abs-target>".to_string(),
544 },
545 ],
546 });
547
548 let examples: &[(Utf8PathBuf, &str)] = &[
549 ("target/foo".into(), "<target-dir>/foo"),
550 ("target/debug/bar".into(), "<target-debug>/bar"),
551 ("target2/foo".into(), "target2/foo"),
552 (
553 ["target", "foo", "bar"].iter().collect(),
556 "<target-dir>/foo/bar",
557 ),
558 (abs_path.clone(), "<abs-target>"),
559 (abs_path.join("foo"), "<abs-target>/foo"),
560 ];
561
562 for (orig, expected) in examples {
563 assert_eq!(
564 redactor.redact_path(orig).to_string(),
565 *expected,
566 "redacting {orig:?}"
567 );
568 }
569 }
570
571 #[cfg(unix)]
572 fn make_abs_path() -> Utf8PathBuf {
573 "/path/to/target".into()
574 }
575
576 #[cfg(windows)]
577 fn make_abs_path() -> Utf8PathBuf {
578 "C:\\path\\to\\target".into()
579 }
581
582 #[test]
583 fn test_size_display() {
584 insta::assert_snapshot!(SizeDisplay(0).to_string(), @"0 B");
586 insta::assert_snapshot!(SizeDisplay(512).to_string(), @"512 B");
587 insta::assert_snapshot!(SizeDisplay(1023).to_string(), @"1023 B");
588
589 insta::assert_snapshot!(SizeDisplay(1024).to_string(), @"1 KB");
591 insta::assert_snapshot!(SizeDisplay(1536).to_string(), @"1 KB");
592 insta::assert_snapshot!(SizeDisplay(10 * 1024).to_string(), @"10 KB");
593 insta::assert_snapshot!(SizeDisplay(1024 * 1024 - 1).to_string(), @"1023 KB");
594
595 insta::assert_snapshot!(SizeDisplay(1024 * 1024).to_string(), @"1.0 MB");
597 insta::assert_snapshot!(SizeDisplay(1024 * 1024 + 512 * 1024).to_string(), @"1.5 MB");
598 insta::assert_snapshot!(SizeDisplay(10 * 1024 * 1024).to_string(), @"10.0 MB");
599 insta::assert_snapshot!(SizeDisplay(1024 * 1024 * 1024).to_string(), @"1024.0 MB");
600
601 let test_cases = [
603 0,
604 512,
605 1023,
606 1024,
607 1536,
608 10 * 1024,
609 1024 * 1024 - 1,
610 1024 * 1024,
611 1024 * 1024 + 512 * 1024,
612 10 * 1024 * 1024,
613 1024 * 1024 * 1024,
614 ];
615
616 for bytes in test_cases {
617 let display = SizeDisplay(bytes);
618 let formatted = display.to_string();
619 assert_eq!(
620 display.display_width(),
621 formatted.len(),
622 "display_width matches for {bytes} bytes: formatted as {formatted:?}"
623 );
624 }
625 }
626}