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 {
347 let kb = bytes / 1024;
349 u64_decimal_char_width(kb) + 3
350 }
351 }
352}
353
354impl fmt::Display for SizeDisplay {
355 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356 let bytes = self.0;
357 match (bytes >= 1024 * 1024, f.width().map(|w| w.saturating_sub(3))) {
359 (true, Some(width)) => {
360 write!(f, "{:>width$.1} MB", bytes as f64 / (1024.0 * 1024.0))
361 }
362 (true, None) => {
363 write!(f, "{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
364 }
365 (false, Some(width)) => {
366 write!(f, "{:>width$} KB", bytes / 1024)
367 }
368 (false, None) => {
369 write!(f, "{} KB", bytes / 1024)
370 }
371 }
372 }
373}
374
375#[derive(Debug)]
379pub struct RedactorBuilder {
380 redactions: Vec<Redaction>,
381}
382
383impl RedactorBuilder {
384 pub fn with_path(mut self, path: Utf8PathBuf, replacement: String) -> Self {
386 self.redactions.push(Redaction::Path { path, replacement });
387 self
388 }
389
390 pub fn build(self) -> Redactor {
392 Redactor::new_with_kind(RedactorKind::Active {
393 redactions: self.redactions,
394 })
395 }
396}
397
398#[derive(Debug)]
400pub enum RedactorOutput<T> {
401 Unredacted(T),
403
404 Redacted(String),
406}
407
408impl<T: fmt::Display> fmt::Display for RedactorOutput<T> {
409 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410 match self {
411 RedactorOutput::Unredacted(value) => value.fmt(f),
412 RedactorOutput::Redacted(replacement) => replacement.fmt(f),
413 }
414 }
415}
416
417#[derive(Debug)]
418enum RedactorKind {
419 Noop,
420 Active {
421 redactions: Vec<Redaction>,
423 },
424}
425
426impl RedactorKind {
427 fn is_active(&self) -> bool {
428 matches!(self, Self::Active { .. })
429 }
430
431 fn iter_redactions(&self) -> impl Iterator<Item = &Redaction> {
432 match self {
433 Self::Active { redactions } => redactions.iter(),
434 Self::Noop => [].iter(),
435 }
436 }
437}
438
439#[derive(Debug)]
441enum Redaction {
442 Path {
444 path: Utf8PathBuf,
446
447 replacement: String,
449 },
450}
451
452fn build_linked_path_redactions<'a>(
453 linked_paths: impl Iterator<Item = &'a Utf8Path>,
454) -> BTreeMap<Utf8PathBuf, String> {
455 let mut linked_path_redactions = BTreeMap::new();
457
458 for linked_path in linked_paths {
459 let mut source = Utf8PathBuf::new();
465 let mut replacement = ReplacementBuilder::new();
466
467 for elem in linked_path {
468 if let Some(captures) = CRATE_NAME_HASH_REGEX.captures(elem) {
469 let crate_name = captures.get(1).expect("regex had one capture");
471 source.push(elem);
472 replacement.push(&format!("<{}-hash>", crate_name.as_str()));
473 linked_path_redactions.insert(source, replacement.into_string());
474 break;
475 } else {
476 source.push(elem);
478 replacement.push(elem);
479 }
480
481 }
483 }
484
485 linked_path_redactions
486}
487
488#[derive(Debug)]
489struct ReplacementBuilder {
490 replacement: String,
491}
492
493impl ReplacementBuilder {
494 fn new() -> Self {
495 Self {
496 replacement: String::new(),
497 }
498 }
499
500 fn push(&mut self, s: &str) {
501 if self.replacement.is_empty() {
502 self.replacement.push_str(s);
503 } else {
504 self.replacement.push('/');
505 self.replacement.push_str(s);
506 }
507 }
508
509 fn into_string(self) -> String {
510 self.replacement
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[test]
519 fn test_redact_path() {
520 let abs_path = make_abs_path();
521 let redactor = Redactor::new_with_kind(RedactorKind::Active {
522 redactions: vec![
523 Redaction::Path {
524 path: "target/debug".into(),
525 replacement: "<target-debug>".to_string(),
526 },
527 Redaction::Path {
528 path: "target".into(),
529 replacement: "<target-dir>".to_string(),
530 },
531 Redaction::Path {
532 path: abs_path.clone(),
533 replacement: "<abs-target>".to_string(),
534 },
535 ],
536 });
537
538 let examples: &[(Utf8PathBuf, &str)] = &[
539 ("target/foo".into(), "<target-dir>/foo"),
540 ("target/debug/bar".into(), "<target-debug>/bar"),
541 ("target2/foo".into(), "target2/foo"),
542 (
543 ["target", "foo", "bar"].iter().collect(),
546 "<target-dir>/foo/bar",
547 ),
548 (abs_path.clone(), "<abs-target>"),
549 (abs_path.join("foo"), "<abs-target>/foo"),
550 ];
551
552 for (orig, expected) in examples {
553 assert_eq!(
554 redactor.redact_path(orig).to_string(),
555 *expected,
556 "redacting {orig:?}"
557 );
558 }
559 }
560
561 #[cfg(unix)]
562 fn make_abs_path() -> Utf8PathBuf {
563 "/path/to/target".into()
564 }
565
566 #[cfg(windows)]
567 fn make_abs_path() -> Utf8PathBuf {
568 "C:\\path\\to\\target".into()
569 }
571
572 #[test]
573 fn test_size_display() {
574 insta::assert_snapshot!(SizeDisplay(0).to_string(), @"0 KB");
575 insta::assert_snapshot!(SizeDisplay(512).to_string(), @"0 KB");
576 insta::assert_snapshot!(SizeDisplay(1024).to_string(), @"1 KB");
577 insta::assert_snapshot!(SizeDisplay(1536).to_string(), @"1 KB");
578 insta::assert_snapshot!(SizeDisplay(10 * 1024).to_string(), @"10 KB");
579 insta::assert_snapshot!(SizeDisplay(1024 * 1024 - 1).to_string(), @"1023 KB");
580
581 insta::assert_snapshot!(SizeDisplay(1024 * 1024).to_string(), @"1.0 MB");
582 insta::assert_snapshot!(SizeDisplay(1024 * 1024 + 512 * 1024).to_string(), @"1.5 MB");
583 insta::assert_snapshot!(SizeDisplay(10 * 1024 * 1024).to_string(), @"10.0 MB");
584 insta::assert_snapshot!(SizeDisplay(1024 * 1024 * 1024).to_string(), @"1024.0 MB");
585 }
586}