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 pub fn warn_messages(&self) -> Vec<String> {
116 self.inner
117 .lock()
118 .map(|g| {
119 g.iter()
120 .filter(|(lvl, _)| *lvl == LogLevel::Warn)
121 .map(|(_, m)| m.clone())
122 .collect()
123 })
124 .unwrap_or_default()
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
130pub enum Verbosity {
131 Quiet,
132 #[default]
133 Normal,
134 Verbose,
135 Debug,
136}
137
138impl Verbosity {
139 pub fn from_flags(quiet: bool, verbose: bool, debug: bool) -> Self {
142 if debug {
143 Verbosity::Debug
144 } else if quiet {
145 Verbosity::Quiet
146 } else if verbose {
147 Verbosity::Verbose
148 } else {
149 Verbosity::Normal
150 }
151 }
152}
153
154#[derive(Clone)]
170pub struct StageLogger {
171 stage: &'static str,
172 verbosity: Verbosity,
173 env: Option<Arc<Vec<(String, String)>>>,
178 #[cfg(feature = "test-helpers")]
185 capture: Option<LogCapture>,
186}
187
188impl StageLogger {
189 pub fn new(stage: &'static str, verbosity: Verbosity) -> Self {
190 Self {
191 stage,
192 verbosity,
193 env: None,
194 #[cfg(feature = "test-helpers")]
195 capture: None,
196 }
197 }
198
199 #[cfg(feature = "test-helpers")]
208 pub fn with_capture(stage: &'static str, verbosity: Verbosity) -> (Self, LogCapture) {
209 let capture = LogCapture::new();
210 let logger = Self {
211 stage,
212 verbosity,
213 env: None,
214 capture: Some(capture.clone()),
215 };
216 (logger, capture)
217 }
218
219 #[cfg(feature = "test-helpers")]
225 pub fn with_capture_handle(mut self, capture: LogCapture) -> Self {
226 self.capture = Some(capture);
227 self
228 }
229
230 pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
235 self.env = Some(Arc::new(env));
236 self
237 }
238
239 pub fn redact(&self, s: &str) -> String {
247 let credential_stripped = crate::redact::redact_url_credentials(s);
248 match self.env.as_deref() {
249 Some(env) => crate::redact::string(&credential_stripped, env),
250 None => credential_stripped,
251 }
252 }
253
254 pub fn error(&self, msg: &str) {
256 eprintln!("{} [{}] {}", "Error:".red().bold(), self.stage, msg);
257 #[cfg(feature = "test-helpers")]
258 if let Some(cap) = &self.capture {
259 cap.record(LogLevel::Error, msg);
260 }
261 }
262
263 pub fn warn(&self, msg: &str) {
265 if self.verbosity >= Verbosity::Normal {
266 eprintln!("{} [{}] {}", "Warning:".yellow().bold(), self.stage, msg);
267 }
268 #[cfg(feature = "test-helpers")]
269 if let Some(cap) = &self.capture {
270 cap.record(LogLevel::Warn, msg);
271 }
272 }
273
274 pub fn status(&self, msg: &str) {
277 if self.verbosity >= Verbosity::Normal {
278 eprintln!("[{}] {}", self.stage, msg);
279 }
280 #[cfg(feature = "test-helpers")]
281 if let Some(cap) = &self.capture {
282 cap.record(LogLevel::Status, msg);
283 }
284 }
285
286 pub fn verbose(&self, msg: &str) {
289 if self.verbosity >= Verbosity::Verbose {
290 eprintln!("[{}] {}", self.stage, msg);
291 }
292 #[cfg(feature = "test-helpers")]
293 if let Some(cap) = &self.capture {
294 cap.record(LogLevel::Verbose, msg);
295 }
296 }
297
298 pub fn debug(&self, msg: &str) {
301 if self.verbosity >= Verbosity::Debug {
302 eprintln!("[{}] {}", self.stage.dimmed(), msg.dimmed());
303 }
304 #[cfg(feature = "test-helpers")]
305 if let Some(cap) = &self.capture {
306 cap.record(LogLevel::Debug, msg);
307 }
308 }
309
310 pub fn verbosity(&self) -> Verbosity {
312 self.verbosity
313 }
314
315 pub fn is_verbose(&self) -> bool {
317 self.verbosity >= Verbosity::Verbose
318 }
319
320 pub fn is_debug(&self) -> bool {
322 self.verbosity >= Verbosity::Debug
323 }
324
325 pub fn check_output(
336 &self,
337 output: std::process::Output,
338 label: &str,
339 ) -> anyhow::Result<std::process::Output> {
340 let (stderr_line, stdout_line) = self.format_output_lines(&output, label);
341 if !output.status.success() {
342 if let Some(line) = stderr_line {
343 self.error(&line);
344 }
345 if let Some(line) = stdout_line {
346 self.error(&line);
347 }
348 let stderr_raw = String::from_utf8_lossy(&output.stderr);
355 let stderr_tail = if stderr_raw.is_empty() {
356 String::from("<no stderr>")
357 } else {
358 let redacted = self.redact(&stderr_raw);
359 let trimmed = redacted.trim();
360 const MAX: usize = 2048;
362 if trimmed.len() > MAX {
363 let cut = trimmed
364 .char_indices()
365 .nth(MAX)
366 .map(|(i, _)| i)
367 .unwrap_or(MAX);
368 format!("{}…", &trimmed[..cut])
369 } else {
370 trimmed.to_string()
371 }
372 };
373 anyhow::bail!(
374 "{} failed with exit code: {}; stderr: {}",
375 label,
376 output.status.code().unwrap_or(-1),
377 stderr_tail
378 );
379 }
380 if self.is_verbose()
381 && let Some(line) = stdout_line
382 {
383 self.verbose(&line);
384 }
385 Ok(output)
386 }
387
388 pub(crate) fn format_output_lines(
396 &self,
397 output: &std::process::Output,
398 label: &str,
399 ) -> (Option<String>, Option<String>) {
400 let stderr_raw = String::from_utf8_lossy(&output.stderr);
401 let stderr_line = if stderr_raw.is_empty() {
402 None
403 } else {
404 let stderr = self.redact(&stderr_raw);
405 let prefix = if output.status.success() {
406 "output"
407 } else {
408 "stderr"
409 };
410 if output.status.success() {
414 None
416 } else {
417 Some(format!("{label} {prefix}:\n{stderr}"))
418 }
419 };
420 let stdout_raw = String::from_utf8_lossy(&output.stdout);
421 let stdout_line = if stdout_raw.is_empty() {
422 None
423 } else {
424 let stdout = self.redact(&stdout_raw);
425 let prefix = if output.status.success() {
426 "output"
427 } else {
428 "stdout"
429 };
430 Some(format!("{label} {prefix}:\n{stdout}"))
431 };
432 (stderr_line, stdout_line)
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 #[test]
441 fn test_verbosity_from_flags_default() {
442 assert_eq!(
443 Verbosity::from_flags(false, false, false),
444 Verbosity::Normal
445 );
446 }
447
448 #[test]
449 fn test_verbosity_from_flags_quiet() {
450 assert_eq!(Verbosity::from_flags(true, false, false), Verbosity::Quiet);
451 }
452
453 #[test]
454 fn test_verbosity_from_flags_verbose() {
455 assert_eq!(
456 Verbosity::from_flags(false, true, false),
457 Verbosity::Verbose
458 );
459 }
460
461 #[test]
462 fn test_verbosity_from_flags_debug() {
463 assert_eq!(Verbosity::from_flags(false, false, true), Verbosity::Debug);
464 }
465
466 #[test]
467 fn test_verbosity_from_flags_debug_wins_over_verbose() {
468 assert_eq!(Verbosity::from_flags(false, true, true), Verbosity::Debug);
469 }
470
471 #[test]
472 fn test_verbosity_from_flags_debug_wins_over_quiet() {
473 assert_eq!(Verbosity::from_flags(true, false, true), Verbosity::Debug);
474 }
475
476 #[test]
477 fn test_verbosity_from_flags_quiet_overrides_verbose() {
478 assert_eq!(Verbosity::from_flags(true, true, false), Verbosity::Quiet);
479 }
480
481 #[test]
482 fn test_verbosity_ordering() {
483 assert!(Verbosity::Quiet < Verbosity::Normal);
484 assert!(Verbosity::Normal < Verbosity::Verbose);
485 assert!(Verbosity::Verbose < Verbosity::Debug);
486 }
487
488 #[test]
489 fn test_stage_logger_is_verbose() {
490 let log = StageLogger::new("test", Verbosity::Verbose);
491 assert!(log.is_verbose());
492 assert!(!log.is_debug());
493 }
494
495 #[test]
496 fn test_stage_logger_is_debug() {
497 let log = StageLogger::new("test", Verbosity::Debug);
498 assert!(log.is_verbose());
499 assert!(log.is_debug());
500 }
501
502 #[test]
503 fn test_stage_logger_normal_not_verbose() {
504 let log = StageLogger::new("test", Verbosity::Normal);
505 assert!(!log.is_verbose());
506 assert!(!log.is_debug());
507 }
508
509 #[test]
510 fn test_default_verbosity_is_normal() {
511 assert_eq!(Verbosity::default(), Verbosity::Normal);
512 }
513
514 #[cfg(unix)]
519 fn fake_output(stdout: &[u8], stderr: &[u8], code: i32) -> std::process::Output {
520 use std::os::unix::process::ExitStatusExt;
521 std::process::Output {
522 status: std::process::ExitStatus::from_raw(code << 8),
523 stdout: stdout.to_vec(),
524 stderr: stderr.to_vec(),
525 }
526 }
527
528 #[test]
529 fn test_redact_uses_attached_env() {
530 let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
532 "GITHUB_TOKEN".to_string(),
533 "ghp_real_secret_token".to_string(),
534 )]);
535 let out = log.redact("auth header: ghp_real_secret_token");
536 assert_eq!(out, "auth header: $GITHUB_TOKEN");
537 assert!(!out.contains("ghp_real_secret_token"));
538 }
539
540 #[test]
541 fn test_redact_without_env_only_scrubs_inline_urls() {
542 let log = StageLogger::new("test", Verbosity::Normal);
546 let out = log.redact("fetched from https://user:tok@example.com/path");
547 assert_eq!(out, "fetched from https://<redacted>@example.com/path");
548 }
549
550 #[test]
551 fn test_redact_combines_env_and_url_credentials() {
552 let log = StageLogger::new("test", Verbosity::Normal)
553 .with_env(vec![("API_TOKEN".to_string(), "ghp_tok123".to_string())]);
554 let out = log.redact("remote: https://ghp_tok123@github.com/x/y");
557 assert_eq!(out, "remote: https://<redacted>@github.com/x/y");
561 assert!(!out.contains("ghp_tok123"));
562 }
563
564 #[cfg(unix)]
565 #[test]
566 fn test_check_output_redacts_stderr_on_failure() {
567 let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
571 "REGISTRY_PASSWORD".to_string(),
572 "supersecret_pw_123".to_string(),
573 )]);
574 let output = fake_output(
575 b"",
576 b"docker login failed: invalid password 'supersecret_pw_123'",
577 1,
578 );
579 let (stderr_line, _) = log.format_output_lines(&output, "docker login");
580 let line = stderr_line.expect("stderr should be present on failure");
581 assert!(
582 !line.contains("supersecret_pw_123"),
583 "stderr must be redacted: {line}"
584 );
585 assert!(line.contains("$REGISTRY_PASSWORD"));
586 }
587
588 #[cfg(unix)]
589 #[test]
590 fn test_check_output_redacts_stdout_on_failure() {
591 let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
595 "DOCKER_PASSWORD".to_string(),
596 "tok_dckr_abc".to_string(),
597 )]);
598 let output = fake_output(b"echoed config: DOCKER_PASSWORD=tok_dckr_abc\n", b"", 2);
599 let (_, stdout_line) = log.format_output_lines(&output, "docker");
600 let line = stdout_line.expect("stdout should be present on failure");
601 assert!(!line.contains("tok_dckr_abc"));
602 assert!(line.contains("$DOCKER_PASSWORD"));
603 }
604
605 #[cfg(unix)]
606 #[test]
607 fn test_check_output_redacts_stdout_on_verbose_success() {
608 let log = StageLogger::new("test", Verbosity::Verbose).with_env(vec![(
611 "MY_API_KEY".to_string(),
612 "key-abcdef-123".to_string(),
613 )]);
614 let output = fake_output(b"echo: key-abcdef-123 OK\n", b"", 0);
615 let (_, stdout_line) = log.format_output_lines(&output, "echo");
616 let line = stdout_line.expect("stdout should be present on success");
617 assert!(!line.contains("key-abcdef-123"));
618 assert!(line.contains("$MY_API_KEY"));
619 }
620
621 #[cfg(unix)]
622 #[test]
623 fn test_check_output_strips_inline_url_credentials_without_env() {
624 let log = StageLogger::new("test", Verbosity::Normal);
628 let output = fake_output(
629 b"",
630 b"fatal: cannot read https://user:p4ssw0rd@example.com/repo.git\n",
631 128,
632 );
633 let (stderr_line, _) = log.format_output_lines(&output, "git fetch");
634 let line = stderr_line.expect("stderr should be present on failure");
635 assert!(
636 !line.contains("p4ssw0rd"),
637 "userinfo must be redacted: {line}"
638 );
639 assert!(line.contains("<redacted>@example.com"));
640 }
641
642 #[cfg(unix)]
643 #[test]
644 fn test_check_output_bail_message_excludes_raw_secret() {
645 let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
651 "AUTH_TOKEN".to_string(),
652 "secret_zzz_yyy".to_string(),
653 )]);
654 let output = fake_output(b"", b"401 Unauthorized: secret_zzz_yyy\n", 1);
655 let err = log
656 .check_output(output, "curl")
657 .expect_err("non-zero exit should bail");
658 let msg = format!("{err:#}");
659 assert!(
660 !msg.contains("secret_zzz_yyy"),
661 "bail message leaks secret: {msg}"
662 );
663 assert!(
664 msg.contains("stderr:") && msg.contains("401 Unauthorized"),
665 "bail message should embed redacted stderr tail: {msg}"
666 );
667 }
668
669 #[cfg(unix)]
670 #[test]
671 fn test_check_output_bail_includes_no_stderr_marker_when_empty() {
672 let log = StageLogger::new("test", Verbosity::Normal);
676 let output = fake_output(b"", b"", 7);
677 let err = log
678 .check_output(output, "tool")
679 .expect_err("non-zero exit should bail");
680 let msg = format!("{err:#}");
681 assert!(
682 msg.contains("stderr: <no stderr>"),
683 "expected explicit <no stderr> marker: {msg}"
684 );
685 }
686
687 #[cfg(unix)]
688 #[test]
689 fn test_check_output_bail_truncates_long_stderr() {
690 let log = StageLogger::new("test", Verbosity::Normal);
693 let big = vec![b'x'; 3072];
695 let output = fake_output(b"", &big, 1);
696 let err = log
697 .check_output(output, "tool")
698 .expect_err("non-zero exit should bail");
699 let msg = format!("{err:#}");
700 assert!(
701 msg.ends_with('…'),
702 "expected ellipsis on truncated stderr: {msg}"
703 );
704 assert!(
707 msg.len() < 2500,
708 "bail message too long: {} bytes",
709 msg.len()
710 );
711 }
712
713 #[test]
714 fn test_with_env_is_arc_shared() {
715 let env = vec![("K".to_string(), "v_long_enough_to_be_a_token".to_string())];
718 let a = StageLogger::new("a", Verbosity::Normal).with_env(env);
719 let b = a.clone();
720 let pa: *const Vec<(String, String)> = a.env.as_ref().unwrap().as_ref();
721 let pb: *const Vec<(String, String)> = b.env.as_ref().unwrap().as_ref();
722 assert_eq!(pa, pb);
723 }
724}