use std::sync::Arc;
#[cfg(feature = "test-helpers")]
use std::sync::Mutex;
use colored::Colorize;
#[cfg(feature = "test-helpers")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Error,
Warn,
Status,
Verbose,
Debug,
}
#[cfg(feature = "test-helpers")]
#[derive(Clone, Default)]
pub struct LogCapture {
inner: Arc<Mutex<Vec<(LogLevel, String)>>>,
}
#[cfg(feature = "test-helpers")]
impl LogCapture {
pub fn new() -> Self {
Self::default()
}
pub(crate) fn record(&self, level: LogLevel, msg: impl Into<String>) {
if let Ok(mut guard) = self.inner.lock() {
guard.push((level, msg.into()));
}
}
pub fn status_count(&self) -> usize {
self.count(LogLevel::Status)
}
pub fn warn_count(&self) -> usize {
self.count(LogLevel::Warn)
}
pub fn error_count(&self) -> usize {
self.count(LogLevel::Error)
}
pub fn total_count(&self) -> usize {
self.inner.lock().map(|g| g.len()).unwrap_or(0)
}
fn count(&self, level: LogLevel) -> usize {
self.inner
.lock()
.map(|g| g.iter().filter(|(l, _)| *l == level).count())
.unwrap_or(0)
}
pub fn all_messages(&self) -> Vec<(LogLevel, String)> {
self.inner.lock().map(|g| g.clone()).unwrap_or_default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum Verbosity {
Quiet,
#[default]
Normal,
Verbose,
Debug,
}
impl Verbosity {
pub fn from_flags(quiet: bool, verbose: bool, debug: bool) -> Self {
if debug {
Verbosity::Debug
} else if quiet {
Verbosity::Quiet
} else if verbose {
Verbosity::Verbose
} else {
Verbosity::Normal
}
}
}
#[derive(Clone)]
pub struct StageLogger {
stage: &'static str,
verbosity: Verbosity,
env: Option<Arc<Vec<(String, String)>>>,
#[cfg(feature = "test-helpers")]
capture: Option<LogCapture>,
}
impl StageLogger {
pub fn new(stage: &'static str, verbosity: Verbosity) -> Self {
Self {
stage,
verbosity,
env: None,
#[cfg(feature = "test-helpers")]
capture: None,
}
}
#[cfg(feature = "test-helpers")]
pub fn with_capture(stage: &'static str, verbosity: Verbosity) -> (Self, LogCapture) {
let capture = LogCapture::new();
let logger = Self {
stage,
verbosity,
env: None,
capture: Some(capture.clone()),
};
(logger, capture)
}
#[cfg(feature = "test-helpers")]
pub fn with_capture_handle(mut self, capture: LogCapture) -> Self {
self.capture = Some(capture);
self
}
pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
self.env = Some(Arc::new(env));
self
}
pub fn redact(&self, s: &str) -> String {
let credential_stripped = crate::redact::redact_url_credentials(s);
match self.env.as_deref() {
Some(env) => crate::redact::string(&credential_stripped, env),
None => credential_stripped,
}
}
pub fn error(&self, msg: &str) {
eprintln!("{} [{}] {}", "Error:".red().bold(), self.stage, msg);
#[cfg(feature = "test-helpers")]
if let Some(cap) = &self.capture {
cap.record(LogLevel::Error, msg);
}
}
pub fn warn(&self, msg: &str) {
if self.verbosity >= Verbosity::Normal {
eprintln!("{} [{}] {}", "Warning:".yellow().bold(), self.stage, msg);
}
#[cfg(feature = "test-helpers")]
if let Some(cap) = &self.capture {
cap.record(LogLevel::Warn, msg);
}
}
pub fn status(&self, msg: &str) {
if self.verbosity >= Verbosity::Normal {
eprintln!("[{}] {}", self.stage, msg);
}
#[cfg(feature = "test-helpers")]
if let Some(cap) = &self.capture {
cap.record(LogLevel::Status, msg);
}
}
pub fn verbose(&self, msg: &str) {
if self.verbosity >= Verbosity::Verbose {
eprintln!("[{}] {}", self.stage, msg);
}
#[cfg(feature = "test-helpers")]
if let Some(cap) = &self.capture {
cap.record(LogLevel::Verbose, msg);
}
}
pub fn debug(&self, msg: &str) {
if self.verbosity >= Verbosity::Debug {
eprintln!("[{}] {}", self.stage.dimmed(), msg.dimmed());
}
#[cfg(feature = "test-helpers")]
if let Some(cap) = &self.capture {
cap.record(LogLevel::Debug, msg);
}
}
pub fn verbosity(&self) -> Verbosity {
self.verbosity
}
pub fn is_verbose(&self) -> bool {
self.verbosity >= Verbosity::Verbose
}
pub fn is_debug(&self) -> bool {
self.verbosity >= Verbosity::Debug
}
pub fn check_output(
&self,
output: std::process::Output,
label: &str,
) -> anyhow::Result<std::process::Output> {
let (stderr_line, stdout_line) = self.format_output_lines(&output, label);
if !output.status.success() {
if let Some(line) = stderr_line {
self.error(&line);
}
if let Some(line) = stdout_line {
self.error(&line);
}
let stderr_raw = String::from_utf8_lossy(&output.stderr);
let stderr_tail = if stderr_raw.is_empty() {
String::from("<no stderr>")
} else {
let redacted = self.redact(&stderr_raw);
let trimmed = redacted.trim();
const MAX: usize = 2048;
if trimmed.len() > MAX {
let cut = trimmed
.char_indices()
.nth(MAX)
.map(|(i, _)| i)
.unwrap_or(MAX);
format!("{}…", &trimmed[..cut])
} else {
trimmed.to_string()
}
};
anyhow::bail!(
"{} failed with exit code: {}; stderr: {}",
label,
output.status.code().unwrap_or(-1),
stderr_tail
);
}
if self.is_verbose()
&& let Some(line) = stdout_line
{
self.verbose(&line);
}
Ok(output)
}
pub(crate) fn format_output_lines(
&self,
output: &std::process::Output,
label: &str,
) -> (Option<String>, Option<String>) {
let stderr_raw = String::from_utf8_lossy(&output.stderr);
let stderr_line = if stderr_raw.is_empty() {
None
} else {
let stderr = self.redact(&stderr_raw);
let prefix = if output.status.success() {
"output"
} else {
"stderr"
};
if output.status.success() {
None
} else {
Some(format!("{label} {prefix}:\n{stderr}"))
}
};
let stdout_raw = String::from_utf8_lossy(&output.stdout);
let stdout_line = if stdout_raw.is_empty() {
None
} else {
let stdout = self.redact(&stdout_raw);
let prefix = if output.status.success() {
"output"
} else {
"stdout"
};
Some(format!("{label} {prefix}:\n{stdout}"))
};
(stderr_line, stdout_line)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_verbosity_from_flags_default() {
assert_eq!(
Verbosity::from_flags(false, false, false),
Verbosity::Normal
);
}
#[test]
fn test_verbosity_from_flags_quiet() {
assert_eq!(Verbosity::from_flags(true, false, false), Verbosity::Quiet);
}
#[test]
fn test_verbosity_from_flags_verbose() {
assert_eq!(
Verbosity::from_flags(false, true, false),
Verbosity::Verbose
);
}
#[test]
fn test_verbosity_from_flags_debug() {
assert_eq!(Verbosity::from_flags(false, false, true), Verbosity::Debug);
}
#[test]
fn test_verbosity_from_flags_debug_wins_over_verbose() {
assert_eq!(Verbosity::from_flags(false, true, true), Verbosity::Debug);
}
#[test]
fn test_verbosity_from_flags_debug_wins_over_quiet() {
assert_eq!(Verbosity::from_flags(true, false, true), Verbosity::Debug);
}
#[test]
fn test_verbosity_from_flags_quiet_overrides_verbose() {
assert_eq!(Verbosity::from_flags(true, true, false), Verbosity::Quiet);
}
#[test]
fn test_verbosity_ordering() {
assert!(Verbosity::Quiet < Verbosity::Normal);
assert!(Verbosity::Normal < Verbosity::Verbose);
assert!(Verbosity::Verbose < Verbosity::Debug);
}
#[test]
fn test_stage_logger_is_verbose() {
let log = StageLogger::new("test", Verbosity::Verbose);
assert!(log.is_verbose());
assert!(!log.is_debug());
}
#[test]
fn test_stage_logger_is_debug() {
let log = StageLogger::new("test", Verbosity::Debug);
assert!(log.is_verbose());
assert!(log.is_debug());
}
#[test]
fn test_stage_logger_normal_not_verbose() {
let log = StageLogger::new("test", Verbosity::Normal);
assert!(!log.is_verbose());
assert!(!log.is_debug());
}
#[test]
fn test_default_verbosity_is_normal() {
assert_eq!(Verbosity::default(), Verbosity::Normal);
}
#[cfg(unix)]
fn fake_output(stdout: &[u8], stderr: &[u8], code: i32) -> std::process::Output {
use std::os::unix::process::ExitStatusExt;
std::process::Output {
status: std::process::ExitStatus::from_raw(code << 8),
stdout: stdout.to_vec(),
stderr: stderr.to_vec(),
}
}
#[test]
fn test_redact_uses_attached_env() {
let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
"GITHUB_TOKEN".to_string(),
"ghp_real_secret_token".to_string(),
)]);
let out = log.redact("auth header: ghp_real_secret_token");
assert_eq!(out, "auth header: $GITHUB_TOKEN");
assert!(!out.contains("ghp_real_secret_token"));
}
#[test]
fn test_redact_without_env_only_scrubs_inline_urls() {
let log = StageLogger::new("test", Verbosity::Normal);
let out = log.redact("fetched from https://user:tok@example.com/path");
assert_eq!(out, "fetched from https://<redacted>@example.com/path");
}
#[test]
fn test_redact_combines_env_and_url_credentials() {
let log = StageLogger::new("test", Verbosity::Normal)
.with_env(vec![("API_TOKEN".to_string(), "ghp_tok123".to_string())]);
let out = log.redact("remote: https://ghp_tok123@github.com/x/y");
assert_eq!(out, "remote: https://<redacted>@github.com/x/y");
assert!(!out.contains("ghp_tok123"));
}
#[cfg(unix)]
#[test]
fn test_check_output_redacts_stderr_on_failure() {
let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
"REGISTRY_PASSWORD".to_string(),
"supersecret_pw_123".to_string(),
)]);
let output = fake_output(
b"",
b"docker login failed: invalid password 'supersecret_pw_123'",
1,
);
let (stderr_line, _) = log.format_output_lines(&output, "docker login");
let line = stderr_line.expect("stderr should be present on failure");
assert!(
!line.contains("supersecret_pw_123"),
"stderr must be redacted: {line}"
);
assert!(line.contains("$REGISTRY_PASSWORD"));
}
#[cfg(unix)]
#[test]
fn test_check_output_redacts_stdout_on_failure() {
let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
"DOCKER_PASSWORD".to_string(),
"tok_dckr_abc".to_string(),
)]);
let output = fake_output(b"echoed config: DOCKER_PASSWORD=tok_dckr_abc\n", b"", 2);
let (_, stdout_line) = log.format_output_lines(&output, "docker");
let line = stdout_line.expect("stdout should be present on failure");
assert!(!line.contains("tok_dckr_abc"));
assert!(line.contains("$DOCKER_PASSWORD"));
}
#[cfg(unix)]
#[test]
fn test_check_output_redacts_stdout_on_verbose_success() {
let log = StageLogger::new("test", Verbosity::Verbose).with_env(vec![(
"MY_API_KEY".to_string(),
"key-abcdef-123".to_string(),
)]);
let output = fake_output(b"echo: key-abcdef-123 OK\n", b"", 0);
let (_, stdout_line) = log.format_output_lines(&output, "echo");
let line = stdout_line.expect("stdout should be present on success");
assert!(!line.contains("key-abcdef-123"));
assert!(line.contains("$MY_API_KEY"));
}
#[cfg(unix)]
#[test]
fn test_check_output_strips_inline_url_credentials_without_env() {
let log = StageLogger::new("test", Verbosity::Normal);
let output = fake_output(
b"",
b"fatal: cannot read https://user:p4ssw0rd@example.com/repo.git\n",
128,
);
let (stderr_line, _) = log.format_output_lines(&output, "git fetch");
let line = stderr_line.expect("stderr should be present on failure");
assert!(
!line.contains("p4ssw0rd"),
"userinfo must be redacted: {line}"
);
assert!(line.contains("<redacted>@example.com"));
}
#[cfg(unix)]
#[test]
fn test_check_output_bail_message_excludes_raw_secret() {
let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
"AUTH_TOKEN".to_string(),
"secret_zzz_yyy".to_string(),
)]);
let output = fake_output(b"", b"401 Unauthorized: secret_zzz_yyy\n", 1);
let err = log
.check_output(output, "curl")
.expect_err("non-zero exit should bail");
let msg = format!("{err:#}");
assert!(
!msg.contains("secret_zzz_yyy"),
"bail message leaks secret: {msg}"
);
assert!(
msg.contains("stderr:") && msg.contains("401 Unauthorized"),
"bail message should embed redacted stderr tail: {msg}"
);
}
#[cfg(unix)]
#[test]
fn test_check_output_bail_includes_no_stderr_marker_when_empty() {
let log = StageLogger::new("test", Verbosity::Normal);
let output = fake_output(b"", b"", 7);
let err = log
.check_output(output, "tool")
.expect_err("non-zero exit should bail");
let msg = format!("{err:#}");
assert!(
msg.contains("stderr: <no stderr>"),
"expected explicit <no stderr> marker: {msg}"
);
}
#[cfg(unix)]
#[test]
fn test_check_output_bail_truncates_long_stderr() {
let log = StageLogger::new("test", Verbosity::Normal);
let big = vec![b'x'; 3072];
let output = fake_output(b"", &big, 1);
let err = log
.check_output(output, "tool")
.expect_err("non-zero exit should bail");
let msg = format!("{err:#}");
assert!(
msg.ends_with('…'),
"expected ellipsis on truncated stderr: {msg}"
);
assert!(
msg.len() < 2500,
"bail message too long: {} bytes",
msg.len()
);
}
#[test]
fn test_with_env_is_arc_shared() {
let env = vec![("K".to_string(), "v_long_enough_to_be_a_token".to_string())];
let a = StageLogger::new("a", Verbosity::Normal).with_env(env);
let b = a.clone();
let pa: *const Vec<(String, String)> = a.env.as_ref().unwrap().as_ref();
let pb: *const Vec<(String, String)> = b.env.as_ref().unwrap().as_ref();
assert_eq!(pa, pb);
}
}