Skip to main content

anodizer_core/
hooks.rs

1use crate::artifact::Artifact;
2use crate::config::{self, BeforePublishArtifactFilter, HookEntry};
3use crate::log::StageLogger;
4use crate::template::{self, TemplateVars};
5use anyhow::{Context as _, Result};
6use std::process::Command;
7
8/// Redact sensitive environment variable values from output strings.
9///
10/// Auto-discovers secret-looking env vars using the same heuristics as
11/// secret detection: key suffix matching and value prefix matching.
12/// This catches both well-known and user-defined secrets.
13fn redact_secrets(output: &str) -> String {
14    let env: Vec<(String, String)> = std::env::vars().collect();
15    crate::redact::string(output, &env)
16}
17
18/// Render a hook template string through the full Tera engine.
19///
20/// Hard-bails on render failure: a typo like `{{ .Teg }}` in a hook command
21/// would otherwise execute literal `{{ .Teg }}` and produce a confusing
22/// shell error rather than a clear template diagnostic.
23fn render_hook_template(template: &str, vars: &TemplateVars, label: &str) -> Result<String> {
24    template::render(template, vars)
25        .with_context(|| format!("{} hook: render template '{}'", label, template))
26}
27
28/// Cross-cutting inputs for [`run_hooks`] that stay constant across every
29/// hook in a single invocation. Bundled into one struct so call sites read
30/// as named fields instead of a run of positional `bool` / `Option`
31/// arguments. Cheap to pass by value — every field is `Copy`.
32#[derive(Clone, Copy)]
33pub struct HookRunContext<'a> {
34    /// Log each hook command instead of executing it.
35    pub dry_run: bool,
36    /// Sink for status lines and captured command output.
37    pub log: &'a StageLogger,
38    /// When set, hook `cmd` / `dir` / `env` / `if` are Tera-expanded with
39    /// these vars before execution, supporting
40    /// conditionals, filters, and `{{ .Env.VAR }}`. `None` runs the literal
41    /// command with no rendering.
42    pub template_vars: Option<&'a TemplateVars>,
43    /// The active build's per-target `builds[].env` map (already
44    /// rendered/expanded by the build planner). It layers BETWEEN the
45    /// inherited process env (base) and each hook's own `env:` (most
46    /// specific) — hook `env:` entries are appended after the build env,
47    /// so a key present
48    /// in both resolves to the hook value. `None` (the default via
49    /// [`HookRunContext::new`]) at non-build hook sites — they have no
50    /// build env.
51    pub build_env: Option<&'a std::collections::HashMap<String, String>>,
52    /// Extra environment variables the hook site injects into the hook
53    /// process (e.g. the `ANODIZER_*` failure-context vars set by publish
54    /// `on_error` hooks). The env channel exists so untrusted values (error
55    /// bodies, remote stderr) reach hooks WITHOUT being interpolated into
56    /// the `sh -c` command string, where shell metacharacters would be
57    /// command injection. Layered after `build_env` and before each hook's
58    /// own `env:` entries, so hook `env:` still wins on key conflicts.
59    pub extra_env: Option<&'a [(String, String)]>,
60}
61
62impl<'a> HookRunContext<'a> {
63    /// Context for a non-build hook run (the common case): no per-target
64    /// `build_env`. Build sites construct the struct directly and set
65    /// [`HookRunContext::build_env`].
66    pub fn new(
67        dry_run: bool,
68        log: &'a StageLogger,
69        template_vars: Option<&'a TemplateVars>,
70    ) -> Self {
71        Self {
72            dry_run,
73            log,
74            template_vars,
75            build_env: None,
76            extra_env: None,
77        }
78    }
79
80    /// Attach site-injected env vars (see [`HookRunContext::extra_env`]).
81    pub fn with_extra_env(mut self, extra_env: &'a [(String, String)]) -> Self {
82        self.extra_env = Some(extra_env);
83        self
84    }
85}
86
87/// Execute a list of shell hook commands.
88///
89/// In dry-run mode, log but do not execute.
90/// Supports both simple string hooks and structured hooks with cmd/dir/env/output.
91///
92/// Note: Rust's `Command` inherits the process environment by default.
93/// Pipeline env vars (VERSION, TAG, etc.) should be set in the process
94/// environment before calling `run_hooks`, which `setup_env()` handles. See
95/// [`HookRunContext`] for how `template_vars` and `build_env` are applied.
96pub fn run_hooks(hooks: &[HookEntry], label: &str, ctx: HookRunContext<'_>) -> Result<()> {
97    let HookRunContext {
98        dry_run,
99        log,
100        template_vars,
101        build_env,
102        extra_env,
103    } = ctx;
104    for hook in hooks {
105        let (raw_cmd, raw_dir, env, output_flag, if_cond) = match hook {
106            HookEntry::Simple(s) => (s.as_str(), None, None, None, None),
107            HookEntry::Structured(h) => (
108                h.cmd.as_str(),
109                h.dir.as_deref(),
110                h.env.as_ref(),
111                h.output,
112                h.if_condition.as_deref(),
113            ),
114        };
115
116        if let Some(tv) = template_vars {
117            let proceed = config::evaluate_if_condition(if_cond, &format!("{label} hook"), |t| {
118                render_hook_template(t, tv, label)
119            })?;
120            if !proceed {
121                tracing::debug!(
122                    label = label,
123                    cmd = raw_cmd,
124                    "skipped hook — `if` condition evaluated falsy"
125                );
126                continue;
127            }
128        } else if let Some(cond) = if_cond {
129            // Without template_vars there's no way to render — treat the gate
130            // as proceed unless the literal condition is explicitly falsy.
131            // An EMPTY `if:` is the "no gate set" no-op (proceed), matching
132            // `evaluate_if_condition`'s `Some("")` → proceed contract used on
133            // the template path above; only `false`/`0`/`no` skip here.
134            let trimmed = cond.trim();
135            let falsy = matches!(trimmed, "false" | "0" | "no");
136            if falsy {
137                tracing::debug!(
138                    label = label,
139                    cmd = raw_cmd,
140                    "skipped hook — literal `if` condition is falsy"
141                );
142                continue;
143            }
144        }
145
146        let cmd_str = if let Some(tv) = template_vars {
147            render_hook_template(raw_cmd, tv, label)?
148        } else {
149            raw_cmd.to_string()
150        };
151
152        let dir_str = match raw_dir {
153            Some(d) => Some(if let Some(tv) = template_vars {
154                render_hook_template(d, tv, label)?
155            } else {
156                d.to_string()
157            }),
158            None => None,
159        };
160
161        let expanded_env: Option<Vec<(String, String)>> = match env {
162            Some(envs) => {
163                let pairs = if let Some(tv) = template_vars {
164                    config::render_env_entries(envs, |s| render_hook_template(s, tv, label))
165                        .with_context(|| format!("{label} hook: render env entries"))?
166                } else {
167                    config::parse_env_entries(envs)
168                        .with_context(|| format!("{label} hook: parse env entries"))?
169                };
170                Some(pairs)
171            }
172            None => None,
173        };
174
175        if dry_run {
176            log.status(&format!(
177                "(dry-run) would run {} hook via `{}`",
178                label, cmd_str
179            ));
180        } else {
181            log.verbose(&format!("running {} hook via `{}`", label, cmd_str));
182            let mut command = Command::new("sh");
183            command.arg("-c").arg(&cmd_str);
184            // Hooks inherit the host env so toolchain env vars (PATH, MSVC
185            // INCLUDE/LIB, RUSTUP_HOME) flow through. Secret leakage is gated
186            // by `redact_secrets` on the output side.
187            if let Some(ref d) = dir_str {
188                command.current_dir(d);
189            }
190            // Precedence: process env (inherited, base) < build env
191            // < extra env < hook env. Apply the per-target build env first so
192            // a same-key hook `env:` entry below overrides it (the
193            // `append(buildEnv, hook.Env...)`).
194            if let Some(be) = build_env {
195                for (k, v) in be {
196                    command.env(k, v);
197                }
198            }
199            if let Some(ee) = extra_env {
200                for (k, v) in ee {
201                    command.env(k, v);
202                }
203            }
204            if let Some(ref envs) = expanded_env {
205                for (k, v) in envs {
206                    command.env(k, v);
207                }
208            }
209            // Run via the shared helper: captures stdout+stderr, surfaces
210            // them on failure, and (at verbose) streams the hook's output
211            // live so a long-running before/after hook shows progress. Hook
212            // output may contain secrets from the inherited host env, so the
213            // helper logger carries the full process env for redaction —
214            // matching the prior `redact_secrets` (process-env) coverage,
215            // which is broader than the caller logger's attached env.
216            let redacting_log = log.clone().with_env(std::env::vars().collect::<Vec<_>>());
217            let output = crate::run::run_checked(
218                &mut command,
219                &redacting_log,
220                &format!("{} hook: {}", label, cmd_str),
221            )?;
222
223            log.status(&format!("ran {label} hook"));
224
225            // When output flag is true, echo the hook's (redacted) stdout as a
226            // `[hook output]` summary line — but NOT at verbose, where the run
227            // helper already teed that stdout live; echoing again would print
228            // it twice. The summary exists precisely for the non-verbose case
229            // where the live stream is suppressed.
230            if output_flag == Some(true) && !log.is_verbose() {
231                let redacted_stdout = redact_secrets(&String::from_utf8_lossy(&output.stdout));
232                if !redacted_stdout.trim().is_empty() {
233                    log.status(&format!("[hook output] {}", redacted_stdout.trim())); // status-ok: opt-in hook stdout summary, only emitted when output: true and non-verbose
234                }
235            }
236        }
237    }
238    Ok(())
239}
240
241/// Pipeline stage that runs `config.before_publish.hooks` immediately
242/// before any publisher dispatches.
243///
244/// Sits between the integrity stages (sign / checksum) and the publish
245/// phase (release / publish / blob / snapcraft-publish). A non-zero hook
246/// exit aborts the release before any publisher writes to a registry,
247/// giving operators a last-chance hook for smoke tests, antivirus scans,
248/// or external state staging against the staged dist tree.
249///
250/// Each `hooks[*]` entry runs **once per matching artifact** (the
251/// Pro `before_publish:` semantics). For each entry:
252///
253/// 1. Resolve the entry's `ids:` + `artifacts:` filters and walk
254///    `ctx.artifacts.all()` for matches.
255/// 2. For each match, bind per-artifact template variables
256///    (`ArtifactPath`, `ArtifactName`, `ArtifactExt`, `Os`, `Arch`,
257///    plus `ArtifactID` / `ArtifactKind` for parity with the publisher
258///    template surface) and render `cmd` / `dir` / `env` / `if`.
259/// 3. Execute the rendered hook (or log it under `--dry-run`).
260///
261/// `HookEntry::Simple` (bare string) implies `artifacts: all` and no
262/// `ids:` filter — it runs against every registered artifact. Zero
263/// matching artifacts means the hook runs zero times (the lifecycle
264/// semantics of `before:` / `after:` do not apply here).
265///
266/// Honors `--skip=before-publish`: when set, the pipeline's generic
267/// skip handling fires before `run` is invoked, so this stage never
268/// executes.
269pub struct BeforePublishStage;
270
271impl crate::stage::Stage for BeforePublishStage {
272    fn name(&self) -> &str {
273        "before-publish"
274    }
275
276    fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
277        let log = ctx.logger("before-publish");
278        let Some(cfg) = ctx.config.before_publish.as_ref() else {
279            return Ok(());
280        };
281        let Some(hooks) = cfg.hooks.as_ref() else {
282            return Ok(());
283        };
284        if hooks.is_empty() {
285            return Ok(());
286        }
287
288        let dry_run = ctx.is_dry_run();
289        let base_vars = ctx.template_vars().clone();
290        let artifacts: Vec<Artifact> = ctx.artifacts.all().to_vec();
291
292        for entry in hooks {
293            run_before_publish_entry(entry, &artifacts, dry_run, &log, &base_vars)?;
294        }
295        Ok(())
296    }
297}
298
299/// Iterate `artifacts` for a single `before_publish[*]` entry, executing
300/// the hook command once per match with per-artifact template variables
301/// bound. Returns `Err` on first hook failure so the pipeline aborts
302/// before any publisher dispatches.
303fn run_before_publish_entry(
304    entry: &HookEntry,
305    artifacts: &[Artifact],
306    dry_run: bool,
307    log: &StageLogger,
308    base_vars: &TemplateVars,
309) -> Result<()> {
310    let (ids_filter, kind_filter) = match entry {
311        HookEntry::Simple(_) => (None, BeforePublishArtifactFilter::All),
312        HookEntry::Structured(h) => (
313            h.ids.as_deref(),
314            h.artifacts.unwrap_or(BeforePublishArtifactFilter::All),
315        ),
316    };
317
318    for artifact in artifacts {
319        if !kind_filter.matches(artifact.kind) {
320            continue;
321        }
322        if let Some(allow_ids) = ids_filter {
323            let id = artifact
324                .metadata
325                .get("id")
326                .map(String::as_str)
327                .unwrap_or("");
328            if !allow_ids.iter().any(|a| a == id) {
329                continue;
330            }
331        }
332
333        let mut vars = base_vars.clone();
334        bind_per_artifact_vars(&mut vars, artifact);
335        // Reuse the existing single-entry runner so dry-run, output capture,
336        // env allow-list, redaction, and `if:` evaluation behave identically
337        // to the lifecycle hook sites — only the per-artifact iteration is
338        // new here.
339        let single = std::slice::from_ref(entry);
340        // before_publish hooks are not build hooks — no per-target build env.
341        run_hooks(
342            single,
343            "before-publish",
344            HookRunContext::new(dry_run, log, Some(&vars)),
345        )?;
346    }
347    Ok(())
348}
349
350/// Bind per-artifact template variables onto `vars` for a single
351/// `before_publish:` iteration. Mirrors the publisher-side template
352/// surface (`crates/cli/src/commands/publisher.rs::build_publisher_command`)
353/// so user templates work identically across `publishers:` and
354/// `before_publish:`.
355fn bind_per_artifact_vars(vars: &mut TemplateVars, artifact: &Artifact) {
356    vars.set("ArtifactPath", &artifact.path.to_string_lossy());
357    vars.set("ArtifactName", artifact.name());
358    vars.set("ArtifactExt", &artifact.ext());
359    vars.set("ArtifactKind", artifact.kind.as_str());
360    vars.set(
361        "ArtifactID",
362        artifact
363            .metadata
364            .get("id")
365            .map(String::as_str)
366            .unwrap_or(""),
367    );
368    if let Some(target) = artifact.target.as_deref() {
369        let (os, arch) = crate::target::map_target(target);
370        vars.set("Os", &os);
371        vars.set("Arch", &arch);
372        vars.set("Target", target);
373    } else {
374        vars.set("Os", "");
375        vars.set("Arch", "");
376        vars.set("Target", "");
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::config::StructuredHook;
384    use crate::log::{StageLogger, Verbosity};
385    use std::collections::HashMap;
386
387    fn test_logger() -> StageLogger {
388        StageLogger::new("test", Verbosity::Normal)
389    }
390
391    fn vars_with_snapshot(is_snapshot: bool) -> TemplateVars {
392        let mut v = TemplateVars::new();
393        v.set("IsSnapshot", if is_snapshot { "true" } else { "false" });
394        v
395    }
396
397    fn structured(cmd: &str, if_cond: Option<&str>) -> HookEntry {
398        HookEntry::Structured(StructuredHook {
399            cmd: cmd.to_string(),
400            if_condition: if_cond.map(str::to_string),
401            ..Default::default()
402        })
403    }
404
405    #[test]
406    fn hook_if_snapshot_template_runs_on_snapshot() {
407        let log = test_logger();
408        let vars = vars_with_snapshot(true);
409        let hooks = vec![structured("true", Some("{{ IsSnapshot }}"))];
410        // dry_run=true → no actual exec; the function still walks the gate.
411        run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
412            .expect("snapshot=true must let the hook proceed");
413    }
414
415    #[test]
416    fn hook_if_snapshot_template_skips_when_not_snapshot() {
417        let log = test_logger();
418        let vars = vars_with_snapshot(false);
419        let hooks = vec![structured(
420            "false-cmd-must-be-skipped",
421            Some("{{ IsSnapshot }}"),
422        )];
423        run_hooks(
424            &hooks,
425            "test",
426            HookRunContext::new(false, &log, Some(&vars)),
427        )
428        .expect("falsy `if:` must skip without spawning the cmd");
429    }
430
431    #[test]
432    fn hook_if_literal_true_always_runs() {
433        let log = test_logger();
434        let vars = vars_with_snapshot(false);
435        let hooks = vec![structured("true", Some("true"))];
436        run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
437            .expect("`if: true` must proceed");
438    }
439
440    #[test]
441    fn hook_if_empty_literal_is_noop_gate() {
442        let log = test_logger();
443        let vars = vars_with_snapshot(false);
444        let hooks = vec![structured("true", Some(""))];
445        run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
446            .expect("empty `if:` literal must be a no-op (always proceed)");
447    }
448
449    #[test]
450    fn hook_if_empty_literal_no_vars_proceeds() {
451        // The no-`template_vars` branch must treat an empty `if:` as
452        // "no gate set" → proceed, identical to the template path and to
453        // `evaluate_if_condition`'s `Some("")` contract. A dry-run hook that
454        // would loudly fail if executed proves the gate let it through (the
455        // command is logged, never spawned, under dry-run).
456        let log = test_logger();
457        let hooks = vec![structured("true", Some(""))];
458        run_hooks(&hooks, "test", HookRunContext::new(true, &log, None))
459            .expect("empty `if:` with no vars must proceed (no-op gate)");
460    }
461
462    #[test]
463    fn hook_if_falsy_literal_no_vars_skips() {
464        // The complementary pin: an explicitly-falsy literal still skips on
465        // the no-vars path. `false-cmd` would error if spawned; a non-dry-run
466        // call succeeding proves it was skipped, not executed.
467        let log = test_logger();
468        let hooks = vec![structured("false-cmd-must-be-skipped", Some("false"))];
469        run_hooks(&hooks, "test", HookRunContext::new(false, &log, None))
470            .expect("falsy literal `if:` with no vars must skip without spawning");
471    }
472
473    /// Run a single real (non-dry-run) hook that appends `KEY=$KEY` lines to
474    /// `out_file` for each requested key, so the caller can assert what the
475    /// hook actually saw in its process environment.
476    fn run_env_probe_hook(
477        out_file: &std::path::Path,
478        keys: &[&str],
479        hook_env: Option<Vec<String>>,
480        build_env: Option<&HashMap<String, String>>,
481    ) -> Result<()> {
482        let log = test_logger();
483        // sh -c mangles backslashes; feed it a forward-slash path so the redirect target resolves on Windows
484        let out = out_file.display().to_string().replace('\\', "/");
485        let probe = keys
486            .iter()
487            .map(|k| format!("echo {k}=${k} >> {out}"))
488            .collect::<Vec<_>>()
489            .join("; ");
490        let hooks = vec![HookEntry::Structured(StructuredHook {
491            cmd: probe,
492            env: hook_env,
493            ..Default::default()
494        })];
495        let vars = TemplateVars::new();
496        run_hooks(
497            &hooks,
498            "build",
499            HookRunContext {
500                dry_run: false,
501                log: &log,
502                template_vars: Some(&vars),
503                build_env,
504                extra_env: None,
505            },
506        )
507    }
508
509    #[test]
510    fn build_env_reaches_build_hook() {
511        let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
512        std::fs::create_dir_all(&dir).unwrap();
513        let out = dir.join("reaches.txt");
514        let _ = std::fs::remove_file(&out);
515
516        let mut build_env = HashMap::new();
517        build_env.insert("MY_BUILD_VAR".to_string(), "from-build-env".to_string());
518
519        run_env_probe_hook(&out, &["MY_BUILD_VAR"], None, Some(&build_env)).expect("hook must run");
520
521        let contents = std::fs::read_to_string(&out).unwrap();
522        assert!(
523            contents.contains("MY_BUILD_VAR=from-build-env"),
524            "build env var must reach the hook; got: {contents:?}"
525        );
526        let _ = std::fs::remove_file(&out);
527    }
528
529    #[test]
530    fn hook_env_overrides_build_env_on_key_conflict() {
531        let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
532        std::fs::create_dir_all(&dir).unwrap();
533        let out = dir.join("precedence.txt");
534        let _ = std::fs::remove_file(&out);
535
536        let mut build_env = HashMap::new();
537        build_env.insert("SHARED".to_string(), "build-loses".to_string());
538
539        run_env_probe_hook(
540            &out,
541            &["SHARED"],
542            Some(vec!["SHARED=hook-wins".to_string()]),
543            Some(&build_env),
544        )
545        .expect("hook must run");
546
547        let contents = std::fs::read_to_string(&out).unwrap();
548        assert!(
549            contents.contains("SHARED=hook-wins"),
550            "hook env must override build env on key conflict (GR append order); got: {contents:?}"
551        );
552        assert!(
553            !contents.contains("SHARED=build-loses"),
554            "build env value must not survive a hook-env override; got: {contents:?}"
555        );
556        let _ = std::fs::remove_file(&out);
557    }
558
559    /// Like [`run_env_probe_hook`] but exercises the `extra_env` channel.
560    fn run_extra_env_probe_hook(
561        out_file: &std::path::Path,
562        keys: &[&str],
563        hook_env: Option<Vec<String>>,
564        extra_env: &[(String, String)],
565    ) -> Result<()> {
566        let log = test_logger();
567        let out = out_file.display().to_string().replace('\\', "/");
568        let probe = keys
569            .iter()
570            .map(|k| format!("echo {k}=${k} >> {out}"))
571            .collect::<Vec<_>>()
572            .join("; ");
573        let hooks = vec![HookEntry::Structured(StructuredHook {
574            cmd: probe,
575            env: hook_env,
576            ..Default::default()
577        })];
578        let vars = TemplateVars::new();
579        run_hooks(
580            &hooks,
581            "test",
582            HookRunContext::new(false, &log, Some(&vars)).with_extra_env(extra_env),
583        )
584    }
585
586    #[test]
587    fn extra_env_reaches_hook() {
588        let dir = std::env::temp_dir().join(format!("anodizer-ee-{}", std::process::id()));
589        std::fs::create_dir_all(&dir).unwrap();
590        let out = dir.join("reaches.txt");
591        let _ = std::fs::remove_file(&out);
592
593        let extra = vec![("MY_EXTRA_VAR".to_string(), "from-extra-env".to_string())];
594        run_extra_env_probe_hook(&out, &["MY_EXTRA_VAR"], None, &extra).expect("hook must run");
595
596        let contents = std::fs::read_to_string(&out).unwrap();
597        assert!(
598            contents.contains("MY_EXTRA_VAR=from-extra-env"),
599            "extra env var must reach the hook; got: {contents:?}"
600        );
601        let _ = std::fs::remove_file(&out);
602    }
603
604    #[test]
605    fn hook_env_overrides_extra_env_on_key_conflict() {
606        let dir = std::env::temp_dir().join(format!("anodizer-ee-{}", std::process::id()));
607        std::fs::create_dir_all(&dir).unwrap();
608        let out = dir.join("precedence.txt");
609        let _ = std::fs::remove_file(&out);
610
611        let extra = vec![("SHARED".to_string(), "extra-loses".to_string())];
612        run_extra_env_probe_hook(
613            &out,
614            &["SHARED"],
615            Some(vec!["SHARED=hook-wins".to_string()]),
616            &extra,
617        )
618        .expect("hook must run");
619
620        let contents = std::fs::read_to_string(&out).unwrap();
621        assert!(
622            contents.contains("SHARED=hook-wins"),
623            "hook env must override extra env on key conflict; got: {contents:?}"
624        );
625        let _ = std::fs::remove_file(&out);
626    }
627
628    #[test]
629    fn absent_build_env_is_unchanged_behavior() {
630        let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
631        std::fs::create_dir_all(&dir).unwrap();
632        let out = dir.join("absent.txt");
633        let _ = std::fs::remove_file(&out);
634
635        // No build env at all, and the probed key is unset → empty value, no panic.
636        run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, None).expect("hook must run");
637
638        let contents = std::fs::read_to_string(&out).unwrap();
639        assert!(
640            contents.contains("NOT_SET_ANYWHERE="),
641            "absent build env must leave behavior unchanged; got: {contents:?}"
642        );
643        let _ = std::fs::remove_file(&out);
644    }
645
646    #[test]
647    fn empty_build_env_map_adds_nothing() {
648        let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
649        std::fs::create_dir_all(&dir).unwrap();
650        let out = dir.join("empty.txt");
651        let _ = std::fs::remove_file(&out);
652
653        let build_env: HashMap<String, String> = HashMap::new();
654        run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, Some(&build_env))
655            .expect("hook must run");
656
657        let contents = std::fs::read_to_string(&out).unwrap();
658        assert!(
659            contents.contains("NOT_SET_ANYWHERE="),
660            "empty build env map must be a no-op; got: {contents:?}"
661        );
662        let _ = std::fs::remove_file(&out);
663    }
664
665    #[test]
666    fn hook_if_render_error_propagates() {
667        let log = test_logger();
668        let vars = vars_with_snapshot(false);
669        let hooks = vec![structured("true", Some("{{ UndefinedSymbol }}"))];
670        let err = run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
671            .expect_err("unrenderable template must surface as Err");
672        let chain = format!("{err:#}");
673        assert!(
674            chain.contains("template render failed") || chain.contains("UndefinedSymbol"),
675            "expected render-error diagnostic, got: {chain}",
676        );
677    }
678
679    #[cfg(feature = "test-helpers")]
680    #[test]
681    fn hook_output_summary_suppressed_at_verbose() {
682        // With `output: true` the run helper tees the hook's stdout live at
683        // verbose, so the `[hook output]` status summary must NOT also fire —
684        // otherwise the same line prints twice. The capture records the status
685        // lines; none may carry the `[hook output]` prefix at verbose.
686        let (log, cap) = StageLogger::with_capture("test", Verbosity::Verbose);
687        let hooks = vec![HookEntry::Structured(StructuredHook {
688            cmd: "echo HOOKSTDOUTLINE".to_string(),
689            output: Some(true),
690            ..Default::default()
691        })];
692        let vars = TemplateVars::new();
693        run_hooks(
694            &hooks,
695            "test",
696            HookRunContext::new(false, &log, Some(&vars)),
697        )
698        .expect("hook must run");
699        let summarized = cap
700            .all_messages()
701            .into_iter()
702            .any(|(_, m)| m.contains("[hook output]"));
703        assert!(
704            !summarized,
705            "at verbose the live tee owns the hook stdout; the [hook output] \
706             summary must be suppressed to avoid a double print"
707        );
708    }
709
710    #[cfg(feature = "test-helpers")]
711    #[test]
712    fn hook_output_summary_present_at_normal() {
713        // The complementary pin: at non-verbose verbosity the live stream is
714        // suppressed, so the `[hook output]` summary IS the only surface for
715        // the hook's stdout and must still fire.
716        let (log, cap) = StageLogger::with_capture("test", Verbosity::Normal);
717        let hooks = vec![HookEntry::Structured(StructuredHook {
718            cmd: "echo HOOKSTDOUTLINE".to_string(),
719            output: Some(true),
720            ..Default::default()
721        })];
722        let vars = TemplateVars::new();
723        run_hooks(
724            &hooks,
725            "test",
726            HookRunContext::new(false, &log, Some(&vars)),
727        )
728        .expect("hook must run");
729        let summarized = cap
730            .all_messages()
731            .into_iter()
732            .any(|(_, m)| m.contains("[hook output]") && m.contains("HOOKSTDOUTLINE"));
733        assert!(
734            summarized,
735            "at normal verbosity the [hook output] summary must carry the hook's stdout"
736        );
737    }
738}