Skip to main content

anodizer_core/
log.rs

1//! Thin structured logging helper for anodizer stages.
2//!
3//! Provides level-gated output to stderr, with consistent `[stage] message`
4//! formatting. Keeps stdout clean for machine-parseable output (e.g. `anodizer tag`).
5//!
6//! # Verbosity levels
7//!
8//! - **quiet**: errors only (for CI where only failures matter)
9//! - **default**: status messages (stage start/complete, key actions)
10//! - **verbose**: detail (command output, env vars, file paths)
11//! - **debug**: everything (HTTP request/response, template contexts, resolved config)
12//!
13//! # Secret redaction
14//!
15//! Every `StageLogger` carries an optional env-pairs list that drives the
16//! redaction policy applied inside [`StageLogger::check_output`]. Callers
17//! that go through [`crate::context::Context::logger`] inherit the merged
18//! `{process env, config env}` pairs automatically; manual constructors
19//! (`StageLogger::new`) start with no env and can be enriched via
20//! [`StageLogger::with_env`]. Stderr / stdout interpolated into log lines
21//! or `bail!` messages is therefore redacted without callers having to
22//! remember to scrub at every site.
23
24use std::sync::Arc;
25#[cfg(feature = "test-helpers")]
26use std::sync::Mutex;
27
28use colored::Colorize;
29
30/// Level of a log line captured by a [`LogCapture`]. Mirrors the
31/// [`StageLogger`] methods that produce each level.
32///
33/// Gated behind the `test-helpers` Cargo feature — production binaries
34/// do not link the capture infrastructure.
35#[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/// In-memory sink that records every log line a [`StageLogger`] emits.
46///
47/// Cheap clone (`Arc<Mutex<Vec<…>>>` underneath) — pass the same handle to
48/// every logger derived from a [`crate::context::Context`] and read aggregated
49/// counts back via the accessor methods. Intended for tests that need to
50/// assert "publisher emitted ≥N status lines" — calls still write to stderr
51/// so test output stays debuggable.
52///
53/// Gated behind the `test-helpers` Cargo feature.
54#[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    /// Construct a fresh empty capture sink.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Append a log line to the capture vec. Called from the
68    /// [`StageLogger`] methods when a capture is attached.
69    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    /// Number of [`LogLevel::Status`] lines recorded.
76    pub fn status_count(&self) -> usize {
77        self.count(LogLevel::Status)
78    }
79
80    /// Number of [`LogLevel::Warn`] lines recorded.
81    pub fn warn_count(&self) -> usize {
82        self.count(LogLevel::Warn)
83    }
84
85    /// Number of [`LogLevel::Error`] lines recorded.
86    pub fn error_count(&self) -> usize {
87        self.count(LogLevel::Error)
88    }
89
90    /// Total count across all levels (useful sanity check).
91    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    /// Snapshot of every recorded line in insertion order.
103    pub fn all_messages(&self) -> Vec<(LogLevel, String)> {
104        self.inner.lock().map(|g| g.clone()).unwrap_or_default()
105    }
106
107    /// Snapshot of every [`LogLevel::Warn`] message in insertion order.
108    ///
109    /// Convenience accessor for tests that care only about warns — strips
110    /// the level tuple [`all_messages`] returns so callers can write
111    /// `cap.warn_messages().iter().any(|m| m.contains("..."))` without
112    /// the per-call filter+map boilerplate.
113    ///
114    /// [`all_messages`]: Self::all_messages
115    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/// Verbosity level, derived from CLI flags.
129#[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    /// Derive verbosity from CLI flag combination.
140    /// `--quiet` overrides `--verbose`; `--debug` overrides everything.
141    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/// Stage logger: wraps a stage name, verbosity level, and an optional
155/// env-pairs list used for secret redaction.
156///
157/// All output goes to stderr. Create one per stage via [`StageLogger::new`].
158/// Prefer `Context::logger("name")` over `StageLogger::new` when a
159/// `Context` is in scope, because it carries the env automatically.
160///
161/// ```rust,ignore
162/// let log = ctx.logger("build");                  // env pre-populated
163/// let log = StageLogger::new("build", verbosity)  // no env yet
164///     .with_env(env_pairs);                       // attach env for redact
165/// log.status("compiling for x86_64-unknown-linux-gnu");
166/// log.verbose(&format!("RUSTFLAGS={}", flags));
167/// log.debug(&format!("full env: {:?}", env));
168/// ```
169#[derive(Clone)]
170pub struct StageLogger {
171    stage: &'static str,
172    verbosity: Verbosity,
173    /// Env-pairs used to redact subprocess output and bail messages. The
174    /// inner vec is shared via `Arc` so cloning a logger does not copy the
175    /// env every time. `None` means redaction is a no-op (matches the
176    /// behaviour before this field existed).
177    env: Option<Arc<Vec<(String, String)>>>,
178    /// Optional in-memory capture sink. When present, every log method also
179    /// appends to the capture vec (after the stderr write). `None` means
180    /// the logger only writes to stderr (production default).
181    ///
182    /// Gated behind the `test-helpers` Cargo feature — production binaries
183    /// do not carry the field, so no per-log-call `is_none()` check fires.
184    #[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    /// Construct a logger backed by an in-memory [`LogCapture`] alongside the
200    /// usual stderr writes. Returns the logger plus a clone of the capture
201    /// handle so the test can read counts back after the SUT runs.
202    ///
203    /// Intended exclusively for tests — production code uses
204    /// [`StageLogger::new`] or [`crate::context::Context::logger`].
205    ///
206    /// Gated behind the `test-helpers` Cargo feature.
207    #[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    /// Attach an existing [`LogCapture`] to this logger. Useful when the
220    /// capture is owned by a [`crate::context::Context`] and every derived
221    /// logger should append to the same vec.
222    ///
223    /// Gated behind the `test-helpers` Cargo feature.
224    #[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    /// Attach an env-pairs list to drive secret redaction inside
231    /// [`StageLogger::check_output`] and [`StageLogger::redact`]. The list
232    /// is shared via `Arc`, so passing the same vec to many loggers does
233    /// not duplicate the underlying storage.
234    pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
235        self.env = Some(Arc::new(env));
236        self
237    }
238
239    /// Redact secret values from `s` using this logger's attached env.
240    ///
241    /// When no env has been attached (the default for `StageLogger::new`),
242    /// returns the input unchanged. Combines `redact::string` (for
243    /// known-secret env values) with `redact::redact_url_credentials`
244    /// (for inline `https://<user>:<pass>@host` URL credentials that may
245    /// not match any exported env-var value).
246    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    /// Error message — always shown (even in quiet mode).
255    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    /// Warning message — shown at Normal and above.
264    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    /// Status message — shown at Normal and above. This is the default level
275    /// for key actions (stage start, completion, skips, dry-run notes).
276    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    /// Detail message — shown only at Verbose and above.
287    /// Use for: command output on success, env vars, file paths, template vars.
288    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    /// Debug message — shown only at Debug level.
299    /// Use for: HTTP request/response details, full template contexts, resolved config.
300    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    /// Return the current verbosity level.
311    pub fn verbosity(&self) -> Verbosity {
312        self.verbosity
313    }
314
315    /// Check if verbose output is enabled.
316    pub fn is_verbose(&self) -> bool {
317        self.verbosity >= Verbosity::Verbose
318    }
319
320    /// Check if debug output is enabled.
321    pub fn is_debug(&self) -> bool {
322        self.verbosity >= Verbosity::Debug
323    }
324
325    /// Check command output, log stderr/stdout on failure, and bail with context.
326    /// On success, log stdout at verbose level. Returns `Ok(output)` on success.
327    ///
328    /// Stderr and stdout are passed through [`StageLogger::redact`] before
329    /// they reach the log sink, so any secret env-var values present in the
330    /// subprocess output are replaced with `$KEY_NAME` (and inline
331    /// `https://<user>:<pass>@host` URL credentials are scrubbed) without
332    /// callers having to remember to redact at each call site. Mirrors
333    /// GoReleaser's `gio.Safe(stderr)` pattern at every subprocess
334    /// boundary.
335    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            // Embed a (truncated, redacted) stderr tail in the bubbled
349            // error so operators reading the final anyhow chain see
350            // something more actionable than just an exit code. The
351            // separately-emitted `log.error` lines above remain the
352            // primary surface; this is defense in depth for callers
353            // that propagate the error past the StageLogger context.
354            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                // Cap at 2 KiB to keep error chains scannable.
361                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    /// Compose the redacted stderr / stdout log lines that
389    /// [`StageLogger::check_output`] would emit for `output`. Returned as
390    /// `(stderr_line, stdout_line)` where each `Option` is `Some` only when
391    /// the corresponding stream had any content. Exposed via
392    /// `pub(crate)` so the redaction logic can be unit-tested without
393    /// having to capture stderr (`eprintln!` cannot be intercepted from
394    /// the same process portably).
395    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            // Failure messages format stderr separately from stdout (under
411            // the "stderr" label); success uses one "output" label for
412            // stdout only.
413            if output.status.success() {
414                // success path: stderr is never surfaced through check_output
415                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    // -----------------------------------------------------------------
515    // Redaction inside check_output
516    // -----------------------------------------------------------------
517
518    #[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        // A logger built via `with_env` must scrub configured secrets.
531        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        // A logger constructed without `with_env` still scrubs inline URL
543        // credentials, even if the bare token is not in env (the env-pair
544        // list is empty).
545        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        // Both the env-value token AND the inline URL credential should be
555        // scrubbed in a single call.
556        let out = log.redact("remote: https://ghp_tok123@github.com/x/y");
557        // URL credential strip runs first, so the `ghp_tok123` between
558        // `://` and `@` becomes `<redacted>`. The path / host text never
559        // contains `ghp_tok123`, so the env-value pass is a no-op here.
560        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        // Stderr from a failing subprocess must be redacted before
568        // the logger surfaces it, so secrets present in `output.stderr`
569        // never reach the eprintln sink (or any future log appender).
570        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        // Stdout on the failure path must be redacted alongside
592        // stderr. Some tools dump credentials onto stdout (e.g. helm
593        // login prints a warning to stdout, not stderr).
594        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        // At verbose level, successful subprocess stdout is logged
609        // too; it must also be redacted.
610        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        // A logger built without env still strips URL credentials,
625        // so even when the user did not export a matching env var, an
626        // inline `https://<user>:<pw>@host` in stderr is scrubbed.
627        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        // The bail message embeds the (truncated, redacted) stderr tail
646        // so an operator reading the bubbled anyhow chain sees something
647        // more actionable than the bare exit code. That redaction must
648        // still strip env-resolved secrets — otherwise the new tail
649        // would leak whatever stderr the subprocess emitted.
650        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        // Subprocess failed with empty stderr — the bail still wants
673        // SOMETHING after `stderr:` so a grep on operator logs sees a
674        // deterministic marker rather than blank text.
675        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        // Stderr larger than the 2 KiB cap is truncated with an ellipsis
691        // so the operator's error chain remains scannable.
692        let log = StageLogger::new("test", Verbosity::Normal);
693        // 3 KiB of stderr.
694        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        // Truncation must keep the surface manageable — well under
705        // 3 KiB of raw stderr should make it into the bail.
706        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        // Cloning a logger should share the env vec via Arc, not deep-copy.
716        // Verified by pointer equality on the inner Vec backing the Arc.
717        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}