Skip to main content

anodizer_core/log/
render.rs

1//! Line rendering: the verb gutter, body markers, and the deferred section
2//! header that only prints once its section produces output.
3
4use super::depth::{base_depth, current_depth};
5use std::sync::Mutex;
6
7use colored::Colorize;
8
9/// A section header that has been opened ([`StageLogger::group`](crate::log::StageLogger::group)) but not yet
10/// printed. The header line is deferred until the section actually emits a
11/// body line, so a stage that does nothing prints nothing at all (matching
12/// GoReleaser, which only prints a section header once the section has output).
13pub(super) struct PendingHeader {
14    /// Section depth captured at open time. The header renders at *this* depth,
15    /// not the current global depth, so a nested section's deferred header is
16    /// still indented to its own level when flushed alongside its ancestors.
17    pub(super) depth: usize,
18    /// Right-aligned bold-green verb (the leading word of the stage's
19    /// [`stage_header`] phrase).
20    pub(super) verb: String,
21    /// The remaining words of the phrase, printed after the verb (empty for a
22    /// single-word phrase, which renders a bare gutter verb).
23    pub(super) msg: String,
24    /// Whether this header has already been printed. A flushed entry stays on
25    /// the stack (so the LIFO pop in [`SectionGuard::drop`](crate::log::SectionGuard::drop) removes the right
26    /// one) but is never reprinted.
27    pub(super) flushed: bool,
28}
29
30/// Stack of section headers awaiting their first body line. Pushed by
31/// [`StageLogger::group`](crate::log::StageLogger::group), drained by [`flush_pending`] when a real line is
32/// about to print, and popped (LIFO) by [`SectionGuard::drop`](crate::log::SectionGuard::drop).
33///
34/// A `Mutex` (not a thread-local) because sections are opened on the main
35/// thread like [`SECTION_DEPTH`](super::depth::SECTION_DEPTH), but body lines may flush from a stage's
36/// worker threads (e.g. `build` spawning per-target threads). The lock
37/// serializes the flush state transition: each header's `flushed` flag flips
38/// under the lock, so a header prints exactly once — never lost, never
39/// duplicated. Header-before-body ordering holds because every emit method
40/// calls [`flush_pending`] then writes its body line with no early return
41/// between. It does not serialize body-line-vs-body-line ordering across
42/// workers — two `build` threads may print their body lines in either order
43/// under a just-flushed header, matching build's existing unordered parallel
44/// output.
45pub(super) static PENDING: Mutex<Vec<PendingHeader>> = Mutex::new(Vec::new());
46
47/// Print every still-unflushed pending section header, in ancestor-first
48/// (bottom-to-top) order, then mark each flushed.
49///
50/// Called immediately before any method actually writes a visible body line,
51/// so the deferred headers appear above their first line in correct nesting
52/// order. A header renders at its own stored [`PendingHeader::depth`] — the
53/// 2-space-per-level indent, the right-aligned bold-green verb in the
54/// `VERB_COLUMN` gutter, then (if non-empty) one space and the message.
55///
56/// No-op when nothing is pending (the common case once a section has already
57/// emitted its first line), so the per-body-line cost is one uncontended lock.
58pub(super) fn flush_pending() {
59    // Recover a poisoned guard rather than bailing: pending headers are pure
60    // presentation state, and silently muting every section header for the
61    // rest of the run on one panic-mid-format is worse than reusing it.
62    let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
63    for entry in pending.iter_mut() {
64        if entry.flushed {
65            continue;
66        }
67        eprintln!("{}", render_header(entry.depth, &entry.verb, &entry.msg));
68        entry.flushed = true;
69    }
70}
71
72/// Render a section/stage header line at `depth`: the 2-space-per-level
73/// nesting indent, a bold-green verb right-aligned in the `VERB_COLUMN`
74/// gutter, then (only for a non-empty `msg`) a single space and the message.
75///
76/// The single source of truth shared by both header-emitting paths — the
77/// deferred section header in [`flush_pending`] and the direct
78/// [`StageLogger::step`](crate::log::StageLogger::step) — so interleaved headers and steps land in
79/// byte-identical columns for the same depth. A single-word phrase (empty
80/// `msg`) renders the bare gutter verb with no trailing space, so headers
81/// never carry stray whitespace.
82pub(super) fn render_header(depth: usize, verb: &str, msg: &str) -> String {
83    render_gutter(&"  ".repeat(depth), verb, |s| s.green().bold(), msg)
84}
85
86/// Render one gutter line: `label` right-aligned in the `VERB_COLUMN` gutter
87/// (painted by `paint`) after `prefix`, then a space and `msg` — or, when `msg`
88/// is empty, the bare painted label with no trailing space. The single column
89/// system shared by section headers and the Warning/Error/Note status labels,
90/// so a status label's message lands in the same column as a header's.
91pub(super) fn render_gutter(
92    prefix: &str,
93    label: &str,
94    paint: impl Fn(String) -> colored::ColoredString,
95    msg: &str,
96) -> String {
97    let label = paint(format!("{label:>VERB_COLUMN$}"));
98    if msg.is_empty() {
99        format!("{prefix}{label}")
100    } else {
101        format!("{prefix}{label} {msg}")
102    }
103}
104
105/// Width of the right-aligned verb column in [`StageLogger::step`](crate::log::StageLogger::step),
106/// matching Cargo's `   Compiling foo` look (3 leading spaces + 9-char
107/// verb = a 12-column gutter before the message).
108pub(super) const VERB_COLUMN: usize = 12;
109
110/// Indent (after any section nesting) of a body line — a [`StageLogger::detail`](crate::log::StageLogger::detail)
111/// / [`success`] / [`failure`] / [`kv`] row, or a status label. Three spaces
112/// place the marker column one stop in from the section header's text, so body
113/// lines read as subordinate to the header above them.
114///
115/// [`success`]: crate::log::StageLogger::success
116/// [`failure`]: crate::log::StageLogger::failure
117/// [`kv`]: crate::log::StageLogger::kv
118pub(super) const BODY_INDENT: &str = "   ";
119
120/// Marker for an info / detail body line (`•`). Rendered cyan.
121pub(super) const MARKER_DETAIL: &str = "•";
122
123/// Marker for a success body line (`✓`). Rendered green.
124pub(super) const MARKER_SUCCESS: &str = "✓";
125
126/// Marker for a failure body line (`✗`). Rendered red.
127pub(super) const MARKER_FAILURE: &str = "✗";
128
129/// Map a pipeline stage name to its full Cargo-style header phrase
130/// (`"Building binaries"`, `"Signing artifacts"`, `"Publishing"`). Drives
131/// [`StageLogger::group`](crate::log::StageLogger::group)'s deferred header: the leading verb is right-aligned
132/// into the `VERB_COLUMN` gutter (bold-green, matching `cargo`'s
133/// `   Compiling foo` look), and the remaining words form the message that
134/// follows. A single-word phrase (`"Publishing"`) renders just the gutter
135/// verb with no trailing message.
136///
137/// The phrase is a *readable description* of the work, not an echo of the
138/// stage name — `group("build")` reads `   Building binaries`, not
139/// `   Building build`. This keeps the continuous log scannable: a reader
140/// sees what each section does, not the internal stage identifier.
141///
142/// Falls back to `"Running <stage>"` for any stage without a bespoke
143/// phrase, so a newly-added stage still renders in the system vocabulary
144/// (`   Running myfancystage`) without a code change here.
145pub fn stage_header(stage: &str) -> &'static str {
146    match stage {
147        "setup" => "Preparing release",
148        "build" => "Building binaries",
149        "archive" => "Creating archives",
150        "checksum" => "Computing checksums",
151        "sbom" => "Cataloging dependencies",
152        "templatefiles" => "Rendering templates",
153        "changelog" => "Generating changelog",
154        "attest" => "Generating attestations",
155        "binary-sign" => "Signing binaries",
156        "sign" => "Signing artifacts",
157        "docker" => "Building images",
158        "docker-sign" => "Signing images",
159        "upx" => "Compressing binaries",
160        "nfpm" => "Building packages",
161        "snapcraft" => "Building snap",
162        "flatpak" => "Building Flatpak",
163        "msi" => "Building MSI",
164        "nsis" => "Building installer",
165        "dmg" => "Building DMG",
166        "pkg" => "Building pkg",
167        "notarize" => "Notarizing app",
168        "makeself" => "Building installer",
169        "install-script" => "Building install script",
170        "srpm" => "Building source RPM",
171        "appbundle" => "Building app bundle",
172        "appimage" => "Building AppImage",
173        "universal" => "Merging binaries",
174        "source" => "Archiving source",
175        "release" => "Creating release",
176        "before-publish" => "Preparing publishers",
177        "emission-validate" => "Validating output",
178        "publish" => "Publishing",
179        "blob" => "Uploading blobs",
180        "snapcraft-publish" => "Publishing snap",
181        "announce" => "Announcing release",
182        "verify-release" => "Verifying release",
183        "publisher-summary" => "Summary",
184        "check-determinism" => "Checking determinism",
185        "finalize" => "Finalizing",
186        "prepare" => "Preparing",
187        _ => "Running",
188    }
189}
190
191/// Render the themed `Warning` line for `msg`: the label right-aligned in the
192/// gutter (bold-yellow, no colon) at the enclosing section's header indent
193/// (`label_indent`), so it reads as a peer of the section verbs and its
194/// message aligns with theirs. The single source of truth for the warning
195/// palette and label, shared by [`StageLogger::warn`](crate::log::StageLogger::warn) and the CLI's tracing
196/// formatter so a library-side `warn!` looks identical to a logger warn (one
197/// output authority).
198pub fn render_warning(msg: &str) -> String {
199    render_gutter(&label_indent(), "Warning", |s| s.yellow().bold(), msg)
200}
201
202/// Render the themed `Error` line for `msg` — gutter-aligned bold-red label,
203/// companion to [`render_warning`]; shared so the error palette/label lives in
204/// exactly one place.
205pub fn render_error(msg: &str) -> String {
206    render_gutter(&label_indent(), "Error", |s| s.red().bold(), msg)
207}
208
209/// Render the themed `Note` line for `msg`: gutter-aligned bold-green label.
210/// The third status label — informational lines that are neither warnings nor
211/// errors (host-target selection, auto-snapshot activation). The bold-green
212/// deliberately matches the section-verb palette (not yellow/red): a note is a
213/// neutral peer of the section headers, not an alert. Shared so the `Note`
214/// palette/label lives in exactly one place rather than being open-coded per
215/// call site.
216pub fn render_note(msg: &str) -> String {
217    render_gutter(&label_indent(), "Note", |s| s.green().bold(), msg)
218}
219
220/// Current indentation prefix (2 spaces per open section). Empty at the
221/// top level. Applied identically everywhere — including under GitHub
222/// Actions, where the indentation (not a collapsible `::group::` block) is
223/// what conveys section nesting, matching the continuous single-stream log
224/// GoReleaser emits.
225///
226/// This is the body-line depth (`•` detail / `success` / `failure` / `kv`
227/// rows). Status labels do NOT use it — they sit one level shallower at the
228/// enclosing header's depth via `label_indent`.
229pub fn indent() -> String {
230    "  ".repeat(current_depth())
231}
232
233/// Indentation prefix for a status label (`Warning` / `Error` / `Note`).
234///
235/// A label is a body-level event, but unlike a `•` detail line it renders
236/// through the right-aligned [`render_gutter`] system (like a section header),
237/// so to land its label in the verb column and its message in the header's
238/// message column it must sit at the ENCLOSING header's depth — one level
239/// shallower than [`indent`] (which tracks the deeper body depth). Using the
240/// body indent here would push the gutter-aligned label two columns past both
241/// the sibling headers and the body bullets, leaving it floating on its own.
242///
243/// Floored at the inherited `base_depth` so a label emitted with no open
244/// section never dedents below the ambient (e.g. GitHub Actions) indent.
245pub(super) fn label_indent() -> String {
246    "  ".repeat(current_depth().saturating_sub(1).max(base_depth()))
247}
248
249/// Strip ANSI CSI escape sequences (SGR color codes, cursor moves) from `s`.
250///
251/// Captured subprocess output (cargo, gpg, …) carries terminal color codes
252/// when color is forced for the live CI log. Those bytes must never leak into
253/// a propagated error message that reaches a non-terminal sink — a failure
254/// email, the `on_error` hook's `$ANODIZER_ERROR`, a JSON run summary — where
255/// they render as garbage around every styled token.
256pub(crate) fn strip_ansi(s: &str) -> String {
257    let mut out = String::with_capacity(s.len());
258    let mut chars = s.chars();
259    while let Some(c) = chars.next() {
260        if c == '\u{1b}' {
261            // Only a CSI introducer (`ESC [`) starts a parameterized sequence
262            // we must consume to its final byte (0x40–0x7E); any other escape
263            // form (a lone ESC, a two-char sequence) drops just the introducer.
264            if chars.next() == Some('[') {
265                for ec in chars.by_ref() {
266                    if ('\u{40}'..='\u{7e}').contains(&ec) {
267                        break;
268                    }
269                }
270            }
271        } else {
272            out.push(c);
273        }
274    }
275    out
276}