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