Skip to main content

anodizer_core/
log.rs

1//! Thin structured logging helper for anodizer stages.
2//!
3//! Provides level-gated output to stderr in a single unified style ("format
4//! B"). Keeps stdout clean for machine-parseable output (e.g. `anodizer tag`).
5//!
6//! # Output style
7//!
8//! Two visual registers, one source of truth — never hand-format a stage line:
9//!
10//! ```text
11//!  Checking determinism          ← SECTION HEADER (group / step)
12//!    • targets  aarch64-…         ← META key/value row     (kv)
13//!    • stages   build, sign       ← META key/value row     (kv)
14//!    • runs     2                 ← META key/value row     (kv)
15//!  Building binaries              ← SECTION HEADER
16//!    • compiling x86_64-…         ← DETAIL / info line     (detail / status)
17//!    ✓ x86_64-…    1.2 MiB         ← SUCCESS line           (success)
18//!    ✗ aarch64-…   build failed    ← FAILURE line           (failure)
19//! ```
20//!
21//! Body lines belonging to a [`StageLogger::group`] section additionally
22//! carry the section's 2-space nesting indent, so a header's own detail
23//! rows sit one level beneath it (the indents in the sketch above are
24//! relative, not absolute columns).
25//!
26//! - **Section headers** ([`StageLogger::step`] / [`StageLogger::group`]) put a
27//!   bold-green present-participle verb (the leading word of the stage's
28//!   [`stage_header`] phrase) right-aligned in a fixed 12-column gutter, then
29//!   one space, then the message. ONLY verbs live in this gutter — never a
30//!   lowercase key.
31//! - **Body lines** ([`StageLogger::detail`] / [`success`] / [`failure`], plus
32//!   the retargeted [`StageLogger::status`]) sit at a 3-space body indent under
33//!   their header, prefixed by a marker — `•` info (cyan), `✓` success (green),
34//!   `✗` failure (red) — one space, then the text.
35//! - **Key/value rows** ([`StageLogger::kv`]) are `•` detail lines whose
36//!   lowercase dimmed key is padded so the values align within a group.
37//! - **Status labels** (`Warning:` / `Error:` / `Note:`, via
38//!   [`render_warning`] / [`render_error`] / [`render_note`]) render at the body
39//!   indent.
40//!
41//! [`success`]: StageLogger::success
42//! [`failure`]: StageLogger::failure
43//!
44//! # Verbosity levels
45//!
46//! - **quiet**: errors only (for CI where only failures matter)
47//! - **default**: status messages (stage start/complete, key actions)
48//! - **verbose**: detail (command output, env vars, file paths)
49//! - **debug**: everything (HTTP request/response, template contexts, resolved config)
50//!
51//! # Secret redaction
52//!
53//! Every `StageLogger` carries an optional env-pairs list that drives the
54//! redaction policy applied inside [`StageLogger::check_output`]. Callers
55//! that go through [`crate::context::Context::logger`] inherit the merged
56//! `{process env, config env}` pairs automatically; manual constructors
57//! (`StageLogger::new`) start with no env and can be enriched via
58//! [`StageLogger::with_env`]. Stderr / stdout interpolated into log lines
59//! or `bail!` messages is therefore redacted without callers having to
60//! remember to scrub at every site.
61
62use std::sync::Arc;
63use std::sync::Mutex;
64use std::sync::OnceLock;
65use std::sync::atomic::{AtomicUsize, Ordering};
66
67use colored::Colorize;
68
69/// Process-global section nesting depth. Drives the 2-space-per-level
70/// indentation applied to every stderr log line so output produced
71/// inside a [`StageLogger::group`] sits visually beneath its header.
72///
73/// A single atomic (rather than per-logger state) is correct because the
74/// release pipeline drives one stderr stream and no `group()` is ever
75/// opened from a worker thread — sections bracket whole stages on the
76/// main thread, while a stage's interior parallelism (e.g. `build`
77/// spawning per-target threads) emits *inside* an already-open section.
78/// The depth is therefore a property of "where the main thread is in the
79/// run", not of any individual logger clone or worker.
80static SECTION_DEPTH: AtomicUsize = AtomicUsize::new(0);
81
82/// Env var carrying a parent `anodizer` process's visual nesting depth.
83///
84/// The determinism harness spawns child `anodizer release` subprocesses
85/// whose stderr is inherited, so the child's lines interleave directly
86/// into the parent's stream. Without an inherited base depth the child's
87/// section headers would render flush-left, visually escaping the
88/// parent's open section. The parent exports its depth here; the child
89/// reads it once (see [`base_depth`]) and offsets every indent by it.
90pub const LOG_DEPTH_ENV: &str = "ANODIZER_LOG_DEPTH";
91
92/// Base nesting depth inherited from a parent process via
93/// [`LOG_DEPTH_ENV`], parsed once on first use. Zero when the var is
94/// absent or unparseable (a standalone process indents from column 0).
95static BASE_DEPTH: OnceLock<usize> = OnceLock::new();
96
97/// Parse the inherited base depth from a raw [`LOG_DEPTH_ENV`] value.
98/// Lenient by design: a missing or malformed value degrades to 0 (the
99/// standalone-process default) rather than failing — indentation is
100/// presentation, never worth aborting a release over.
101fn parse_base_depth(raw: Option<&str>) -> usize {
102    raw.and_then(|v| v.trim().parse().ok()).unwrap_or(0)
103}
104
105/// The process's inherited base depth (see [`LOG_DEPTH_ENV`]).
106fn base_depth() -> usize {
107    *BASE_DEPTH.get_or_init(|| parse_base_depth(std::env::var(LOG_DEPTH_ENV).ok().as_deref()))
108}
109
110/// Current absolute nesting depth: the inherited base plus every open
111/// section. This is the value [`indent`] renders and the value a parent
112/// exports (offset for the child's nesting) when spawning a subprocess
113/// whose stderr joins this process's stream.
114pub fn current_depth() -> usize {
115    base_depth() + SECTION_DEPTH.load(Ordering::Relaxed)
116}
117
118/// A section header that has been opened ([`StageLogger::group`]) but not yet
119/// printed. The header line is deferred until the section actually emits a
120/// body line, so a stage that does nothing prints nothing at all (matching
121/// GoReleaser, which only prints a section header once the section has output).
122struct PendingHeader {
123    /// Section depth captured at open time. The header renders at *this* depth,
124    /// not the current global depth, so a nested section's deferred header is
125    /// still indented to its own level when flushed alongside its ancestors.
126    depth: usize,
127    /// Right-aligned bold-green verb (the leading word of the stage's
128    /// [`stage_header`] phrase).
129    verb: String,
130    /// The remaining words of the phrase, printed after the verb (empty for a
131    /// single-word phrase, which renders a bare gutter verb).
132    msg: String,
133    /// Whether this header has already been printed. A flushed entry stays on
134    /// the stack (so the LIFO pop in [`SectionGuard::drop`] removes the right
135    /// one) but is never reprinted.
136    flushed: bool,
137}
138
139/// Stack of section headers awaiting their first body line. Pushed by
140/// [`StageLogger::group`], drained by [`flush_pending`] when a real line is
141/// about to print, and popped (LIFO) by [`SectionGuard::drop`].
142///
143/// A `Mutex` (not a thread-local) because sections are opened on the main
144/// thread like [`SECTION_DEPTH`], but body lines may flush from a stage's
145/// worker threads (e.g. `build` spawning per-target threads). The lock
146/// serializes the flush state transition: each header's `flushed` flag flips
147/// under the lock, so a header prints exactly once — never lost, never
148/// duplicated. Header-before-body ordering holds because every emit method
149/// calls [`flush_pending`] then writes its body line with no early return
150/// between. It does not serialize body-line-vs-body-line ordering across
151/// workers — two `build` threads may print their body lines in either order
152/// under a just-flushed header, matching build's existing unordered parallel
153/// output.
154static PENDING: Mutex<Vec<PendingHeader>> = Mutex::new(Vec::new());
155
156/// Print every still-unflushed pending section header, in ancestor-first
157/// (bottom-to-top) order, then mark each flushed.
158///
159/// Called immediately before any method actually writes a visible body line,
160/// so the deferred headers appear above their first line in correct nesting
161/// order. A header renders at its own stored [`PendingHeader::depth`] — the
162/// 2-space-per-level indent, the right-aligned bold-green verb in the
163/// [`VERB_COLUMN`] gutter, then (if non-empty) one space and the message.
164///
165/// No-op when nothing is pending (the common case once a section has already
166/// emitted its first line), so the per-body-line cost is one uncontended lock.
167fn flush_pending() {
168    // Recover a poisoned guard rather than bailing: pending headers are pure
169    // presentation state, and silently muting every section header for the
170    // rest of the run on one panic-mid-format is worse than reusing it.
171    let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
172    for entry in pending.iter_mut() {
173        if entry.flushed {
174            continue;
175        }
176        let prefix = "  ".repeat(entry.depth);
177        let verb = format!("{:>VERB_COLUMN$}", entry.verb).green().bold();
178        if entry.msg.is_empty() {
179            eprintln!("{prefix}{verb}");
180        } else {
181            eprintln!("{prefix}{verb} {}", entry.msg);
182        }
183        entry.flushed = true;
184    }
185}
186
187/// Width of the right-aligned verb column in [`StageLogger::step`],
188/// matching Cargo's `   Compiling foo` look (3 leading spaces + 9-char
189/// verb = a 12-column gutter before the message).
190const VERB_COLUMN: usize = 12;
191
192/// Indent (after any section nesting) of a body line — a [`StageLogger::detail`]
193/// / [`success`] / [`failure`] / [`kv`] row, or a status label. Three spaces
194/// place the marker column one stop in from the section header's text, so body
195/// lines read as subordinate to the header above them.
196///
197/// [`success`]: StageLogger::success
198/// [`failure`]: StageLogger::failure
199/// [`kv`]: StageLogger::kv
200const BODY_INDENT: &str = "   ";
201
202/// Marker for an info / detail body line (`•`). Rendered cyan.
203const MARKER_DETAIL: &str = "•";
204
205/// Marker for a success body line (`✓`). Rendered green.
206const MARKER_SUCCESS: &str = "✓";
207
208/// Marker for a failure body line (`✗`). Rendered red.
209const MARKER_FAILURE: &str = "✗";
210
211/// Map a pipeline stage name to its full Cargo-style header phrase
212/// (`"Building binaries"`, `"Signing artifacts"`, `"Publishing"`). Drives
213/// [`StageLogger::group`]'s deferred header: the leading verb is right-aligned
214/// into the [`VERB_COLUMN`] gutter (bold-green, matching `cargo`'s
215/// `   Compiling foo` look), and the remaining words form the message that
216/// follows. A single-word phrase (`"Publishing"`) renders just the gutter
217/// verb with no trailing message.
218///
219/// The phrase is a *readable description* of the work, not an echo of the
220/// stage name — `group("build")` reads `   Building binaries`, not
221/// `   Building build`. This keeps the continuous log scannable: a reader
222/// sees what each section does, not the internal stage identifier.
223///
224/// Falls back to `"Running <stage>"` for any stage without a bespoke
225/// phrase, so a newly-added stage still renders in the system vocabulary
226/// (`   Running myfancystage`) without a code change here.
227pub fn stage_header(stage: &str) -> &'static str {
228    match stage {
229        "setup" => "Preparing release",
230        "build" => "Building binaries",
231        "archive" => "Creating archives",
232        "checksum" => "Computing checksums",
233        "sbom" => "Cataloging dependencies",
234        "templatefiles" => "Rendering templates",
235        "changelog" => "Generating changelog",
236        "attest" => "Generating attestations",
237        "binary-sign" => "Signing binaries",
238        "sign" => "Signing artifacts",
239        "docker" => "Building images",
240        "docker-sign" => "Signing images",
241        "upx" => "Compressing binaries",
242        "nfpm" => "Building packages",
243        "snapcraft" => "Building snap",
244        "flatpak" => "Building Flatpak",
245        "msi" => "Building MSI",
246        "nsis" => "Building installer",
247        "dmg" => "Building DMG",
248        "pkg" => "Building pkg",
249        "notarize" => "Notarizing app",
250        "makeself" => "Building installer",
251        "srpm" => "Building source RPM",
252        "appbundle" => "Building app bundle",
253        "appimage" => "Building AppImage",
254        "universal" => "Merging binaries",
255        "source" => "Archiving source",
256        "release" => "Creating release",
257        "before-publish" => "Preparing publishers",
258        "emission-validate" => "Validating output",
259        "publish" => "Publishing",
260        "blob" => "Uploading blobs",
261        "snapcraft-publish" => "Publishing snap",
262        "announce" => "Announcing release",
263        "verify-release" => "Verifying release",
264        "publisher-summary" => "Summary",
265        "check-determinism" => "Checking determinism",
266        "finalize" => "Finalizing",
267        "prepare" => "Preparing",
268        _ => "Running",
269    }
270}
271
272/// Render the themed `Warning:` line for `msg`, aligned to the body indent
273/// beneath the current section. The single source of truth for the warning
274/// palette and label, shared by [`StageLogger::warn`] and the CLI's tracing
275/// formatter so a library-side `warn!` looks identical to a logger warn
276/// (one output authority).
277pub fn render_warning(msg: &str) -> String {
278    format!(
279        "{}{}{} {}",
280        indent(),
281        BODY_INDENT,
282        "Warning:".yellow().bold(),
283        msg
284    )
285}
286
287/// Render the themed `Error:` line for `msg`, aligned to the body indent
288/// beneath the current section. Companion to [`render_warning`]; shared so
289/// the error palette/label lives in exactly one place.
290pub fn render_error(msg: &str) -> String {
291    format!(
292        "{}{}{} {}",
293        indent(),
294        BODY_INDENT,
295        "Error:".red().bold(),
296        msg
297    )
298}
299
300/// Render the themed `Note:` line for `msg`, aligned to the body indent
301/// beneath the current section. The third (and final) status label in the
302/// vocabulary — informational lines that are neither warnings nor errors
303/// (host-target selection, auto-snapshot activation). Bold-green to read as
304/// a benign status, distinct from the yellow `Warning:` and red `Error:`.
305/// Shared so the `Note:` palette/label lives in exactly one place rather than
306/// being open-coded per call site.
307pub fn render_note(msg: &str) -> String {
308    format!(
309        "{}{}{} {}",
310        indent(),
311        BODY_INDENT,
312        "Note:".green().bold(),
313        msg
314    )
315}
316
317/// Current indentation prefix (2 spaces per open section). Empty at the
318/// top level. Applied identically everywhere — including under GitHub
319/// Actions, where the indentation (not a collapsible `::group::` block) is
320/// what conveys section nesting, matching the continuous single-stream log
321/// GoReleaser emits.
322///
323/// Exposed so the CLI's loggerless `tracing` warning formatter can apply
324/// the same indent a library warn fired mid-stage would otherwise lack,
325/// keeping it aligned with the surrounding body lines.
326pub fn indent() -> String {
327    "  ".repeat(current_depth())
328}
329
330/// RAII guard returned by [`indent_one_level`]. Removes the extra indent
331/// level when dropped.
332#[must_use = "dropping the guard immediately removes the extra indent"]
333pub struct IndentGuard {
334    _private: (),
335}
336
337impl Drop for IndentGuard {
338    fn drop(&mut self) {
339        SECTION_DEPTH.fetch_sub(1, Ordering::Relaxed);
340    }
341}
342
343/// Deepen the body indent by one level WITHOUT opening a section header.
344///
345/// For rows that must align with the body bullets of sibling sections
346/// while no section is open — e.g. the pipeline's consolidated
347/// `skipped  a, b, c` row, which prints between stage sections (the
348/// previous stage's guard has already dropped) but should sit at the
349/// same column as those sections' own `•` lines instead of two columns
350/// to their left. Unlike [`StageLogger::group`] this pushes no pending
351/// header, so nothing extra ever prints.
352pub fn indent_one_level() -> IndentGuard {
353    SECTION_DEPTH.fetch_add(1, Ordering::Relaxed);
354    IndentGuard { _private: () }
355}
356
357/// RAII guard returned by [`StageLogger::group`]. Closes the section
358/// (decrements the indent depth) when dropped, so a stage's body
359/// indentation is always balanced even if the stage bails early with `?`.
360#[must_use = "dropping the guard immediately ends the section"]
361pub struct SectionGuard {
362    _private: (),
363}
364
365impl Drop for SectionGuard {
366    fn drop(&mut self) {
367        // Take the PENDING lock BEFORE decrementing the depth: a
368        // flush_pending observer on another thread serializes on this
369        // lock, so it sees the depth decrement and the pop as one
370        // transition instead of a window where the depth is already
371        // lowered but the section's pending header is still queued.
372        let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
373        SECTION_DEPTH.fetch_sub(1, Ordering::Relaxed);
374        // Remove this section's pending entry (LIFO matches nesting). An
375        // unflushed entry means the section emitted no body line — a no-op
376        // stage — so dropping it without printing is exactly the desired
377        // "no-op stages print nothing" behavior.
378        pending.pop();
379    }
380}
381
382/// Level of a log line captured by a [`LogCapture`]. Mirrors the
383/// [`StageLogger`] methods that produce each level.
384///
385/// Gated behind the `test-helpers` Cargo feature — production binaries
386/// do not link the capture infrastructure.
387#[cfg(feature = "test-helpers")]
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum LogLevel {
390    Error,
391    Warn,
392    Status,
393    Verbose,
394    Debug,
395}
396
397/// In-memory sink that records every log line a [`StageLogger`] emits.
398///
399/// Cheap clone (`Arc<Mutex<Vec<…>>>` underneath) — pass the same handle to
400/// every logger derived from a [`crate::context::Context`] and read aggregated
401/// counts back via the accessor methods. Intended for tests that need to
402/// assert "publisher emitted ≥N status lines" — calls still write to stderr
403/// so test output stays debuggable.
404///
405/// Gated behind the `test-helpers` Cargo feature.
406#[cfg(feature = "test-helpers")]
407#[derive(Clone, Default)]
408pub struct LogCapture {
409    inner: Arc<Mutex<Vec<(LogLevel, String)>>>,
410}
411
412#[cfg(feature = "test-helpers")]
413impl LogCapture {
414    /// Construct a fresh empty capture sink.
415    pub fn new() -> Self {
416        Self::default()
417    }
418
419    /// Append a log line to the capture vec. Called from the
420    /// [`StageLogger`] methods when a capture is attached.
421    pub(crate) fn record(&self, level: LogLevel, msg: impl Into<String>) {
422        if let Ok(mut guard) = self.inner.lock() {
423            guard.push((level, msg.into()));
424        }
425    }
426
427    /// Number of [`LogLevel::Status`] lines recorded.
428    pub fn status_count(&self) -> usize {
429        self.count(LogLevel::Status)
430    }
431
432    /// Number of [`LogLevel::Debug`] lines recorded.
433    pub fn debug_count(&self) -> usize {
434        self.count(LogLevel::Debug)
435    }
436
437    /// Number of [`LogLevel::Warn`] lines recorded.
438    pub fn warn_count(&self) -> usize {
439        self.count(LogLevel::Warn)
440    }
441
442    /// Number of [`LogLevel::Error`] lines recorded.
443    pub fn error_count(&self) -> usize {
444        self.count(LogLevel::Error)
445    }
446
447    /// Total count across all levels (useful sanity check).
448    pub fn total_count(&self) -> usize {
449        self.inner.lock().map(|g| g.len()).unwrap_or(0)
450    }
451
452    fn count(&self, level: LogLevel) -> usize {
453        self.inner
454            .lock()
455            .map(|g| g.iter().filter(|(l, _)| *l == level).count())
456            .unwrap_or(0)
457    }
458
459    /// Snapshot of every recorded line in insertion order.
460    pub fn all_messages(&self) -> Vec<(LogLevel, String)> {
461        self.inner.lock().map(|g| g.clone()).unwrap_or_default()
462    }
463
464    /// Snapshot of every [`LogLevel::Warn`] message in insertion order.
465    ///
466    /// Convenience accessor for tests that care only about warns — strips
467    /// the level tuple [`all_messages`] returns so callers can write
468    /// `cap.warn_messages().iter().any(|m| m.contains("..."))` without
469    /// the per-call filter+map boilerplate.
470    ///
471    /// [`all_messages`]: Self::all_messages
472    pub fn warn_messages(&self) -> Vec<String> {
473        self.inner
474            .lock()
475            .map(|g| {
476                g.iter()
477                    .filter(|(lvl, _)| *lvl == LogLevel::Warn)
478                    .map(|(_, m)| m.clone())
479                    .collect()
480            })
481            .unwrap_or_default()
482    }
483}
484
485/// Verbosity level, derived from CLI flags.
486#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
487pub enum Verbosity {
488    Quiet,
489    #[default]
490    Normal,
491    Verbose,
492    Debug,
493}
494
495impl Verbosity {
496    /// Derive verbosity from CLI flag combination.
497    /// `--quiet` overrides `--verbose`; `--debug` overrides everything.
498    pub fn from_flags(quiet: bool, verbose: bool, debug: bool) -> Self {
499        if debug {
500            Verbosity::Debug
501        } else if quiet {
502            Verbosity::Quiet
503        } else if verbose {
504            Verbosity::Verbose
505        } else {
506            Verbosity::Normal
507        }
508    }
509}
510
511/// Stage logger: wraps a stage name, verbosity level, and an optional
512/// env-pairs list used for secret redaction.
513///
514/// All output goes to stderr. Create one per stage via [`StageLogger::new`].
515/// Prefer `Context::logger("name")` over `StageLogger::new` when a
516/// `Context` is in scope, because it carries the env automatically.
517///
518/// ```rust,ignore
519/// let log = ctx.logger("build");                  // env pre-populated
520/// let log = StageLogger::new("build", verbosity)  // no env yet
521///     .with_env(env_pairs);                       // attach env for redact
522/// log.status("compiling for x86_64-unknown-linux-gnu");
523/// log.verbose(&format!("RUSTFLAGS={}", flags));
524/// log.debug(&format!("full env = {:?}", env));
525/// ```
526#[derive(Clone)]
527pub struct StageLogger {
528    /// The logger's stage identity. No longer printed (the per-line
529    /// `[stage]` tag was dropped for the unified body style — section
530    /// headers name the stage instead), but retained as the constructor
531    /// contract: callers build a logger per stage via [`Self::new`] /
532    /// [`crate::context::Context::logger`] and retag sub-sections via
533    /// [`Self::with_stage`]. Kept so those entry points keep a stable
534    /// signature.
535    #[allow(dead_code)]
536    stage: &'static str,
537    verbosity: Verbosity,
538    /// Env-pairs used to redact subprocess output and bail messages. The
539    /// inner vec is shared via `Arc` so cloning a logger does not copy the
540    /// env every time. `None` means redaction is a no-op (matches the
541    /// behaviour before this field existed).
542    env: Option<Arc<Vec<(String, String)>>>,
543    /// Optional in-memory capture sink. When present, every log method also
544    /// appends to the capture vec (after the stderr write). `None` means
545    /// the logger only writes to stderr (production default).
546    ///
547    /// Gated behind the `test-helpers` Cargo feature — production binaries
548    /// do not carry the field, so no per-log-call `is_none()` check fires.
549    #[cfg(feature = "test-helpers")]
550    capture: Option<LogCapture>,
551}
552
553impl StageLogger {
554    pub fn new(stage: &'static str, verbosity: Verbosity) -> Self {
555        Self {
556            stage,
557            verbosity,
558            env: None,
559            #[cfg(feature = "test-helpers")]
560            capture: None,
561        }
562    }
563
564    /// Construct a logger backed by an in-memory [`LogCapture`] alongside the
565    /// usual stderr writes. Returns the logger plus a clone of the capture
566    /// handle so the test can read counts back after the SUT runs.
567    ///
568    /// Intended exclusively for tests — production code uses
569    /// [`StageLogger::new`] or [`crate::context::Context::logger`].
570    ///
571    /// Gated behind the `test-helpers` Cargo feature.
572    #[cfg(feature = "test-helpers")]
573    pub fn with_capture(stage: &'static str, verbosity: Verbosity) -> (Self, LogCapture) {
574        let capture = LogCapture::new();
575        let logger = Self {
576            stage,
577            verbosity,
578            env: None,
579            capture: Some(capture.clone()),
580        };
581        (logger, capture)
582    }
583
584    /// Attach an existing [`LogCapture`] to this logger. Useful when the
585    /// capture is owned by a [`crate::context::Context`] and every derived
586    /// logger should append to the same vec.
587    ///
588    /// Gated behind the `test-helpers` Cargo feature.
589    #[cfg(feature = "test-helpers")]
590    pub fn with_capture_handle(mut self, capture: LogCapture) -> Self {
591        self.capture = Some(capture);
592        self
593    }
594
595    /// Attach an env-pairs list to drive secret redaction inside
596    /// [`StageLogger::check_output`] and [`StageLogger::redact`]. The list
597    /// is shared via `Arc`, so passing the same vec to many loggers does
598    /// not duplicate the underlying storage.
599    pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
600        self.env = Some(Arc::new(env));
601        self
602    }
603
604    /// Derive a clone of this logger tagged for a different `stage`, keeping
605    /// verbosity, the (Arc-shared) redaction env, and any capture sink.
606    ///
607    /// The pipeline driver owns one `[release]`-tagged logger but brackets
608    /// sub-sections (`setup`, `finalize`, `publisher-summary`) with their own
609    /// `group()`. Body lines emitted inside such a section must carry the
610    /// *section's* tag, not `[release]`, or the output reads
611    /// `[release] wrote …` underneath `::group::finalize`. Retagging once at
612    /// the section boundary lets every helper called within the section emit
613    /// under the correct tag without threading an explicit `stage` argument
614    /// through each call.
615    pub fn with_stage(&self, stage: &'static str) -> Self {
616        Self {
617            stage,
618            verbosity: self.verbosity,
619            env: self.env.clone(),
620            #[cfg(feature = "test-helpers")]
621            capture: self.capture.clone(),
622        }
623    }
624
625    /// Redact secret values from `s` using this logger's attached env.
626    ///
627    /// When no env has been attached (the default for `StageLogger::new`),
628    /// returns the input unchanged. Combines `redact::string` (for
629    /// known-secret env values) with `redact::redact_url_credentials`
630    /// (for inline `https://<user>:<pass>@host` URL credentials that may
631    /// not match any exported env-var value).
632    pub fn redact(&self, s: &str) -> String {
633        let credential_stripped = crate::redact::redact_url_credentials(s);
634        match self.env.as_deref() {
635            Some(env) => crate::redact::string(&credential_stripped, env),
636            None => credential_stripped,
637        }
638    }
639
640    /// Render a body line: the current section indent, the 3-space body
641    /// indent, a colored `marker`, one space, then `text`. The single source
642    /// of truth for the `•` / `✓` / `✗` body register so every marker line
643    /// aligns byte-identically under its section header.
644    fn render_body(marker: &str, text: &str) -> String {
645        format!("{}{}{} {}", indent(), BODY_INDENT, marker, text)
646    }
647
648    /// Error message — always shown (even in quiet mode). Renders the
649    /// `Error:` status label at the body indent beneath the current section.
650    pub fn error(&self, msg: &str) {
651        flush_pending();
652        eprintln!("{}", render_error(msg));
653        #[cfg(feature = "test-helpers")]
654        if let Some(cap) = &self.capture {
655            cap.record(LogLevel::Error, msg);
656        }
657    }
658
659    /// Warning message — shown at Normal and above. Renders the `Warning:`
660    /// status label at the body indent beneath the current section.
661    pub fn warn(&self, msg: &str) {
662        if self.verbosity >= Verbosity::Normal {
663            flush_pending();
664            eprintln!("{}", render_warning(msg));
665        }
666        #[cfg(feature = "test-helpers")]
667        if let Some(cap) = &self.capture {
668            cap.record(LogLevel::Warn, msg);
669        }
670    }
671
672    /// Status message — shown at Normal and above. This is the default level
673    /// for key actions (stage start, completion, skips, dry-run notes).
674    ///
675    /// Renders as a `•` detail body line beneath the current section. An
676    /// empty `msg` is preserved as a bare blank spacer line (no marker, no
677    /// indent) so callers using `status("")` for vertical rhythm keep a
678    /// clean blank even inside a group. For an explicit register, prefer
679    /// [`Self::detail`] / [`Self::success`] / [`Self::failure`].
680    pub fn status(&self, msg: &str) {
681        if self.verbosity >= Verbosity::Normal {
682            if msg.is_empty() {
683                // A marker on a "blank" line would render as a stray bullet;
684                // emit a truly empty line to preserve the caller's rhythm.
685                // A blank spacer is NOT a real body line, so it does not flush
686                // pending headers (a no-op section must stay invisible).
687                eprintln!();
688            } else {
689                flush_pending();
690                eprintln!(
691                    "{}",
692                    Self::render_body(&MARKER_DETAIL.cyan().to_string(), msg)
693                );
694            }
695        }
696        #[cfg(feature = "test-helpers")]
697        if let Some(cap) = &self.capture {
698            cap.record(LogLevel::Status, msg);
699        }
700    }
701
702    /// Info / detail body line — a cyan `•` marker, then `msg`, at the body
703    /// indent beneath the current section. Shown at Normal and above. The
704    /// explicit-register sibling of [`Self::status`] for callers that want to
705    /// name the `•` style directly.
706    pub fn detail(&self, msg: &str) {
707        if self.verbosity >= Verbosity::Normal {
708            flush_pending();
709            eprintln!(
710                "{}",
711                Self::render_body(&MARKER_DETAIL.cyan().to_string(), msg)
712            );
713        }
714        #[cfg(feature = "test-helpers")]
715        if let Some(cap) = &self.capture {
716            cap.record(LogLevel::Status, msg);
717        }
718    }
719
720    /// Success body line — a green `✓` marker, then `msg`, at the body indent
721    /// beneath the current section. Shown at Normal and above. Use for a
722    /// completed unit of work (`✓ x86_64-… 1.2 MiB`, `✓ signed 6 artifacts`).
723    pub fn success(&self, msg: &str) {
724        if self.verbosity >= Verbosity::Normal {
725            flush_pending();
726            eprintln!(
727                "{}",
728                Self::render_body(&MARKER_SUCCESS.green().to_string(), msg)
729            );
730        }
731        #[cfg(feature = "test-helpers")]
732        if let Some(cap) = &self.capture {
733            cap.record(LogLevel::Status, msg);
734        }
735    }
736
737    /// Failure body line — a red `✗` marker, then `msg`, at the body indent
738    /// beneath the current section. Shown at Normal and above. Use for a
739    /// failed unit of work that is reported inline (the run continues or the
740    /// error is surfaced separately via [`Self::error`]).
741    pub fn failure(&self, msg: &str) {
742        if self.verbosity >= Verbosity::Normal {
743            flush_pending();
744            eprintln!(
745                "{}",
746                Self::render_body(&MARKER_FAILURE.red().to_string(), msg)
747            );
748        }
749        #[cfg(feature = "test-helpers")]
750        if let Some(cap) = &self.capture {
751            cap.record(LogLevel::Status, msg);
752        }
753    }
754
755    /// Key/value meta row — a `•` detail line whose lowercase dimmed `key` is
756    /// left-padded to `key_width` so the values line up within a group, then
757    /// the `value`. Shown at Normal and above.
758    ///
759    /// Lowercase keys must never sit in the verb gutter (that column is for
760    /// bold capitalized verbs only), so meta rows render in the body
761    /// register. Callers that emit several rows pass the width of their
762    /// widest key as `key_width` so the values share a column:
763    ///
764    /// ```rust,ignore
765    /// let w = ["targets", "stages", "runs"].iter().map(|k| k.len()).max().unwrap();
766    /// log.kv("targets", "aarch64-pc-windows-msvc", w);
767    /// log.kv("stages", "build, source, sign", w);
768    /// log.kv("runs", "2", w);
769    /// //   • targets  aarch64-pc-windows-msvc
770    /// //   • stages   build, source, sign
771    /// //   • runs     2
772    /// ```
773    pub fn kv(&self, key: &str, value: &str, key_width: usize) {
774        if self.verbosity >= Verbosity::Normal {
775            // Pad the PLAIN key to width before coloring — padding the
776            // already-dimmed string would count the ANSI escape bytes toward
777            // the field width and misalign the value column. Two spaces after
778            // the padded key give a readable gutter without a separator glyph.
779            let padded = format!("{key:<key_width$}");
780            let row = format!("{}  {}", padded.dimmed(), value);
781            flush_pending();
782            eprintln!(
783                "{}",
784                Self::render_body(&MARKER_DETAIL.cyan().to_string(), &row)
785            );
786        }
787        #[cfg(feature = "test-helpers")]
788        if let Some(cap) = &self.capture {
789            cap.record(LogLevel::Status, format!("{key} = {value}"));
790        }
791    }
792
793    /// Cargo-style status line: a capitalized, right-aligned, bold-green
794    /// `verb` in a fixed-width gutter followed by `msg`
795    /// (`   Building binaries`, `   Signing artifacts`). Shown at Normal and
796    /// above. Use for section/stage headers where there is a natural
797    /// verb; plain key-action lines stay on [`StageLogger::status`].
798    pub fn step(&self, verb: &str, msg: &str) {
799        if self.verbosity >= Verbosity::Normal {
800            eprintln!(
801                "{}{} {}",
802                indent(),
803                format!("{verb:>VERB_COLUMN$}").green().bold(),
804                msg
805            );
806        }
807        #[cfg(feature = "test-helpers")]
808        if let Some(cap) = &self.capture {
809            cap.record(LogLevel::Status, msg);
810        }
811    }
812
813    /// Open a log section for stage `title`.
814    ///
815    /// The Cargo-style header (derived from [`stage_header`]: the phrase's
816    /// leading verb bold-green and right-aligned in the [`VERB_COLUMN`]
817    /// gutter, then one space and the remaining words — `   Building binaries`,
818    /// ` Publishing` for a single-word phrase) is *deferred*: it prints only
819    /// when this section emits its first real body line, matching GoReleaser
820    /// (a section header appears only once the section has output). A stage
821    /// that does nothing therefore prints no header at all — no bare
822    /// `Verifying release` over an empty body. The header renders identically
823    /// everywhere — locally and under GitHub Actions — because anodizer streams
824    /// one continuous log; the body indentation (not a collapsible `::group::`
825    /// block) conveys nesting. Every subsequent log line is indented two spaces
826    /// until the guard drops. Sections nest.
827    ///
828    /// ```rust,ignore
829    /// let _section = log.group("build");                 // header pending…
830    /// log.status("compiling x86_64-unknown-linux-gnu");  //    Building binaries
831    ///                                                     //    • compiling …
832    /// // section closes here as `_section` drops
833    /// ```
834    #[must_use = "the section stays open only while the guard is alive"]
835    pub fn group(&self, title: &str) -> SectionGuard {
836        // Defer the header: push it onto the pending stack at the CURRENT depth
837        // (before incrementing) and print it only when this section actually
838        // emits a body line via `flush_pending`. A stage that does nothing
839        // therefore prints no header at all.
840        let (verb, msg) = self.split_header(title);
841        let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
842        pending.push(PendingHeader {
843            depth: current_depth(),
844            verb: verb.to_string(),
845            msg: msg.to_string(),
846            flushed: false,
847        });
848        // Track depth even at Quiet verbosity so any line that DOES print
849        // (errors) indents correctly and the guard's decrement is balanced.
850        SECTION_DEPTH.fetch_add(1, Ordering::Relaxed);
851        SectionGuard { _private: () }
852    }
853
854    /// Split a stage's [`stage_header`] phrase into the `(verb, message)`
855    /// pair [`Self::group`] feeds to [`Self::step`]. The verb is everything
856    /// up to the first space; the message is the remainder (empty for a
857    /// single-word phrase, which renders as a bare gutter verb). An unknown
858    /// stage (default `"Running"`) takes the stage name itself as the
859    /// message, so it reads `   Running myfancystage`.
860    fn split_header<'a>(&self, title: &'a str) -> (&'a str, &'a str) {
861        let phrase = stage_header(title);
862        match phrase.split_once(' ') {
863            Some((verb, rest)) => (verb, rest),
864            // Single-word phrase: the default "Running" echoes the stage name
865            // as its object; any other single word renders verb-only.
866            None if phrase == "Running" => (phrase, title),
867            None => (phrase, ""),
868        }
869    }
870
871    /// Detail message — shown only at Verbose and above. Renders as a `•`
872    /// detail body line beneath the current section.
873    /// Use for: command output on success, env vars, file paths, template vars.
874    pub fn verbose(&self, msg: &str) {
875        if self.verbosity >= Verbosity::Verbose {
876            flush_pending();
877            eprintln!(
878                "{}",
879                Self::render_body(&MARKER_DETAIL.cyan().to_string(), msg)
880            );
881        }
882        #[cfg(feature = "test-helpers")]
883        if let Some(cap) = &self.capture {
884            cap.record(LogLevel::Verbose, msg);
885        }
886    }
887
888    /// Debug message — shown only at Debug level. Renders as a dimmed `•`
889    /// detail body line beneath the current section.
890    /// Use for: HTTP request/response details, full template contexts, resolved config.
891    pub fn debug(&self, msg: &str) {
892        if self.verbosity >= Verbosity::Debug {
893            flush_pending();
894            eprintln!(
895                "{}",
896                Self::render_body(
897                    &MARKER_DETAIL.dimmed().to_string(),
898                    &msg.dimmed().to_string()
899                )
900            );
901        }
902        #[cfg(feature = "test-helpers")]
903        if let Some(cap) = &self.capture {
904            cap.record(LogLevel::Debug, msg);
905        }
906    }
907
908    /// Emit a per-crate "no `<publisher>` config block" skip line at the
909    /// verbosity the operator asked for.
910    ///
911    /// These lines fire once per non-applicable crate in workspace mode (every
912    /// PR-based publisher visits every selected crate and skips the ones whose
913    /// config lacks its block), so at default verbosity they would bury the
914    /// real output under hundreds of lines of pure no-op noise. They are routed
915    /// to [`Self::debug`] (invisible at default and `--verbose`, visible at
916    /// `--debug`) unless `show` is set — `--show-skipped`, the diagnostic
917    /// escape hatch for "why didn't publisher X run for crate Y?" — in which
918    /// case they surface at [`Self::status`] like any other key action.
919    pub fn skip_line(&self, show: bool, msg: &str) {
920        if show {
921            self.status(msg);
922        } else {
923            self.debug(msg);
924        }
925    }
926
927    /// Return the current verbosity level.
928    pub fn verbosity(&self) -> Verbosity {
929        self.verbosity
930    }
931
932    /// Snapshot this logger's redaction env-pairs (empty when none is
933    /// attached). Lets a caller construct a sibling logger at a different
934    /// verbosity while preserving the same secret-redaction policy — e.g. the
935    /// blob KMS path, which runs its encrypt subprocesses through a
936    /// Normal-verbosity clone so ciphertext is never teed live, yet must keep
937    /// the original logger's redaction coverage.
938    pub fn redaction_env(&self) -> Vec<(String, String)> {
939        self.env.as_deref().cloned().unwrap_or_default()
940    }
941
942    /// Check if verbose output is enabled.
943    pub fn is_verbose(&self) -> bool {
944        self.verbosity >= Verbosity::Verbose
945    }
946
947    /// Check if debug output is enabled.
948    pub fn is_debug(&self) -> bool {
949        self.verbosity >= Verbosity::Debug
950    }
951
952    /// Tee a single line of a child process's standard output to this
953    /// process's STDERR, unmodified except for secret redaction.
954    ///
955    /// Used by the verbose live-stream in [`crate::run::run_checked`]: long
956    /// tools (cargo, snapcraft, nix-build) show progress as they run. Unlike
957    /// the marker-prefixed body register ([`Self::verbose`]), the line is
958    /// written *raw* — no `•` marker, no section indent — so the streamed
959    /// output looks exactly as the tool produced it, matching a tee.
960    ///
961    /// The tee goes to **stderr**, not stdout: anodizer's stdout is a
962    /// machine-readable data channel (GHA step outputs like `new_tag=…`, plus
963    /// json/changelog/metadata payloads), and a hook's child stdout teed onto
964    /// stdout would corrupt it. Progress UX belongs on stderr with every other
965    /// human/log line. Recorded into any attached [`LogCapture`] at
966    /// [`LogLevel::Verbose`] so tests can assert the stream surfaced.
967    ///
968    /// `line` must be a single line with its trailing newline already
969    /// stripped (the caller's line reader does this); the newline is added
970    /// here by `eprintln!`.
971    pub fn stream_child_stdout(&self, line: &str) {
972        self.stream_child_line(line, false);
973    }
974
975    /// Tee a single line of a child process's standard error to this
976    /// process's stderr, unmodified except for secret redaction. The stderr
977    /// companion to [`Self::stream_child_stdout`]; recorded at
978    /// [`LogLevel::Error`] so the verbose-failure double-emit guard is
979    /// observable in tests.
980    pub fn stream_child_stderr(&self, line: &str) {
981        self.stream_child_line(line, true);
982    }
983
984    /// Shared body of [`stream_child_stdout`](Self::stream_child_stdout) and
985    /// [`stream_child_stderr`](Self::stream_child_stderr): flush any deferred
986    /// section header, write the redacted line to stderr, and record it.
987    /// Both child streams tee to stderr (see
988    /// [`stream_child_stdout`](Self::stream_child_stdout)); the methods differ
989    /// only in which capture level the line is recorded under
990    /// (`from_stderr` selects [`LogLevel::Error`] vs [`LogLevel::Verbose`]),
991    /// so the write lives here once.
992    ///
993    /// `flush_pending()` runs first so a streamed line never prints *above* its
994    /// deferred section header — the same header-before-body ordering every
995    /// other body-line emitter (`verbose` / `status` / `error` / `debug`)
996    /// upholds.
997    fn stream_child_line(&self, line: &str, from_stderr: bool) {
998        let redacted = self.redact(line);
999        flush_pending();
1000        eprintln!("{redacted}");
1001        #[cfg(feature = "test-helpers")]
1002        if let Some(cap) = &self.capture {
1003            let level = if from_stderr {
1004                LogLevel::Error
1005            } else {
1006                LogLevel::Verbose
1007            };
1008            cap.record(level, redacted);
1009        }
1010        #[cfg(not(feature = "test-helpers"))]
1011        let _ = from_stderr;
1012    }
1013
1014    /// Check command output, log stderr/stdout on failure, and bail with context.
1015    /// On success, log stdout at verbose level. Returns `Ok(output)` on success.
1016    ///
1017    /// Stderr and stdout are passed through [`StageLogger::redact`] before
1018    /// they reach the log sink, so any secret env-var values present in the
1019    /// subprocess output are replaced with `$KEY_NAME` (and inline
1020    /// `https://<user>:<pass>@host` URL credentials are scrubbed) without
1021    /// callers having to remember to redact at each call site. Mirrors
1022    /// a safe-stderr pattern at every subprocess
1023    /// boundary.
1024    pub fn check_output(
1025        &self,
1026        output: std::process::Output,
1027        label: &str,
1028    ) -> anyhow::Result<std::process::Output> {
1029        self.check_output_inner(output, label, false)
1030    }
1031
1032    /// Like [`StageLogger::check_output`], but for callers that already
1033    /// streamed the child's stdout/stderr live (the verbose tee in
1034    /// [`crate::run::run_checked`]). Suppresses the stderr/stdout re-emit
1035    /// — both on the success-verbose path and the failure path — so output
1036    /// that was teed line-by-line is not printed a second time. The
1037    /// `bail!` embed (tail-truncated, redacted stderr in the error chain)
1038    /// is preserved unchanged, so error-chain consumers still see context.
1039    pub fn check_output_streamed(
1040        &self,
1041        output: std::process::Output,
1042        label: &str,
1043    ) -> anyhow::Result<std::process::Output> {
1044        self.check_output_inner(output, label, true)
1045    }
1046
1047    /// Shared body of [`check_output`](Self::check_output) and
1048    /// [`check_output_streamed`](Self::check_output_streamed).
1049    ///
1050    /// `already_streamed` suppresses the stderr/stdout log re-emit (the
1051    /// caller's live tee already wrote those lines), but never the `bail!`
1052    /// embed: an error chain propagated past the logger still carries the
1053    /// redacted, truncated stderr tail regardless of streaming.
1054    fn check_output_inner(
1055        &self,
1056        output: std::process::Output,
1057        label: &str,
1058        already_streamed: bool,
1059    ) -> anyhow::Result<std::process::Output> {
1060        let (stderr_line, stdout_line) = self.format_output_lines(&output, label);
1061        if !output.status.success() {
1062            if !already_streamed {
1063                if let Some(line) = stderr_line {
1064                    self.error(&line);
1065                }
1066                if let Some(line) = stdout_line {
1067                    self.error(&line);
1068                }
1069            }
1070            // Embed a (truncated, redacted) stderr tail in the bubbled
1071            // error so operators reading the final anyhow chain see
1072            // something more actionable than just an exit code. The
1073            // separately-emitted `log.error` lines above remain the
1074            // primary surface; this is defense in depth for callers
1075            // that propagate the error past the StageLogger context.
1076            let stderr_raw = String::from_utf8_lossy(&output.stderr);
1077            let stderr_tail = if stderr_raw.is_empty() {
1078                String::from("<no stderr>")
1079            } else {
1080                let redacted = self.redact(&stderr_raw);
1081                let trimmed = redacted.trim();
1082                // Cap at 2 KiB to keep error chains scannable.
1083                const MAX: usize = 2048;
1084                if trimmed.len() > MAX {
1085                    let cut = trimmed
1086                        .char_indices()
1087                        .nth(MAX)
1088                        .map(|(i, _)| i)
1089                        .unwrap_or(MAX);
1090                    format!("{}…", &trimmed[..cut])
1091                } else {
1092                    trimmed.to_string()
1093                }
1094            };
1095            anyhow::bail!(
1096                "{} failed with exit code: {}; stderr: {}",
1097                label,
1098                output.status.code().unwrap_or(-1),
1099                stderr_tail
1100            );
1101        }
1102        if !already_streamed
1103            && self.is_verbose()
1104            && let Some(line) = stdout_line
1105        {
1106            self.verbose(&line);
1107        }
1108        Ok(output)
1109    }
1110
1111    /// Compose the redacted stderr / stdout log lines that
1112    /// [`StageLogger::check_output`] would emit for `output`. Returned as
1113    /// `(stderr_line, stdout_line)` where each `Option` is `Some` only when
1114    /// the corresponding stream had any content. Exposed via
1115    /// `pub(crate)` so the redaction logic can be unit-tested without
1116    /// having to capture stderr (`eprintln!` cannot be intercepted from
1117    /// the same process portably).
1118    pub(crate) fn format_output_lines(
1119        &self,
1120        output: &std::process::Output,
1121        label: &str,
1122    ) -> (Option<String>, Option<String>) {
1123        let stderr_raw = String::from_utf8_lossy(&output.stderr);
1124        let stderr_line = if stderr_raw.is_empty() {
1125            None
1126        } else {
1127            let stderr = self.redact(&stderr_raw);
1128            let prefix = if output.status.success() {
1129                "output"
1130            } else {
1131                "stderr"
1132            };
1133            // Failure messages format stderr separately from stdout (under
1134            // the "stderr" label); success uses one "output" label for
1135            // stdout only.
1136            if output.status.success() {
1137                // success path: stderr is never surfaced through check_output
1138                None
1139            } else {
1140                Some(format!("{label} {prefix}:\n{stderr}"))
1141            }
1142        };
1143        let stdout_raw = String::from_utf8_lossy(&output.stdout);
1144        let stdout_line = if stdout_raw.is_empty() {
1145            None
1146        } else {
1147            let stdout = self.redact(&stdout_raw);
1148            let prefix = if output.status.success() {
1149                "output"
1150            } else {
1151                "stdout"
1152            };
1153            Some(format!("{label} {prefix}:\n{stdout}"))
1154        };
1155        (stderr_line, stdout_line)
1156    }
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161    use super::*;
1162
1163    /// Serializes the section-depth tests: `SECTION_DEPTH` is a
1164    /// process-global atomic, so two grouping tests running on parallel
1165    /// threads would observe each other's increments.
1166    static SECTION_TEST_LOCK: Mutex<()> = Mutex::new(());
1167
1168    #[test]
1169    fn test_group_guard_balances_depth_locally() {
1170        // `group()` increments depth on open and the guard decrements on
1171        // drop, so nested sections always balance back to the start depth.
1172        let _guard = SECTION_TEST_LOCK.lock().unwrap();
1173        let log = StageLogger::new("build", Verbosity::Normal);
1174        let start = SECTION_DEPTH.load(Ordering::Relaxed);
1175        {
1176            let _outer = log.group("build");
1177            assert_eq!(SECTION_DEPTH.load(Ordering::Relaxed), start + 1);
1178            {
1179                let _inner = log.group("sign");
1180                assert_eq!(SECTION_DEPTH.load(Ordering::Relaxed), start + 2);
1181            }
1182            assert_eq!(SECTION_DEPTH.load(Ordering::Relaxed), start + 1);
1183        }
1184        assert_eq!(SECTION_DEPTH.load(Ordering::Relaxed), start);
1185    }
1186
1187    #[test]
1188    fn test_group_quiet_still_tracks_local_depth() {
1189        // Even at Quiet verbosity the indent depth must stay balanced so
1190        // any status lines that DO print (errors) indent correctly and the
1191        // guard's decrement has a matching increment.
1192        let _guard = SECTION_TEST_LOCK.lock().unwrap();
1193        let log = StageLogger::new("build", Verbosity::Quiet);
1194        let start = SECTION_DEPTH.load(Ordering::Relaxed);
1195        {
1196            let _s = log.group("build");
1197            assert_eq!(SECTION_DEPTH.load(Ordering::Relaxed), start + 1);
1198        }
1199        assert_eq!(SECTION_DEPTH.load(Ordering::Relaxed), start);
1200    }
1201
1202    #[test]
1203    fn test_group_with_body_flushes_header_once() {
1204        // A section that emits a real body line flushes its deferred header:
1205        // the pending entry is marked `flushed` exactly once and stays at its
1206        // own depth. (`flush_pending` writes the header to stderr; we assert
1207        // the state transition rather than capture the uncapturable eprintln.)
1208        let _guard = SECTION_TEST_LOCK.lock().unwrap();
1209        let log = StageLogger::new("build", Verbosity::Normal);
1210        {
1211            let _section = log.group("build");
1212            // Header is pending, not yet printed.
1213            assert!(!PENDING.lock().unwrap().last().unwrap().flushed);
1214            log.status("compiling x86_64-unknown-linux-gnu");
1215            // The body line flushed the header.
1216            let pending = PENDING.lock().unwrap();
1217            let entry = pending.last().unwrap();
1218            assert!(entry.flushed, "body line must flush the header");
1219            assert_eq!(entry.verb, "Building");
1220            assert_eq!(entry.msg, "binaries");
1221        }
1222        // Guard drop popped the (flushed) entry.
1223        assert!(PENDING.lock().unwrap().is_empty());
1224    }
1225
1226    #[test]
1227    fn test_noop_group_prints_no_header() {
1228        // A section that emits NOTHING leaves its pending entry unflushed, and
1229        // the guard drop pops it without ever printing — a no-op stage shows
1230        // no bare header (the GoReleaser behavior). A blank `status("")` spacer
1231        // is NOT a real body line, so it does not flush either.
1232        let _guard = SECTION_TEST_LOCK.lock().unwrap();
1233        let log = StageLogger::new("verify-release", Verbosity::Normal);
1234        {
1235            let _section = log.group("verify-release");
1236            log.status(""); // blank spacer — must not flush
1237            assert!(
1238                !PENDING.lock().unwrap().last().unwrap().flushed,
1239                "a no-op section's header must stay unflushed"
1240            );
1241        }
1242        assert!(PENDING.lock().unwrap().is_empty());
1243    }
1244
1245    #[test]
1246    fn test_nested_groups_flush_in_ancestor_order() {
1247        // A body line in a nested section flushes BOTH the ancestor and the
1248        // nested header (each at its own stored depth), so the deferred
1249        // headers appear in correct order above the first line.
1250        let _guard = SECTION_TEST_LOCK.lock().unwrap();
1251        let log = StageLogger::new("publish", Verbosity::Normal);
1252        let start = SECTION_DEPTH.load(Ordering::Relaxed);
1253        {
1254            let _outer = log.group("publish");
1255            {
1256                let _inner = log.group("blob");
1257                log.status("uploading blob");
1258                let pending = PENDING.lock().unwrap();
1259                assert_eq!(pending.len(), 2);
1260                assert!(pending[0].flushed, "ancestor header must flush");
1261                assert!(pending[1].flushed, "nested header must flush");
1262                assert_eq!(pending[0].depth, start);
1263                assert_eq!(pending[1].depth, start + 1);
1264            }
1265        }
1266        assert!(PENDING.lock().unwrap().is_empty());
1267    }
1268
1269    #[test]
1270    fn test_indent_reflects_section_depth() {
1271        // Indentation tracks the open-section depth (2 spaces per level)
1272        // identically everywhere — anodizer streams one continuous log, so
1273        // indentation (not a collapsible `::group::` block) conveys nesting.
1274        let _guard = SECTION_TEST_LOCK.lock().unwrap();
1275        let log = StageLogger::new("build", Verbosity::Normal);
1276        // Relative to the inherited base so an exported ANODIZER_LOG_DEPTH
1277        // in the test environment shifts every expectation uniformly.
1278        let base = "  ".repeat(base_depth());
1279        assert_eq!(indent(), base);
1280        {
1281            let _outer = log.group("build");
1282            assert_eq!(indent(), format!("{base}  "));
1283            {
1284                let _inner = log.group("sign");
1285                assert_eq!(indent(), format!("{base}    "));
1286            }
1287            assert_eq!(indent(), format!("{base}  "));
1288        }
1289        assert_eq!(indent(), base);
1290    }
1291
1292    #[test]
1293    fn test_indent_one_level_adds_depth_without_pending_header() {
1294        // The header-less guard must deepen the indent (so the row aligns
1295        // with sibling sections' body bullets) without registering a
1296        // pending header that a later body line could spuriously flush.
1297        let _guard = SECTION_TEST_LOCK.lock().unwrap();
1298        let start = SECTION_DEPTH.load(Ordering::Relaxed);
1299        let pending_before = PENDING.lock().unwrap().len();
1300        {
1301            let _indent = indent_one_level();
1302            assert_eq!(SECTION_DEPTH.load(Ordering::Relaxed), start + 1);
1303            assert_eq!(
1304                PENDING.lock().unwrap().len(),
1305                pending_before,
1306                "indent_one_level must not push a pending header"
1307            );
1308            assert_eq!(indent(), "  ".repeat(current_depth()));
1309        }
1310        assert_eq!(SECTION_DEPTH.load(Ordering::Relaxed), start);
1311    }
1312
1313    #[test]
1314    fn test_parse_base_depth_accepts_valid_and_degrades_invalid() {
1315        // A subprocess child inherits a numeric depth; anything else
1316        // (absent, junk, negative) degrades to the standalone default 0 —
1317        // indentation must never abort a run.
1318        assert_eq!(parse_base_depth(Some("3")), 3);
1319        assert_eq!(parse_base_depth(Some(" 2 ")), 2);
1320        assert_eq!(parse_base_depth(Some("0")), 0);
1321        assert_eq!(parse_base_depth(Some("-1")), 0);
1322        assert_eq!(parse_base_depth(Some("abc")), 0);
1323        assert_eq!(parse_base_depth(Some("")), 0);
1324        assert_eq!(parse_base_depth(None), 0);
1325    }
1326
1327    #[test]
1328    fn test_current_depth_tracks_sections() {
1329        // `current_depth` = inherited base (0 in tests — the env var is
1330        // not set under cargo test) + open sections; it is the value a
1331        // parent exports to children via LOG_DEPTH_ENV.
1332        let _guard = SECTION_TEST_LOCK.lock().unwrap();
1333        let log = StageLogger::new("build", Verbosity::Normal);
1334        let start = current_depth();
1335        {
1336            let _outer = log.group("build");
1337            assert_eq!(current_depth(), start + 1);
1338        }
1339        assert_eq!(current_depth(), start);
1340    }
1341
1342    #[test]
1343    fn test_stage_header_splits_into_verb_and_message() {
1344        // A multi-word phrase splits on the FIRST space: the verb feeds the
1345        // right-aligned gutter, the remainder is the section message.
1346        let log = StageLogger::new("build", Verbosity::Normal);
1347        assert_eq!(log.split_header("build"), ("Building", "binaries"));
1348        assert_eq!(log.split_header("sign"), ("Signing", "artifacts"));
1349        assert_eq!(log.split_header("source"), ("Archiving", "source"));
1350    }
1351
1352    #[test]
1353    fn test_stage_header_single_word_renders_verb_only() {
1354        // A known single-word phrase ("Publishing") renders just the gutter
1355        // verb with an empty message — no stage-name echo.
1356        let log = StageLogger::new("publish", Verbosity::Normal);
1357        assert_eq!(log.split_header("publish"), ("Publishing", ""));
1358    }
1359
1360    #[test]
1361    fn test_stage_header_unknown_stage_uses_running_plus_name() {
1362        // An unknown stage falls back to "Running" + the stage name, so it
1363        // still renders in the system vocabulary (`   Running myfancystage`).
1364        let log = StageLogger::new("x", Verbosity::Normal);
1365        assert_eq!(
1366            log.split_header("myfancystage"),
1367            ("Running", "myfancystage")
1368        );
1369    }
1370
1371    #[test]
1372    fn test_verbosity_from_flags_default() {
1373        assert_eq!(
1374            Verbosity::from_flags(false, false, false),
1375            Verbosity::Normal
1376        );
1377    }
1378
1379    #[test]
1380    fn test_verbosity_from_flags_quiet() {
1381        assert_eq!(Verbosity::from_flags(true, false, false), Verbosity::Quiet);
1382    }
1383
1384    #[test]
1385    fn test_verbosity_from_flags_verbose() {
1386        assert_eq!(
1387            Verbosity::from_flags(false, true, false),
1388            Verbosity::Verbose
1389        );
1390    }
1391
1392    #[test]
1393    fn test_verbosity_from_flags_debug() {
1394        assert_eq!(Verbosity::from_flags(false, false, true), Verbosity::Debug);
1395    }
1396
1397    #[test]
1398    fn test_verbosity_from_flags_debug_wins_over_verbose() {
1399        assert_eq!(Verbosity::from_flags(false, true, true), Verbosity::Debug);
1400    }
1401
1402    #[test]
1403    fn test_verbosity_from_flags_debug_wins_over_quiet() {
1404        assert_eq!(Verbosity::from_flags(true, false, true), Verbosity::Debug);
1405    }
1406
1407    #[test]
1408    fn test_verbosity_from_flags_quiet_overrides_verbose() {
1409        assert_eq!(Verbosity::from_flags(true, true, false), Verbosity::Quiet);
1410    }
1411
1412    #[test]
1413    fn test_verbosity_ordering() {
1414        assert!(Verbosity::Quiet < Verbosity::Normal);
1415        assert!(Verbosity::Normal < Verbosity::Verbose);
1416        assert!(Verbosity::Verbose < Verbosity::Debug);
1417    }
1418
1419    #[test]
1420    fn test_stage_logger_is_verbose() {
1421        let log = StageLogger::new("test", Verbosity::Verbose);
1422        assert!(log.is_verbose());
1423        assert!(!log.is_debug());
1424    }
1425
1426    #[test]
1427    fn test_stage_logger_is_debug() {
1428        let log = StageLogger::new("test", Verbosity::Debug);
1429        assert!(log.is_verbose());
1430        assert!(log.is_debug());
1431    }
1432
1433    #[test]
1434    fn test_stage_logger_normal_not_verbose() {
1435        let log = StageLogger::new("test", Verbosity::Normal);
1436        assert!(!log.is_verbose());
1437        assert!(!log.is_debug());
1438    }
1439
1440    #[test]
1441    fn test_default_verbosity_is_normal() {
1442        assert_eq!(Verbosity::default(), Verbosity::Normal);
1443    }
1444
1445    // -----------------------------------------------------------------
1446    // Redaction inside check_output
1447    // -----------------------------------------------------------------
1448
1449    #[cfg(unix)]
1450    fn fake_output(stdout: &[u8], stderr: &[u8], code: i32) -> std::process::Output {
1451        use std::os::unix::process::ExitStatusExt;
1452        std::process::Output {
1453            status: std::process::ExitStatus::from_raw(code << 8),
1454            stdout: stdout.to_vec(),
1455            stderr: stderr.to_vec(),
1456        }
1457    }
1458
1459    #[test]
1460    fn test_redact_uses_attached_env() {
1461        // A logger built via `with_env` must scrub configured secrets.
1462        let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
1463            "GITHUB_TOKEN".to_string(),
1464            "ghp_real_secret_token".to_string(),
1465        )]);
1466        let out = log.redact("auth header: ghp_real_secret_token");
1467        assert_eq!(out, "auth header: $GITHUB_TOKEN");
1468        assert!(!out.contains("ghp_real_secret_token"));
1469    }
1470
1471    #[test]
1472    fn test_redact_without_env_only_scrubs_inline_urls() {
1473        // A logger constructed without `with_env` still scrubs inline URL
1474        // credentials, even if the bare token is not in env (the env-pair
1475        // list is empty).
1476        let log = StageLogger::new("test", Verbosity::Normal);
1477        let out = log.redact("fetched from https://user:tok@example.com/path");
1478        assert_eq!(out, "fetched from https://<redacted>@example.com/path");
1479    }
1480
1481    #[test]
1482    fn test_redact_combines_env_and_url_credentials() {
1483        let log = StageLogger::new("test", Verbosity::Normal)
1484            .with_env(vec![("API_TOKEN".to_string(), "ghp_tok123".to_string())]);
1485        // Both the env-value token AND the inline URL credential should be
1486        // scrubbed in a single call.
1487        let out = log.redact("remote: https://ghp_tok123@github.com/x/y");
1488        // URL credential strip runs first, so the `ghp_tok123` between
1489        // `://` and `@` becomes `<redacted>`. The path / host text never
1490        // contains `ghp_tok123`, so the env-value pass is a no-op here.
1491        assert_eq!(out, "remote: https://<redacted>@github.com/x/y");
1492        assert!(!out.contains("ghp_tok123"));
1493    }
1494
1495    #[cfg(unix)]
1496    #[test]
1497    fn test_check_output_redacts_stderr_on_failure() {
1498        // Stderr from a failing subprocess must be redacted before
1499        // the logger surfaces it, so secrets present in `output.stderr`
1500        // never reach the eprintln sink (or any future log appender).
1501        let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
1502            "REGISTRY_PASSWORD".to_string(),
1503            "supersecret_pw_123".to_string(),
1504        )]);
1505        let output = fake_output(
1506            b"",
1507            b"docker login failed: invalid password 'supersecret_pw_123'",
1508            1,
1509        );
1510        let (stderr_line, _) = log.format_output_lines(&output, "docker login");
1511        let line = stderr_line.expect("stderr should be present on failure");
1512        assert!(
1513            !line.contains("supersecret_pw_123"),
1514            "stderr must be redacted: {line}"
1515        );
1516        assert!(line.contains("$REGISTRY_PASSWORD"));
1517    }
1518
1519    #[cfg(unix)]
1520    #[test]
1521    fn test_check_output_redacts_stdout_on_failure() {
1522        // Stdout on the failure path must be redacted alongside
1523        // stderr. Some tools dump credentials onto stdout (e.g. helm
1524        // login prints a warning to stdout, not stderr).
1525        let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
1526            "DOCKER_PASSWORD".to_string(),
1527            "tok_dckr_abc".to_string(),
1528        )]);
1529        let output = fake_output(b"echoed config: DOCKER_PASSWORD=tok_dckr_abc\n", b"", 2);
1530        let (_, stdout_line) = log.format_output_lines(&output, "docker");
1531        let line = stdout_line.expect("stdout should be present on failure");
1532        assert!(!line.contains("tok_dckr_abc"));
1533        assert!(line.contains("$DOCKER_PASSWORD"));
1534    }
1535
1536    #[cfg(unix)]
1537    #[test]
1538    fn test_check_output_redacts_stdout_on_verbose_success() {
1539        // At verbose level, successful subprocess stdout is logged
1540        // too; it must also be redacted.
1541        let log = StageLogger::new("test", Verbosity::Verbose).with_env(vec![(
1542            "MY_API_KEY".to_string(),
1543            "key-abcdef-123".to_string(),
1544        )]);
1545        let output = fake_output(b"echo: key-abcdef-123 OK\n", b"", 0);
1546        let (_, stdout_line) = log.format_output_lines(&output, "echo");
1547        let line = stdout_line.expect("stdout should be present on success");
1548        assert!(!line.contains("key-abcdef-123"));
1549        assert!(line.contains("$MY_API_KEY"));
1550    }
1551
1552    #[cfg(unix)]
1553    #[test]
1554    fn test_check_output_strips_inline_url_credentials_without_env() {
1555        // A logger built without env still strips URL credentials,
1556        // so even when the user did not export a matching env var, an
1557        // inline `https://<user>:<pw>@host` in stderr is scrubbed.
1558        let log = StageLogger::new("test", Verbosity::Normal);
1559        let output = fake_output(
1560            b"",
1561            b"fatal: cannot read https://user:p4ssw0rd@example.com/repo.git\n",
1562            128,
1563        );
1564        let (stderr_line, _) = log.format_output_lines(&output, "git fetch");
1565        let line = stderr_line.expect("stderr should be present on failure");
1566        assert!(
1567            !line.contains("p4ssw0rd"),
1568            "userinfo must be redacted: {line}"
1569        );
1570        assert!(line.contains("<redacted>@example.com"));
1571    }
1572
1573    #[cfg(unix)]
1574    #[test]
1575    fn test_check_output_bail_message_excludes_raw_secret() {
1576        // The bail message embeds the (truncated, redacted) stderr tail
1577        // so an operator reading the bubbled anyhow chain sees something
1578        // more actionable than the bare exit code. That redaction must
1579        // still strip env-resolved secrets — otherwise the new tail
1580        // would leak whatever stderr the subprocess emitted.
1581        let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
1582            "AUTH_TOKEN".to_string(),
1583            "secret_zzz_yyy".to_string(),
1584        )]);
1585        let output = fake_output(b"", b"401 Unauthorized: secret_zzz_yyy\n", 1);
1586        let err = log
1587            .check_output(output, "curl")
1588            .expect_err("non-zero exit should bail");
1589        let msg = format!("{err:#}");
1590        assert!(
1591            !msg.contains("secret_zzz_yyy"),
1592            "bail message leaks secret: {msg}"
1593        );
1594        assert!(
1595            msg.contains("stderr:") && msg.contains("401 Unauthorized"),
1596            "bail message should embed redacted stderr tail: {msg}"
1597        );
1598    }
1599
1600    #[cfg(unix)]
1601    #[test]
1602    fn test_check_output_bail_includes_no_stderr_marker_when_empty() {
1603        // Subprocess failed with empty stderr — the bail still wants
1604        // SOMETHING after `stderr:` so a grep on operator logs sees a
1605        // deterministic marker rather than blank text.
1606        let log = StageLogger::new("test", Verbosity::Normal);
1607        let output = fake_output(b"", b"", 7);
1608        let err = log
1609            .check_output(output, "tool")
1610            .expect_err("non-zero exit should bail");
1611        let msg = format!("{err:#}");
1612        assert!(
1613            msg.contains("stderr: <no stderr>"),
1614            "expected explicit <no stderr> marker: {msg}"
1615        );
1616    }
1617
1618    #[cfg(unix)]
1619    #[test]
1620    fn test_check_output_bail_truncates_long_stderr() {
1621        // Stderr larger than the 2 KiB cap is truncated with an ellipsis
1622        // so the operator's error chain remains scannable.
1623        let log = StageLogger::new("test", Verbosity::Normal);
1624        // 3 KiB of stderr.
1625        let big = vec![b'x'; 3072];
1626        let output = fake_output(b"", &big, 1);
1627        let err = log
1628            .check_output(output, "tool")
1629            .expect_err("non-zero exit should bail");
1630        let msg = format!("{err:#}");
1631        assert!(
1632            msg.ends_with('…'),
1633            "expected ellipsis on truncated stderr: {msg}"
1634        );
1635        // Truncation must keep the surface manageable — well under
1636        // 3 KiB of raw stderr should make it into the bail.
1637        assert!(
1638            msg.len() < 2500,
1639            "bail message too long: {} bytes",
1640            msg.len()
1641        );
1642    }
1643
1644    #[test]
1645    fn test_with_env_is_arc_shared() {
1646        // Cloning a logger should share the env vec via Arc, not deep-copy.
1647        // Verified by pointer equality on the inner Vec backing the Arc.
1648        let env = vec![("K".to_string(), "v_long_enough_to_be_a_token".to_string())];
1649        let a = StageLogger::new("a", Verbosity::Normal).with_env(env);
1650        let b = a.clone();
1651        let pa: *const Vec<(String, String)> = a.env.as_ref().unwrap().as_ref();
1652        let pb: *const Vec<(String, String)> = b.env.as_ref().unwrap().as_ref();
1653        assert_eq!(pa, pb);
1654    }
1655
1656    #[test]
1657    fn test_with_stage_rebinds_stage_field() {
1658        // The per-line `[stage]` tag is gone from rendered output, but
1659        // `with_stage` still rebinds the `stage` field a logger carries (it
1660        // drives redaction env inheritance, not line formatting now).
1661        let log = StageLogger::new("release", Verbosity::Normal);
1662        assert_eq!(log.stage, "release");
1663        assert_eq!(log.with_stage("finalize").stage, "finalize");
1664    }
1665
1666    #[test]
1667    fn test_body_markers_render_at_body_indent() {
1668        // Body lines sit at the 3-space body indent (top level: no section
1669        // nesting) behind a colored marker glyph. ANSI codes are stripped
1670        // for the assertion so the test pins the visible shape, not palette.
1671        let _guard = SECTION_TEST_LOCK.lock().unwrap();
1672        // SAFETY: single-threaded under SECTION_TEST_LOCK.
1673        unsafe {
1674            std::env::remove_var("GITHUB_ACTIONS");
1675        }
1676        let strip = |s: String| {
1677            // Drop CSI sequences so the assertion is palette-independent.
1678            let mut out = String::new();
1679            let mut chars = s.chars().peekable();
1680            while let Some(c) = chars.next() {
1681                if c == '\u{1b}' {
1682                    for n in chars.by_ref() {
1683                        if n == 'm' {
1684                            break;
1685                        }
1686                    }
1687                } else {
1688                    out.push(c);
1689                }
1690            }
1691            out
1692        };
1693        // Relative to the live indent so an exported ANODIZER_LOG_DEPTH
1694        // (or a section left open by a parallel test) cannot skew the
1695        // absolute column.
1696        let prefix = indent();
1697        assert_eq!(
1698            strip(StageLogger::render_body(MARKER_DETAIL, "x")),
1699            format!("{prefix}   • x")
1700        );
1701        assert_eq!(
1702            strip(StageLogger::render_body(MARKER_SUCCESS, "ok")),
1703            format!("{prefix}   ✓ ok")
1704        );
1705        assert_eq!(
1706            strip(StageLogger::render_body(MARKER_FAILURE, "bad")),
1707            format!("{prefix}   ✗ bad")
1708        );
1709    }
1710
1711    #[test]
1712    fn test_kv_pads_plain_key_so_values_align() {
1713        // The padded key width counts the PLAIN key, not the ANSI-dimmed
1714        // bytes, so a short key and a long key share the same value column.
1715        let (log, cap) = StageLogger::with_capture("check", Verbosity::Normal);
1716        let w = ["targets", "runs"].iter().map(|k| k.len()).max().unwrap();
1717        log.kv("targets", "aarch64", w);
1718        log.kv("runs", "2", w);
1719        // The capture stores a normalized `key = value` form regardless of
1720        // the rendered padding/palette.
1721        assert_eq!(
1722            cap.all_messages(),
1723            vec![
1724                (LogLevel::Status, "targets = aarch64".to_string()),
1725                (LogLevel::Status, "runs = 2".to_string()),
1726            ]
1727        );
1728    }
1729
1730    #[test]
1731    fn test_retag_helpers_record_under_shared_capture() {
1732        // The retagged clone shares the capture sink, and the plain
1733        // delegations still record at the right level — locking the plumbing
1734        // independent of the rendered tag (which the capture does not store).
1735        let (log, cap) = StageLogger::with_capture("release", Verbosity::Normal);
1736
1737        log.with_stage("finalize").status("x");
1738        log.error("y");
1739        log.status("own-status");
1740        log.error("own-error");
1741
1742        assert_eq!(
1743            cap.all_messages(),
1744            vec![
1745                (LogLevel::Status, "x".to_string()),
1746                (LogLevel::Error, "y".to_string()),
1747                (LogLevel::Status, "own-status".to_string()),
1748                (LogLevel::Error, "own-error".to_string()),
1749            ]
1750        );
1751    }
1752
1753    #[test]
1754    fn skip_line_records_debug_when_not_shown() {
1755        // The default (show=false) routes a per-crate "no config block" skip to
1756        // debug() so it stays invisible at Normal/Verbose and only surfaces at
1757        // --debug — the fix for the 300+-line workspace skip-noise problem.
1758        let (log, cap) = StageLogger::with_capture("homebrew", Verbosity::Normal);
1759        log.skip_line(
1760            false,
1761            "skipping homebrew for crate 'demo' — no homebrew config block",
1762        );
1763        assert_eq!(cap.debug_count(), 1);
1764        assert_eq!(cap.status_count(), 0);
1765        assert_eq!(
1766            cap.all_messages(),
1767            vec![(
1768                LogLevel::Debug,
1769                "skipping homebrew for crate 'demo' — no homebrew config block".to_string()
1770            )]
1771        );
1772    }
1773
1774    #[test]
1775    fn skip_line_records_status_when_shown() {
1776        // --show-skipped (show=true) forces the skip line back to status so the
1777        // operator can diagnose why a publisher didn't run for a given crate.
1778        let (log, cap) = StageLogger::with_capture("homebrew", Verbosity::Normal);
1779        log.skip_line(
1780            true,
1781            "skipping homebrew for crate 'demo' — no homebrew config block",
1782        );
1783        assert_eq!(cap.status_count(), 1);
1784        assert_eq!(cap.debug_count(), 0);
1785    }
1786}