1use std::sync::Arc;
25
26use colored::Colorize;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
30pub enum Verbosity {
31 Quiet,
32 #[default]
33 Normal,
34 Verbose,
35 Debug,
36}
37
38impl Verbosity {
39 pub fn from_flags(quiet: bool, verbose: bool, debug: bool) -> Self {
42 if debug {
43 Verbosity::Debug
44 } else if quiet {
45 Verbosity::Quiet
46 } else if verbose {
47 Verbosity::Verbose
48 } else {
49 Verbosity::Normal
50 }
51 }
52}
53
54#[derive(Clone)]
70pub struct StageLogger {
71 stage: &'static str,
72 verbosity: Verbosity,
73 env: Option<Arc<Vec<(String, String)>>>,
78}
79
80impl StageLogger {
81 pub fn new(stage: &'static str, verbosity: Verbosity) -> Self {
82 Self {
83 stage,
84 verbosity,
85 env: None,
86 }
87 }
88
89 pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
94 self.env = Some(Arc::new(env));
95 self
96 }
97
98 pub fn redact(&self, s: &str) -> String {
106 let credential_stripped = crate::redact::redact_url_credentials(s);
107 match self.env.as_deref() {
108 Some(env) => crate::redact::string(&credential_stripped, env),
109 None => credential_stripped,
110 }
111 }
112
113 pub fn error(&self, msg: &str) {
115 eprintln!("{} [{}] {}", "Error:".red().bold(), self.stage, msg);
116 }
117
118 pub fn warn(&self, msg: &str) {
120 if self.verbosity >= Verbosity::Normal {
121 eprintln!("{} [{}] {}", "Warning:".yellow().bold(), self.stage, msg);
122 }
123 }
124
125 pub fn status(&self, msg: &str) {
128 if self.verbosity >= Verbosity::Normal {
129 eprintln!("[{}] {}", self.stage, msg);
130 }
131 }
132
133 pub fn verbose(&self, msg: &str) {
136 if self.verbosity >= Verbosity::Verbose {
137 eprintln!("[{}] {}", self.stage, msg);
138 }
139 }
140
141 pub fn debug(&self, msg: &str) {
144 if self.verbosity >= Verbosity::Debug {
145 eprintln!("[{}] {}", self.stage.dimmed(), msg.dimmed());
146 }
147 }
148
149 pub fn verbosity(&self) -> Verbosity {
151 self.verbosity
152 }
153
154 pub fn is_verbose(&self) -> bool {
156 self.verbosity >= Verbosity::Verbose
157 }
158
159 pub fn is_debug(&self) -> bool {
161 self.verbosity >= Verbosity::Debug
162 }
163
164 pub fn check_output(
175 &self,
176 output: std::process::Output,
177 label: &str,
178 ) -> anyhow::Result<std::process::Output> {
179 let (stderr_line, stdout_line) = self.format_output_lines(&output, label);
180 if !output.status.success() {
181 if let Some(line) = stderr_line {
182 self.error(&line);
183 }
184 if let Some(line) = stdout_line {
185 self.error(&line);
186 }
187 anyhow::bail!(
188 "{} failed with exit code: {}",
189 label,
190 output.status.code().unwrap_or(-1)
191 );
192 }
193 if self.is_verbose()
194 && let Some(line) = stdout_line
195 {
196 self.verbose(&line);
197 }
198 Ok(output)
199 }
200
201 pub(crate) fn format_output_lines(
209 &self,
210 output: &std::process::Output,
211 label: &str,
212 ) -> (Option<String>, Option<String>) {
213 let stderr_raw = String::from_utf8_lossy(&output.stderr);
214 let stderr_line = if stderr_raw.is_empty() {
215 None
216 } else {
217 let stderr = self.redact(&stderr_raw);
218 let prefix = if output.status.success() {
219 "output"
220 } else {
221 "stderr"
222 };
223 if output.status.success() {
227 None
229 } else {
230 Some(format!("{label} {prefix}:\n{stderr}"))
231 }
232 };
233 let stdout_raw = String::from_utf8_lossy(&output.stdout);
234 let stdout_line = if stdout_raw.is_empty() {
235 None
236 } else {
237 let stdout = self.redact(&stdout_raw);
238 let prefix = if output.status.success() {
239 "output"
240 } else {
241 "stdout"
242 };
243 Some(format!("{label} {prefix}:\n{stdout}"))
244 };
245 (stderr_line, stdout_line)
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn test_verbosity_from_flags_default() {
255 assert_eq!(
256 Verbosity::from_flags(false, false, false),
257 Verbosity::Normal
258 );
259 }
260
261 #[test]
262 fn test_verbosity_from_flags_quiet() {
263 assert_eq!(Verbosity::from_flags(true, false, false), Verbosity::Quiet);
264 }
265
266 #[test]
267 fn test_verbosity_from_flags_verbose() {
268 assert_eq!(
269 Verbosity::from_flags(false, true, false),
270 Verbosity::Verbose
271 );
272 }
273
274 #[test]
275 fn test_verbosity_from_flags_debug() {
276 assert_eq!(Verbosity::from_flags(false, false, true), Verbosity::Debug);
277 }
278
279 #[test]
280 fn test_verbosity_from_flags_debug_wins_over_verbose() {
281 assert_eq!(Verbosity::from_flags(false, true, true), Verbosity::Debug);
282 }
283
284 #[test]
285 fn test_verbosity_from_flags_debug_wins_over_quiet() {
286 assert_eq!(Verbosity::from_flags(true, false, true), Verbosity::Debug);
287 }
288
289 #[test]
290 fn test_verbosity_from_flags_quiet_overrides_verbose() {
291 assert_eq!(Verbosity::from_flags(true, true, false), Verbosity::Quiet);
292 }
293
294 #[test]
295 fn test_verbosity_ordering() {
296 assert!(Verbosity::Quiet < Verbosity::Normal);
297 assert!(Verbosity::Normal < Verbosity::Verbose);
298 assert!(Verbosity::Verbose < Verbosity::Debug);
299 }
300
301 #[test]
302 fn test_stage_logger_is_verbose() {
303 let log = StageLogger::new("test", Verbosity::Verbose);
304 assert!(log.is_verbose());
305 assert!(!log.is_debug());
306 }
307
308 #[test]
309 fn test_stage_logger_is_debug() {
310 let log = StageLogger::new("test", Verbosity::Debug);
311 assert!(log.is_verbose());
312 assert!(log.is_debug());
313 }
314
315 #[test]
316 fn test_stage_logger_normal_not_verbose() {
317 let log = StageLogger::new("test", Verbosity::Normal);
318 assert!(!log.is_verbose());
319 assert!(!log.is_debug());
320 }
321
322 #[test]
323 fn test_default_verbosity_is_normal() {
324 assert_eq!(Verbosity::default(), Verbosity::Normal);
325 }
326
327 #[cfg(unix)]
332 fn fake_output(stdout: &[u8], stderr: &[u8], code: i32) -> std::process::Output {
333 use std::os::unix::process::ExitStatusExt;
334 std::process::Output {
335 status: std::process::ExitStatus::from_raw(code << 8),
336 stdout: stdout.to_vec(),
337 stderr: stderr.to_vec(),
338 }
339 }
340
341 #[test]
342 fn test_redact_uses_attached_env() {
343 let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
345 "GITHUB_TOKEN".to_string(),
346 "ghp_real_secret_token".to_string(),
347 )]);
348 let out = log.redact("auth header: ghp_real_secret_token");
349 assert_eq!(out, "auth header: $GITHUB_TOKEN");
350 assert!(!out.contains("ghp_real_secret_token"));
351 }
352
353 #[test]
354 fn test_redact_without_env_only_scrubs_inline_urls() {
355 let log = StageLogger::new("test", Verbosity::Normal);
359 let out = log.redact("fetched from https://user:tok@example.com/path");
360 assert_eq!(out, "fetched from https://<redacted>@example.com/path");
361 }
362
363 #[test]
364 fn test_redact_combines_env_and_url_credentials() {
365 let log = StageLogger::new("test", Verbosity::Normal)
366 .with_env(vec![("API_TOKEN".to_string(), "ghp_tok123".to_string())]);
367 let out = log.redact("remote: https://ghp_tok123@github.com/x/y");
370 assert_eq!(out, "remote: https://<redacted>@github.com/x/y");
374 assert!(!out.contains("ghp_tok123"));
375 }
376
377 #[cfg(unix)]
378 #[test]
379 fn test_check_output_redacts_stderr_on_failure() {
380 let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
384 "REGISTRY_PASSWORD".to_string(),
385 "supersecret_pw_123".to_string(),
386 )]);
387 let output = fake_output(
388 b"",
389 b"docker login failed: invalid password 'supersecret_pw_123'",
390 1,
391 );
392 let (stderr_line, _) = log.format_output_lines(&output, "docker login");
393 let line = stderr_line.expect("stderr should be present on failure");
394 assert!(
395 !line.contains("supersecret_pw_123"),
396 "stderr must be redacted: {line}"
397 );
398 assert!(line.contains("$REGISTRY_PASSWORD"));
399 }
400
401 #[cfg(unix)]
402 #[test]
403 fn test_check_output_redacts_stdout_on_failure() {
404 let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
408 "DOCKER_PASSWORD".to_string(),
409 "tok_dckr_abc".to_string(),
410 )]);
411 let output = fake_output(b"echoed config: DOCKER_PASSWORD=tok_dckr_abc\n", b"", 2);
412 let (_, stdout_line) = log.format_output_lines(&output, "docker");
413 let line = stdout_line.expect("stdout should be present on failure");
414 assert!(!line.contains("tok_dckr_abc"));
415 assert!(line.contains("$DOCKER_PASSWORD"));
416 }
417
418 #[cfg(unix)]
419 #[test]
420 fn test_check_output_redacts_stdout_on_verbose_success() {
421 let log = StageLogger::new("test", Verbosity::Verbose).with_env(vec![(
424 "MY_API_KEY".to_string(),
425 "key-abcdef-123".to_string(),
426 )]);
427 let output = fake_output(b"echo: key-abcdef-123 OK\n", b"", 0);
428 let (_, stdout_line) = log.format_output_lines(&output, "echo");
429 let line = stdout_line.expect("stdout should be present on success");
430 assert!(!line.contains("key-abcdef-123"));
431 assert!(line.contains("$MY_API_KEY"));
432 }
433
434 #[cfg(unix)]
435 #[test]
436 fn test_check_output_strips_inline_url_credentials_without_env() {
437 let log = StageLogger::new("test", Verbosity::Normal);
441 let output = fake_output(
442 b"",
443 b"fatal: cannot read https://user:p4ssw0rd@example.com/repo.git\n",
444 128,
445 );
446 let (stderr_line, _) = log.format_output_lines(&output, "git fetch");
447 let line = stderr_line.expect("stderr should be present on failure");
448 assert!(
449 !line.contains("p4ssw0rd"),
450 "userinfo must be redacted: {line}"
451 );
452 assert!(line.contains("<redacted>@example.com"));
453 }
454
455 #[cfg(unix)]
456 #[test]
457 fn test_check_output_bail_message_excludes_raw_secret() {
458 let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
463 "AUTH_TOKEN".to_string(),
464 "secret_zzz_yyy".to_string(),
465 )]);
466 let output = fake_output(b"", b"401 Unauthorized: secret_zzz_yyy\n", 1);
467 let err = log
468 .check_output(output, "curl")
469 .expect_err("non-zero exit should bail");
470 let msg = format!("{err:#}");
471 assert!(
472 !msg.contains("secret_zzz_yyy"),
473 "bail message leaks secret: {msg}"
474 );
475 }
476
477 #[test]
478 fn test_with_env_is_arc_shared() {
479 let env = vec![("K".to_string(), "v_long_enough_to_be_a_token".to_string())];
482 let a = StageLogger::new("a", Verbosity::Normal).with_env(env);
483 let b = a.clone();
484 let pa: *const Vec<(String, String)> = a.env.as_ref().unwrap().as_ref();
485 let pb: *const Vec<(String, String)> = b.env.as_ref().unwrap().as_ref();
486 assert_eq!(pa, pb);
487 }
488}