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
8fn redact_secrets(output: &str) -> String {
14 let env: Vec<(String, String)> = std::env::vars().collect();
15 crate::redact::string(output, &env)
16}
17
18fn 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#[derive(Clone, Copy)]
33pub struct HookRunContext<'a> {
34 pub dry_run: bool,
36 pub log: &'a StageLogger,
38 pub template_vars: Option<&'a TemplateVars>,
43 pub build_env: Option<&'a std::collections::HashMap<String, String>>,
52 pub extra_env: Option<&'a [(String, String)]>,
60}
61
62impl<'a> HookRunContext<'a> {
63 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 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
87pub fn run_hooks(hooks: &[HookEntry], label: &str, ctx: HookRunContext<'_>) -> Result<()> {
97 run_hooks_inner(hooks, label, ctx, HookResultLine::Status).map(|_| ())
98}
99
100fn 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#[derive(Clone, Copy)]
119enum HookResultLine<'a> {
120 Status,
123 PerArtifact(&'a str),
126 Suppressed,
129}
130
131fn 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 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 if let Some(ref d) = dir_str {
239 command.current_dir(d);
240 }
241 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 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 HookResultLine::Suppressed => {
281 log.verbose(&format!("ran {label} hook '{cmd_str}' once"))
282 }
283 HookResultLine::Status => {
284 let hook_name = hook_cmd_summary(&cmd_str);
288 log.status(&format!("ran {label} hook: {hook_name}"));
289 }
290 }
291
292 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())); }
302 }
303 }
304 }
305 Ok(executed)
306}
307
308pub 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
350fn 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 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 run_per_crate_before_publish_with_resolver(ctx, dry_run, log, resolve_tag)
384}
385
386#[derive(Clone, Copy)]
389enum CrateLifecycleKind {
390 Before,
393 After,
396}
397
398impl CrateLifecycleKind {
399 fn label(self) -> &'static str {
404 match self {
405 CrateLifecycleKind::Before => "before",
406 CrateLifecycleKind::After => "after",
407 }
408 }
409
410 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
419pub 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
458pub 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
478fn 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
504fn 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 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
544fn 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 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 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
606fn 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 if run_once {
633 let single = std::slice::from_ref(entry);
634 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 let hook_name = hook_cmd_summary(cmd_label);
649 log.status(&format!("ran before-publish hook: {hook_name} (once)")); }
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 let artifact_env = [("ANODIZER_ARTIFACT".to_string(), artifact.name().to_string())];
679 let single = std::slice::from_ref(entry);
684 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 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 )); }
707 Ok(())
708}
709
710fn 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 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 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 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 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 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 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 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 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 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 use crate::artifact::{Artifact, ArtifactKind};
1104 use crate::config::{Config, CrateConfig, HooksConfig};
1105 use crate::context::{Context, ContextOptions};
1106
1107 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 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 #[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 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 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 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 #[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 #[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 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 #[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 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 #[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 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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 assert_eq!(
2036 hook_cmd_summary(" cargo fmt --check "),
2037 "cargo fmt --check"
2038 );
2039 assert_eq!(
2041 hook_cmd_summary("cargo build\ncargo test\ncargo clippy"),
2042 "cargo build"
2043 );
2044 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}