1use std::sync::Arc;
25#[cfg(feature = "test-helpers")]
26use std::sync::Mutex;
27
28use colored::Colorize;
29
30#[cfg(feature = "test-helpers")]
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum LogLevel {
38 Error,
39 Warn,
40 Status,
41 Verbose,
42 Debug,
43}
44
45#[cfg(feature = "test-helpers")]
55#[derive(Clone, Default)]
56pub struct LogCapture {
57 inner: Arc<Mutex<Vec<(LogLevel, String)>>>,
58}
59
60#[cfg(feature = "test-helpers")]
61impl LogCapture {
62 pub fn new() -> Self {
64 Self::default()
65 }
66
67 pub(crate) fn record(&self, level: LogLevel, msg: impl Into<String>) {
70 if let Ok(mut guard) = self.inner.lock() {
71 guard.push((level, msg.into()));
72 }
73 }
74
75 pub fn status_count(&self) -> usize {
77 self.count(LogLevel::Status)
78 }
79
80 pub fn warn_count(&self) -> usize {
82 self.count(LogLevel::Warn)
83 }
84
85 pub fn error_count(&self) -> usize {
87 self.count(LogLevel::Error)
88 }
89
90 pub fn total_count(&self) -> usize {
92 self.inner.lock().map(|g| g.len()).unwrap_or(0)
93 }
94
95 fn count(&self, level: LogLevel) -> usize {
96 self.inner
97 .lock()
98 .map(|g| g.iter().filter(|(l, _)| *l == level).count())
99 .unwrap_or(0)
100 }
101
102 pub fn all_messages(&self) -> Vec<(LogLevel, String)> {
104 self.inner.lock().map(|g| g.clone()).unwrap_or_default()
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
110pub enum Verbosity {
111 Quiet,
112 #[default]
113 Normal,
114 Verbose,
115 Debug,
116}
117
118impl Verbosity {
119 pub fn from_flags(quiet: bool, verbose: bool, debug: bool) -> Self {
122 if debug {
123 Verbosity::Debug
124 } else if quiet {
125 Verbosity::Quiet
126 } else if verbose {
127 Verbosity::Verbose
128 } else {
129 Verbosity::Normal
130 }
131 }
132}
133
134#[derive(Clone)]
150pub struct StageLogger {
151 stage: &'static str,
152 verbosity: Verbosity,
153 env: Option<Arc<Vec<(String, String)>>>,
158 #[cfg(feature = "test-helpers")]
165 capture: Option<LogCapture>,
166}
167
168impl StageLogger {
169 pub fn new(stage: &'static str, verbosity: Verbosity) -> Self {
170 Self {
171 stage,
172 verbosity,
173 env: None,
174 #[cfg(feature = "test-helpers")]
175 capture: None,
176 }
177 }
178
179 #[cfg(feature = "test-helpers")]
188 pub fn with_capture(stage: &'static str, verbosity: Verbosity) -> (Self, LogCapture) {
189 let capture = LogCapture::new();
190 let logger = Self {
191 stage,
192 verbosity,
193 env: None,
194 capture: Some(capture.clone()),
195 };
196 (logger, capture)
197 }
198
199 #[cfg(feature = "test-helpers")]
205 pub fn with_capture_handle(mut self, capture: LogCapture) -> Self {
206 self.capture = Some(capture);
207 self
208 }
209
210 pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
215 self.env = Some(Arc::new(env));
216 self
217 }
218
219 pub fn redact(&self, s: &str) -> String {
227 let credential_stripped = crate::redact::redact_url_credentials(s);
228 match self.env.as_deref() {
229 Some(env) => crate::redact::string(&credential_stripped, env),
230 None => credential_stripped,
231 }
232 }
233
234 pub fn error(&self, msg: &str) {
236 eprintln!("{} [{}] {}", "Error:".red().bold(), self.stage, msg);
237 #[cfg(feature = "test-helpers")]
238 if let Some(cap) = &self.capture {
239 cap.record(LogLevel::Error, msg);
240 }
241 }
242
243 pub fn warn(&self, msg: &str) {
245 if self.verbosity >= Verbosity::Normal {
246 eprintln!("{} [{}] {}", "Warning:".yellow().bold(), self.stage, msg);
247 }
248 #[cfg(feature = "test-helpers")]
249 if let Some(cap) = &self.capture {
250 cap.record(LogLevel::Warn, msg);
251 }
252 }
253
254 pub fn status(&self, msg: &str) {
257 if self.verbosity >= Verbosity::Normal {
258 eprintln!("[{}] {}", self.stage, msg);
259 }
260 #[cfg(feature = "test-helpers")]
261 if let Some(cap) = &self.capture {
262 cap.record(LogLevel::Status, msg);
263 }
264 }
265
266 pub fn verbose(&self, msg: &str) {
269 if self.verbosity >= Verbosity::Verbose {
270 eprintln!("[{}] {}", self.stage, msg);
271 }
272 #[cfg(feature = "test-helpers")]
273 if let Some(cap) = &self.capture {
274 cap.record(LogLevel::Verbose, msg);
275 }
276 }
277
278 pub fn debug(&self, msg: &str) {
281 if self.verbosity >= Verbosity::Debug {
282 eprintln!("[{}] {}", self.stage.dimmed(), msg.dimmed());
283 }
284 #[cfg(feature = "test-helpers")]
285 if let Some(cap) = &self.capture {
286 cap.record(LogLevel::Debug, msg);
287 }
288 }
289
290 pub fn verbosity(&self) -> Verbosity {
292 self.verbosity
293 }
294
295 pub fn is_verbose(&self) -> bool {
297 self.verbosity >= Verbosity::Verbose
298 }
299
300 pub fn is_debug(&self) -> bool {
302 self.verbosity >= Verbosity::Debug
303 }
304
305 pub fn check_output(
316 &self,
317 output: std::process::Output,
318 label: &str,
319 ) -> anyhow::Result<std::process::Output> {
320 let (stderr_line, stdout_line) = self.format_output_lines(&output, label);
321 if !output.status.success() {
322 if let Some(line) = stderr_line {
323 self.error(&line);
324 }
325 if let Some(line) = stdout_line {
326 self.error(&line);
327 }
328 let stderr_raw = String::from_utf8_lossy(&output.stderr);
335 let stderr_tail = if stderr_raw.is_empty() {
336 String::from("<no stderr>")
337 } else {
338 let redacted = self.redact(&stderr_raw);
339 let trimmed = redacted.trim();
340 const MAX: usize = 2048;
342 if trimmed.len() > MAX {
343 let cut = trimmed
344 .char_indices()
345 .nth(MAX)
346 .map(|(i, _)| i)
347 .unwrap_or(MAX);
348 format!("{}…", &trimmed[..cut])
349 } else {
350 trimmed.to_string()
351 }
352 };
353 anyhow::bail!(
354 "{} failed with exit code: {}; stderr: {}",
355 label,
356 output.status.code().unwrap_or(-1),
357 stderr_tail
358 );
359 }
360 if self.is_verbose()
361 && let Some(line) = stdout_line
362 {
363 self.verbose(&line);
364 }
365 Ok(output)
366 }
367
368 pub(crate) fn format_output_lines(
376 &self,
377 output: &std::process::Output,
378 label: &str,
379 ) -> (Option<String>, Option<String>) {
380 let stderr_raw = String::from_utf8_lossy(&output.stderr);
381 let stderr_line = if stderr_raw.is_empty() {
382 None
383 } else {
384 let stderr = self.redact(&stderr_raw);
385 let prefix = if output.status.success() {
386 "output"
387 } else {
388 "stderr"
389 };
390 if output.status.success() {
394 None
396 } else {
397 Some(format!("{label} {prefix}:\n{stderr}"))
398 }
399 };
400 let stdout_raw = String::from_utf8_lossy(&output.stdout);
401 let stdout_line = if stdout_raw.is_empty() {
402 None
403 } else {
404 let stdout = self.redact(&stdout_raw);
405 let prefix = if output.status.success() {
406 "output"
407 } else {
408 "stdout"
409 };
410 Some(format!("{label} {prefix}:\n{stdout}"))
411 };
412 (stderr_line, stdout_line)
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn test_verbosity_from_flags_default() {
422 assert_eq!(
423 Verbosity::from_flags(false, false, false),
424 Verbosity::Normal
425 );
426 }
427
428 #[test]
429 fn test_verbosity_from_flags_quiet() {
430 assert_eq!(Verbosity::from_flags(true, false, false), Verbosity::Quiet);
431 }
432
433 #[test]
434 fn test_verbosity_from_flags_verbose() {
435 assert_eq!(
436 Verbosity::from_flags(false, true, false),
437 Verbosity::Verbose
438 );
439 }
440
441 #[test]
442 fn test_verbosity_from_flags_debug() {
443 assert_eq!(Verbosity::from_flags(false, false, true), Verbosity::Debug);
444 }
445
446 #[test]
447 fn test_verbosity_from_flags_debug_wins_over_verbose() {
448 assert_eq!(Verbosity::from_flags(false, true, true), Verbosity::Debug);
449 }
450
451 #[test]
452 fn test_verbosity_from_flags_debug_wins_over_quiet() {
453 assert_eq!(Verbosity::from_flags(true, false, true), Verbosity::Debug);
454 }
455
456 #[test]
457 fn test_verbosity_from_flags_quiet_overrides_verbose() {
458 assert_eq!(Verbosity::from_flags(true, true, false), Verbosity::Quiet);
459 }
460
461 #[test]
462 fn test_verbosity_ordering() {
463 assert!(Verbosity::Quiet < Verbosity::Normal);
464 assert!(Verbosity::Normal < Verbosity::Verbose);
465 assert!(Verbosity::Verbose < Verbosity::Debug);
466 }
467
468 #[test]
469 fn test_stage_logger_is_verbose() {
470 let log = StageLogger::new("test", Verbosity::Verbose);
471 assert!(log.is_verbose());
472 assert!(!log.is_debug());
473 }
474
475 #[test]
476 fn test_stage_logger_is_debug() {
477 let log = StageLogger::new("test", Verbosity::Debug);
478 assert!(log.is_verbose());
479 assert!(log.is_debug());
480 }
481
482 #[test]
483 fn test_stage_logger_normal_not_verbose() {
484 let log = StageLogger::new("test", Verbosity::Normal);
485 assert!(!log.is_verbose());
486 assert!(!log.is_debug());
487 }
488
489 #[test]
490 fn test_default_verbosity_is_normal() {
491 assert_eq!(Verbosity::default(), Verbosity::Normal);
492 }
493
494 #[cfg(unix)]
499 fn fake_output(stdout: &[u8], stderr: &[u8], code: i32) -> std::process::Output {
500 use std::os::unix::process::ExitStatusExt;
501 std::process::Output {
502 status: std::process::ExitStatus::from_raw(code << 8),
503 stdout: stdout.to_vec(),
504 stderr: stderr.to_vec(),
505 }
506 }
507
508 #[test]
509 fn test_redact_uses_attached_env() {
510 let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
512 "GITHUB_TOKEN".to_string(),
513 "ghp_real_secret_token".to_string(),
514 )]);
515 let out = log.redact("auth header: ghp_real_secret_token");
516 assert_eq!(out, "auth header: $GITHUB_TOKEN");
517 assert!(!out.contains("ghp_real_secret_token"));
518 }
519
520 #[test]
521 fn test_redact_without_env_only_scrubs_inline_urls() {
522 let log = StageLogger::new("test", Verbosity::Normal);
526 let out = log.redact("fetched from https://user:tok@example.com/path");
527 assert_eq!(out, "fetched from https://<redacted>@example.com/path");
528 }
529
530 #[test]
531 fn test_redact_combines_env_and_url_credentials() {
532 let log = StageLogger::new("test", Verbosity::Normal)
533 .with_env(vec![("API_TOKEN".to_string(), "ghp_tok123".to_string())]);
534 let out = log.redact("remote: https://ghp_tok123@github.com/x/y");
537 assert_eq!(out, "remote: https://<redacted>@github.com/x/y");
541 assert!(!out.contains("ghp_tok123"));
542 }
543
544 #[cfg(unix)]
545 #[test]
546 fn test_check_output_redacts_stderr_on_failure() {
547 let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
551 "REGISTRY_PASSWORD".to_string(),
552 "supersecret_pw_123".to_string(),
553 )]);
554 let output = fake_output(
555 b"",
556 b"docker login failed: invalid password 'supersecret_pw_123'",
557 1,
558 );
559 let (stderr_line, _) = log.format_output_lines(&output, "docker login");
560 let line = stderr_line.expect("stderr should be present on failure");
561 assert!(
562 !line.contains("supersecret_pw_123"),
563 "stderr must be redacted: {line}"
564 );
565 assert!(line.contains("$REGISTRY_PASSWORD"));
566 }
567
568 #[cfg(unix)]
569 #[test]
570 fn test_check_output_redacts_stdout_on_failure() {
571 let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
575 "DOCKER_PASSWORD".to_string(),
576 "tok_dckr_abc".to_string(),
577 )]);
578 let output = fake_output(b"echoed config: DOCKER_PASSWORD=tok_dckr_abc\n", b"", 2);
579 let (_, stdout_line) = log.format_output_lines(&output, "docker");
580 let line = stdout_line.expect("stdout should be present on failure");
581 assert!(!line.contains("tok_dckr_abc"));
582 assert!(line.contains("$DOCKER_PASSWORD"));
583 }
584
585 #[cfg(unix)]
586 #[test]
587 fn test_check_output_redacts_stdout_on_verbose_success() {
588 let log = StageLogger::new("test", Verbosity::Verbose).with_env(vec![(
591 "MY_API_KEY".to_string(),
592 "key-abcdef-123".to_string(),
593 )]);
594 let output = fake_output(b"echo: key-abcdef-123 OK\n", b"", 0);
595 let (_, stdout_line) = log.format_output_lines(&output, "echo");
596 let line = stdout_line.expect("stdout should be present on success");
597 assert!(!line.contains("key-abcdef-123"));
598 assert!(line.contains("$MY_API_KEY"));
599 }
600
601 #[cfg(unix)]
602 #[test]
603 fn test_check_output_strips_inline_url_credentials_without_env() {
604 let log = StageLogger::new("test", Verbosity::Normal);
608 let output = fake_output(
609 b"",
610 b"fatal: cannot read https://user:p4ssw0rd@example.com/repo.git\n",
611 128,
612 );
613 let (stderr_line, _) = log.format_output_lines(&output, "git fetch");
614 let line = stderr_line.expect("stderr should be present on failure");
615 assert!(
616 !line.contains("p4ssw0rd"),
617 "userinfo must be redacted: {line}"
618 );
619 assert!(line.contains("<redacted>@example.com"));
620 }
621
622 #[cfg(unix)]
623 #[test]
624 fn test_check_output_bail_message_excludes_raw_secret() {
625 let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
631 "AUTH_TOKEN".to_string(),
632 "secret_zzz_yyy".to_string(),
633 )]);
634 let output = fake_output(b"", b"401 Unauthorized: secret_zzz_yyy\n", 1);
635 let err = log
636 .check_output(output, "curl")
637 .expect_err("non-zero exit should bail");
638 let msg = format!("{err:#}");
639 assert!(
640 !msg.contains("secret_zzz_yyy"),
641 "bail message leaks secret: {msg}"
642 );
643 assert!(
644 msg.contains("stderr:") && msg.contains("401 Unauthorized"),
645 "bail message should embed redacted stderr tail: {msg}"
646 );
647 }
648
649 #[cfg(unix)]
650 #[test]
651 fn test_check_output_bail_includes_no_stderr_marker_when_empty() {
652 let log = StageLogger::new("test", Verbosity::Normal);
656 let output = fake_output(b"", b"", 7);
657 let err = log
658 .check_output(output, "tool")
659 .expect_err("non-zero exit should bail");
660 let msg = format!("{err:#}");
661 assert!(
662 msg.contains("stderr: <no stderr>"),
663 "expected explicit <no stderr> marker: {msg}"
664 );
665 }
666
667 #[cfg(unix)]
668 #[test]
669 fn test_check_output_bail_truncates_long_stderr() {
670 let log = StageLogger::new("test", Verbosity::Normal);
673 let big = vec![b'x'; 3072];
675 let output = fake_output(b"", &big, 1);
676 let err = log
677 .check_output(output, "tool")
678 .expect_err("non-zero exit should bail");
679 let msg = format!("{err:#}");
680 assert!(
681 msg.ends_with('…'),
682 "expected ellipsis on truncated stderr: {msg}"
683 );
684 assert!(
687 msg.len() < 2500,
688 "bail message too long: {} bytes",
689 msg.len()
690 );
691 }
692
693 #[test]
694 fn test_with_env_is_arc_shared() {
695 let env = vec![("K".to_string(), "v_long_enough_to_be_a_token".to_string())];
698 let a = StageLogger::new("a", Verbosity::Normal).with_env(env);
699 let b = a.clone();
700 let pa: *const Vec<(String, String)> = a.env.as_ref().unwrap().as_ref();
701 let pb: *const Vec<(String, String)> = b.env.as_ref().unwrap().as_ref();
702 assert_eq!(pa, pb);
703 }
704}