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
108/// Verbosity level, derived from CLI flags.
109#[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    /// Derive verbosity from CLI flag combination.
120    /// `--quiet` overrides `--verbose`; `--debug` overrides everything.
121    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/// Stage logger: wraps a stage name, verbosity level, and an optional
135/// env-pairs list used for secret redaction.
136///
137/// All output goes to stderr. Create one per stage via [`StageLogger::new`].
138/// Prefer `Context::logger("name")` over `StageLogger::new` when a
139/// `Context` is in scope, because it carries the env automatically.
140///
141/// ```rust,ignore
142/// let log = ctx.logger("build");                  // env pre-populated
143/// let log = StageLogger::new("build", verbosity)  // no env yet
144///     .with_env(env_pairs);                       // attach env for redact
145/// log.status("compiling for x86_64-unknown-linux-gnu");
146/// log.verbose(&format!("RUSTFLAGS={}", flags));
147/// log.debug(&format!("full env: {:?}", env));
148/// ```
149#[derive(Clone)]
150pub struct StageLogger {
151    stage: &'static str,
152    verbosity: Verbosity,
153    /// Env-pairs used to redact subprocess output and bail messages. The
154    /// inner vec is shared via `Arc` so cloning a logger does not copy the
155    /// env every time. `None` means redaction is a no-op (matches the
156    /// behaviour before this field existed).
157    env: Option<Arc<Vec<(String, String)>>>,
158    /// Optional in-memory capture sink. When present, every log method also
159    /// appends to the capture vec (after the stderr write). `None` means
160    /// the logger only writes to stderr (production default).
161    ///
162    /// Gated behind the `test-helpers` Cargo feature — production binaries
163    /// do not carry the field, so no per-log-call `is_none()` check fires.
164    #[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    /// Construct a logger backed by an in-memory [`LogCapture`] alongside the
180    /// usual stderr writes. Returns the logger plus a clone of the capture
181    /// handle so the test can read counts back after the SUT runs.
182    ///
183    /// Intended exclusively for tests — production code uses
184    /// [`StageLogger::new`] or [`crate::context::Context::logger`].
185    ///
186    /// Gated behind the `test-helpers` Cargo feature.
187    #[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    /// Attach an existing [`LogCapture`] to this logger. Useful when the
200    /// capture is owned by a [`crate::context::Context`] and every derived
201    /// logger should append to the same vec.
202    ///
203    /// Gated behind the `test-helpers` Cargo feature.
204    #[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    /// Attach an env-pairs list to drive secret redaction inside
211    /// [`StageLogger::check_output`] and [`StageLogger::redact`]. The list
212    /// is shared via `Arc`, so passing the same vec to many loggers does
213    /// not duplicate the underlying storage.
214    pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
215        self.env = Some(Arc::new(env));
216        self
217    }
218
219    /// Redact secret values from `s` using this logger's attached env.
220    ///
221    /// When no env has been attached (the default for `StageLogger::new`),
222    /// returns the input unchanged. Combines `redact::string` (for
223    /// known-secret env values) with `redact::redact_url_credentials`
224    /// (for inline `https://<user>:<pass>@host` URL credentials that may
225    /// not match any exported env-var value).
226    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    /// Error message — always shown (even in quiet mode).
235    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    /// Warning message — shown at Normal and above.
244    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    /// Status message — shown at Normal and above. This is the default level
255    /// for key actions (stage start, completion, skips, dry-run notes).
256    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    /// Detail message — shown only at Verbose and above.
267    /// Use for: command output on success, env vars, file paths, template vars.
268    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    /// Debug message — shown only at Debug level.
279    /// Use for: HTTP request/response details, full template contexts, resolved config.
280    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    /// Return the current verbosity level.
291    pub fn verbosity(&self) -> Verbosity {
292        self.verbosity
293    }
294
295    /// Check if verbose output is enabled.
296    pub fn is_verbose(&self) -> bool {
297        self.verbosity >= Verbosity::Verbose
298    }
299
300    /// Check if debug output is enabled.
301    pub fn is_debug(&self) -> bool {
302        self.verbosity >= Verbosity::Debug
303    }
304
305    /// Check command output, log stderr/stdout on failure, and bail with context.
306    /// On success, log stdout at verbose level. Returns `Ok(output)` on success.
307    ///
308    /// Stderr and stdout are passed through [`StageLogger::redact`] before
309    /// they reach the log sink, so any secret env-var values present in the
310    /// subprocess output are replaced with `$KEY_NAME` (and inline
311    /// `https://<user>:<pass>@host` URL credentials are scrubbed) without
312    /// callers having to remember to redact at each call site. Mirrors
313    /// GoReleaser's `gio.Safe(stderr)` pattern at every subprocess
314    /// boundary.
315    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            // Embed a (truncated, redacted) stderr tail in the bubbled
329            // error so operators reading the final anyhow chain see
330            // something more actionable than just an exit code. The
331            // separately-emitted `log.error` lines above remain the
332            // primary surface; this is defense in depth for callers
333            // that propagate the error past the StageLogger context.
334            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                // Cap at 2 KiB to keep error chains scannable.
341                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    /// Compose the redacted stderr / stdout log lines that
369    /// [`StageLogger::check_output`] would emit for `output`. Returned as
370    /// `(stderr_line, stdout_line)` where each `Option` is `Some` only when
371    /// the corresponding stream had any content. Exposed via
372    /// `pub(crate)` so the redaction logic can be unit-tested without
373    /// having to capture stderr (`eprintln!` cannot be intercepted from
374    /// the same process portably).
375    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            // Failure messages format stderr separately from stdout (under
391            // the "stderr" label); success uses one "output" label for
392            // stdout only.
393            if output.status.success() {
394                // success path: stderr is never surfaced through check_output
395                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    // -----------------------------------------------------------------
495    // Redaction inside check_output
496    // -----------------------------------------------------------------
497
498    #[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        // A logger built via `with_env` must scrub configured secrets.
511        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        // A logger constructed without `with_env` still scrubs inline URL
523        // credentials, even if the bare token is not in env (the env-pair
524        // list is empty).
525        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        // Both the env-value token AND the inline URL credential should be
535        // scrubbed in a single call.
536        let out = log.redact("remote: https://ghp_tok123@github.com/x/y");
537        // URL credential strip runs first, so the `ghp_tok123` between
538        // `://` and `@` becomes `<redacted>`. The path / host text never
539        // contains `ghp_tok123`, so the env-value pass is a no-op here.
540        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        // Stderr from a failing subprocess must be redacted before
548        // the logger surfaces it, so secrets present in `output.stderr`
549        // never reach the eprintln sink (or any future log appender).
550        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        // Stdout on the failure path must be redacted alongside
572        // stderr. Some tools dump credentials onto stdout (e.g. helm
573        // login prints a warning to stdout, not stderr).
574        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        // At verbose level, successful subprocess stdout is logged
589        // too; it must also be redacted.
590        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        // A logger built without env still strips URL credentials,
605        // so even when the user did not export a matching env var, an
606        // inline `https://<user>:<pw>@host` in stderr is scrubbed.
607        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        // The bail message embeds the (truncated, redacted) stderr tail
626        // so an operator reading the bubbled anyhow chain sees something
627        // more actionable than the bare exit code. That redaction must
628        // still strip env-resolved secrets — otherwise the new tail
629        // would leak whatever stderr the subprocess emitted.
630        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        // Subprocess failed with empty stderr — the bail still wants
653        // SOMETHING after `stderr:` so a grep on operator logs sees a
654        // deterministic marker rather than blank text.
655        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        // Stderr larger than the 2 KiB cap is truncated with an ellipsis
671        // so the operator's error chain remains scannable.
672        let log = StageLogger::new("test", Verbosity::Normal);
673        // 3 KiB of stderr.
674        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        // Truncation must keep the surface manageable — well under
685        // 3 KiB of raw stderr should make it into the bail.
686        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        // Cloning a logger should share the env vec via Arc, not deep-copy.
696        // Verified by pointer equality on the inner Vec backing the Arc.
697        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}