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    run_hooks_inner(hooks, label, ctx, None).map(|_| ())
98}
99
100/// First line of a (rendered) hook command, truncated for a single-line status
101/// echo. Keeps a multi-line or very long hook from flooding the default log
102/// while still naming WHICH hook ran.
103fn hook_cmd_summary(cmd: &str) -> String {
104    const MAX: usize = 80;
105    let first = cmd.lines().next().unwrap_or("").trim();
106    if first.chars().count() > MAX {
107        let truncated: String = first.chars().take(MAX).collect();
108        format!("{truncated}…")
109    } else {
110        first.to_string()
111    }
112}
113
114/// [`run_hooks`] with an optional bound artifact name. When `Some`, this is a
115/// per-artifact `before_publish` iteration and the "ran <label> hook" echo is
116/// demoted to verbose and names the artifact; `None` keeps the single default
117/// status line.
118///
119/// Returns `true` when at least one entry executed (reached the run past the
120/// `if:` gate) and `false` when every entry was `if:`-skipped. Dry-run counts
121/// as executed.
122fn run_hooks_inner(
123    hooks: &[HookEntry],
124    label: &str,
125    ctx: HookRunContext<'_>,
126    artifact_name: Option<&str>,
127) -> Result<bool> {
128    let HookRunContext {
129        dry_run,
130        log,
131        template_vars,
132        build_env,
133        extra_env,
134    } = ctx;
135    let mut executed = false;
136    for hook in hooks {
137        let (raw_cmd, raw_dir, env, output_flag, if_cond) = match hook {
138            HookEntry::Simple(s) => (s.as_str(), None, None, None, None),
139            HookEntry::Structured(h) => (
140                h.cmd.as_str(),
141                h.dir.as_deref(),
142                h.env.as_ref(),
143                h.output,
144                h.if_condition.as_deref(),
145            ),
146        };
147
148        if let Some(tv) = template_vars {
149            let proceed = config::evaluate_if_condition(if_cond, &format!("{label} hook"), |t| {
150                render_hook_template(t, tv, label)
151            })?;
152            if !proceed {
153                tracing::debug!(
154                    label = label,
155                    cmd = raw_cmd,
156                    "skipped hook — `if` condition evaluated falsy"
157                );
158                continue;
159            }
160        } else if let Some(cond) = if_cond {
161            // Without template_vars there's no way to render — treat the gate
162            // as proceed unless the literal condition is explicitly falsy.
163            // An EMPTY `if:` is the "no gate set" no-op (proceed), matching
164            // `evaluate_if_condition`'s `Some("")` → proceed contract used on
165            // the template path above; only `false`/`0`/`no` skip here.
166            let trimmed = cond.trim();
167            let falsy = matches!(trimmed, "false" | "0" | "no");
168            if falsy {
169                tracing::debug!(
170                    label = label,
171                    cmd = raw_cmd,
172                    "skipped hook — literal `if` condition is falsy"
173                );
174                continue;
175            }
176        }
177
178        let cmd_str = if let Some(tv) = template_vars {
179            render_hook_template(raw_cmd, tv, label)?
180        } else {
181            raw_cmd.to_string()
182        };
183
184        let dir_str = match raw_dir {
185            Some(d) => Some(if let Some(tv) = template_vars {
186                render_hook_template(d, tv, label)?
187            } else {
188                d.to_string()
189            }),
190            None => None,
191        };
192
193        let expanded_env: Option<Vec<(String, String)>> = match env {
194            Some(envs) => {
195                let pairs = if let Some(tv) = template_vars {
196                    config::render_env_entries(envs, |s| render_hook_template(s, tv, label))
197                        .with_context(|| format!("{label} hook: render env entries"))?
198                } else {
199                    config::parse_env_entries(envs)
200                        .with_context(|| format!("{label} hook: parse env entries"))?
201                };
202                Some(pairs)
203            }
204            None => None,
205        };
206
207        executed = true;
208        if dry_run {
209            log.status(&format!(
210                "(dry-run) would run {} hook via `{}`",
211                label, cmd_str
212            ));
213        } else {
214            log.verbose(&format!("running {} hook via `{}`", label, cmd_str));
215            let mut command = Command::new("sh");
216            command.arg("-c").arg(&cmd_str);
217            // Hooks inherit the host env so toolchain env vars (PATH, MSVC
218            // INCLUDE/LIB, RUSTUP_HOME) flow through. Secret leakage is gated
219            // by `redact_secrets` on the output side.
220            if let Some(ref d) = dir_str {
221                command.current_dir(d);
222            }
223            // Precedence: process env (inherited, base) < build env
224            // < extra env < hook env. Apply the per-target build env first so
225            // a same-key hook `env:` entry below overrides it (the
226            // `append(buildEnv, hook.Env...)`).
227            if let Some(be) = build_env {
228                for (k, v) in be {
229                    command.env(k, v);
230                }
231            }
232            if let Some(ee) = extra_env {
233                for (k, v) in ee {
234                    command.env(k, v);
235                }
236            }
237            if let Some(ref envs) = expanded_env {
238                for (k, v) in envs {
239                    command.env(k, v);
240                }
241            }
242            // Run via the shared helper: captures stdout+stderr, surfaces
243            // them on failure, and (at verbose) streams the hook's output
244            // live so a long-running before/after hook shows progress. Hook
245            // output may contain secrets from the inherited host env, so the
246            // helper logger carries the full process env for redaction —
247            // matching the prior `redact_secrets` (process-env) coverage,
248            // which is broader than the caller logger's attached env.
249            let redacting_log = log.clone().with_env(std::env::vars().collect::<Vec<_>>());
250            let output = crate::run::run_checked(
251                &mut command,
252                &redacting_log,
253                &format!("{} hook: {}", label, cmd_str),
254            )?;
255
256            match artifact_name {
257                Some(name) => log.verbose(&format!("ran {label} hook '{cmd_str}' on {name}")),
258                None => {
259                    // Name WHICH hook ran so several `before:`/`after:` entries
260                    // produce distinct default lines instead of N identical
261                    // "ran before hook" echoes.
262                    let hook_name = hook_cmd_summary(&cmd_str);
263                    log.status(&format!("ran {label} hook: {hook_name}"));
264                }
265            }
266
267            // When output flag is true, echo the hook's (redacted) stdout as a
268            // `[hook output]` summary line — but NOT at verbose, where the run
269            // helper already teed that stdout live; echoing again would print
270            // it twice. The summary exists precisely for the non-verbose case
271            // where the live stream is suppressed.
272            if output_flag == Some(true) && !log.is_verbose() {
273                let redacted_stdout = redact_secrets(&String::from_utf8_lossy(&output.stdout));
274                if !redacted_stdout.trim().is_empty() {
275                    log.status(&format!("[hook output] {}", redacted_stdout.trim())); // status-ok: opt-in hook stdout summary, only emitted when output: true and non-verbose
276                }
277            }
278        }
279    }
280    Ok(executed)
281}
282
283/// Pipeline stage that runs `config.before_publish.hooks` immediately
284/// before any publisher dispatches.
285///
286/// Sits between the integrity stages (sign / checksum) and the publish
287/// phase (release / publish / blob / snapcraft-publish). A non-zero hook
288/// exit aborts the release before any publisher writes to a registry,
289/// giving operators a last-chance hook for smoke tests, antivirus scans,
290/// or external state staging against the staged dist tree.
291///
292/// Each `hooks[*]` entry runs **once per matching artifact** (the
293/// Pro `before_publish:` semantics). For each entry:
294///
295/// 1. Resolve the entry's `ids:` + `artifacts:` filters and walk
296///    `ctx.artifacts.all()` for matches.
297/// 2. For each match, bind per-artifact template variables
298///    (`ArtifactPath`, `ArtifactName`, `ArtifactExt`, `Os`, `Arch`,
299///    plus `ArtifactID` / `ArtifactKind` for parity with the publisher
300///    template surface) and render `cmd` / `dir` / `env` / `if`.
301/// 3. Execute the rendered hook (or log it under `--dry-run`).
302///
303/// `HookEntry::Simple` (bare string) implies `artifacts: all` and no
304/// `ids:` filter — it runs against every registered artifact. Zero
305/// matching artifacts means the hook runs zero times (the lifecycle
306/// semantics of `before:` / `after:` do not apply here).
307///
308/// Honors `--skip=before-publish`: when set, the pipeline's generic
309/// skip handling fires before `run` is invoked, so this stage never
310/// executes.
311pub struct BeforePublishStage;
312
313impl crate::stage::Stage for BeforePublishStage {
314    fn name(&self) -> &str {
315        "before-publish"
316    }
317
318    fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
319        let log = ctx.logger("before-publish");
320        let dry_run = ctx.is_dry_run();
321        before_publish_stage_inner(ctx, dry_run, &log, &crate::crate_scope::resolve_crate_tag)
322    }
323}
324
325/// Body of [`BeforePublishStage::run`] with an injectable per-crate tag
326/// resolver. Production passes [`crate::crate_scope::resolve_crate_tag`];
327/// tests inject a fixed-tag closure so the full stage (global + per-crate
328/// passes) can be exercised without a git fixture.
329fn before_publish_stage_inner(
330    ctx: &mut crate::context::Context,
331    dry_run: bool,
332    log: &StageLogger,
333    resolve_tag: &dyn Fn(&crate::context::Context, &crate::config::CrateConfig) -> Option<String>,
334) -> Result<()> {
335    // 1. Global `before_publish:` — fires ONCE over the full artifact set
336    //    with the run-level template vars (UNCHANGED behavior).
337    if let Some(hooks) = ctx
338        .config
339        .before_publish
340        .as_ref()
341        .and_then(|c| c.hooks.as_ref())
342        .filter(|h| !h.is_empty())
343    {
344        let base_vars = ctx.template_vars().clone();
345        let artifacts: Vec<Artifact> = ctx.artifacts.all().to_vec();
346        for entry in hooks {
347            run_before_publish_entry(entry, &artifacts, dry_run, log, &base_vars)?;
348        }
349    }
350
351    // 2. Per-crate `before_publish:` — fires once per selected crate that
352    //    declares the block, scoped to that crate's own template vars
353    //    (`Version` / `Tag` / `ProjectName`) and restricted to that crate's
354    //    artifacts. Works in workspace per-crate mode (each crate's hooks
355    //    run before its publishers) and in publish-only per-crate mode
356    //    (the stage re-runs per crate, with a single selected crate). A
357    //    single-crate / lockstep config with no per-crate block is a no-op.
358    run_per_crate_before_publish_with_resolver(ctx, dry_run, log, resolve_tag)
359}
360
361/// Which per-crate lifecycle hook block ([`BeforeCrateStage`] /
362/// [`AfterCrateStage`]) to run.
363#[derive(Clone, Copy)]
364enum CrateLifecycleKind {
365    /// `crates[].before` — fires at the pipeline HEAD (after version/tag
366    /// anchoring, before the build) for each crate.
367    Before,
368    /// `crates[].after` — fires at the pipeline TAIL (after publish) for
369    /// each crate.
370    After,
371}
372
373impl CrateLifecycleKind {
374    /// Stage name + hook label. Matches the top-level `before:` / `after:`
375    /// labels so the per-crate and top-level surfaces read identically in
376    /// logs and so the pipeline's `--skip=before` / `--skip=after` gate
377    /// (keyed on `Stage::name`) suppresses both surfaces with one flag.
378    fn label(self) -> &'static str {
379        match self {
380            CrateLifecycleKind::Before => "before",
381            CrateLifecycleKind::After => "after",
382        }
383    }
384
385    /// The per-crate hook block this kind reads from a [`CrateConfig`].
386    fn block(self, c: &crate::config::CrateConfig) -> Option<&crate::config::HooksConfig> {
387        match self {
388            CrateLifecycleKind::Before => c.before.as_ref(),
389            CrateLifecycleKind::After => c.after.as_ref(),
390        }
391    }
392}
393
394/// Pipeline stage that runs each selected crate's `crates[].before` hooks
395/// at the HEAD of a full release pipeline (after version/tag anchoring,
396/// before the build stage), scoped to that crate's own template vars.
397///
398/// This is the full-release counterpart of the publish-only per-crate
399/// loop's lifecycle hooks (`run_per_crate_lifecycle_hooks`). A plain
400/// `anodizer release` on a workspace-per-crate (or lockstep-with-multiple-
401/// publisher-crates) config runs as ONE pipeline pass with no Rust-level
402/// per-crate loop, so without this stage `crates[].before` would silently
403/// no-op there — even though `crates[].before_publish` and the publishers
404/// DO iterate per crate internally. This stage closes that gap by
405/// iterating the selected crates the SAME way [`BeforePublishStage`] does
406/// (`crate::crate_scope::with_crate_scope` + the `selected_crates` filter,
407/// empty = all configured crates).
408///
409/// Single-crate (no `crates:` block) → no-op. Honors `--skip=before` via
410/// the pipeline's generic skip gate, keyed on this stage's `name()`.
411///
412/// Not added to `build_publish_only_pipeline`: publish-only's per-crate
413/// loop re-anchors each crate's `Version`/`Tag` from its preserved
414/// `context.json` (not a fresh git tag lookup) and fires before/after via
415/// `run_per_crate_lifecycle_hooks` against those already-anchored vars.
416/// A `with_crate_scope`-based stage would re-resolve the tag from git and
417/// could fail or stamp a divergent version, so the two paths are kept
418/// distinct by design — see that function's docs and the pipeline builder.
419pub struct BeforeCrateStage;
420
421impl crate::stage::Stage for BeforeCrateStage {
422    fn name(&self) -> &str {
423        "before"
424    }
425
426    fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
427        let log = ctx.logger("before");
428        let dry_run = ctx.is_dry_run();
429        run_per_crate_lifecycle(ctx, CrateLifecycleKind::Before, dry_run, &log)
430    }
431}
432
433/// Pipeline stage that runs each selected crate's `crates[].after` hooks at
434/// the TAIL of a full release pipeline (after publish), scoped to that
435/// crate's own template vars. Tail counterpart of [`BeforeCrateStage`];
436/// see that stage's docs for the full-release vs publish-only rationale.
437///
438/// Honors `--skip=after` via the pipeline's generic skip gate.
439pub struct AfterCrateStage;
440
441impl crate::stage::Stage for AfterCrateStage {
442    fn name(&self) -> &str {
443        "after"
444    }
445
446    fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
447        let log = ctx.logger("after");
448        let dry_run = ctx.is_dry_run();
449        run_per_crate_lifecycle(ctx, CrateLifecycleKind::After, dry_run, &log)
450    }
451}
452
453/// Run the per-crate `before` / `after` lifecycle hooks for every selected
454/// crate that declares the block, each rendered against that crate's own
455/// scoped template vars (`Version` / `Tag` / `ProjectName`) via
456/// [`crate::crate_scope::with_crate_scope`].
457///
458/// Crate selection mirrors [`run_per_crate_before_publish_with_resolver`]:
459/// a non-empty `ctx.options.selected_crates` (explicit `--crate` subset)
460/// restricts the set; an empty selection iterates every configured crate. A
461/// crate with no matching hook block contributes nothing. Single-crate /
462/// lockstep configs with no per-crate block are a no-op.
463fn run_per_crate_lifecycle(
464    ctx: &mut crate::context::Context,
465    kind: CrateLifecycleKind,
466    dry_run: bool,
467    log: &StageLogger,
468) -> Result<()> {
469    run_per_crate_lifecycle_with_resolver(
470        ctx,
471        kind,
472        dry_run,
473        log,
474        &crate::crate_scope::resolve_crate_tag,
475    )
476}
477
478/// [`run_per_crate_lifecycle`] with an injectable tag resolver so tests can
479/// exercise the per-crate scoping without a git fixture (production passes
480/// [`crate::crate_scope::resolve_crate_tag`]). Mirrors
481/// [`run_per_crate_before_publish_with_resolver`].
482fn run_per_crate_lifecycle_with_resolver(
483    ctx: &mut crate::context::Context,
484    kind: CrateLifecycleKind,
485    dry_run: bool,
486    log: &StageLogger,
487    resolve_tag: &dyn Fn(&crate::context::Context, &crate::config::CrateConfig) -> Option<String>,
488) -> Result<()> {
489    let label = kind.label();
490    // Collect the (crate, hooks) pairs up-front so the per-crate scope can
491    // take a `&mut Context` without holding a borrow on `ctx.config`.
492    let selected = &ctx.options.selected_crates;
493    let pending: Vec<(crate::config::CrateConfig, Vec<HookEntry>)> = ctx
494        .config
495        .crates
496        .iter()
497        .filter(|c| selected.is_empty() || selected.iter().any(|s| s == &c.name))
498        .filter_map(|c| {
499            kind.block(c)
500                .and_then(|b| b.hooks.as_ref())
501                .filter(|h| !h.is_empty())
502                .map(|h| (c.clone(), h.clone()))
503        })
504        .collect();
505
506    for (crate_cfg, hooks) in pending {
507        crate::crate_scope::with_crate_scope(ctx, &crate_cfg, resolve_tag, |ctx| {
508            run_hooks(
509                &hooks,
510                label,
511                HookRunContext::new(dry_run, log, Some(ctx.template_vars())),
512            )
513        })?;
514    }
515    Ok(())
516}
517
518/// Run the per-crate `before_publish:` hooks for every selected crate that
519/// declares the block. Each crate's hooks render against its own scoped
520/// template vars (via [`crate::crate_scope::with_crate_scope`]) and iterate
521/// only that crate's artifacts.
522///
523/// Crate selection: when `ctx.options.selected_crates` is non-empty (an
524/// explicit `--crate` subset, or the publish-only per-crate loop's single
525/// entry) only those crates are considered; otherwise every configured crate
526/// is. A crate with no per-crate `before_publish` block contributes nothing.
527///
528/// Takes an injectable tag resolver so tests can exercise the per-crate
529/// scoping without a git fixture (production passes
530/// [`crate::crate_scope::resolve_crate_tag`] via
531/// [`before_publish_stage_inner`]). Mirrors the `with_published_crate_scope`
532/// test seam used by the publisher stages.
533fn run_per_crate_before_publish_with_resolver(
534    ctx: &mut crate::context::Context,
535    dry_run: bool,
536    log: &StageLogger,
537    resolve_tag: &dyn Fn(&crate::context::Context, &crate::config::CrateConfig) -> Option<String>,
538) -> Result<()> {
539    // Collect the (crate, hooks) pairs up-front so the per-crate scope can take
540    // a `&mut Context` without holding a borrow on `ctx.config`.
541    let selected = &ctx.options.selected_crates;
542    let pending: Vec<(crate::config::CrateConfig, Vec<HookEntry>)> = ctx
543        .config
544        .crates
545        .iter()
546        .filter(|c| selected.is_empty() || selected.iter().any(|s| s == &c.name))
547        .filter_map(|c| {
548            c.before_publish
549                .as_ref()
550                .and_then(|b| b.hooks.as_ref())
551                .filter(|h| !h.is_empty())
552                .map(|h| (c.clone(), h.clone()))
553        })
554        .collect();
555
556    for (crate_cfg, hooks) in pending {
557        let crate_name = crate_cfg.name.clone();
558        crate::crate_scope::with_crate_scope(ctx, &crate_cfg, resolve_tag, |ctx| {
559            let base_vars = ctx.template_vars().clone();
560            // Only this crate's artifacts — a per-crate hook must not see a
561            // sibling crate's packages (the `ids:` / `artifacts:` filters
562            // still apply on top).
563            let artifacts: Vec<Artifact> = ctx
564                .artifacts
565                .all()
566                .iter()
567                .filter(|a| a.crate_name == crate_name)
568                .cloned()
569                .collect();
570            for entry in &hooks {
571                run_before_publish_entry(entry, &artifacts, dry_run, log, &base_vars)?;
572            }
573            Ok(())
574        })?;
575    }
576    Ok(())
577}
578
579/// Iterate `artifacts` for a single `before_publish[*]` entry, executing
580/// the hook command once per match with per-artifact template variables
581/// bound. Returns `Err` on first hook failure so the pipeline aborts
582/// before any publisher dispatches.
583fn run_before_publish_entry(
584    entry: &HookEntry,
585    artifacts: &[Artifact],
586    dry_run: bool,
587    log: &StageLogger,
588    base_vars: &TemplateVars,
589) -> Result<()> {
590    let (cmd_label, ids_filter, kind_filter, run_once) = match entry {
591        HookEntry::Simple(s) => (s.as_str(), None, BeforePublishArtifactFilter::All, false),
592        HookEntry::Structured(h) => (
593            h.cmd.as_str(),
594            h.ids.as_deref(),
595            h.artifacts.unwrap_or(BeforePublishArtifactFilter::All),
596            h.run_once,
597        ),
598    };
599
600    // run_once: fire the command a single time with run-level vars, not bound
601    // to any artifact. The `ids` / `artifacts` filters and `$ANODIZER_ARTIFACT`
602    // are per-artifact concepts that don't apply — the command iterates the
603    // dist dir itself. Same single-invocation shape as the run-once `before:`
604    // lifecycle hook (one entry, run-level vars, one default status line).
605    if run_once {
606        let single = std::slice::from_ref(entry);
607        run_hooks(
608            single,
609            "before-publish",
610            HookRunContext::new(dry_run, log, Some(base_vars)),
611        )?;
612        if !dry_run {
613            log.status(&format!("ran before-publish hook '{cmd_label}' once")); // status-ok: run-once summary
614        }
615        return Ok(());
616    }
617
618    let mut ran = 0usize;
619    for artifact in artifacts {
620        if !kind_filter.matches(artifact.kind) {
621            continue;
622        }
623        if let Some(allow_ids) = ids_filter {
624            let id = artifact
625                .metadata
626                .get("id")
627                .map(String::as_str)
628                .unwrap_or("");
629            if !allow_ids.iter().any(|a| a == id) {
630                continue;
631            }
632        }
633
634        let mut vars = base_vars.clone();
635        bind_per_artifact_vars(&mut vars, artifact);
636        // Reuse the existing single-entry runner so dry-run, output capture,
637        // env allow-list, redaction, and `if:` evaluation behave identically
638        // to the lifecycle hook sites — only the per-artifact iteration is
639        // new here.
640        let single = std::slice::from_ref(entry);
641        // before_publish hooks are not build hooks — no per-target build env.
642        // Naming the artifact demotes the per-iteration "ran" echo to verbose
643        // so the default-level result is the one summary line below.
644        let executed = run_hooks_inner(
645            single,
646            "before-publish",
647            HookRunContext::new(dry_run, log, Some(&vars)),
648            Some(artifact.name()),
649        )?;
650        // An `if:`-skipped artifact reaches no command, so it must not inflate
651        // the summary count (which would contradict the verbose per-artifact
652        // lines that only print for executed runs).
653        if executed {
654            ran += 1;
655        }
656    }
657
658    if !dry_run && ran > 0 {
659        let suffix = if ran == 1 { "" } else { "s" };
660        log.status(&format!(
661            "ran before-publish hook '{cmd_label}' over {ran} artifact{suffix}"
662        )); // status-ok: once-per-hook summary; per-artifact detail is verbose
663    }
664    Ok(())
665}
666
667/// Bind per-artifact template variables onto `vars` for a single
668/// `before_publish:` iteration. Mirrors the publisher-side template
669/// surface (`crates/cli/src/commands/publisher.rs::build_publisher_command`)
670/// so user templates work identically across `publishers:` and
671/// `before_publish:`.
672fn bind_per_artifact_vars(vars: &mut TemplateVars, artifact: &Artifact) {
673    vars.set("ArtifactPath", &artifact.path.to_string_lossy());
674    vars.set("ArtifactName", artifact.name());
675    vars.set("ArtifactExt", &artifact.ext());
676    vars.set("ArtifactKind", artifact.kind.as_str());
677    vars.set(
678        "ArtifactID",
679        artifact
680            .metadata
681            .get("id")
682            .map(String::as_str)
683            .unwrap_or(""),
684    );
685    if let Some(target) = artifact.target.as_deref() {
686        let (os, arch) = crate::target::map_target(target);
687        vars.set("Os", &os);
688        vars.set("Arch", &arch);
689        vars.set("Target", target);
690    } else {
691        vars.set("Os", "");
692        vars.set("Arch", "");
693        vars.set("Target", "");
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use crate::config::StructuredHook;
701    #[cfg(feature = "test-helpers")]
702    use crate::log::LogLevel;
703    use crate::log::{StageLogger, Verbosity};
704    use std::collections::HashMap;
705
706    fn test_logger() -> StageLogger {
707        StageLogger::new("test", Verbosity::Normal)
708    }
709
710    fn vars_with_snapshot(is_snapshot: bool) -> TemplateVars {
711        let mut v = TemplateVars::new();
712        v.set("IsSnapshot", if is_snapshot { "true" } else { "false" });
713        v
714    }
715
716    fn structured(cmd: &str, if_cond: Option<&str>) -> HookEntry {
717        HookEntry::Structured(StructuredHook {
718            cmd: cmd.to_string(),
719            if_condition: if_cond.map(str::to_string),
720            ..Default::default()
721        })
722    }
723
724    #[test]
725    fn hook_if_snapshot_template_runs_on_snapshot() {
726        let log = test_logger();
727        let vars = vars_with_snapshot(true);
728        let hooks = vec![structured("true", Some("{{ IsSnapshot }}"))];
729        // dry_run=true → no actual exec; the function still walks the gate.
730        run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
731            .expect("snapshot=true must let the hook proceed");
732    }
733
734    #[test]
735    fn hook_if_snapshot_template_skips_when_not_snapshot() {
736        let log = test_logger();
737        let vars = vars_with_snapshot(false);
738        let hooks = vec![structured(
739            "false-cmd-must-be-skipped",
740            Some("{{ IsSnapshot }}"),
741        )];
742        run_hooks(
743            &hooks,
744            "test",
745            HookRunContext::new(false, &log, Some(&vars)),
746        )
747        .expect("falsy `if:` must skip without spawning the cmd");
748    }
749
750    #[test]
751    fn hook_if_literal_true_always_runs() {
752        let log = test_logger();
753        let vars = vars_with_snapshot(false);
754        let hooks = vec![structured("true", Some("true"))];
755        run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
756            .expect("`if: true` must proceed");
757    }
758
759    #[test]
760    fn hook_if_empty_literal_is_noop_gate() {
761        let log = test_logger();
762        let vars = vars_with_snapshot(false);
763        let hooks = vec![structured("true", Some(""))];
764        run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
765            .expect("empty `if:` literal must be a no-op (always proceed)");
766    }
767
768    #[test]
769    fn hook_if_empty_literal_no_vars_proceeds() {
770        // The no-`template_vars` branch must treat an empty `if:` as
771        // "no gate set" → proceed, identical to the template path and to
772        // `evaluate_if_condition`'s `Some("")` contract. A dry-run hook that
773        // would loudly fail if executed proves the gate let it through (the
774        // command is logged, never spawned, under dry-run).
775        let log = test_logger();
776        let hooks = vec![structured("true", Some(""))];
777        run_hooks(&hooks, "test", HookRunContext::new(true, &log, None))
778            .expect("empty `if:` with no vars must proceed (no-op gate)");
779    }
780
781    #[test]
782    fn hook_if_falsy_literal_no_vars_skips() {
783        // The complementary pin: an explicitly-falsy literal still skips on
784        // the no-vars path. `false-cmd` would error if spawned; a non-dry-run
785        // call succeeding proves it was skipped, not executed.
786        let log = test_logger();
787        let hooks = vec![structured("false-cmd-must-be-skipped", Some("false"))];
788        run_hooks(&hooks, "test", HookRunContext::new(false, &log, None))
789            .expect("falsy literal `if:` with no vars must skip without spawning");
790    }
791
792    /// Run a single real (non-dry-run) hook that appends `KEY=$KEY` lines to
793    /// `out_file` for each requested key, so the caller can assert what the
794    /// hook actually saw in its process environment.
795    fn run_env_probe_hook(
796        out_file: &std::path::Path,
797        keys: &[&str],
798        hook_env: Option<Vec<String>>,
799        build_env: Option<&HashMap<String, String>>,
800    ) -> Result<()> {
801        let log = test_logger();
802        // sh -c mangles backslashes; feed it a forward-slash path so the redirect target resolves on Windows
803        let out = out_file.display().to_string().replace('\\', "/");
804        let probe = keys
805            .iter()
806            .map(|k| format!("echo {k}=${k} >> {out}"))
807            .collect::<Vec<_>>()
808            .join("; ");
809        let hooks = vec![HookEntry::Structured(StructuredHook {
810            cmd: probe,
811            env: hook_env,
812            ..Default::default()
813        })];
814        let vars = TemplateVars::new();
815        run_hooks(
816            &hooks,
817            "build",
818            HookRunContext {
819                dry_run: false,
820                log: &log,
821                template_vars: Some(&vars),
822                build_env,
823                extra_env: None,
824            },
825        )
826    }
827
828    #[test]
829    fn build_env_reaches_build_hook() {
830        let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
831        std::fs::create_dir_all(&dir).unwrap();
832        let out = dir.join("reaches.txt");
833        let _ = std::fs::remove_file(&out);
834
835        let mut build_env = HashMap::new();
836        build_env.insert("MY_BUILD_VAR".to_string(), "from-build-env".to_string());
837
838        run_env_probe_hook(&out, &["MY_BUILD_VAR"], None, Some(&build_env)).expect("hook must run");
839
840        let contents = std::fs::read_to_string(&out).unwrap();
841        assert!(
842            contents.contains("MY_BUILD_VAR=from-build-env"),
843            "build env var must reach the hook; got: {contents:?}"
844        );
845        let _ = std::fs::remove_file(&out);
846    }
847
848    #[test]
849    fn hook_env_overrides_build_env_on_key_conflict() {
850        let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
851        std::fs::create_dir_all(&dir).unwrap();
852        let out = dir.join("precedence.txt");
853        let _ = std::fs::remove_file(&out);
854
855        let mut build_env = HashMap::new();
856        build_env.insert("SHARED".to_string(), "build-loses".to_string());
857
858        run_env_probe_hook(
859            &out,
860            &["SHARED"],
861            Some(vec!["SHARED=hook-wins".to_string()]),
862            Some(&build_env),
863        )
864        .expect("hook must run");
865
866        let contents = std::fs::read_to_string(&out).unwrap();
867        assert!(
868            contents.contains("SHARED=hook-wins"),
869            "hook env must override build env on key conflict (GR append order); got: {contents:?}"
870        );
871        assert!(
872            !contents.contains("SHARED=build-loses"),
873            "build env value must not survive a hook-env override; got: {contents:?}"
874        );
875        let _ = std::fs::remove_file(&out);
876    }
877
878    /// Like [`run_env_probe_hook`] but exercises the `extra_env` channel.
879    fn run_extra_env_probe_hook(
880        out_file: &std::path::Path,
881        keys: &[&str],
882        hook_env: Option<Vec<String>>,
883        extra_env: &[(String, String)],
884    ) -> Result<()> {
885        let log = test_logger();
886        let out = out_file.display().to_string().replace('\\', "/");
887        let probe = keys
888            .iter()
889            .map(|k| format!("echo {k}=${k} >> {out}"))
890            .collect::<Vec<_>>()
891            .join("; ");
892        let hooks = vec![HookEntry::Structured(StructuredHook {
893            cmd: probe,
894            env: hook_env,
895            ..Default::default()
896        })];
897        let vars = TemplateVars::new();
898        run_hooks(
899            &hooks,
900            "test",
901            HookRunContext::new(false, &log, Some(&vars)).with_extra_env(extra_env),
902        )
903    }
904
905    #[test]
906    fn extra_env_reaches_hook() {
907        let dir = std::env::temp_dir().join(format!("anodizer-ee-{}", std::process::id()));
908        std::fs::create_dir_all(&dir).unwrap();
909        let out = dir.join("reaches.txt");
910        let _ = std::fs::remove_file(&out);
911
912        let extra = vec![("MY_EXTRA_VAR".to_string(), "from-extra-env".to_string())];
913        run_extra_env_probe_hook(&out, &["MY_EXTRA_VAR"], None, &extra).expect("hook must run");
914
915        let contents = std::fs::read_to_string(&out).unwrap();
916        assert!(
917            contents.contains("MY_EXTRA_VAR=from-extra-env"),
918            "extra env var must reach the hook; got: {contents:?}"
919        );
920        let _ = std::fs::remove_file(&out);
921    }
922
923    #[test]
924    fn hook_env_overrides_extra_env_on_key_conflict() {
925        let dir = std::env::temp_dir().join(format!("anodizer-ee-{}", std::process::id()));
926        std::fs::create_dir_all(&dir).unwrap();
927        let out = dir.join("precedence.txt");
928        let _ = std::fs::remove_file(&out);
929
930        let extra = vec![("SHARED".to_string(), "extra-loses".to_string())];
931        run_extra_env_probe_hook(
932            &out,
933            &["SHARED"],
934            Some(vec!["SHARED=hook-wins".to_string()]),
935            &extra,
936        )
937        .expect("hook must run");
938
939        let contents = std::fs::read_to_string(&out).unwrap();
940        assert!(
941            contents.contains("SHARED=hook-wins"),
942            "hook env must override extra env on key conflict; got: {contents:?}"
943        );
944        let _ = std::fs::remove_file(&out);
945    }
946
947    #[test]
948    fn absent_build_env_is_unchanged_behavior() {
949        let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
950        std::fs::create_dir_all(&dir).unwrap();
951        let out = dir.join("absent.txt");
952        let _ = std::fs::remove_file(&out);
953
954        // No build env at all, and the probed key is unset → empty value, no panic.
955        run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, None).expect("hook must run");
956
957        let contents = std::fs::read_to_string(&out).unwrap();
958        assert!(
959            contents.contains("NOT_SET_ANYWHERE="),
960            "absent build env must leave behavior unchanged; got: {contents:?}"
961        );
962        let _ = std::fs::remove_file(&out);
963    }
964
965    #[test]
966    fn empty_build_env_map_adds_nothing() {
967        let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
968        std::fs::create_dir_all(&dir).unwrap();
969        let out = dir.join("empty.txt");
970        let _ = std::fs::remove_file(&out);
971
972        let build_env: HashMap<String, String> = HashMap::new();
973        run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, Some(&build_env))
974            .expect("hook must run");
975
976        let contents = std::fs::read_to_string(&out).unwrap();
977        assert!(
978            contents.contains("NOT_SET_ANYWHERE="),
979            "empty build env map must be a no-op; got: {contents:?}"
980        );
981        let _ = std::fs::remove_file(&out);
982    }
983
984    #[test]
985    fn hook_if_render_error_propagates() {
986        let log = test_logger();
987        let vars = vars_with_snapshot(false);
988        let hooks = vec![structured("true", Some("{{ UndefinedSymbol }}"))];
989        let err = run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
990            .expect_err("unrenderable template must surface as Err");
991        let chain = format!("{err:#}");
992        assert!(
993            chain.contains("template render failed") || chain.contains("UndefinedSymbol"),
994            "expected render-error diagnostic, got: {chain}",
995        );
996    }
997
998    #[cfg(feature = "test-helpers")]
999    #[test]
1000    fn hook_output_summary_suppressed_at_verbose() {
1001        // With `output: true` the run helper tees the hook's stdout live at
1002        // verbose, so the `[hook output]` status summary must NOT also fire —
1003        // otherwise the same line prints twice. The capture records the status
1004        // lines; none may carry the `[hook output]` prefix at verbose.
1005        let (log, cap) = StageLogger::with_capture("test", Verbosity::Verbose);
1006        let hooks = vec![HookEntry::Structured(StructuredHook {
1007            cmd: "echo HOOKSTDOUTLINE".to_string(),
1008            output: Some(true),
1009            ..Default::default()
1010        })];
1011        let vars = TemplateVars::new();
1012        run_hooks(
1013            &hooks,
1014            "test",
1015            HookRunContext::new(false, &log, Some(&vars)),
1016        )
1017        .expect("hook must run");
1018        let summarized = cap
1019            .all_messages()
1020            .into_iter()
1021            .any(|(_, m)| m.contains("[hook output]"));
1022        assert!(
1023            !summarized,
1024            "at verbose the live tee owns the hook stdout; the [hook output] \
1025             summary must be suppressed to avoid a double print"
1026        );
1027    }
1028
1029    #[cfg(feature = "test-helpers")]
1030    #[test]
1031    fn hook_output_summary_present_at_normal() {
1032        // The complementary pin: at non-verbose verbosity the live stream is
1033        // suppressed, so the `[hook output]` summary IS the only surface for
1034        // the hook's stdout and must still fire.
1035        let (log, cap) = StageLogger::with_capture("test", Verbosity::Normal);
1036        let hooks = vec![HookEntry::Structured(StructuredHook {
1037            cmd: "echo HOOKSTDOUTLINE".to_string(),
1038            output: Some(true),
1039            ..Default::default()
1040        })];
1041        let vars = TemplateVars::new();
1042        run_hooks(
1043            &hooks,
1044            "test",
1045            HookRunContext::new(false, &log, Some(&vars)),
1046        )
1047        .expect("hook must run");
1048        let summarized = cap
1049            .all_messages()
1050            .into_iter()
1051            .any(|(_, m)| m.contains("[hook output]") && m.contains("HOOKSTDOUTLINE"));
1052        assert!(
1053            summarized,
1054            "at normal verbosity the [hook output] summary must carry the hook's stdout"
1055        );
1056    }
1057
1058    // ── per-crate before_publish ────────────────────────────────────────
1059
1060    use crate::artifact::{Artifact, ArtifactKind};
1061    use crate::config::{Config, CrateConfig, HooksConfig};
1062    use crate::context::{Context, ContextOptions};
1063
1064    /// A crate config carrying a per-crate `before_publish` hook that writes
1065    /// the rendered `{{ Version }}` and each `{{ ArtifactName }}` it sees to
1066    /// `out_file` — so a test can assert which version-scope and which
1067    /// artifacts the hook observed.
1068    fn crate_with_before_publish(name: &str, out_file: &str) -> CrateConfig {
1069        let probe = format!("echo {name}:{{{{ Version }}}}:{{{{ ArtifactName }}}} >> {out_file}");
1070        CrateConfig {
1071            name: name.to_string(),
1072            path: ".".to_string(),
1073            tag_template: "v{{ .Version }}".to_string(),
1074            before_publish: Some(HooksConfig {
1075                hooks: Some(vec![HookEntry::Simple(probe)]),
1076                post: None,
1077            }),
1078            ..Default::default()
1079        }
1080    }
1081
1082    fn pkg_artifact(crate_name: &str, file_name: &str) -> Artifact {
1083        Artifact {
1084            kind: ArtifactKind::LinuxPackage,
1085            path: std::path::PathBuf::from(file_name),
1086            name: file_name.to_string(),
1087            target: None,
1088            crate_name: crate_name.to_string(),
1089            metadata: HashMap::new(),
1090            size: None,
1091        }
1092    }
1093
1094    /// Fixed-tag resolver: each crate gets a distinct version so the test can
1095    /// prove the per-crate scope re-anchors `Version` per crate.
1096    fn fixed_tag(_ctx: &Context, c: &CrateConfig) -> Option<String> {
1097        match c.name.as_str() {
1098            "foo" => Some("v1.2.3".to_string()),
1099            "bar" => Some("v9.9.9".to_string()),
1100            _ => Some("v0.0.0".to_string()),
1101        }
1102    }
1103
1104    /// Per-crate `before_publish` fires once per crate, scoped to that crate's
1105    /// own `Version` and restricted to that crate's artifacts. The global
1106    /// `before_publish` (absent here) does not fire.
1107    #[test]
1108    fn per_crate_before_publish_scopes_version_and_artifacts() {
1109        let dir = std::env::temp_dir().join(format!("anodizer-pcbp-{}", std::process::id()));
1110        std::fs::create_dir_all(&dir).unwrap();
1111        let out = dir.join("scoped.txt");
1112        let _ = std::fs::remove_file(&out);
1113        let out_s = out.display().to_string().replace('\\', "/");
1114
1115        let config = Config {
1116            crates: vec![
1117                crate_with_before_publish("foo", &out_s),
1118                crate_with_before_publish("bar", &out_s),
1119            ],
1120            ..Default::default()
1121        };
1122        let mut ctx = Context::new(config, ContextOptions::default());
1123        ctx.artifacts.add(pkg_artifact("foo", "foo_1.2.3.deb"));
1124        ctx.artifacts.add(pkg_artifact("bar", "bar_9.9.9.deb"));
1125
1126        let log = ctx.logger("before-publish");
1127        run_per_crate_before_publish_with_resolver(&mut ctx, false, &log, &fixed_tag)
1128            .expect("per-crate before_publish must run");
1129
1130        let contents = std::fs::read_to_string(&out).unwrap();
1131        // foo's hook ran with foo's version, seeing only foo's artifact.
1132        assert!(
1133            contents.contains("foo:1.2.3:foo_1.2.3.deb"),
1134            "foo hook must be scoped to foo's version + artifact; got: {contents:?}"
1135        );
1136        // bar's hook ran with bar's version, seeing only bar's artifact.
1137        assert!(
1138            contents.contains("bar:9.9.9:bar_9.9.9.deb"),
1139            "bar hook must be scoped to bar's version + artifact; got: {contents:?}"
1140        );
1141        // No cross-contamination: foo's hook never saw bar's artifact.
1142        assert!(
1143            !contents.contains("foo:1.2.3:bar_9.9.9.deb"),
1144            "foo hook must NOT see bar's artifact; got: {contents:?}"
1145        );
1146        assert!(
1147            !contents.contains("bar:9.9.9:foo_1.2.3.deb"),
1148            "bar hook must NOT see foo's artifact; got: {contents:?}"
1149        );
1150        let _ = std::fs::remove_file(&out);
1151    }
1152
1153    /// With a non-empty `selected_crates`, only the selected crate's per-crate
1154    /// `before_publish` fires — the publish-only per-crate loop sets a single
1155    /// selected crate, so a sibling's hooks must not leak into that iteration.
1156    #[test]
1157    fn per_crate_before_publish_honors_selected_crates() {
1158        let dir = std::env::temp_dir().join(format!("anodizer-pcbp-sel-{}", std::process::id()));
1159        std::fs::create_dir_all(&dir).unwrap();
1160        let out = dir.join("selected.txt");
1161        let _ = std::fs::remove_file(&out);
1162        let out_s = out.display().to_string().replace('\\', "/");
1163
1164        let config = Config {
1165            crates: vec![
1166                crate_with_before_publish("foo", &out_s),
1167                crate_with_before_publish("bar", &out_s),
1168            ],
1169            ..Default::default()
1170        };
1171        let opts = ContextOptions {
1172            selected_crates: vec!["foo".to_string()],
1173            ..Default::default()
1174        };
1175        let mut ctx = Context::new(config, opts);
1176        ctx.artifacts.add(pkg_artifact("foo", "foo_1.2.3.deb"));
1177        ctx.artifacts.add(pkg_artifact("bar", "bar_9.9.9.deb"));
1178
1179        let log = ctx.logger("before-publish");
1180        run_per_crate_before_publish_with_resolver(&mut ctx, false, &log, &fixed_tag)
1181            .expect("selected per-crate before_publish must run");
1182
1183        let contents = std::fs::read_to_string(&out).unwrap();
1184        assert!(
1185            contents.contains("foo:1.2.3:foo_1.2.3.deb"),
1186            "selected crate foo's hook must fire; got: {contents:?}"
1187        );
1188        assert!(
1189            !contents.contains("bar:"),
1190            "unselected crate bar's hook must NOT fire; got: {contents:?}"
1191        );
1192        let _ = std::fs::remove_file(&out);
1193    }
1194
1195    /// A config with NO per-crate `before_publish` block is a no-op (single
1196    /// crate / lockstep parity — the global block, if any, still fires
1197    /// separately in `run`).
1198    #[test]
1199    fn per_crate_before_publish_noop_without_block() {
1200        let config = Config {
1201            crates: vec![CrateConfig {
1202                name: "foo".to_string(),
1203                path: ".".to_string(),
1204                tag_template: "v{{ .Version }}".to_string(),
1205                ..Default::default()
1206            }],
1207            ..Default::default()
1208        };
1209        let mut ctx = Context::new(config, ContextOptions::default());
1210        ctx.artifacts.add(pkg_artifact("foo", "foo.deb"));
1211        let log = ctx.logger("before-publish");
1212        // No hooks → must succeed without spawning anything.
1213        run_per_crate_before_publish_with_resolver(&mut ctx, false, &log, &fixed_tag)
1214            .expect("absent per-crate before_publish must be a no-op");
1215    }
1216
1217    /// Full `BeforePublishStage::run` path (via the resolver-injectable inner)
1218    /// with an EMPTY `selected_crates`: every configured crate's per-crate
1219    /// `before_publish` must fire, each scoped to its own version and
1220    /// artifacts. This is the full-release path — no `--crate` subset, so the
1221    /// implicit-all selection iterates every crate.
1222    #[test]
1223    fn before_publish_stage_empty_selection_fires_every_crate() {
1224        let dir = std::env::temp_dir().join(format!("anodizer-bps-all-{}", std::process::id()));
1225        std::fs::create_dir_all(&dir).unwrap();
1226        let out = dir.join("all.txt");
1227        let _ = std::fs::remove_file(&out);
1228        let out_s = out.display().to_string().replace('\\', "/");
1229
1230        let config = Config {
1231            crates: vec![
1232                crate_with_before_publish("foo", &out_s),
1233                crate_with_before_publish("bar", &out_s),
1234            ],
1235            ..Default::default()
1236        };
1237        // ContextOptions::default() leaves selected_crates empty → implicit-all.
1238        let mut ctx = Context::new(config, ContextOptions::default());
1239        assert!(
1240            ctx.options.selected_crates.is_empty(),
1241            "this test exercises the empty-selection (full-release) path"
1242        );
1243        ctx.artifacts.add(pkg_artifact("foo", "foo_1.2.3.deb"));
1244        ctx.artifacts.add(pkg_artifact("bar", "bar_9.9.9.deb"));
1245
1246        let log = ctx.logger("before-publish");
1247        before_publish_stage_inner(&mut ctx, false, &log, &fixed_tag)
1248            .expect("full before-publish stage must run with empty selection");
1249
1250        let contents = std::fs::read_to_string(&out).unwrap();
1251        assert!(
1252            contents.contains("foo:1.2.3:foo_1.2.3.deb"),
1253            "every crate's before_publish must fire on the full-release path; \
1254             foo missing in: {contents:?}"
1255        );
1256        assert!(
1257            contents.contains("bar:9.9.9:bar_9.9.9.deb"),
1258            "every crate's before_publish must fire on the full-release path; \
1259             bar missing in: {contents:?}"
1260        );
1261        let _ = std::fs::remove_file(&out);
1262    }
1263
1264    #[cfg(feature = "test-helpers")]
1265    #[test]
1266    fn before_publish_entry_emits_one_summary_not_per_artifact() {
1267        let (log, cap) = StageLogger::with_capture("before-publish", Verbosity::Verbose);
1268        let artifacts = vec![
1269            pkg_artifact("foo", "a.deb"),
1270            pkg_artifact("foo", "b.deb"),
1271            pkg_artifact("foo", "c.deb"),
1272        ];
1273        let base = TemplateVars::new();
1274        run_before_publish_entry(
1275            &HookEntry::Simple("true".to_string()),
1276            &artifacts,
1277            false,
1278            &log,
1279            &base,
1280        )
1281        .expect("entry must run over every artifact");
1282
1283        let msgs = cap.all_messages();
1284        let summary_status: Vec<&String> = msgs
1285            .iter()
1286            .filter(|(lvl, m)| {
1287                *lvl == LogLevel::Status && m.contains("before-publish hook") && m.contains('3')
1288            })
1289            .map(|(_, m)| m)
1290            .collect();
1291        assert_eq!(
1292            summary_status.len(),
1293            1,
1294            "exactly one default-level summary line for 3 artifacts; got: {:?}",
1295            msgs
1296        );
1297        let ran_status = msgs
1298            .iter()
1299            .filter(|(lvl, m)| *lvl == LogLevel::Status && m.starts_with("ran "))
1300            .count();
1301        assert_eq!(
1302            ran_status, 1,
1303            "no per-artifact 'ran' line may reach default level; got: {msgs:?}"
1304        );
1305        let per_artifact_verbose = msgs
1306            .iter()
1307            .filter(|(lvl, m)| {
1308                *lvl == LogLevel::Verbose && m.starts_with("ran ") && m.contains(".deb")
1309            })
1310            .count();
1311        assert_eq!(
1312            per_artifact_verbose, 3,
1313            "each artifact's 'ran' detail must be verbose-only and name the artifact; got: {msgs:?}"
1314        );
1315    }
1316
1317    /// The summary counts only artifacts the hook actually ran on, not `if:`-skipped ones.
1318    #[cfg(feature = "test-helpers")]
1319    #[test]
1320    fn before_publish_summary_excludes_if_skipped_artifacts() {
1321        let (log, cap) = StageLogger::with_capture("before-publish", Verbosity::Verbose);
1322        let artifacts = vec![
1323            pkg_artifact("foo", "a.deb"),
1324            pkg_artifact("foo", "b.deb"),
1325            pkg_artifact("foo", "c.deb"),
1326        ];
1327        let base = TemplateVars::new();
1328        // Truthy only for `b.deb`; the other two are `if:`-skipped.
1329        run_before_publish_entry(
1330            &HookEntry::Structured(StructuredHook {
1331                cmd: "true".to_string(),
1332                if_condition: Some("{{ ArtifactName == \"b.deb\" }}".to_string()),
1333                ..Default::default()
1334            }),
1335            &artifacts,
1336            false,
1337            &log,
1338            &base,
1339        )
1340        .expect("entry must run");
1341
1342        let msgs = cap.all_messages();
1343        let summary: Vec<&String> = msgs
1344            .iter()
1345            .filter(|(lvl, m)| *lvl == LogLevel::Status && m.contains("before-publish hook"))
1346            .map(|(_, m)| m)
1347            .collect();
1348        assert_eq!(
1349            summary.len(),
1350            1,
1351            "exactly one default-level summary; got: {msgs:?}"
1352        );
1353        assert!(
1354            summary[0].contains("over 1 artifact") && !summary[0].contains("over 1 artifacts"),
1355            "summary must count only the one executed artifact (singular), not the 3 filtered-in; \
1356             got: {:?}",
1357            summary[0]
1358        );
1359        let per_artifact_verbose: Vec<&String> = msgs
1360            .iter()
1361            .filter(|(lvl, m)| {
1362                *lvl == LogLevel::Verbose && m.starts_with("ran ") && m.contains(".deb")
1363            })
1364            .map(|(_, m)| m)
1365            .collect();
1366        assert_eq!(
1367            per_artifact_verbose.len(),
1368            1,
1369            "only the executed artifact emits a verbose 'ran ... on <name>' line; got: {msgs:?}"
1370        );
1371        assert!(
1372            per_artifact_verbose[0].contains("b.deb"),
1373            "the one verbose line must name the artifact that actually ran; got: {:?}",
1374            per_artifact_verbose[0]
1375        );
1376    }
1377
1378    /// `run_once: true` executes the command exactly once regardless of how many artifacts match.
1379    #[test]
1380    fn before_publish_run_once_executes_a_single_time_for_many_artifacts() {
1381        let dir = std::env::temp_dir().join(format!("anodizer-bp-runonce-{}", std::process::id()));
1382        std::fs::create_dir_all(&dir).unwrap();
1383        let out = dir.join("runs.txt");
1384        let _ = std::fs::remove_file(&out);
1385        let out_s = out.display().to_string().replace('\\', "/");
1386
1387        let log = test_logger();
1388        let artifacts = vec![
1389            pkg_artifact("foo", "a.deb"),
1390            pkg_artifact("foo", "b.deb"),
1391            pkg_artifact("foo", "c.deb"),
1392        ];
1393        let mut base = TemplateVars::new();
1394        base.set("Version", "1.2.3");
1395        run_before_publish_entry(
1396            &HookEntry::Structured(StructuredHook {
1397                cmd: format!("echo once:{{{{ Version }}}} >> {out_s}"),
1398                run_once: true,
1399                ..Default::default()
1400            }),
1401            &artifacts,
1402            false,
1403            &log,
1404            &base,
1405        )
1406        .expect("run_once entry must execute");
1407
1408        let contents = std::fs::read_to_string(&out).unwrap();
1409        let lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect();
1410        assert_eq!(
1411            lines.len(),
1412            1,
1413            "run_once hook must fire exactly once for 3 artifacts; got: {contents:?}"
1414        );
1415        assert_eq!(
1416            lines[0], "once:1.2.3",
1417            "run_once hook must render run-level vars (Version), not per-artifact vars; got: {contents:?}"
1418        );
1419        let _ = std::fs::remove_file(&out);
1420    }
1421
1422    /// A `run_once` hook whose command exits non-zero must abort the stage
1423    /// (return `Err`), exactly like a per-artifact hook failure — so a failing
1424    /// integrity gate still blocks the publish.
1425    #[test]
1426    fn before_publish_run_once_nonzero_exit_fails_stage() {
1427        let log = test_logger();
1428        let artifacts = vec![pkg_artifact("foo", "a.deb"), pkg_artifact("foo", "b.deb")];
1429        let base = TemplateVars::new();
1430        let err = run_before_publish_entry(
1431            &HookEntry::Structured(StructuredHook {
1432                cmd: "exit 1".to_string(),
1433                run_once: true,
1434                ..Default::default()
1435            }),
1436            &artifacts,
1437            false,
1438            &log,
1439            &base,
1440        )
1441        .expect_err("a non-zero run_once command must fail the stage");
1442        let _ = err;
1443    }
1444
1445    /// `run_once: true` ignores the per-artifact `ids` / `artifacts` filters and still runs once.
1446    #[test]
1447    fn before_publish_run_once_ignores_artifact_filters() {
1448        let dir =
1449            std::env::temp_dir().join(format!("anodizer-bp-runonce-flt-{}", std::process::id()));
1450        std::fs::create_dir_all(&dir).unwrap();
1451        let out = dir.join("flt.txt");
1452        let _ = std::fs::remove_file(&out);
1453        let out_s = out.display().to_string().replace('\\', "/");
1454
1455        let log = test_logger();
1456        // Only LinuxPackage artifacts exist; filter asks for Checksum (no match).
1457        let artifacts = vec![pkg_artifact("foo", "a.deb")];
1458        let base = TemplateVars::new();
1459        run_before_publish_entry(
1460            &HookEntry::Structured(StructuredHook {
1461                cmd: format!("echo ran >> {out_s}"),
1462                run_once: true,
1463                artifacts: Some(BeforePublishArtifactFilter::Checksum),
1464                ids: Some(vec!["nonexistent".to_string()]),
1465                ..Default::default()
1466            }),
1467            &artifacts,
1468            false,
1469            &log,
1470            &base,
1471        )
1472        .expect("run_once entry must run despite non-matching filters");
1473
1474        let contents = std::fs::read_to_string(&out).unwrap();
1475        assert_eq!(
1476            contents.lines().filter(|l| !l.is_empty()).count(),
1477            1,
1478            "run_once must fire exactly once regardless of artifact filters; got: {contents:?}"
1479        );
1480        let _ = std::fs::remove_file(&out);
1481    }
1482
1483    /// `run_once: false` (and absent) runs the command once per matching artifact.
1484    #[test]
1485    fn before_publish_run_once_false_stays_per_artifact() {
1486        let dir = std::env::temp_dir().join(format!("anodizer-bp-perart-{}", std::process::id()));
1487        std::fs::create_dir_all(&dir).unwrap();
1488        let out = dir.join("perart.txt");
1489        let _ = std::fs::remove_file(&out);
1490        let out_s = out.display().to_string().replace('\\', "/");
1491
1492        let log = test_logger();
1493        let artifacts = vec![
1494            pkg_artifact("foo", "a.deb"),
1495            pkg_artifact("foo", "b.deb"),
1496            pkg_artifact("foo", "c.deb"),
1497        ];
1498        let base = TemplateVars::new();
1499        run_before_publish_entry(
1500            &HookEntry::Structured(StructuredHook {
1501                cmd: format!("echo {{{{ ArtifactName }}}} >> {out_s}"),
1502                run_once: false,
1503                ..Default::default()
1504            }),
1505            &artifacts,
1506            false,
1507            &log,
1508            &base,
1509        )
1510        .expect("per-artifact entry must run");
1511
1512        let contents = std::fs::read_to_string(&out).unwrap();
1513        let mut lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect();
1514        lines.sort_unstable();
1515        assert_eq!(
1516            lines,
1517            vec!["a.deb", "b.deb", "c.deb"],
1518            "run_once:false must fire once per artifact with per-artifact vars; got: {contents:?}"
1519        );
1520        let _ = std::fs::remove_file(&out);
1521    }
1522
1523    // ── per-crate before / after lifecycle stages ───────────────────────────
1524
1525    /// A crate config carrying a per-crate `before` (or `after`) lifecycle
1526    /// hook that appends `<phase>:<name>:{{ Version }}` to `out_file`, so a
1527    /// test can assert which phase fired, for which crate, under which
1528    /// version scope. `phase` is the label written by the hook ("before" /
1529    /// "after"); whether it lands in `.before` or `.after` is the caller's
1530    /// choice via `set_before`.
1531    fn crate_with_lifecycle(name: &str, out_file: &str, set_before: bool) -> CrateConfig {
1532        let phase = if set_before { "before" } else { "after" };
1533        let probe = format!("echo {phase}:{name}:{{{{ Version }}}} >> {out_file}");
1534        let hooks = Some(HooksConfig {
1535            hooks: Some(vec![HookEntry::Simple(probe)]),
1536            post: None,
1537        });
1538        let mut c = CrateConfig {
1539            name: name.to_string(),
1540            path: ".".to_string(),
1541            tag_template: "v{{ .Version }}".to_string(),
1542            ..Default::default()
1543        };
1544        if set_before {
1545            c.before = hooks;
1546        } else {
1547            c.after = hooks;
1548        }
1549        c
1550    }
1551
1552    /// `crates[].before` fires once per crate on the full-release path (empty
1553    /// selection), each scoped to that crate's own `Version`. This is the gap
1554    /// the new stage closes: a workspace-per-crate / lockstep-multi-crate full
1555    /// release previously no-op'd these hooks.
1556    #[test]
1557    fn per_crate_before_fires_once_per_crate_full_release() {
1558        let dir = std::env::temp_dir().join(format!("anodizer-pcb-{}", std::process::id()));
1559        std::fs::create_dir_all(&dir).unwrap();
1560        let out = dir.join("before.txt");
1561        let _ = std::fs::remove_file(&out);
1562        let out_s = out.display().to_string().replace('\\', "/");
1563
1564        let config = Config {
1565            crates: vec![
1566                crate_with_lifecycle("foo", &out_s, true),
1567                crate_with_lifecycle("bar", &out_s, true),
1568            ],
1569            ..Default::default()
1570        };
1571        let mut ctx = Context::new(config, ContextOptions::default());
1572        let log = ctx.logger("before");
1573        run_per_crate_lifecycle_with_resolver(
1574            &mut ctx,
1575            CrateLifecycleKind::Before,
1576            false,
1577            &log,
1578            &fixed_tag,
1579        )
1580        .expect("per-crate before must run");
1581
1582        let contents = std::fs::read_to_string(&out).unwrap();
1583        // Exactly two firings — one per crate, no double-fire.
1584        assert_eq!(
1585            contents.matches("before:").count(),
1586            2,
1587            "before must fire exactly once per crate (no double-fire); got: {contents:?}"
1588        );
1589        assert!(
1590            contents.contains("before:foo:1.2.3"),
1591            "foo's before must render under foo's version; got: {contents:?}"
1592        );
1593        assert!(
1594            contents.contains("before:bar:9.9.9"),
1595            "bar's before must render under bar's version; got: {contents:?}"
1596        );
1597        let _ = std::fs::remove_file(&out);
1598    }
1599
1600    /// `crates[].after` fires once per crate on the full-release path, scoped
1601    /// per crate — the tail counterpart of the before test.
1602    #[test]
1603    fn per_crate_after_fires_once_per_crate_full_release() {
1604        let dir = std::env::temp_dir().join(format!("anodizer-pca-{}", std::process::id()));
1605        std::fs::create_dir_all(&dir).unwrap();
1606        let out = dir.join("after.txt");
1607        let _ = std::fs::remove_file(&out);
1608        let out_s = out.display().to_string().replace('\\', "/");
1609
1610        let config = Config {
1611            crates: vec![
1612                crate_with_lifecycle("foo", &out_s, false),
1613                crate_with_lifecycle("bar", &out_s, false),
1614            ],
1615            ..Default::default()
1616        };
1617        let mut ctx = Context::new(config, ContextOptions::default());
1618        let log = ctx.logger("after");
1619        run_per_crate_lifecycle_with_resolver(
1620            &mut ctx,
1621            CrateLifecycleKind::After,
1622            false,
1623            &log,
1624            &fixed_tag,
1625        )
1626        .expect("per-crate after must run");
1627
1628        let contents = std::fs::read_to_string(&out).unwrap();
1629        assert_eq!(
1630            contents.matches("after:").count(),
1631            2,
1632            "after must fire exactly once per crate; got: {contents:?}"
1633        );
1634        assert!(
1635            contents.contains("after:foo:1.2.3") && contents.contains("after:bar:9.9.9"),
1636            "each crate's after must render under its own version; got: {contents:?}"
1637        );
1638        let _ = std::fs::remove_file(&out);
1639    }
1640
1641    /// Lockstep-with-multiple-publisher-crates: every crate shares one version
1642    /// (the `fixed_lockstep` resolver hands all crates the same tag), and
1643    /// `before` still fires once per crate — parity with `before_publish` /
1644    /// the publishers, which iterate per crate in lockstep too.
1645    #[test]
1646    fn per_crate_before_fires_per_crate_in_lockstep() {
1647        fn fixed_lockstep(_ctx: &Context, _c: &CrateConfig) -> Option<String> {
1648            Some("v2.0.0".to_string())
1649        }
1650        let dir = std::env::temp_dir().join(format!("anodizer-pcl-{}", std::process::id()));
1651        std::fs::create_dir_all(&dir).unwrap();
1652        let out = dir.join("lockstep.txt");
1653        let _ = std::fs::remove_file(&out);
1654        let out_s = out.display().to_string().replace('\\', "/");
1655
1656        let config = Config {
1657            crates: vec![
1658                crate_with_lifecycle("foo", &out_s, true),
1659                crate_with_lifecycle("bar", &out_s, true),
1660            ],
1661            ..Default::default()
1662        };
1663        let mut ctx = Context::new(config, ContextOptions::default());
1664        let log = ctx.logger("before");
1665        run_per_crate_lifecycle_with_resolver(
1666            &mut ctx,
1667            CrateLifecycleKind::Before,
1668            false,
1669            &log,
1670            &fixed_lockstep,
1671        )
1672        .expect("per-crate before must run in lockstep");
1673
1674        let contents = std::fs::read_to_string(&out).unwrap();
1675        assert_eq!(
1676            contents.matches("before:").count(),
1677            2,
1678            "lockstep multi-crate must still fire before once per crate; got: {contents:?}"
1679        );
1680        assert!(
1681            contents.contains("before:foo:2.0.0") && contents.contains("before:bar:2.0.0"),
1682            "both crates share the lockstep version; got: {contents:?}"
1683        );
1684        let _ = std::fs::remove_file(&out);
1685    }
1686
1687    /// With a non-empty `selected_crates` (the publish-only per-crate loop's
1688    /// single entry, or an explicit `--crate` subset), only the selected
1689    /// crate's per-crate `before` fires — proving no sibling leak AND that
1690    /// firing exactly the single selected crate's hook once is what
1691    /// publish-only's per-crate loop would produce (one stage invocation per
1692    /// crate, each with `selected_crates=[one]`).
1693    #[test]
1694    fn per_crate_before_honors_selected_crates_single_fire() {
1695        let dir = std::env::temp_dir().join(format!("anodizer-pcb-sel-{}", std::process::id()));
1696        std::fs::create_dir_all(&dir).unwrap();
1697        let out = dir.join("selected.txt");
1698        let _ = std::fs::remove_file(&out);
1699        let out_s = out.display().to_string().replace('\\', "/");
1700
1701        let config = Config {
1702            crates: vec![
1703                crate_with_lifecycle("foo", &out_s, true),
1704                crate_with_lifecycle("bar", &out_s, true),
1705            ],
1706            ..Default::default()
1707        };
1708        let opts = ContextOptions {
1709            selected_crates: vec!["foo".to_string()],
1710            ..Default::default()
1711        };
1712        let mut ctx = Context::new(config, opts);
1713        let log = ctx.logger("before");
1714        run_per_crate_lifecycle_with_resolver(
1715            &mut ctx,
1716            CrateLifecycleKind::Before,
1717            false,
1718            &log,
1719            &fixed_tag,
1720        )
1721        .expect("selected per-crate before must run");
1722
1723        let contents = std::fs::read_to_string(&out).unwrap();
1724        assert_eq!(
1725            contents.matches("before:").count(),
1726            1,
1727            "exactly the single selected crate's hook fires once; got: {contents:?}"
1728        );
1729        assert!(
1730            contents.contains("before:foo:1.2.3"),
1731            "selected crate foo's before must fire; got: {contents:?}"
1732        );
1733        assert!(
1734            !contents.contains("bar"),
1735            "unselected crate bar's before must NOT fire; got: {contents:?}"
1736        );
1737        let _ = std::fs::remove_file(&out);
1738    }
1739
1740    /// A config with NO per-crate `before` / `after` block is a no-op
1741    /// (single-crate / lockstep parity — the top-level `before:` / `after:`
1742    /// fire separately in the dispatcher).
1743    #[test]
1744    fn per_crate_lifecycle_noop_without_block() {
1745        let config = Config {
1746            crates: vec![CrateConfig {
1747                name: "foo".to_string(),
1748                path: ".".to_string(),
1749                tag_template: "v{{ .Version }}".to_string(),
1750                ..Default::default()
1751            }],
1752            ..Default::default()
1753        };
1754        let mut ctx = Context::new(config, ContextOptions::default());
1755        let log = ctx.logger("before");
1756        run_per_crate_lifecycle_with_resolver(
1757            &mut ctx,
1758            CrateLifecycleKind::Before,
1759            false,
1760            &log,
1761            &fixed_tag,
1762        )
1763        .expect("absent per-crate before must be a no-op");
1764        run_per_crate_lifecycle_with_resolver(
1765            &mut ctx,
1766            CrateLifecycleKind::After,
1767            false,
1768            &log,
1769            &fixed_tag,
1770        )
1771        .expect("absent per-crate after must be a no-op");
1772    }
1773
1774    #[test]
1775    fn hook_cmd_summary_takes_first_line_and_truncates() {
1776        // Short single-line: returned verbatim (trimmed).
1777        assert_eq!(
1778            hook_cmd_summary("  cargo fmt --check  "),
1779            "cargo fmt --check"
1780        );
1781        // Multi-line: only the first line names the hook.
1782        assert_eq!(
1783            hook_cmd_summary("cargo build\ncargo test\ncargo clippy"),
1784            "cargo build"
1785        );
1786        // Over the cap: truncated with an ellipsis, never flooding the log.
1787        let long = "x".repeat(200);
1788        let summary = hook_cmd_summary(&long);
1789        assert!(
1790            summary.ends_with('…'),
1791            "long command must be elided: {summary}"
1792        );
1793        assert_eq!(summary.chars().count(), 81, "80 chars + the ellipsis");
1794    }
1795}