use crate::{
helpers::{
DurationRounding, FormattedDuration, FormattedHhMmSs, FormattedRelativeDuration,
convert_rel_path_to_forward_slash, decimal_char_width,
},
list::RustBuildMeta,
};
use camino::{Utf8Path, Utf8PathBuf};
use chrono::{DateTime, TimeZone};
use regex::Regex;
use std::{
collections::BTreeMap,
fmt,
sync::{Arc, LazyLock},
time::Duration,
};
static CRATE_NAME_HASH_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^([a-zA-Z0-9_-]+)-[a-f0-9]{16}$").unwrap());
static TARGET_DIR_REDACTION: &str = "<target-dir>";
static BUILD_DIR_REDACTION: &str = "<build-dir>";
static FILE_COUNT_REDACTION: &str = "<file-count>";
static DURATION_REDACTION: &str = "<duration>";
static TIMESTAMP_REDACTION: &str = "XXXX-XX-XX XX:XX:XX";
static SIZE_REDACTION: &str = "<size>";
static VERSION_REDACTION: &str = "<version>";
static RELATIVE_DURATION_REDACTION: &str = "<ago>";
static HHMMSS_REDACTION: &str = "HH:MM:SS";
#[derive(Clone, Debug)]
pub struct Redactor {
kind: Arc<RedactorKind>,
}
impl Default for Redactor {
fn default() -> Self {
Self::noop()
}
}
impl Redactor {
pub fn noop() -> Self {
Self::new_with_kind(RedactorKind::Noop)
}
fn new_with_kind(kind: RedactorKind) -> Self {
Self {
kind: Arc::new(kind),
}
}
pub fn build_active<State>(build_meta: &RustBuildMeta<State>) -> RedactorBuilder {
let mut redactions = Vec::new();
let linked_path_redactions =
build_linked_path_redactions(build_meta.linked_paths.keys().map(|p| p.as_ref()));
let linked_path_dir_redaction = if build_meta.build_directory == build_meta.target_directory
{
TARGET_DIR_REDACTION
} else {
BUILD_DIR_REDACTION
};
for (source, replacement) in linked_path_redactions {
redactions.push(Redaction::Path {
path: build_meta.build_directory.join(&source),
replacement: format!("{linked_path_dir_redaction}/{replacement}"),
});
redactions.push(Redaction::Path {
path: source,
replacement,
});
}
if build_meta.build_directory != build_meta.target_directory {
redactions.push(Redaction::Path {
path: build_meta.build_directory.clone(),
replacement: BUILD_DIR_REDACTION.to_string(),
});
}
redactions.push(Redaction::Path {
path: build_meta.target_directory.clone(),
replacement: TARGET_DIR_REDACTION.to_string(),
});
RedactorBuilder { redactions }
}
pub fn redact_path<'a>(&self, orig: &'a Utf8Path) -> RedactorOutput<&'a Utf8Path> {
for redaction in self.kind.iter_redactions() {
match redaction {
Redaction::Path { path, replacement } => {
if let Ok(suffix) = orig.strip_prefix(path) {
if suffix.as_str().is_empty() {
return RedactorOutput::Redacted(replacement.clone());
} else {
let path = Utf8PathBuf::from(format!("{replacement}/{suffix}"));
return RedactorOutput::Redacted(
convert_rel_path_to_forward_slash(&path).into(),
);
}
}
}
}
}
RedactorOutput::Unredacted(orig)
}
pub fn redact_file_count(&self, orig: usize) -> RedactorOutput<usize> {
if self.kind.is_active() {
RedactorOutput::Redacted(FILE_COUNT_REDACTION.to_string())
} else {
RedactorOutput::Unredacted(orig)
}
}
pub(crate) fn redact_duration(&self, orig: Duration) -> RedactorOutput<FormattedDuration> {
if self.kind.is_active() {
RedactorOutput::Redacted(DURATION_REDACTION.to_string())
} else {
RedactorOutput::Unredacted(FormattedDuration(orig))
}
}
pub(crate) fn redact_hhmmss_duration(
&self,
duration: Duration,
rounding: DurationRounding,
) -> RedactorOutput<FormattedHhMmSs> {
if self.kind.is_active() {
RedactorOutput::Redacted(HHMMSS_REDACTION.to_string())
} else {
RedactorOutput::Unredacted(FormattedHhMmSs { duration, rounding })
}
}
pub fn is_active(&self) -> bool {
self.kind.is_active()
}
pub fn for_snapshot_testing() -> Self {
Self::new_with_kind(RedactorKind::Active {
redactions: Vec::new(),
})
}
pub fn redact_timestamp<Tz>(&self, orig: &DateTime<Tz>) -> RedactorOutput<DisplayTimestamp<Tz>>
where
Tz: TimeZone + Clone,
Tz::Offset: fmt::Display,
{
if self.kind.is_active() {
RedactorOutput::Redacted(TIMESTAMP_REDACTION.to_string())
} else {
RedactorOutput::Unredacted(DisplayTimestamp(orig.clone()))
}
}
pub fn redact_size(&self, orig: u64) -> RedactorOutput<SizeDisplay> {
if self.kind.is_active() {
RedactorOutput::Redacted(SIZE_REDACTION.to_string())
} else {
RedactorOutput::Unredacted(SizeDisplay(orig))
}
}
pub fn redact_version(&self, orig: &semver::Version) -> String {
if self.kind.is_active() {
VERSION_REDACTION.to_string()
} else {
orig.to_string()
}
}
pub fn redact_store_duration(&self, orig: Option<f64>) -> RedactorOutput<StoreDurationDisplay> {
if self.kind.is_active() {
RedactorOutput::Redacted(format!("{:>10}", DURATION_REDACTION))
} else {
RedactorOutput::Unredacted(StoreDurationDisplay(orig))
}
}
pub fn redact_detailed_timestamp<Tz>(&self, orig: &DateTime<Tz>) -> String
where
Tz: TimeZone,
Tz::Offset: fmt::Display,
{
if self.kind.is_active() {
TIMESTAMP_REDACTION.to_string()
} else {
orig.format("%Y-%m-%d %H:%M:%S %:z").to_string()
}
}
pub fn redact_detailed_duration(&self, orig: Option<f64>) -> String {
if self.kind.is_active() {
DURATION_REDACTION.to_string()
} else {
match orig {
Some(secs) => format!("{:.3}s", secs),
None => "-".to_string(),
}
}
}
pub(crate) fn redact_relative_duration(
&self,
orig: Duration,
) -> RedactorOutput<FormattedRelativeDuration> {
if self.kind.is_active() {
RedactorOutput::Redacted(RELATIVE_DURATION_REDACTION.to_string())
} else {
RedactorOutput::Unredacted(FormattedRelativeDuration(orig))
}
}
pub fn redact_cli_args(&self, args: &[String]) -> String {
if !self.kind.is_active() {
return shell_words::join(args);
}
let redacted: Vec<_> = args
.iter()
.enumerate()
.map(|(i, arg)| {
if i == 0 {
"[EXE]".to_string()
} else if is_absolute_path(arg) {
"[PATH]".to_string()
} else {
arg.clone()
}
})
.collect();
shell_words::join(&redacted)
}
pub fn redact_env_vars(&self, env_vars: &BTreeMap<String, String>) -> String {
let pairs: Vec<_> = env_vars
.iter()
.map(|(k, v)| {
format!(
"{}={}",
shell_words::quote(k),
shell_words::quote(self.redact_env_value(v)),
)
})
.collect();
pairs.join(" ")
}
pub fn redact_env_value<'a>(&self, value: &'a str) -> &'a str {
if self.kind.is_active() && is_absolute_path(value) {
"[PATH]"
} else {
value
}
}
}
fn is_absolute_path(s: &str) -> bool {
s.starts_with('/') || (s.len() >= 3 && s.chars().nth(1) == Some(':'))
}
#[derive(Clone, Debug)]
pub struct DisplayTimestamp<Tz: TimeZone>(pub DateTime<Tz>);
impl<Tz: TimeZone> fmt::Display for DisplayTimestamp<Tz>
where
Tz::Offset: fmt::Display,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.format("%Y-%m-%d %H:%M:%S"))
}
}
#[derive(Clone, Debug)]
pub struct StoreDurationDisplay(pub Option<f64>);
impl fmt::Display for StoreDurationDisplay {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
Some(secs) => write!(f, "{secs:>9.3}s"),
None => write!(f, "{:>10}", "-"),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct SizeDisplay(pub u64);
impl SizeDisplay {
pub fn display_width(self) -> usize {
let bytes = self.0;
if bytes >= 1024 * 1024 * 1024 {
let gb_val = bytes as f64 / (1024.0 * 1024.0 * 1024.0);
decimal_char_width(rounded_1dp_integer_part(gb_val)) + 2 + 3
} else if bytes >= 1024 * 1024 {
let mb_val = bytes as f64 / (1024.0 * 1024.0);
decimal_char_width(rounded_1dp_integer_part(mb_val)) + 2 + 3
} else if bytes >= 1024 {
let kb = bytes / 1024;
decimal_char_width(kb) + 3
} else {
decimal_char_width(bytes) + 2
}
}
}
fn rounded_1dp_integer_part(val: f64) -> u64 {
(val * 10.0).round() as u64 / 10
}
impl fmt::Display for SizeDisplay {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let bytes = self.0;
if bytes >= 1024 * 1024 * 1024 {
let width = f.width().map(|w| w.saturating_sub(3));
match width {
Some(w) => {
write!(f, "{:>w$.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
None => write!(f, "{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)),
}
} else if bytes >= 1024 * 1024 {
let width = f.width().map(|w| w.saturating_sub(3));
match width {
Some(w) => write!(f, "{:>w$.1} MB", bytes as f64 / (1024.0 * 1024.0)),
None => write!(f, "{:.1} MB", bytes as f64 / (1024.0 * 1024.0)),
}
} else if bytes >= 1024 {
let width = f.width().map(|w| w.saturating_sub(3));
match width {
Some(w) => write!(f, "{:>w$} KB", bytes / 1024),
None => write!(f, "{} KB", bytes / 1024),
}
} else {
let width = f.width().map(|w| w.saturating_sub(2));
match width {
Some(w) => write!(f, "{bytes:>w$} B"),
None => write!(f, "{bytes} B"),
}
}
}
}
#[derive(Debug)]
pub struct RedactorBuilder {
redactions: Vec<Redaction>,
}
impl RedactorBuilder {
pub fn with_path(mut self, path: Utf8PathBuf, replacement: String) -> Self {
self.redactions.push(Redaction::Path { path, replacement });
self
}
pub fn build(self) -> Redactor {
Redactor::new_with_kind(RedactorKind::Active {
redactions: self.redactions,
})
}
}
#[derive(Debug)]
pub enum RedactorOutput<T> {
Unredacted(T),
Redacted(String),
}
impl<T: fmt::Display> fmt::Display for RedactorOutput<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RedactorOutput::Unredacted(value) => value.fmt(f),
RedactorOutput::Redacted(replacement) => replacement.fmt(f),
}
}
}
#[derive(Debug)]
enum RedactorKind {
Noop,
Active {
redactions: Vec<Redaction>,
},
}
impl RedactorKind {
fn is_active(&self) -> bool {
matches!(self, Self::Active { .. })
}
fn iter_redactions(&self) -> impl Iterator<Item = &Redaction> {
match self {
Self::Active { redactions } => redactions.iter(),
Self::Noop => [].iter(),
}
}
}
#[derive(Debug)]
enum Redaction {
Path {
path: Utf8PathBuf,
replacement: String,
},
}
fn build_linked_path_redactions<'a>(
linked_paths: impl Iterator<Item = &'a Utf8Path>,
) -> BTreeMap<Utf8PathBuf, String> {
let mut linked_path_redactions = BTreeMap::new();
for linked_path in linked_paths {
let mut source = Utf8PathBuf::new();
let mut replacement = ReplacementBuilder::new();
for elem in linked_path {
if let Some(captures) = CRATE_NAME_HASH_REGEX.captures(elem) {
let crate_name = captures.get(1).expect("regex had one capture");
source.push(elem);
replacement.push(&format!("<{}-hash>", crate_name.as_str()));
linked_path_redactions.insert(source, replacement.into_string());
break;
} else {
source.push(elem);
replacement.push(elem);
}
}
}
linked_path_redactions
}
#[derive(Debug)]
struct ReplacementBuilder {
replacement: String,
}
impl ReplacementBuilder {
fn new() -> Self {
Self {
replacement: String::new(),
}
}
fn push(&mut self, s: &str) {
if self.replacement.is_empty() {
self.replacement.push_str(s);
} else {
self.replacement.push('/');
self.replacement.push_str(s);
}
}
fn into_string(self) -> String {
self.replacement
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_redact_path() {
let abs_path = make_abs_path();
let redactor = Redactor::new_with_kind(RedactorKind::Active {
redactions: vec![
Redaction::Path {
path: "target/debug".into(),
replacement: "<target-debug>".to_string(),
},
Redaction::Path {
path: "target".into(),
replacement: "<target-dir>".to_string(),
},
Redaction::Path {
path: abs_path.clone(),
replacement: "<abs-target>".to_string(),
},
],
});
let examples: &[(Utf8PathBuf, &str)] = &[
("target/foo".into(), "<target-dir>/foo"),
("target/debug/bar".into(), "<target-debug>/bar"),
("target2/foo".into(), "target2/foo"),
(
["target", "foo", "bar"].iter().collect(),
"<target-dir>/foo/bar",
),
(abs_path.clone(), "<abs-target>"),
(abs_path.join("foo"), "<abs-target>/foo"),
];
for (orig, expected) in examples {
assert_eq!(
redactor.redact_path(orig).to_string(),
*expected,
"redacting {orig:?}"
);
}
}
#[cfg(unix)]
fn make_abs_path() -> Utf8PathBuf {
"/path/to/target".into()
}
#[cfg(windows)]
fn make_abs_path() -> Utf8PathBuf {
"C:\\path\\to\\target".into()
}
#[test]
fn test_size_display() {
insta::assert_snapshot!(SizeDisplay(0).to_string(), @"0 B");
insta::assert_snapshot!(SizeDisplay(512).to_string(), @"512 B");
insta::assert_snapshot!(SizeDisplay(1023).to_string(), @"1023 B");
insta::assert_snapshot!(SizeDisplay(1024).to_string(), @"1 KB");
insta::assert_snapshot!(SizeDisplay(1536).to_string(), @"1 KB");
insta::assert_snapshot!(SizeDisplay(10 * 1024).to_string(), @"10 KB");
insta::assert_snapshot!(SizeDisplay(1024 * 1024 - 1).to_string(), @"1023 KB");
insta::assert_snapshot!(SizeDisplay(1024 * 1024).to_string(), @"1.0 MB");
insta::assert_snapshot!(SizeDisplay(1024 * 1024 + 512 * 1024).to_string(), @"1.5 MB");
insta::assert_snapshot!(SizeDisplay(10 * 1024 * 1024).to_string(), @"10.0 MB");
insta::assert_snapshot!(SizeDisplay(1024 * 1024 * 1024 - 1).to_string(), @"1024.0 MB");
insta::assert_snapshot!(SizeDisplay(1024 * 1024 * 1024).to_string(), @"1.0 GB");
insta::assert_snapshot!(SizeDisplay(4 * 1024 * 1024 * 1024).to_string(), @"4.0 GB");
insta::assert_snapshot!(SizeDisplay(10433332).to_string(), @"10.0 MB");
insta::assert_snapshot!(SizeDisplay(104805172).to_string(), @"100.0 MB");
insta::assert_snapshot!(SizeDisplay(1048523572).to_string(), @"1000.0 MB");
insta::assert_snapshot!(SizeDisplay(10683731149).to_string(), @"10.0 GB");
insta::assert_snapshot!(SizeDisplay(107320495309).to_string(), @"100.0 GB");
insta::assert_snapshot!(SizeDisplay(1073688136909).to_string(), @"1000.0 GB");
let test_cases = [
0,
512,
1023,
1024,
1536,
10 * 1024,
1024 * 1024 - 1,
1024 * 1024,
1024 * 1024 + 512 * 1024,
10 * 1024 * 1024,
10433332,
104805172,
1048523572,
1024 * 1024 * 1024 - 1,
1024 * 1024 * 1024,
4 * 1024 * 1024 * 1024,
10683731149,
107320495309,
1073688136909,
];
for bytes in test_cases {
let display = SizeDisplay(bytes);
let formatted = display.to_string();
assert_eq!(
display.display_width(),
formatted.len(),
"display_width matches for {bytes} bytes: formatted as {formatted:?}"
);
}
}
}