use crate::artifact::Artifact;
use crate::config::{self, BeforePublishArtifactFilter, HookEntry};
use crate::log::StageLogger;
use crate::template::{self, TemplateVars};
use anyhow::{Context as _, Result};
use std::process::Command;
fn redact_secrets(output: &str) -> String {
let env: Vec<(String, String)> = std::env::vars().collect();
crate::redact::string(output, &env)
}
fn render_hook_template(template: &str, vars: &TemplateVars, label: &str) -> Result<String> {
template::render(template, vars)
.with_context(|| format!("{} hook: render template '{}'", label, template))
}
#[derive(Clone, Copy)]
pub struct HookRunContext<'a> {
pub dry_run: bool,
pub log: &'a StageLogger,
pub template_vars: Option<&'a TemplateVars>,
pub build_env: Option<&'a std::collections::HashMap<String, String>>,
pub extra_env: Option<&'a [(String, String)]>,
}
impl<'a> HookRunContext<'a> {
pub fn new(
dry_run: bool,
log: &'a StageLogger,
template_vars: Option<&'a TemplateVars>,
) -> Self {
Self {
dry_run,
log,
template_vars,
build_env: None,
extra_env: None,
}
}
pub fn with_extra_env(mut self, extra_env: &'a [(String, String)]) -> Self {
self.extra_env = Some(extra_env);
self
}
}
pub fn run_hooks(hooks: &[HookEntry], label: &str, ctx: HookRunContext<'_>) -> Result<()> {
run_hooks_inner(hooks, label, ctx, HookResultLine::Status).map(|_| ())
}
fn hook_cmd_summary(cmd: &str) -> String {
const MAX: usize = 80;
let first = cmd.lines().next().unwrap_or("").trim();
if first.chars().count() > MAX {
let truncated: String = first.chars().take(MAX).collect();
format!("{truncated}…")
} else {
first.to_string()
}
}
#[derive(Clone, Copy)]
enum HookResultLine<'a> {
Status,
PerArtifact(&'a str),
Suppressed,
}
fn run_hooks_inner(
hooks: &[HookEntry],
label: &str,
ctx: HookRunContext<'_>,
result_line: HookResultLine<'_>,
) -> Result<bool> {
let HookRunContext {
dry_run,
log,
template_vars,
build_env,
extra_env,
} = ctx;
let mut executed = false;
for hook in hooks {
let (raw_cmd, raw_dir, env, output_flag, if_cond) = match hook {
HookEntry::Simple(s) => (s.as_str(), None, None, None, None),
HookEntry::Structured(h) => (
h.cmd.as_str(),
h.dir.as_deref(),
h.env.as_ref(),
h.output,
h.if_condition.as_deref(),
),
};
if let Some(tv) = template_vars {
let proceed = config::evaluate_if_condition(if_cond, &format!("{label} hook"), |t| {
render_hook_template(t, tv, label)
})?;
if !proceed {
tracing::debug!(
label = label,
cmd = raw_cmd,
"skipped hook — `if` condition evaluated falsy"
);
continue;
}
} else if let Some(cond) = if_cond {
let trimmed = cond.trim();
let falsy = matches!(trimmed, "false" | "0" | "no");
if falsy {
tracing::debug!(
label = label,
cmd = raw_cmd,
"skipped hook — literal `if` condition is falsy"
);
continue;
}
}
let cmd_str = if let Some(tv) = template_vars {
render_hook_template(raw_cmd, tv, label)?
} else {
raw_cmd.to_string()
};
let dir_str = match raw_dir {
Some(d) => Some(if let Some(tv) = template_vars {
render_hook_template(d, tv, label)?
} else {
d.to_string()
}),
None => None,
};
let expanded_env: Option<Vec<(String, String)>> = match env {
Some(envs) => {
let pairs = if let Some(tv) = template_vars {
config::render_env_entries(envs, |s| render_hook_template(s, tv, label))
.with_context(|| format!("{label} hook: render env entries"))?
} else {
config::parse_env_entries(envs)
.with_context(|| format!("{label} hook: parse env entries"))?
};
Some(pairs)
}
None => None,
};
executed = true;
if dry_run {
log.status(&format!(
"(dry-run) would run {} hook via `{}`",
label, cmd_str
));
} else {
log.verbose(&format!("running {} hook via `{}`", label, cmd_str));
let mut command = Command::new("sh");
command.arg("-c").arg(&cmd_str);
if let Some(ref d) = dir_str {
command.current_dir(d);
}
if let Some(be) = build_env {
for (k, v) in be {
command.env(k, v);
}
}
if let Some(ee) = extra_env {
for (k, v) in ee {
command.env(k, v);
}
}
if let Some(ref envs) = expanded_env {
for (k, v) in envs {
command.env(k, v);
}
}
let redacting_log = log.clone().with_env(std::env::vars().collect::<Vec<_>>());
let output = crate::run::run_checked(
&mut command,
&redacting_log,
&format!("{} hook: {}", label, cmd_str),
)?;
match result_line {
HookResultLine::PerArtifact(name) => {
log.verbose(&format!("ran {label} hook '{cmd_str}' on {name}"))
}
HookResultLine::Suppressed => {
log.verbose(&format!("ran {label} hook '{cmd_str}' once"))
}
HookResultLine::Status => {
let hook_name = hook_cmd_summary(&cmd_str);
log.status(&format!("ran {label} hook: {hook_name}"));
}
}
if output_flag == Some(true) && !log.is_verbose() {
let redacted_stdout = redact_secrets(&String::from_utf8_lossy(&output.stdout));
if !redacted_stdout.trim().is_empty() {
log.status(&format!("[hook output] {}", redacted_stdout.trim())); }
}
}
}
Ok(executed)
}
pub struct BeforePublishStage;
impl crate::stage::Stage for BeforePublishStage {
fn name(&self) -> &str {
"before-publish"
}
fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
let log = ctx.logger("before-publish");
let dry_run = ctx.is_dry_run();
before_publish_stage_inner(ctx, dry_run, &log, &crate::crate_scope::resolve_crate_tag)
}
}
fn before_publish_stage_inner(
ctx: &mut crate::context::Context,
dry_run: bool,
log: &StageLogger,
resolve_tag: &dyn Fn(&crate::context::Context, &crate::config::CrateConfig) -> Option<String>,
) -> Result<()> {
if let Some(hooks) = ctx
.config
.before_publish
.as_ref()
.and_then(|c| c.hooks.as_ref())
.filter(|h| !h.is_empty())
{
let base_vars = ctx.template_vars().clone();
let artifacts: Vec<Artifact> = ctx.artifacts.all().to_vec();
for entry in hooks {
run_before_publish_entry(entry, &artifacts, dry_run, log, &base_vars)?;
}
}
run_per_crate_before_publish_with_resolver(ctx, dry_run, log, resolve_tag)
}
#[derive(Clone, Copy)]
enum CrateLifecycleKind {
Before,
After,
}
impl CrateLifecycleKind {
fn label(self) -> &'static str {
match self {
CrateLifecycleKind::Before => "before",
CrateLifecycleKind::After => "after",
}
}
fn block(self, c: &crate::config::CrateConfig) -> Option<&crate::config::HooksConfig> {
match self {
CrateLifecycleKind::Before => c.before.as_ref(),
CrateLifecycleKind::After => c.after.as_ref(),
}
}
}
pub struct BeforeCrateStage;
impl crate::stage::Stage for BeforeCrateStage {
fn name(&self) -> &str {
"before"
}
fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
let log = ctx.logger("before");
let dry_run = ctx.is_dry_run();
run_per_crate_lifecycle(ctx, CrateLifecycleKind::Before, dry_run, &log)
}
}
pub struct AfterCrateStage;
impl crate::stage::Stage for AfterCrateStage {
fn name(&self) -> &str {
"after"
}
fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
let log = ctx.logger("after");
let dry_run = ctx.is_dry_run();
run_per_crate_lifecycle(ctx, CrateLifecycleKind::After, dry_run, &log)
}
}
fn run_per_crate_lifecycle(
ctx: &mut crate::context::Context,
kind: CrateLifecycleKind,
dry_run: bool,
log: &StageLogger,
) -> Result<()> {
run_per_crate_lifecycle_with_resolver(
ctx,
kind,
dry_run,
log,
&crate::crate_scope::resolve_crate_tag,
)
}
fn run_per_crate_lifecycle_with_resolver(
ctx: &mut crate::context::Context,
kind: CrateLifecycleKind,
dry_run: bool,
log: &StageLogger,
resolve_tag: &dyn Fn(&crate::context::Context, &crate::config::CrateConfig) -> Option<String>,
) -> Result<()> {
let label = kind.label();
let selected = &ctx.options.selected_crates;
let pending: Vec<(crate::config::CrateConfig, Vec<HookEntry>)> = ctx
.config
.crates
.iter()
.filter(|c| selected.is_empty() || selected.iter().any(|s| s == &c.name))
.filter_map(|c| {
kind.block(c)
.and_then(|b| b.hooks.as_ref())
.filter(|h| !h.is_empty())
.map(|h| (c.clone(), h.clone()))
})
.collect();
for (crate_cfg, hooks) in pending {
crate::crate_scope::with_crate_scope(ctx, &crate_cfg, resolve_tag, |ctx| {
run_hooks(
&hooks,
label,
HookRunContext::new(dry_run, log, Some(ctx.template_vars())),
)
})?;
}
Ok(())
}
fn run_per_crate_before_publish_with_resolver(
ctx: &mut crate::context::Context,
dry_run: bool,
log: &StageLogger,
resolve_tag: &dyn Fn(&crate::context::Context, &crate::config::CrateConfig) -> Option<String>,
) -> Result<()> {
let selected = &ctx.options.selected_crates;
let pending: Vec<(crate::config::CrateConfig, Vec<HookEntry>)> = ctx
.config
.crates
.iter()
.filter(|c| selected.is_empty() || selected.iter().any(|s| s == &c.name))
.filter_map(|c| {
c.before_publish
.as_ref()
.and_then(|b| b.hooks.as_ref())
.filter(|h| !h.is_empty())
.map(|h| (c.clone(), h.clone()))
})
.collect();
for (crate_cfg, hooks) in pending {
let crate_name = crate_cfg.name.clone();
crate::crate_scope::with_crate_scope(ctx, &crate_cfg, resolve_tag, |ctx| {
let base_vars = ctx.template_vars().clone();
let artifacts: Vec<Artifact> = ctx
.artifacts
.all()
.iter()
.filter(|a| a.crate_name == crate_name)
.cloned()
.collect();
for entry in &hooks {
run_before_publish_entry(entry, &artifacts, dry_run, log, &base_vars)?;
}
Ok(())
})?;
}
Ok(())
}
fn run_before_publish_entry(
entry: &HookEntry,
artifacts: &[Artifact],
dry_run: bool,
log: &StageLogger,
base_vars: &TemplateVars,
) -> Result<()> {
let (cmd_label, ids_filter, kind_filter, run_once) = match entry {
HookEntry::Simple(s) => (s.as_str(), None, BeforePublishArtifactFilter::All, false),
HookEntry::Structured(h) => (
h.cmd.as_str(),
h.ids.as_deref(),
h.artifacts.unwrap_or(BeforePublishArtifactFilter::All),
h.run_once,
),
};
if run_once {
let single = std::slice::from_ref(entry);
run_hooks_inner(
single,
"before-publish",
HookRunContext::new(dry_run, log, Some(base_vars)),
HookResultLine::Suppressed,
)?;
if !dry_run {
let hook_name = hook_cmd_summary(cmd_label);
log.status(&format!("ran before-publish hook: {hook_name} (once)")); }
return Ok(());
}
let mut ran = 0usize;
for artifact in artifacts {
if !kind_filter.matches(artifact.kind) {
continue;
}
if let Some(allow_ids) = ids_filter {
let id = artifact
.metadata
.get("id")
.map(String::as_str)
.unwrap_or("");
if !allow_ids.iter().any(|a| a == id) {
continue;
}
}
let mut vars = base_vars.clone();
bind_per_artifact_vars(&mut vars, artifact);
let single = std::slice::from_ref(entry);
let executed = run_hooks_inner(
single,
"before-publish",
HookRunContext::new(dry_run, log, Some(&vars)),
HookResultLine::PerArtifact(artifact.name()),
)?;
if executed {
ran += 1;
}
}
if !dry_run && ran > 0 {
let suffix = if ran == 1 { "" } else { "s" };
log.status(&format!(
"ran before-publish hook '{cmd_label}' over {ran} artifact{suffix}"
)); }
Ok(())
}
fn bind_per_artifact_vars(vars: &mut TemplateVars, artifact: &Artifact) {
vars.set("ArtifactPath", &artifact.path.to_string_lossy());
vars.set("ArtifactName", artifact.name());
vars.set("ArtifactExt", &artifact.ext());
vars.set("ArtifactKind", artifact.kind.as_str());
vars.set(
"ArtifactID",
artifact
.metadata
.get("id")
.map(String::as_str)
.unwrap_or(""),
);
if let Some(target) = artifact.target.as_deref() {
let (os, arch) = crate::target::map_target(target);
vars.set("Os", &os);
vars.set("Arch", &arch);
vars.set("Target", target);
} else {
vars.set("Os", "");
vars.set("Arch", "");
vars.set("Target", "");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StructuredHook;
#[cfg(feature = "test-helpers")]
use crate::log::LogLevel;
use crate::log::{StageLogger, Verbosity};
use std::collections::HashMap;
fn test_logger() -> StageLogger {
StageLogger::new("test", Verbosity::Normal)
}
fn vars_with_snapshot(is_snapshot: bool) -> TemplateVars {
let mut v = TemplateVars::new();
v.set("IsSnapshot", if is_snapshot { "true" } else { "false" });
v
}
fn structured(cmd: &str, if_cond: Option<&str>) -> HookEntry {
HookEntry::Structured(StructuredHook {
cmd: cmd.to_string(),
if_condition: if_cond.map(str::to_string),
..Default::default()
})
}
#[test]
fn hook_if_snapshot_template_runs_on_snapshot() {
let log = test_logger();
let vars = vars_with_snapshot(true);
let hooks = vec![structured("true", Some("{{ IsSnapshot }}"))];
run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
.expect("snapshot=true must let the hook proceed");
}
#[test]
fn hook_if_snapshot_template_skips_when_not_snapshot() {
let log = test_logger();
let vars = vars_with_snapshot(false);
let hooks = vec![structured(
"false-cmd-must-be-skipped",
Some("{{ IsSnapshot }}"),
)];
run_hooks(
&hooks,
"test",
HookRunContext::new(false, &log, Some(&vars)),
)
.expect("falsy `if:` must skip without spawning the cmd");
}
#[test]
fn hook_if_literal_true_always_runs() {
let log = test_logger();
let vars = vars_with_snapshot(false);
let hooks = vec![structured("true", Some("true"))];
run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
.expect("`if: true` must proceed");
}
#[test]
fn hook_if_empty_literal_is_noop_gate() {
let log = test_logger();
let vars = vars_with_snapshot(false);
let hooks = vec![structured("true", Some(""))];
run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
.expect("empty `if:` literal must be a no-op (always proceed)");
}
#[test]
fn hook_if_empty_literal_no_vars_proceeds() {
let log = test_logger();
let hooks = vec![structured("true", Some(""))];
run_hooks(&hooks, "test", HookRunContext::new(true, &log, None))
.expect("empty `if:` with no vars must proceed (no-op gate)");
}
#[test]
fn hook_if_falsy_literal_no_vars_skips() {
let log = test_logger();
let hooks = vec![structured("false-cmd-must-be-skipped", Some("false"))];
run_hooks(&hooks, "test", HookRunContext::new(false, &log, None))
.expect("falsy literal `if:` with no vars must skip without spawning");
}
fn run_env_probe_hook(
out_file: &std::path::Path,
keys: &[&str],
hook_env: Option<Vec<String>>,
build_env: Option<&HashMap<String, String>>,
) -> Result<()> {
let log = test_logger();
let out = out_file.display().to_string().replace('\\', "/");
let probe = keys
.iter()
.map(|k| format!("echo {k}=${k} >> {out}"))
.collect::<Vec<_>>()
.join("; ");
let hooks = vec![HookEntry::Structured(StructuredHook {
cmd: probe,
env: hook_env,
..Default::default()
})];
let vars = TemplateVars::new();
run_hooks(
&hooks,
"build",
HookRunContext {
dry_run: false,
log: &log,
template_vars: Some(&vars),
build_env,
extra_env: None,
},
)
}
#[test]
fn build_env_reaches_build_hook() {
let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("reaches.txt");
let _ = std::fs::remove_file(&out);
let mut build_env = HashMap::new();
build_env.insert("MY_BUILD_VAR".to_string(), "from-build-env".to_string());
run_env_probe_hook(&out, &["MY_BUILD_VAR"], None, Some(&build_env)).expect("hook must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert!(
contents.contains("MY_BUILD_VAR=from-build-env"),
"build env var must reach the hook; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn hook_env_overrides_build_env_on_key_conflict() {
let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("precedence.txt");
let _ = std::fs::remove_file(&out);
let mut build_env = HashMap::new();
build_env.insert("SHARED".to_string(), "build-loses".to_string());
run_env_probe_hook(
&out,
&["SHARED"],
Some(vec!["SHARED=hook-wins".to_string()]),
Some(&build_env),
)
.expect("hook must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert!(
contents.contains("SHARED=hook-wins"),
"hook env must override build env on key conflict (GR append order); got: {contents:?}"
);
assert!(
!contents.contains("SHARED=build-loses"),
"build env value must not survive a hook-env override; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
fn run_extra_env_probe_hook(
out_file: &std::path::Path,
keys: &[&str],
hook_env: Option<Vec<String>>,
extra_env: &[(String, String)],
) -> Result<()> {
let log = test_logger();
let out = out_file.display().to_string().replace('\\', "/");
let probe = keys
.iter()
.map(|k| format!("echo {k}=${k} >> {out}"))
.collect::<Vec<_>>()
.join("; ");
let hooks = vec![HookEntry::Structured(StructuredHook {
cmd: probe,
env: hook_env,
..Default::default()
})];
let vars = TemplateVars::new();
run_hooks(
&hooks,
"test",
HookRunContext::new(false, &log, Some(&vars)).with_extra_env(extra_env),
)
}
#[test]
fn extra_env_reaches_hook() {
let dir = std::env::temp_dir().join(format!("anodizer-ee-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("reaches.txt");
let _ = std::fs::remove_file(&out);
let extra = vec![("MY_EXTRA_VAR".to_string(), "from-extra-env".to_string())];
run_extra_env_probe_hook(&out, &["MY_EXTRA_VAR"], None, &extra).expect("hook must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert!(
contents.contains("MY_EXTRA_VAR=from-extra-env"),
"extra env var must reach the hook; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn hook_env_overrides_extra_env_on_key_conflict() {
let dir = std::env::temp_dir().join(format!("anodizer-ee-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("precedence.txt");
let _ = std::fs::remove_file(&out);
let extra = vec![("SHARED".to_string(), "extra-loses".to_string())];
run_extra_env_probe_hook(
&out,
&["SHARED"],
Some(vec!["SHARED=hook-wins".to_string()]),
&extra,
)
.expect("hook must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert!(
contents.contains("SHARED=hook-wins"),
"hook env must override extra env on key conflict; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn absent_build_env_is_unchanged_behavior() {
let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("absent.txt");
let _ = std::fs::remove_file(&out);
run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, None).expect("hook must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert!(
contents.contains("NOT_SET_ANYWHERE="),
"absent build env must leave behavior unchanged; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn empty_build_env_map_adds_nothing() {
let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("empty.txt");
let _ = std::fs::remove_file(&out);
let build_env: HashMap<String, String> = HashMap::new();
run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, Some(&build_env))
.expect("hook must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert!(
contents.contains("NOT_SET_ANYWHERE="),
"empty build env map must be a no-op; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn hook_if_render_error_propagates() {
let log = test_logger();
let vars = vars_with_snapshot(false);
let hooks = vec![structured("true", Some("{{ UndefinedSymbol }}"))];
let err = run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
.expect_err("unrenderable template must surface as Err");
let chain = format!("{err:#}");
assert!(
chain.contains("template render failed") || chain.contains("UndefinedSymbol"),
"expected render-error diagnostic, got: {chain}",
);
}
#[cfg(feature = "test-helpers")]
#[test]
fn hook_output_summary_suppressed_at_verbose() {
let (log, cap) = StageLogger::with_capture("test", Verbosity::Verbose);
let hooks = vec![HookEntry::Structured(StructuredHook {
cmd: "echo HOOKSTDOUTLINE".to_string(),
output: Some(true),
..Default::default()
})];
let vars = TemplateVars::new();
run_hooks(
&hooks,
"test",
HookRunContext::new(false, &log, Some(&vars)),
)
.expect("hook must run");
let summarized = cap
.all_messages()
.into_iter()
.any(|(_, m)| m.contains("[hook output]"));
assert!(
!summarized,
"at verbose the live tee owns the hook stdout; the [hook output] \
summary must be suppressed to avoid a double print"
);
}
#[cfg(feature = "test-helpers")]
#[test]
fn hook_output_summary_present_at_normal() {
let (log, cap) = StageLogger::with_capture("test", Verbosity::Normal);
let hooks = vec![HookEntry::Structured(StructuredHook {
cmd: "echo HOOKSTDOUTLINE".to_string(),
output: Some(true),
..Default::default()
})];
let vars = TemplateVars::new();
run_hooks(
&hooks,
"test",
HookRunContext::new(false, &log, Some(&vars)),
)
.expect("hook must run");
let summarized = cap
.all_messages()
.into_iter()
.any(|(_, m)| m.contains("[hook output]") && m.contains("HOOKSTDOUTLINE"));
assert!(
summarized,
"at normal verbosity the [hook output] summary must carry the hook's stdout"
);
}
use crate::artifact::{Artifact, ArtifactKind};
use crate::config::{Config, CrateConfig, HooksConfig};
use crate::context::{Context, ContextOptions};
fn crate_with_before_publish(name: &str, out_file: &str) -> CrateConfig {
let probe = format!("echo {name}:{{{{ Version }}}}:{{{{ ArtifactName }}}} >> {out_file}");
CrateConfig {
name: name.to_string(),
path: ".".to_string(),
tag_template: "v{{ .Version }}".to_string(),
before_publish: Some(HooksConfig {
hooks: Some(vec![HookEntry::Simple(probe)]),
post: None,
}),
..Default::default()
}
}
fn pkg_artifact(crate_name: &str, file_name: &str) -> Artifact {
Artifact {
kind: ArtifactKind::LinuxPackage,
path: std::path::PathBuf::from(file_name),
name: file_name.to_string(),
target: None,
crate_name: crate_name.to_string(),
metadata: HashMap::new(),
size: None,
}
}
fn fixed_tag(_ctx: &Context, c: &CrateConfig) -> Option<String> {
match c.name.as_str() {
"foo" => Some("v1.2.3".to_string()),
"bar" => Some("v9.9.9".to_string()),
_ => Some("v0.0.0".to_string()),
}
}
#[test]
fn per_crate_before_publish_scopes_version_and_artifacts() {
let dir = std::env::temp_dir().join(format!("anodizer-pcbp-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("scoped.txt");
let _ = std::fs::remove_file(&out);
let out_s = out.display().to_string().replace('\\', "/");
let config = Config {
crates: vec![
crate_with_before_publish("foo", &out_s),
crate_with_before_publish("bar", &out_s),
],
..Default::default()
};
let mut ctx = Context::new(config, ContextOptions::default());
ctx.artifacts.add(pkg_artifact("foo", "foo_1.2.3.deb"));
ctx.artifacts.add(pkg_artifact("bar", "bar_9.9.9.deb"));
let log = ctx.logger("before-publish");
run_per_crate_before_publish_with_resolver(&mut ctx, false, &log, &fixed_tag)
.expect("per-crate before_publish must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert!(
contents.contains("foo:1.2.3:foo_1.2.3.deb"),
"foo hook must be scoped to foo's version + artifact; got: {contents:?}"
);
assert!(
contents.contains("bar:9.9.9:bar_9.9.9.deb"),
"bar hook must be scoped to bar's version + artifact; got: {contents:?}"
);
assert!(
!contents.contains("foo:1.2.3:bar_9.9.9.deb"),
"foo hook must NOT see bar's artifact; got: {contents:?}"
);
assert!(
!contents.contains("bar:9.9.9:foo_1.2.3.deb"),
"bar hook must NOT see foo's artifact; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn per_crate_before_publish_honors_selected_crates() {
let dir = std::env::temp_dir().join(format!("anodizer-pcbp-sel-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("selected.txt");
let _ = std::fs::remove_file(&out);
let out_s = out.display().to_string().replace('\\', "/");
let config = Config {
crates: vec![
crate_with_before_publish("foo", &out_s),
crate_with_before_publish("bar", &out_s),
],
..Default::default()
};
let opts = ContextOptions {
selected_crates: vec!["foo".to_string()],
..Default::default()
};
let mut ctx = Context::new(config, opts);
ctx.artifacts.add(pkg_artifact("foo", "foo_1.2.3.deb"));
ctx.artifacts.add(pkg_artifact("bar", "bar_9.9.9.deb"));
let log = ctx.logger("before-publish");
run_per_crate_before_publish_with_resolver(&mut ctx, false, &log, &fixed_tag)
.expect("selected per-crate before_publish must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert!(
contents.contains("foo:1.2.3:foo_1.2.3.deb"),
"selected crate foo's hook must fire; got: {contents:?}"
);
assert!(
!contents.contains("bar:"),
"unselected crate bar's hook must NOT fire; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn per_crate_before_publish_noop_without_block() {
let config = Config {
crates: vec![CrateConfig {
name: "foo".to_string(),
path: ".".to_string(),
tag_template: "v{{ .Version }}".to_string(),
..Default::default()
}],
..Default::default()
};
let mut ctx = Context::new(config, ContextOptions::default());
ctx.artifacts.add(pkg_artifact("foo", "foo.deb"));
let log = ctx.logger("before-publish");
run_per_crate_before_publish_with_resolver(&mut ctx, false, &log, &fixed_tag)
.expect("absent per-crate before_publish must be a no-op");
}
#[test]
fn before_publish_stage_empty_selection_fires_every_crate() {
let dir = std::env::temp_dir().join(format!("anodizer-bps-all-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("all.txt");
let _ = std::fs::remove_file(&out);
let out_s = out.display().to_string().replace('\\', "/");
let config = Config {
crates: vec![
crate_with_before_publish("foo", &out_s),
crate_with_before_publish("bar", &out_s),
],
..Default::default()
};
let mut ctx = Context::new(config, ContextOptions::default());
assert!(
ctx.options.selected_crates.is_empty(),
"this test exercises the empty-selection (full-release) path"
);
ctx.artifacts.add(pkg_artifact("foo", "foo_1.2.3.deb"));
ctx.artifacts.add(pkg_artifact("bar", "bar_9.9.9.deb"));
let log = ctx.logger("before-publish");
before_publish_stage_inner(&mut ctx, false, &log, &fixed_tag)
.expect("full before-publish stage must run with empty selection");
let contents = std::fs::read_to_string(&out).unwrap();
assert!(
contents.contains("foo:1.2.3:foo_1.2.3.deb"),
"every crate's before_publish must fire on the full-release path; \
foo missing in: {contents:?}"
);
assert!(
contents.contains("bar:9.9.9:bar_9.9.9.deb"),
"every crate's before_publish must fire on the full-release path; \
bar missing in: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[cfg(feature = "test-helpers")]
#[test]
fn before_publish_entry_emits_one_summary_not_per_artifact() {
let (log, cap) = StageLogger::with_capture("before-publish", Verbosity::Verbose);
let artifacts = vec![
pkg_artifact("foo", "a.deb"),
pkg_artifact("foo", "b.deb"),
pkg_artifact("foo", "c.deb"),
];
let base = TemplateVars::new();
run_before_publish_entry(
&HookEntry::Simple("true".to_string()),
&artifacts,
false,
&log,
&base,
)
.expect("entry must run over every artifact");
let msgs = cap.all_messages();
let summary_status: Vec<&String> = msgs
.iter()
.filter(|(lvl, m)| {
*lvl == LogLevel::Status && m.contains("before-publish hook") && m.contains('3')
})
.map(|(_, m)| m)
.collect();
assert_eq!(
summary_status.len(),
1,
"exactly one default-level summary line for 3 artifacts; got: {:?}",
msgs
);
let ran_status = msgs
.iter()
.filter(|(lvl, m)| *lvl == LogLevel::Status && m.starts_with("ran "))
.count();
assert_eq!(
ran_status, 1,
"no per-artifact 'ran' line may reach default level; got: {msgs:?}"
);
let per_artifact_verbose = msgs
.iter()
.filter(|(lvl, m)| {
*lvl == LogLevel::Verbose && m.starts_with("ran ") && m.contains(".deb")
})
.count();
assert_eq!(
per_artifact_verbose, 3,
"each artifact's 'ran' detail must be verbose-only and name the artifact; got: {msgs:?}"
);
}
#[cfg(feature = "test-helpers")]
#[test]
fn before_publish_summary_excludes_if_skipped_artifacts() {
let (log, cap) = StageLogger::with_capture("before-publish", Verbosity::Verbose);
let artifacts = vec![
pkg_artifact("foo", "a.deb"),
pkg_artifact("foo", "b.deb"),
pkg_artifact("foo", "c.deb"),
];
let base = TemplateVars::new();
run_before_publish_entry(
&HookEntry::Structured(StructuredHook {
cmd: "true".to_string(),
if_condition: Some("{{ ArtifactName == \"b.deb\" }}".to_string()),
..Default::default()
}),
&artifacts,
false,
&log,
&base,
)
.expect("entry must run");
let msgs = cap.all_messages();
let summary: Vec<&String> = msgs
.iter()
.filter(|(lvl, m)| *lvl == LogLevel::Status && m.contains("before-publish hook"))
.map(|(_, m)| m)
.collect();
assert_eq!(
summary.len(),
1,
"exactly one default-level summary; got: {msgs:?}"
);
assert!(
summary[0].contains("over 1 artifact") && !summary[0].contains("over 1 artifacts"),
"summary must count only the one executed artifact (singular), not the 3 filtered-in; \
got: {:?}",
summary[0]
);
let per_artifact_verbose: Vec<&String> = msgs
.iter()
.filter(|(lvl, m)| {
*lvl == LogLevel::Verbose && m.starts_with("ran ") && m.contains(".deb")
})
.map(|(_, m)| m)
.collect();
assert_eq!(
per_artifact_verbose.len(),
1,
"only the executed artifact emits a verbose 'ran ... on <name>' line; got: {msgs:?}"
);
assert!(
per_artifact_verbose[0].contains("b.deb"),
"the one verbose line must name the artifact that actually ran; got: {:?}",
per_artifact_verbose[0]
);
}
#[test]
fn before_publish_run_once_executes_a_single_time_for_many_artifacts() {
let dir = std::env::temp_dir().join(format!("anodizer-bp-runonce-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("runs.txt");
let _ = std::fs::remove_file(&out);
let out_s = out.display().to_string().replace('\\', "/");
let log = test_logger();
let artifacts = vec![
pkg_artifact("foo", "a.deb"),
pkg_artifact("foo", "b.deb"),
pkg_artifact("foo", "c.deb"),
];
let mut base = TemplateVars::new();
base.set("Version", "1.2.3");
run_before_publish_entry(
&HookEntry::Structured(StructuredHook {
cmd: format!("echo once:{{{{ Version }}}} >> {out_s}"),
run_once: true,
..Default::default()
}),
&artifacts,
false,
&log,
&base,
)
.expect("run_once entry must execute");
let contents = std::fs::read_to_string(&out).unwrap();
let lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
1,
"run_once hook must fire exactly once for 3 artifacts; got: {contents:?}"
);
assert_eq!(
lines[0], "once:1.2.3",
"run_once hook must render run-level vars (Version), not per-artifact vars; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn before_publish_run_once_logs_result_once_no_argv_at_status() {
let (log, cap) = StageLogger::with_capture("before-publish", Verbosity::Verbose);
let artifacts = vec![pkg_artifact("foo", "a.deb"), pkg_artifact("foo", "b.deb")];
let base = TemplateVars::new();
let joined = format!("true {}&& echo TAILSENTINEL", "&& true ".repeat(12));
run_before_publish_entry(
&HookEntry::Structured(StructuredHook {
cmd: joined.clone(),
run_once: true,
..Default::default()
}),
&artifacts,
false,
&log,
&base,
)
.expect("run_once entry must execute");
let msgs = cap.all_messages();
let status_hook_lines: Vec<&String> = msgs
.iter()
.filter(|(lvl, m)| *lvl == LogLevel::Status && m.contains("before-publish hook"))
.map(|(_, m)| m)
.collect();
assert_eq!(
status_hook_lines.len(),
1,
"run-once must emit exactly one default RESULT line (no double-print); got: {msgs:?}"
);
assert!(
status_hook_lines[0].contains("(once)"),
"the single default line must be the run-once summary; got: {:?}",
status_hook_lines[0]
);
assert!(
!msgs
.iter()
.any(|(lvl, m)| *lvl == LogLevel::Status && m.contains("TAILSENTINEL")),
"the full joined bash command must NOT be echoed at default verbosity \
(the summary is truncated); got: {msgs:?}"
);
assert!(
msgs.iter().any(|(lvl, m)| *lvl == LogLevel::Verbose
&& m.contains("ran before-publish hook")
&& m.contains("once")),
"the per-hook command echo must be demoted to verbose; got: {msgs:?}"
);
}
#[test]
fn before_publish_run_once_nonzero_exit_fails_stage() {
let log = test_logger();
let artifacts = vec![pkg_artifact("foo", "a.deb"), pkg_artifact("foo", "b.deb")];
let base = TemplateVars::new();
let err = run_before_publish_entry(
&HookEntry::Structured(StructuredHook {
cmd: "exit 1".to_string(),
run_once: true,
..Default::default()
}),
&artifacts,
false,
&log,
&base,
)
.expect_err("a non-zero run_once command must fail the stage");
let _ = err;
}
#[test]
fn before_publish_run_once_ignores_artifact_filters() {
let dir =
std::env::temp_dir().join(format!("anodizer-bp-runonce-flt-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("flt.txt");
let _ = std::fs::remove_file(&out);
let out_s = out.display().to_string().replace('\\', "/");
let log = test_logger();
let artifacts = vec![pkg_artifact("foo", "a.deb")];
let base = TemplateVars::new();
run_before_publish_entry(
&HookEntry::Structured(StructuredHook {
cmd: format!("echo ran >> {out_s}"),
run_once: true,
artifacts: Some(BeforePublishArtifactFilter::Checksum),
ids: Some(vec!["nonexistent".to_string()]),
..Default::default()
}),
&artifacts,
false,
&log,
&base,
)
.expect("run_once entry must run despite non-matching filters");
let contents = std::fs::read_to_string(&out).unwrap();
assert_eq!(
contents.lines().filter(|l| !l.is_empty()).count(),
1,
"run_once must fire exactly once regardless of artifact filters; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn before_publish_run_once_false_stays_per_artifact() {
let dir = std::env::temp_dir().join(format!("anodizer-bp-perart-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("perart.txt");
let _ = std::fs::remove_file(&out);
let out_s = out.display().to_string().replace('\\', "/");
let log = test_logger();
let artifacts = vec![
pkg_artifact("foo", "a.deb"),
pkg_artifact("foo", "b.deb"),
pkg_artifact("foo", "c.deb"),
];
let base = TemplateVars::new();
run_before_publish_entry(
&HookEntry::Structured(StructuredHook {
cmd: format!("echo {{{{ ArtifactName }}}} >> {out_s}"),
run_once: false,
..Default::default()
}),
&artifacts,
false,
&log,
&base,
)
.expect("per-artifact entry must run");
let contents = std::fs::read_to_string(&out).unwrap();
let mut lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect();
lines.sort_unstable();
assert_eq!(
lines,
vec!["a.deb", "b.deb", "c.deb"],
"run_once:false must fire once per artifact with per-artifact vars; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
fn crate_with_lifecycle(name: &str, out_file: &str, set_before: bool) -> CrateConfig {
let phase = if set_before { "before" } else { "after" };
let probe = format!("echo {phase}:{name}:{{{{ Version }}}} >> {out_file}");
let hooks = Some(HooksConfig {
hooks: Some(vec![HookEntry::Simple(probe)]),
post: None,
});
let mut c = CrateConfig {
name: name.to_string(),
path: ".".to_string(),
tag_template: "v{{ .Version }}".to_string(),
..Default::default()
};
if set_before {
c.before = hooks;
} else {
c.after = hooks;
}
c
}
#[test]
fn per_crate_before_fires_once_per_crate_full_release() {
let dir = std::env::temp_dir().join(format!("anodizer-pcb-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("before.txt");
let _ = std::fs::remove_file(&out);
let out_s = out.display().to_string().replace('\\', "/");
let config = Config {
crates: vec![
crate_with_lifecycle("foo", &out_s, true),
crate_with_lifecycle("bar", &out_s, true),
],
..Default::default()
};
let mut ctx = Context::new(config, ContextOptions::default());
let log = ctx.logger("before");
run_per_crate_lifecycle_with_resolver(
&mut ctx,
CrateLifecycleKind::Before,
false,
&log,
&fixed_tag,
)
.expect("per-crate before must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert_eq!(
contents.matches("before:").count(),
2,
"before must fire exactly once per crate (no double-fire); got: {contents:?}"
);
assert!(
contents.contains("before:foo:1.2.3"),
"foo's before must render under foo's version; got: {contents:?}"
);
assert!(
contents.contains("before:bar:9.9.9"),
"bar's before must render under bar's version; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn per_crate_after_fires_once_per_crate_full_release() {
let dir = std::env::temp_dir().join(format!("anodizer-pca-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("after.txt");
let _ = std::fs::remove_file(&out);
let out_s = out.display().to_string().replace('\\', "/");
let config = Config {
crates: vec![
crate_with_lifecycle("foo", &out_s, false),
crate_with_lifecycle("bar", &out_s, false),
],
..Default::default()
};
let mut ctx = Context::new(config, ContextOptions::default());
let log = ctx.logger("after");
run_per_crate_lifecycle_with_resolver(
&mut ctx,
CrateLifecycleKind::After,
false,
&log,
&fixed_tag,
)
.expect("per-crate after must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert_eq!(
contents.matches("after:").count(),
2,
"after must fire exactly once per crate; got: {contents:?}"
);
assert!(
contents.contains("after:foo:1.2.3") && contents.contains("after:bar:9.9.9"),
"each crate's after must render under its own version; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn per_crate_before_fires_per_crate_in_lockstep() {
fn fixed_lockstep(_ctx: &Context, _c: &CrateConfig) -> Option<String> {
Some("v2.0.0".to_string())
}
let dir = std::env::temp_dir().join(format!("anodizer-pcl-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("lockstep.txt");
let _ = std::fs::remove_file(&out);
let out_s = out.display().to_string().replace('\\', "/");
let config = Config {
crates: vec![
crate_with_lifecycle("foo", &out_s, true),
crate_with_lifecycle("bar", &out_s, true),
],
..Default::default()
};
let mut ctx = Context::new(config, ContextOptions::default());
let log = ctx.logger("before");
run_per_crate_lifecycle_with_resolver(
&mut ctx,
CrateLifecycleKind::Before,
false,
&log,
&fixed_lockstep,
)
.expect("per-crate before must run in lockstep");
let contents = std::fs::read_to_string(&out).unwrap();
assert_eq!(
contents.matches("before:").count(),
2,
"lockstep multi-crate must still fire before once per crate; got: {contents:?}"
);
assert!(
contents.contains("before:foo:2.0.0") && contents.contains("before:bar:2.0.0"),
"both crates share the lockstep version; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn per_crate_before_honors_selected_crates_single_fire() {
let dir = std::env::temp_dir().join(format!("anodizer-pcb-sel-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("selected.txt");
let _ = std::fs::remove_file(&out);
let out_s = out.display().to_string().replace('\\', "/");
let config = Config {
crates: vec![
crate_with_lifecycle("foo", &out_s, true),
crate_with_lifecycle("bar", &out_s, true),
],
..Default::default()
};
let opts = ContextOptions {
selected_crates: vec!["foo".to_string()],
..Default::default()
};
let mut ctx = Context::new(config, opts);
let log = ctx.logger("before");
run_per_crate_lifecycle_with_resolver(
&mut ctx,
CrateLifecycleKind::Before,
false,
&log,
&fixed_tag,
)
.expect("selected per-crate before must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert_eq!(
contents.matches("before:").count(),
1,
"exactly the single selected crate's hook fires once; got: {contents:?}"
);
assert!(
contents.contains("before:foo:1.2.3"),
"selected crate foo's before must fire; got: {contents:?}"
);
assert!(
!contents.contains("bar"),
"unselected crate bar's before must NOT fire; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn per_crate_lifecycle_noop_without_block() {
let config = Config {
crates: vec![CrateConfig {
name: "foo".to_string(),
path: ".".to_string(),
tag_template: "v{{ .Version }}".to_string(),
..Default::default()
}],
..Default::default()
};
let mut ctx = Context::new(config, ContextOptions::default());
let log = ctx.logger("before");
run_per_crate_lifecycle_with_resolver(
&mut ctx,
CrateLifecycleKind::Before,
false,
&log,
&fixed_tag,
)
.expect("absent per-crate before must be a no-op");
run_per_crate_lifecycle_with_resolver(
&mut ctx,
CrateLifecycleKind::After,
false,
&log,
&fixed_tag,
)
.expect("absent per-crate after must be a no-op");
}
#[test]
fn hook_cmd_summary_takes_first_line_and_truncates() {
assert_eq!(
hook_cmd_summary(" cargo fmt --check "),
"cargo fmt --check"
);
assert_eq!(
hook_cmd_summary("cargo build\ncargo test\ncargo clippy"),
"cargo build"
);
let long = "x".repeat(200);
let summary = hook_cmd_summary(&long);
assert!(
summary.ends_with('…'),
"long command must be elided: {summary}"
);
assert_eq!(summary.chars().count(), 81, "80 chars + the ellipsis");
}
}