Skip to main content

anodizer_core/
log.rs

1//! Thin structured logging helper for anodizer stages.
2//!
3//! Provides level-gated output to stderr, with consistent `[stage] message`
4//! formatting. Keeps stdout clean for machine-parseable output (e.g. `anodizer tag`).
5//!
6//! # Verbosity levels
7//!
8//! - **quiet**: errors only (for CI where only failures matter)
9//! - **default**: status messages (stage start/complete, key actions)
10//! - **verbose**: detail (command output, env vars, file paths)
11//! - **debug**: everything (HTTP request/response, template contexts, resolved config)
12//!
13//! # Secret redaction
14//!
15//! Every `StageLogger` carries an optional env-pairs list that drives the
16//! redaction policy applied inside [`StageLogger::check_output`]. Callers
17//! that go through [`crate::context::Context::logger`] inherit the merged
18//! `{process env, config env}` pairs automatically; manual constructors
19//! (`StageLogger::new`) start with no env and can be enriched via
20//! [`StageLogger::with_env`]. Stderr / stdout interpolated into log lines
21//! or `bail!` messages is therefore redacted without callers having to
22//! remember to scrub at every site.
23
24use std::sync::Arc;
25
26use colored::Colorize;
27
28/// Verbosity level, derived from CLI flags.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
30pub enum Verbosity {
31    Quiet,
32    #[default]
33    Normal,
34    Verbose,
35    Debug,
36}
37
38impl Verbosity {
39    /// Derive verbosity from CLI flag combination.
40    /// `--quiet` overrides `--verbose`; `--debug` overrides everything.
41    pub fn from_flags(quiet: bool, verbose: bool, debug: bool) -> Self {
42        if debug {
43            Verbosity::Debug
44        } else if quiet {
45            Verbosity::Quiet
46        } else if verbose {
47            Verbosity::Verbose
48        } else {
49            Verbosity::Normal
50        }
51    }
52}
53
54/// Stage logger: wraps a stage name, verbosity level, and an optional
55/// env-pairs list used for secret redaction.
56///
57/// All output goes to stderr. Create one per stage via [`StageLogger::new`].
58/// Prefer `Context::logger("name")` over `StageLogger::new` when a
59/// `Context` is in scope, because it carries the env automatically.
60///
61/// ```rust,ignore
62/// let log = ctx.logger("build");                  // env pre-populated
63/// let log = StageLogger::new("build", verbosity)  // no env yet
64///     .with_env(env_pairs);                       // attach env for redact
65/// log.status("compiling for x86_64-unknown-linux-gnu");
66/// log.verbose(&format!("RUSTFLAGS={}", flags));
67/// log.debug(&format!("full env: {:?}", env));
68/// ```
69#[derive(Clone)]
70pub struct StageLogger {
71    stage: &'static str,
72    verbosity: Verbosity,
73    /// Env-pairs used to redact subprocess output and bail messages. The
74    /// inner vec is shared via `Arc` so cloning a logger does not copy the
75    /// env every time. `None` means redaction is a no-op (matches the
76    /// behaviour before this field existed).
77    env: Option<Arc<Vec<(String, String)>>>,
78}
79
80impl StageLogger {
81    pub fn new(stage: &'static str, verbosity: Verbosity) -> Self {
82        Self {
83            stage,
84            verbosity,
85            env: None,
86        }
87    }
88
89    /// Attach an env-pairs list to drive secret redaction inside
90    /// [`StageLogger::check_output`] and [`StageLogger::redact`]. The list
91    /// is shared via `Arc`, so passing the same vec to many loggers does
92    /// not duplicate the underlying storage.
93    pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
94        self.env = Some(Arc::new(env));
95        self
96    }
97
98    /// Redact secret values from `s` using this logger's attached env.
99    ///
100    /// When no env has been attached (the default for `StageLogger::new`),
101    /// returns the input unchanged. Combines `redact::string` (for
102    /// known-secret env values) with `redact::redact_url_credentials`
103    /// (for inline `https://<user>:<pass>@host` URL credentials that may
104    /// not match any exported env-var value).
105    pub fn redact(&self, s: &str) -> String {
106        let credential_stripped = crate::redact::redact_url_credentials(s);
107        match self.env.as_deref() {
108            Some(env) => crate::redact::string(&credential_stripped, env),
109            None => credential_stripped,
110        }
111    }
112
113    /// Error message — always shown (even in quiet mode).
114    pub fn error(&self, msg: &str) {
115        eprintln!("{} [{}] {}", "Error:".red().bold(), self.stage, msg);
116    }
117
118    /// Warning message — shown at Normal and above.
119    pub fn warn(&self, msg: &str) {
120        if self.verbosity >= Verbosity::Normal {
121            eprintln!("{} [{}] {}", "Warning:".yellow().bold(), self.stage, msg);
122        }
123    }
124
125    /// Status message — shown at Normal and above. This is the default level
126    /// for key actions (stage start, completion, skips, dry-run notes).
127    pub fn status(&self, msg: &str) {
128        if self.verbosity >= Verbosity::Normal {
129            eprintln!("[{}] {}", self.stage, msg);
130        }
131    }
132
133    /// Detail message — shown only at Verbose and above.
134    /// Use for: command output on success, env vars, file paths, template vars.
135    pub fn verbose(&self, msg: &str) {
136        if self.verbosity >= Verbosity::Verbose {
137            eprintln!("[{}] {}", self.stage, msg);
138        }
139    }
140
141    /// Debug message — shown only at Debug level.
142    /// Use for: HTTP request/response details, full template contexts, resolved config.
143    pub fn debug(&self, msg: &str) {
144        if self.verbosity >= Verbosity::Debug {
145            eprintln!("[{}] {}", self.stage.dimmed(), msg.dimmed());
146        }
147    }
148
149    /// Return the current verbosity level.
150    pub fn verbosity(&self) -> Verbosity {
151        self.verbosity
152    }
153
154    /// Check if verbose output is enabled.
155    pub fn is_verbose(&self) -> bool {
156        self.verbosity >= Verbosity::Verbose
157    }
158
159    /// Check if debug output is enabled.
160    pub fn is_debug(&self) -> bool {
161        self.verbosity >= Verbosity::Debug
162    }
163
164    /// Check command output, log stderr/stdout on failure, and bail with context.
165    /// On success, log stdout at verbose level. Returns `Ok(output)` on success.
166    ///
167    /// Stderr and stdout are passed through [`StageLogger::redact`] before
168    /// they reach the log sink, so any secret env-var values present in the
169    /// subprocess output are replaced with `$KEY_NAME` (and inline
170    /// `https://<user>:<pass>@host` URL credentials are scrubbed) without
171    /// callers having to remember to redact at each call site. Mirrors
172    /// GoReleaser's `gio.Safe(stderr)` pattern at every subprocess
173    /// boundary.
174    pub fn check_output(
175        &self,
176        output: std::process::Output,
177        label: &str,
178    ) -> anyhow::Result<std::process::Output> {
179        let (stderr_line, stdout_line) = self.format_output_lines(&output, label);
180        if !output.status.success() {
181            if let Some(line) = stderr_line {
182                self.error(&line);
183            }
184            if let Some(line) = stdout_line {
185                self.error(&line);
186            }
187            anyhow::bail!(
188                "{} failed with exit code: {}",
189                label,
190                output.status.code().unwrap_or(-1)
191            );
192        }
193        if self.is_verbose()
194            && let Some(line) = stdout_line
195        {
196            self.verbose(&line);
197        }
198        Ok(output)
199    }
200
201    /// Compose the redacted stderr / stdout log lines that
202    /// [`StageLogger::check_output`] would emit for `output`. Returned as
203    /// `(stderr_line, stdout_line)` where each `Option` is `Some` only when
204    /// the corresponding stream had any content. Exposed via
205    /// `pub(crate)` so the redaction logic can be unit-tested without
206    /// having to capture stderr (`eprintln!` cannot be intercepted from
207    /// the same process portably).
208    pub(crate) fn format_output_lines(
209        &self,
210        output: &std::process::Output,
211        label: &str,
212    ) -> (Option<String>, Option<String>) {
213        let stderr_raw = String::from_utf8_lossy(&output.stderr);
214        let stderr_line = if stderr_raw.is_empty() {
215            None
216        } else {
217            let stderr = self.redact(&stderr_raw);
218            let prefix = if output.status.success() {
219                "output"
220            } else {
221                "stderr"
222            };
223            // Failure messages format stderr separately from stdout (under
224            // the "stderr" label); success uses one "output" label for
225            // stdout only.
226            if output.status.success() {
227                // success path: stderr is never surfaced through check_output
228                None
229            } else {
230                Some(format!("{label} {prefix}:\n{stderr}"))
231            }
232        };
233        let stdout_raw = String::from_utf8_lossy(&output.stdout);
234        let stdout_line = if stdout_raw.is_empty() {
235            None
236        } else {
237            let stdout = self.redact(&stdout_raw);
238            let prefix = if output.status.success() {
239                "output"
240            } else {
241                "stdout"
242            };
243            Some(format!("{label} {prefix}:\n{stdout}"))
244        };
245        (stderr_line, stdout_line)
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_verbosity_from_flags_default() {
255        assert_eq!(
256            Verbosity::from_flags(false, false, false),
257            Verbosity::Normal
258        );
259    }
260
261    #[test]
262    fn test_verbosity_from_flags_quiet() {
263        assert_eq!(Verbosity::from_flags(true, false, false), Verbosity::Quiet);
264    }
265
266    #[test]
267    fn test_verbosity_from_flags_verbose() {
268        assert_eq!(
269            Verbosity::from_flags(false, true, false),
270            Verbosity::Verbose
271        );
272    }
273
274    #[test]
275    fn test_verbosity_from_flags_debug() {
276        assert_eq!(Verbosity::from_flags(false, false, true), Verbosity::Debug);
277    }
278
279    #[test]
280    fn test_verbosity_from_flags_debug_wins_over_verbose() {
281        assert_eq!(Verbosity::from_flags(false, true, true), Verbosity::Debug);
282    }
283
284    #[test]
285    fn test_verbosity_from_flags_debug_wins_over_quiet() {
286        assert_eq!(Verbosity::from_flags(true, false, true), Verbosity::Debug);
287    }
288
289    #[test]
290    fn test_verbosity_from_flags_quiet_overrides_verbose() {
291        assert_eq!(Verbosity::from_flags(true, true, false), Verbosity::Quiet);
292    }
293
294    #[test]
295    fn test_verbosity_ordering() {
296        assert!(Verbosity::Quiet < Verbosity::Normal);
297        assert!(Verbosity::Normal < Verbosity::Verbose);
298        assert!(Verbosity::Verbose < Verbosity::Debug);
299    }
300
301    #[test]
302    fn test_stage_logger_is_verbose() {
303        let log = StageLogger::new("test", Verbosity::Verbose);
304        assert!(log.is_verbose());
305        assert!(!log.is_debug());
306    }
307
308    #[test]
309    fn test_stage_logger_is_debug() {
310        let log = StageLogger::new("test", Verbosity::Debug);
311        assert!(log.is_verbose());
312        assert!(log.is_debug());
313    }
314
315    #[test]
316    fn test_stage_logger_normal_not_verbose() {
317        let log = StageLogger::new("test", Verbosity::Normal);
318        assert!(!log.is_verbose());
319        assert!(!log.is_debug());
320    }
321
322    #[test]
323    fn test_default_verbosity_is_normal() {
324        assert_eq!(Verbosity::default(), Verbosity::Normal);
325    }
326
327    // -----------------------------------------------------------------
328    // Redaction inside check_output
329    // -----------------------------------------------------------------
330
331    #[cfg(unix)]
332    fn fake_output(stdout: &[u8], stderr: &[u8], code: i32) -> std::process::Output {
333        use std::os::unix::process::ExitStatusExt;
334        std::process::Output {
335            status: std::process::ExitStatus::from_raw(code << 8),
336            stdout: stdout.to_vec(),
337            stderr: stderr.to_vec(),
338        }
339    }
340
341    #[test]
342    fn test_redact_uses_attached_env() {
343        // A logger built via `with_env` must scrub configured secrets.
344        let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
345            "GITHUB_TOKEN".to_string(),
346            "ghp_real_secret_token".to_string(),
347        )]);
348        let out = log.redact("auth header: ghp_real_secret_token");
349        assert_eq!(out, "auth header: $GITHUB_TOKEN");
350        assert!(!out.contains("ghp_real_secret_token"));
351    }
352
353    #[test]
354    fn test_redact_without_env_only_scrubs_inline_urls() {
355        // A logger constructed without `with_env` still scrubs inline URL
356        // credentials, even if the bare token is not in env (the env-pair
357        // list is empty).
358        let log = StageLogger::new("test", Verbosity::Normal);
359        let out = log.redact("fetched from https://user:tok@example.com/path");
360        assert_eq!(out, "fetched from https://<redacted>@example.com/path");
361    }
362
363    #[test]
364    fn test_redact_combines_env_and_url_credentials() {
365        let log = StageLogger::new("test", Verbosity::Normal)
366            .with_env(vec![("API_TOKEN".to_string(), "ghp_tok123".to_string())]);
367        // Both the env-value token AND the inline URL credential should be
368        // scrubbed in a single call.
369        let out = log.redact("remote: https://ghp_tok123@github.com/x/y");
370        // URL credential strip runs first, so the `ghp_tok123` between
371        // `://` and `@` becomes `<redacted>`. The path / host text never
372        // contains `ghp_tok123`, so the env-value pass is a no-op here.
373        assert_eq!(out, "remote: https://<redacted>@github.com/x/y");
374        assert!(!out.contains("ghp_tok123"));
375    }
376
377    #[cfg(unix)]
378    #[test]
379    fn test_check_output_redacts_stderr_on_failure() {
380        // Stderr from a failing subprocess must be redacted before
381        // the logger surfaces it, so secrets present in `output.stderr`
382        // never reach the eprintln sink (or any future log appender).
383        let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
384            "REGISTRY_PASSWORD".to_string(),
385            "supersecret_pw_123".to_string(),
386        )]);
387        let output = fake_output(
388            b"",
389            b"docker login failed: invalid password 'supersecret_pw_123'",
390            1,
391        );
392        let (stderr_line, _) = log.format_output_lines(&output, "docker login");
393        let line = stderr_line.expect("stderr should be present on failure");
394        assert!(
395            !line.contains("supersecret_pw_123"),
396            "stderr must be redacted: {line}"
397        );
398        assert!(line.contains("$REGISTRY_PASSWORD"));
399    }
400
401    #[cfg(unix)]
402    #[test]
403    fn test_check_output_redacts_stdout_on_failure() {
404        // Stdout on the failure path must be redacted alongside
405        // stderr. Some tools dump credentials onto stdout (e.g. helm
406        // login prints a warning to stdout, not stderr).
407        let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
408            "DOCKER_PASSWORD".to_string(),
409            "tok_dckr_abc".to_string(),
410        )]);
411        let output = fake_output(b"echoed config: DOCKER_PASSWORD=tok_dckr_abc\n", b"", 2);
412        let (_, stdout_line) = log.format_output_lines(&output, "docker");
413        let line = stdout_line.expect("stdout should be present on failure");
414        assert!(!line.contains("tok_dckr_abc"));
415        assert!(line.contains("$DOCKER_PASSWORD"));
416    }
417
418    #[cfg(unix)]
419    #[test]
420    fn test_check_output_redacts_stdout_on_verbose_success() {
421        // At verbose level, successful subprocess stdout is logged
422        // too; it must also be redacted.
423        let log = StageLogger::new("test", Verbosity::Verbose).with_env(vec![(
424            "MY_API_KEY".to_string(),
425            "key-abcdef-123".to_string(),
426        )]);
427        let output = fake_output(b"echo: key-abcdef-123 OK\n", b"", 0);
428        let (_, stdout_line) = log.format_output_lines(&output, "echo");
429        let line = stdout_line.expect("stdout should be present on success");
430        assert!(!line.contains("key-abcdef-123"));
431        assert!(line.contains("$MY_API_KEY"));
432    }
433
434    #[cfg(unix)]
435    #[test]
436    fn test_check_output_strips_inline_url_credentials_without_env() {
437        // A logger built without env still strips URL credentials,
438        // so even when the user did not export a matching env var, an
439        // inline `https://<user>:<pw>@host` in stderr is scrubbed.
440        let log = StageLogger::new("test", Verbosity::Normal);
441        let output = fake_output(
442            b"",
443            b"fatal: cannot read https://user:p4ssw0rd@example.com/repo.git\n",
444            128,
445        );
446        let (stderr_line, _) = log.format_output_lines(&output, "git fetch");
447        let line = stderr_line.expect("stderr should be present on failure");
448        assert!(
449            !line.contains("p4ssw0rd"),
450            "userinfo must be redacted: {line}"
451        );
452        assert!(line.contains("<redacted>@example.com"));
453    }
454
455    #[cfg(unix)]
456    #[test]
457    fn test_check_output_bail_message_excludes_raw_secret() {
458        // The error returned by `check_output` is itself short (only
459        // mentions the label + exit code), but make this explicit so a
460        // refactor that decides to interpolate stderr into the bail
461        // message would also have to redact it.
462        let log = StageLogger::new("test", Verbosity::Normal).with_env(vec![(
463            "AUTH_TOKEN".to_string(),
464            "secret_zzz_yyy".to_string(),
465        )]);
466        let output = fake_output(b"", b"401 Unauthorized: secret_zzz_yyy\n", 1);
467        let err = log
468            .check_output(output, "curl")
469            .expect_err("non-zero exit should bail");
470        let msg = format!("{err:#}");
471        assert!(
472            !msg.contains("secret_zzz_yyy"),
473            "bail message leaks secret: {msg}"
474        );
475    }
476
477    #[test]
478    fn test_with_env_is_arc_shared() {
479        // Cloning a logger should share the env vec via Arc, not deep-copy.
480        // Verified by pointer equality on the inner Vec backing the Arc.
481        let env = vec![("K".to_string(), "v_long_enough_to_be_a_token".to_string())];
482        let a = StageLogger::new("a", Verbosity::Normal).with_env(env);
483        let b = a.clone();
484        let pa: *const Vec<(String, String)> = a.env.as_ref().unwrap().as_ref();
485        let pb: *const Vec<(String, String)> = b.env.as_ref().unwrap().as_ref();
486        assert_eq!(pa, pb);
487    }
488}