use anodizer_core::config::Config;
use anodizer_core::context::Context;
use anodizer_core::hooks::HookRunContext;
use anodizer_core::log::StageLogger;
use anyhow::Result;
pub(crate) fn run_root_before_hooks(
ctx: &Context,
config: &Config,
dry_run: bool,
log: &StageLogger,
) -> Result<()> {
if ctx.should_skip("before") {
return Ok(());
}
let Some(hooks) = config.before.as_ref().and_then(|h| h.hooks.as_deref()) else {
return Ok(());
};
anodizer_core::hooks::run_hooks(
hooks,
"before",
HookRunContext::new(dry_run, log, Some(ctx.template_vars())),
)
}
pub(crate) fn run_root_after_hooks(
ctx: &Context,
config: &Config,
dry_run: bool,
log: &StageLogger,
) -> Result<()> {
if ctx.should_skip("after") {
return Ok(());
}
let Some(hooks) = config.after.as_ref().and_then(|h| h.hooks.as_deref()) else {
return Ok(());
};
anodizer_core::hooks::run_hooks(
hooks,
"after",
HookRunContext::new(dry_run, log, Some(ctx.template_vars())),
)
}
pub(crate) fn finish_with_always_hooks(
ctx: &Context,
outcome: Result<()>,
log: &StageLogger,
) -> Result<()> {
if ctx.should_skip("always") {
return outcome;
}
let Some(hooks) = ctx.config.always.as_ref().and_then(|h| h.hooks.as_deref()) else {
return outcome;
};
if hooks.is_empty() {
return outcome;
}
let success = outcome.is_ok();
let error_text = outcome
.as_ref()
.err()
.map(|err| format!("{err:#}"))
.unwrap_or_default();
let mut vars = ctx.template_vars().clone();
vars.set_bool("Success", success);
vars.set("Error", &error_text);
let env: Vec<(String, String)> = vec![
(
"ANODIZER_SUCCESS".to_string(),
if success { "true" } else { "false" }.to_string(),
),
("ANODIZER_ERROR".to_string(), error_text),
(
"ANODIZER_VERSION".to_string(),
vars.get("Version").cloned().unwrap_or_default(),
),
(
"ANODIZER_TAG".to_string(),
vars.get("Tag").cloned().unwrap_or_default(),
),
];
let hook_ctx = HookRunContext::new(ctx.is_dry_run(), log, Some(&vars)).with_extra_env(&env);
match anodizer_core::hooks::run_hooks(hooks, "always", hook_ctx) {
Ok(()) => outcome,
Err(hook_err) => match outcome {
Err(original) => {
log.warn(&format!(
"always hook failed (ignored — a teardown hook never masks the \
run failure it is cleaning up after): {hook_err:#}"
));
Err(original)
}
Ok(()) => Err(hook_err),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::config::{HookEntry, HooksConfig, StructuredHook};
use anodizer_core::context::ContextOptions;
use anodizer_core::log::Verbosity;
#[test]
fn always_hooks_parse_in_every_config_mode() {
for (label, yaml) in [
(
"single-crate",
"project_name: app\nalways:\n hooks:\n - ./teardown.sh\ncrates:\n - name: app\n path: \".\"\n",
),
(
"lockstep",
"project_name: ws\nalways:\n hooks:\n - ./teardown.sh\nworkspaces:\n - name: ws\n crates:\n - name: a\n path: crates/a\n tag_template: \"v{{ Version }}\"\n",
),
(
"per-crate",
"project_name: ws\nalways:\n hooks:\n - ./teardown.sh\nworkspaces:\n - name: ws\n crates:\n - name: a\n path: crates/a\n tag_template: \"a-v{{ Version }}\"\n",
),
] {
let config: Config = serde_yaml_ng::from_str(yaml)
.unwrap_or_else(|e| panic!("{label}: config must parse: {e}"));
let hooks = config
.always
.as_ref()
.and_then(|h| h.hooks.as_deref())
.unwrap_or_default();
assert_eq!(hooks.len(), 1, "{label}: one always hook resolves");
}
}
#[cfg(unix)]
fn ctx_with_always(cmd: String) -> Context {
let config = Config {
always: Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd,
..Default::default()
})]),
post: None,
}),
..Default::default()
};
Context::new(config, ContextOptions::default())
}
#[test]
#[cfg(unix)]
fn always_hooks_fire_on_success_with_outcome_env() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("fired.txt");
let ctx = ctx_with_always(format!(
"printf '%s\\n' \"success=$ANODIZER_SUCCESS err=[$ANODIZER_ERROR]\" >> {}",
out.display()
));
let log = StageLogger::new("test", Verbosity::Quiet);
finish_with_always_hooks(&ctx, Ok(()), &log).expect("success path must stay Ok");
let fired = std::fs::read_to_string(&out).expect("hook must have fired");
assert!(
fired.contains("success=true") && fired.contains("err=[]"),
"success run must export success=true and an empty error: {fired}"
);
}
#[test]
#[cfg(unix)]
fn always_hooks_fire_on_failure_and_preserve_the_original_error() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("fired.txt");
let ctx = ctx_with_always(format!(
"printf '%s\\n' \"success=$ANODIZER_SUCCESS err=$ANODIZER_ERROR\" >> {}",
out.display()
));
let log = StageLogger::new("test", Verbosity::Quiet);
let err =
finish_with_always_hooks(&ctx, Err(anyhow::anyhow!("sign stage failed: boom")), &log)
.expect_err("the pipeline error must survive");
assert!(
format!("{err:#}").contains("sign stage failed: boom"),
"original error must be returned verbatim: {err:#}"
);
let fired = std::fs::read_to_string(&out).expect("hook must have fired");
assert!(
fired.contains("success=false") && fired.contains("err=sign stage failed: boom"),
"failed run must export success=false and the error text: {fired}"
);
}
#[test]
#[cfg(unix)]
fn failing_always_hook_never_masks_the_pipeline_error() {
let ctx = ctx_with_always("exit 3".to_string());
let log = StageLogger::new("test", Verbosity::Quiet);
let err = finish_with_always_hooks(&ctx, Err(anyhow::anyhow!("publish failed: 502")), &log)
.expect_err("failure path must stay Err");
let rendered = format!("{err:#}");
assert!(
rendered.contains("publish failed: 502"),
"the pipeline error must be the returned one: {rendered}"
);
}
#[test]
#[cfg(unix)]
fn failing_always_hook_fails_an_otherwise_successful_run() {
let ctx = ctx_with_always("exit 3".to_string());
let log = StageLogger::new("test", Verbosity::Quiet);
finish_with_always_hooks(&ctx, Ok(()), &log)
.expect_err("a failing teardown must fail a successful run");
}
#[test]
fn always_without_hooks_passes_the_outcome_through() {
let ctx = Context::new(Config::default(), ContextOptions::default());
let log = StageLogger::new("test", Verbosity::Quiet);
finish_with_always_hooks(&ctx, Ok(()), &log).expect("Ok passes through");
let err = finish_with_always_hooks(&ctx, Err(anyhow::anyhow!("boom")), &log)
.expect_err("Err passes through");
assert!(format!("{err:#}").contains("boom"));
}
#[test]
#[cfg(unix)]
fn each_root_lane_skip_token_suppresses_only_its_own_lane() {
for (token, expect) in [
("before", "after\nalways\n"),
("after", "before\nalways\n"),
("always", "before\nafter\n"),
("on-error", "before\nafter\nalways\n"),
] {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("fired.txt");
let hook = |label: &str| {
Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd: format!("printf '{label}\\n' >> {}", out.display()),
..Default::default()
})]),
post: None,
})
};
let config = Config {
before: hook("before"),
after: hook("after"),
always: hook("always"),
..Default::default()
};
let ctx = Context::new(
config.clone(),
ContextOptions {
skip_stages: vec![token.to_string()],
..ContextOptions::default()
},
);
let log = StageLogger::new("test", Verbosity::Quiet);
run_root_before_hooks(&ctx, &config, false, &log).expect("before lane");
run_root_after_hooks(&ctx, &config, false, &log).expect("after lane");
finish_with_always_hooks(&ctx, Ok(()), &log).expect("always lane");
let fired = std::fs::read_to_string(&out).unwrap_or_default();
assert_eq!(
fired, expect,
"--skip={token} must suppress exactly its own lane, got {fired:?}"
);
}
}
#[test]
#[cfg(unix)]
fn skip_always_preserves_the_failure_outcome() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("fired.txt");
let mut ctx = ctx_with_always(format!("printf 'teardown\\n' >> {}", out.display()));
ctx.options.skip_stages = vec!["always".to_string()];
let log = StageLogger::new("test", Verbosity::Quiet);
let err = finish_with_always_hooks(&ctx, Err(anyhow::anyhow!("publish failed: 502")), &log)
.expect_err("the pipeline error must survive a skipped teardown lane");
assert!(format!("{err:#}").contains("publish failed: 502"));
assert!(
!out.exists(),
"--skip=always must suppress the teardown lane on the failure path too"
);
}
#[test]
fn root_lanes_without_hooks_are_noops() {
let config = Config::default();
let ctx = Context::new(config.clone(), ContextOptions::default());
let log = StageLogger::new("test", Verbosity::Quiet);
run_root_before_hooks(&ctx, &config, false, &log).expect("no before block is a no-op");
run_root_after_hooks(&ctx, &config, false, &log).expect("no after block is a no-op");
}
}