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, None).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
114fn run_hooks_inner(
123 hooks: &[HookEntry],
124 label: &str,
125 ctx: HookRunContext<'_>,
126 artifact_name: Option<&str>,
127) -> Result<bool> {
128 let HookRunContext {
129 dry_run,
130 log,
131 template_vars,
132 build_env,
133 extra_env,
134 } = ctx;
135 let mut executed = false;
136 for hook in hooks {
137 let (raw_cmd, raw_dir, env, output_flag, if_cond) = match hook {
138 HookEntry::Simple(s) => (s.as_str(), None, None, None, None),
139 HookEntry::Structured(h) => (
140 h.cmd.as_str(),
141 h.dir.as_deref(),
142 h.env.as_ref(),
143 h.output,
144 h.if_condition.as_deref(),
145 ),
146 };
147
148 if let Some(tv) = template_vars {
149 let proceed = config::evaluate_if_condition(if_cond, &format!("{label} hook"), |t| {
150 render_hook_template(t, tv, label)
151 })?;
152 if !proceed {
153 tracing::debug!(
154 label = label,
155 cmd = raw_cmd,
156 "skipped hook — `if` condition evaluated falsy"
157 );
158 continue;
159 }
160 } else if let Some(cond) = if_cond {
161 let trimmed = cond.trim();
167 let falsy = matches!(trimmed, "false" | "0" | "no");
168 if falsy {
169 tracing::debug!(
170 label = label,
171 cmd = raw_cmd,
172 "skipped hook — literal `if` condition is falsy"
173 );
174 continue;
175 }
176 }
177
178 let cmd_str = if let Some(tv) = template_vars {
179 render_hook_template(raw_cmd, tv, label)?
180 } else {
181 raw_cmd.to_string()
182 };
183
184 let dir_str = match raw_dir {
185 Some(d) => Some(if let Some(tv) = template_vars {
186 render_hook_template(d, tv, label)?
187 } else {
188 d.to_string()
189 }),
190 None => None,
191 };
192
193 let expanded_env: Option<Vec<(String, String)>> = match env {
194 Some(envs) => {
195 let pairs = if let Some(tv) = template_vars {
196 config::render_env_entries(envs, |s| render_hook_template(s, tv, label))
197 .with_context(|| format!("{label} hook: render env entries"))?
198 } else {
199 config::parse_env_entries(envs)
200 .with_context(|| format!("{label} hook: parse env entries"))?
201 };
202 Some(pairs)
203 }
204 None => None,
205 };
206
207 executed = true;
208 if dry_run {
209 log.status(&format!(
210 "(dry-run) would run {} hook via `{}`",
211 label, cmd_str
212 ));
213 } else {
214 log.verbose(&format!("running {} hook via `{}`", label, cmd_str));
215 let mut command = Command::new("sh");
216 command.arg("-c").arg(&cmd_str);
217 if let Some(ref d) = dir_str {
221 command.current_dir(d);
222 }
223 if let Some(be) = build_env {
228 for (k, v) in be {
229 command.env(k, v);
230 }
231 }
232 if let Some(ee) = extra_env {
233 for (k, v) in ee {
234 command.env(k, v);
235 }
236 }
237 if let Some(ref envs) = expanded_env {
238 for (k, v) in envs {
239 command.env(k, v);
240 }
241 }
242 let redacting_log = log.clone().with_env(std::env::vars().collect::<Vec<_>>());
250 let output = crate::run::run_checked(
251 &mut command,
252 &redacting_log,
253 &format!("{} hook: {}", label, cmd_str),
254 )?;
255
256 match artifact_name {
257 Some(name) => log.verbose(&format!("ran {label} hook '{cmd_str}' on {name}")),
258 None => {
259 let hook_name = hook_cmd_summary(&cmd_str);
263 log.status(&format!("ran {label} hook: {hook_name}"));
264 }
265 }
266
267 if output_flag == Some(true) && !log.is_verbose() {
273 let redacted_stdout = redact_secrets(&String::from_utf8_lossy(&output.stdout));
274 if !redacted_stdout.trim().is_empty() {
275 log.status(&format!("[hook output] {}", redacted_stdout.trim())); }
277 }
278 }
279 }
280 Ok(executed)
281}
282
283pub struct BeforePublishStage;
312
313impl crate::stage::Stage for BeforePublishStage {
314 fn name(&self) -> &str {
315 "before-publish"
316 }
317
318 fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
319 let log = ctx.logger("before-publish");
320 let dry_run = ctx.is_dry_run();
321 before_publish_stage_inner(ctx, dry_run, &log, &crate::crate_scope::resolve_crate_tag)
322 }
323}
324
325fn before_publish_stage_inner(
330 ctx: &mut crate::context::Context,
331 dry_run: bool,
332 log: &StageLogger,
333 resolve_tag: &dyn Fn(&crate::context::Context, &crate::config::CrateConfig) -> Option<String>,
334) -> Result<()> {
335 if let Some(hooks) = ctx
338 .config
339 .before_publish
340 .as_ref()
341 .and_then(|c| c.hooks.as_ref())
342 .filter(|h| !h.is_empty())
343 {
344 let base_vars = ctx.template_vars().clone();
345 let artifacts: Vec<Artifact> = ctx.artifacts.all().to_vec();
346 for entry in hooks {
347 run_before_publish_entry(entry, &artifacts, dry_run, log, &base_vars)?;
348 }
349 }
350
351 run_per_crate_before_publish_with_resolver(ctx, dry_run, log, resolve_tag)
359}
360
361#[derive(Clone, Copy)]
364enum CrateLifecycleKind {
365 Before,
368 After,
371}
372
373impl CrateLifecycleKind {
374 fn label(self) -> &'static str {
379 match self {
380 CrateLifecycleKind::Before => "before",
381 CrateLifecycleKind::After => "after",
382 }
383 }
384
385 fn block(self, c: &crate::config::CrateConfig) -> Option<&crate::config::HooksConfig> {
387 match self {
388 CrateLifecycleKind::Before => c.before.as_ref(),
389 CrateLifecycleKind::After => c.after.as_ref(),
390 }
391 }
392}
393
394pub struct BeforeCrateStage;
420
421impl crate::stage::Stage for BeforeCrateStage {
422 fn name(&self) -> &str {
423 "before"
424 }
425
426 fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
427 let log = ctx.logger("before");
428 let dry_run = ctx.is_dry_run();
429 run_per_crate_lifecycle(ctx, CrateLifecycleKind::Before, dry_run, &log)
430 }
431}
432
433pub struct AfterCrateStage;
440
441impl crate::stage::Stage for AfterCrateStage {
442 fn name(&self) -> &str {
443 "after"
444 }
445
446 fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
447 let log = ctx.logger("after");
448 let dry_run = ctx.is_dry_run();
449 run_per_crate_lifecycle(ctx, CrateLifecycleKind::After, dry_run, &log)
450 }
451}
452
453fn run_per_crate_lifecycle(
464 ctx: &mut crate::context::Context,
465 kind: CrateLifecycleKind,
466 dry_run: bool,
467 log: &StageLogger,
468) -> Result<()> {
469 run_per_crate_lifecycle_with_resolver(
470 ctx,
471 kind,
472 dry_run,
473 log,
474 &crate::crate_scope::resolve_crate_tag,
475 )
476}
477
478fn run_per_crate_lifecycle_with_resolver(
483 ctx: &mut crate::context::Context,
484 kind: CrateLifecycleKind,
485 dry_run: bool,
486 log: &StageLogger,
487 resolve_tag: &dyn Fn(&crate::context::Context, &crate::config::CrateConfig) -> Option<String>,
488) -> Result<()> {
489 let label = kind.label();
490 let selected = &ctx.options.selected_crates;
493 let pending: Vec<(crate::config::CrateConfig, Vec<HookEntry>)> = ctx
494 .config
495 .crates
496 .iter()
497 .filter(|c| selected.is_empty() || selected.iter().any(|s| s == &c.name))
498 .filter_map(|c| {
499 kind.block(c)
500 .and_then(|b| b.hooks.as_ref())
501 .filter(|h| !h.is_empty())
502 .map(|h| (c.clone(), h.clone()))
503 })
504 .collect();
505
506 for (crate_cfg, hooks) in pending {
507 crate::crate_scope::with_crate_scope(ctx, &crate_cfg, resolve_tag, |ctx| {
508 run_hooks(
509 &hooks,
510 label,
511 HookRunContext::new(dry_run, log, Some(ctx.template_vars())),
512 )
513 })?;
514 }
515 Ok(())
516}
517
518fn run_per_crate_before_publish_with_resolver(
534 ctx: &mut crate::context::Context,
535 dry_run: bool,
536 log: &StageLogger,
537 resolve_tag: &dyn Fn(&crate::context::Context, &crate::config::CrateConfig) -> Option<String>,
538) -> Result<()> {
539 let selected = &ctx.options.selected_crates;
542 let pending: Vec<(crate::config::CrateConfig, Vec<HookEntry>)> = ctx
543 .config
544 .crates
545 .iter()
546 .filter(|c| selected.is_empty() || selected.iter().any(|s| s == &c.name))
547 .filter_map(|c| {
548 c.before_publish
549 .as_ref()
550 .and_then(|b| b.hooks.as_ref())
551 .filter(|h| !h.is_empty())
552 .map(|h| (c.clone(), h.clone()))
553 })
554 .collect();
555
556 for (crate_cfg, hooks) in pending {
557 let crate_name = crate_cfg.name.clone();
558 crate::crate_scope::with_crate_scope(ctx, &crate_cfg, resolve_tag, |ctx| {
559 let base_vars = ctx.template_vars().clone();
560 let artifacts: Vec<Artifact> = ctx
564 .artifacts
565 .all()
566 .iter()
567 .filter(|a| a.crate_name == crate_name)
568 .cloned()
569 .collect();
570 for entry in &hooks {
571 run_before_publish_entry(entry, &artifacts, dry_run, log, &base_vars)?;
572 }
573 Ok(())
574 })?;
575 }
576 Ok(())
577}
578
579fn run_before_publish_entry(
584 entry: &HookEntry,
585 artifacts: &[Artifact],
586 dry_run: bool,
587 log: &StageLogger,
588 base_vars: &TemplateVars,
589) -> Result<()> {
590 let (cmd_label, ids_filter, kind_filter, run_once) = match entry {
591 HookEntry::Simple(s) => (s.as_str(), None, BeforePublishArtifactFilter::All, false),
592 HookEntry::Structured(h) => (
593 h.cmd.as_str(),
594 h.ids.as_deref(),
595 h.artifacts.unwrap_or(BeforePublishArtifactFilter::All),
596 h.run_once,
597 ),
598 };
599
600 if run_once {
606 let single = std::slice::from_ref(entry);
607 run_hooks(
608 single,
609 "before-publish",
610 HookRunContext::new(dry_run, log, Some(base_vars)),
611 )?;
612 if !dry_run {
613 log.status(&format!("ran before-publish hook '{cmd_label}' once")); }
615 return Ok(());
616 }
617
618 let mut ran = 0usize;
619 for artifact in artifacts {
620 if !kind_filter.matches(artifact.kind) {
621 continue;
622 }
623 if let Some(allow_ids) = ids_filter {
624 let id = artifact
625 .metadata
626 .get("id")
627 .map(String::as_str)
628 .unwrap_or("");
629 if !allow_ids.iter().any(|a| a == id) {
630 continue;
631 }
632 }
633
634 let mut vars = base_vars.clone();
635 bind_per_artifact_vars(&mut vars, artifact);
636 let single = std::slice::from_ref(entry);
641 let executed = run_hooks_inner(
645 single,
646 "before-publish",
647 HookRunContext::new(dry_run, log, Some(&vars)),
648 Some(artifact.name()),
649 )?;
650 if executed {
654 ran += 1;
655 }
656 }
657
658 if !dry_run && ran > 0 {
659 let suffix = if ran == 1 { "" } else { "s" };
660 log.status(&format!(
661 "ran before-publish hook '{cmd_label}' over {ran} artifact{suffix}"
662 )); }
664 Ok(())
665}
666
667fn bind_per_artifact_vars(vars: &mut TemplateVars, artifact: &Artifact) {
673 vars.set("ArtifactPath", &artifact.path.to_string_lossy());
674 vars.set("ArtifactName", artifact.name());
675 vars.set("ArtifactExt", &artifact.ext());
676 vars.set("ArtifactKind", artifact.kind.as_str());
677 vars.set(
678 "ArtifactID",
679 artifact
680 .metadata
681 .get("id")
682 .map(String::as_str)
683 .unwrap_or(""),
684 );
685 if let Some(target) = artifact.target.as_deref() {
686 let (os, arch) = crate::target::map_target(target);
687 vars.set("Os", &os);
688 vars.set("Arch", &arch);
689 vars.set("Target", target);
690 } else {
691 vars.set("Os", "");
692 vars.set("Arch", "");
693 vars.set("Target", "");
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use crate::config::StructuredHook;
701 #[cfg(feature = "test-helpers")]
702 use crate::log::LogLevel;
703 use crate::log::{StageLogger, Verbosity};
704 use std::collections::HashMap;
705
706 fn test_logger() -> StageLogger {
707 StageLogger::new("test", Verbosity::Normal)
708 }
709
710 fn vars_with_snapshot(is_snapshot: bool) -> TemplateVars {
711 let mut v = TemplateVars::new();
712 v.set("IsSnapshot", if is_snapshot { "true" } else { "false" });
713 v
714 }
715
716 fn structured(cmd: &str, if_cond: Option<&str>) -> HookEntry {
717 HookEntry::Structured(StructuredHook {
718 cmd: cmd.to_string(),
719 if_condition: if_cond.map(str::to_string),
720 ..Default::default()
721 })
722 }
723
724 #[test]
725 fn hook_if_snapshot_template_runs_on_snapshot() {
726 let log = test_logger();
727 let vars = vars_with_snapshot(true);
728 let hooks = vec![structured("true", Some("{{ IsSnapshot }}"))];
729 run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
731 .expect("snapshot=true must let the hook proceed");
732 }
733
734 #[test]
735 fn hook_if_snapshot_template_skips_when_not_snapshot() {
736 let log = test_logger();
737 let vars = vars_with_snapshot(false);
738 let hooks = vec![structured(
739 "false-cmd-must-be-skipped",
740 Some("{{ IsSnapshot }}"),
741 )];
742 run_hooks(
743 &hooks,
744 "test",
745 HookRunContext::new(false, &log, Some(&vars)),
746 )
747 .expect("falsy `if:` must skip without spawning the cmd");
748 }
749
750 #[test]
751 fn hook_if_literal_true_always_runs() {
752 let log = test_logger();
753 let vars = vars_with_snapshot(false);
754 let hooks = vec![structured("true", Some("true"))];
755 run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
756 .expect("`if: true` must proceed");
757 }
758
759 #[test]
760 fn hook_if_empty_literal_is_noop_gate() {
761 let log = test_logger();
762 let vars = vars_with_snapshot(false);
763 let hooks = vec![structured("true", Some(""))];
764 run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
765 .expect("empty `if:` literal must be a no-op (always proceed)");
766 }
767
768 #[test]
769 fn hook_if_empty_literal_no_vars_proceeds() {
770 let log = test_logger();
776 let hooks = vec![structured("true", Some(""))];
777 run_hooks(&hooks, "test", HookRunContext::new(true, &log, None))
778 .expect("empty `if:` with no vars must proceed (no-op gate)");
779 }
780
781 #[test]
782 fn hook_if_falsy_literal_no_vars_skips() {
783 let log = test_logger();
787 let hooks = vec![structured("false-cmd-must-be-skipped", Some("false"))];
788 run_hooks(&hooks, "test", HookRunContext::new(false, &log, None))
789 .expect("falsy literal `if:` with no vars must skip without spawning");
790 }
791
792 fn run_env_probe_hook(
796 out_file: &std::path::Path,
797 keys: &[&str],
798 hook_env: Option<Vec<String>>,
799 build_env: Option<&HashMap<String, String>>,
800 ) -> Result<()> {
801 let log = test_logger();
802 let out = out_file.display().to_string().replace('\\', "/");
804 let probe = keys
805 .iter()
806 .map(|k| format!("echo {k}=${k} >> {out}"))
807 .collect::<Vec<_>>()
808 .join("; ");
809 let hooks = vec![HookEntry::Structured(StructuredHook {
810 cmd: probe,
811 env: hook_env,
812 ..Default::default()
813 })];
814 let vars = TemplateVars::new();
815 run_hooks(
816 &hooks,
817 "build",
818 HookRunContext {
819 dry_run: false,
820 log: &log,
821 template_vars: Some(&vars),
822 build_env,
823 extra_env: None,
824 },
825 )
826 }
827
828 #[test]
829 fn build_env_reaches_build_hook() {
830 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
831 std::fs::create_dir_all(&dir).unwrap();
832 let out = dir.join("reaches.txt");
833 let _ = std::fs::remove_file(&out);
834
835 let mut build_env = HashMap::new();
836 build_env.insert("MY_BUILD_VAR".to_string(), "from-build-env".to_string());
837
838 run_env_probe_hook(&out, &["MY_BUILD_VAR"], None, Some(&build_env)).expect("hook must run");
839
840 let contents = std::fs::read_to_string(&out).unwrap();
841 assert!(
842 contents.contains("MY_BUILD_VAR=from-build-env"),
843 "build env var must reach the hook; got: {contents:?}"
844 );
845 let _ = std::fs::remove_file(&out);
846 }
847
848 #[test]
849 fn hook_env_overrides_build_env_on_key_conflict() {
850 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
851 std::fs::create_dir_all(&dir).unwrap();
852 let out = dir.join("precedence.txt");
853 let _ = std::fs::remove_file(&out);
854
855 let mut build_env = HashMap::new();
856 build_env.insert("SHARED".to_string(), "build-loses".to_string());
857
858 run_env_probe_hook(
859 &out,
860 &["SHARED"],
861 Some(vec!["SHARED=hook-wins".to_string()]),
862 Some(&build_env),
863 )
864 .expect("hook must run");
865
866 let contents = std::fs::read_to_string(&out).unwrap();
867 assert!(
868 contents.contains("SHARED=hook-wins"),
869 "hook env must override build env on key conflict (GR append order); got: {contents:?}"
870 );
871 assert!(
872 !contents.contains("SHARED=build-loses"),
873 "build env value must not survive a hook-env override; got: {contents:?}"
874 );
875 let _ = std::fs::remove_file(&out);
876 }
877
878 fn run_extra_env_probe_hook(
880 out_file: &std::path::Path,
881 keys: &[&str],
882 hook_env: Option<Vec<String>>,
883 extra_env: &[(String, String)],
884 ) -> Result<()> {
885 let log = test_logger();
886 let out = out_file.display().to_string().replace('\\', "/");
887 let probe = keys
888 .iter()
889 .map(|k| format!("echo {k}=${k} >> {out}"))
890 .collect::<Vec<_>>()
891 .join("; ");
892 let hooks = vec![HookEntry::Structured(StructuredHook {
893 cmd: probe,
894 env: hook_env,
895 ..Default::default()
896 })];
897 let vars = TemplateVars::new();
898 run_hooks(
899 &hooks,
900 "test",
901 HookRunContext::new(false, &log, Some(&vars)).with_extra_env(extra_env),
902 )
903 }
904
905 #[test]
906 fn extra_env_reaches_hook() {
907 let dir = std::env::temp_dir().join(format!("anodizer-ee-{}", std::process::id()));
908 std::fs::create_dir_all(&dir).unwrap();
909 let out = dir.join("reaches.txt");
910 let _ = std::fs::remove_file(&out);
911
912 let extra = vec![("MY_EXTRA_VAR".to_string(), "from-extra-env".to_string())];
913 run_extra_env_probe_hook(&out, &["MY_EXTRA_VAR"], None, &extra).expect("hook must run");
914
915 let contents = std::fs::read_to_string(&out).unwrap();
916 assert!(
917 contents.contains("MY_EXTRA_VAR=from-extra-env"),
918 "extra env var must reach the hook; got: {contents:?}"
919 );
920 let _ = std::fs::remove_file(&out);
921 }
922
923 #[test]
924 fn hook_env_overrides_extra_env_on_key_conflict() {
925 let dir = std::env::temp_dir().join(format!("anodizer-ee-{}", std::process::id()));
926 std::fs::create_dir_all(&dir).unwrap();
927 let out = dir.join("precedence.txt");
928 let _ = std::fs::remove_file(&out);
929
930 let extra = vec![("SHARED".to_string(), "extra-loses".to_string())];
931 run_extra_env_probe_hook(
932 &out,
933 &["SHARED"],
934 Some(vec!["SHARED=hook-wins".to_string()]),
935 &extra,
936 )
937 .expect("hook must run");
938
939 let contents = std::fs::read_to_string(&out).unwrap();
940 assert!(
941 contents.contains("SHARED=hook-wins"),
942 "hook env must override extra env on key conflict; got: {contents:?}"
943 );
944 let _ = std::fs::remove_file(&out);
945 }
946
947 #[test]
948 fn absent_build_env_is_unchanged_behavior() {
949 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
950 std::fs::create_dir_all(&dir).unwrap();
951 let out = dir.join("absent.txt");
952 let _ = std::fs::remove_file(&out);
953
954 run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, None).expect("hook must run");
956
957 let contents = std::fs::read_to_string(&out).unwrap();
958 assert!(
959 contents.contains("NOT_SET_ANYWHERE="),
960 "absent build env must leave behavior unchanged; got: {contents:?}"
961 );
962 let _ = std::fs::remove_file(&out);
963 }
964
965 #[test]
966 fn empty_build_env_map_adds_nothing() {
967 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
968 std::fs::create_dir_all(&dir).unwrap();
969 let out = dir.join("empty.txt");
970 let _ = std::fs::remove_file(&out);
971
972 let build_env: HashMap<String, String> = HashMap::new();
973 run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, Some(&build_env))
974 .expect("hook must run");
975
976 let contents = std::fs::read_to_string(&out).unwrap();
977 assert!(
978 contents.contains("NOT_SET_ANYWHERE="),
979 "empty build env map must be a no-op; got: {contents:?}"
980 );
981 let _ = std::fs::remove_file(&out);
982 }
983
984 #[test]
985 fn hook_if_render_error_propagates() {
986 let log = test_logger();
987 let vars = vars_with_snapshot(false);
988 let hooks = vec![structured("true", Some("{{ UndefinedSymbol }}"))];
989 let err = run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
990 .expect_err("unrenderable template must surface as Err");
991 let chain = format!("{err:#}");
992 assert!(
993 chain.contains("template render failed") || chain.contains("UndefinedSymbol"),
994 "expected render-error diagnostic, got: {chain}",
995 );
996 }
997
998 #[cfg(feature = "test-helpers")]
999 #[test]
1000 fn hook_output_summary_suppressed_at_verbose() {
1001 let (log, cap) = StageLogger::with_capture("test", Verbosity::Verbose);
1006 let hooks = vec![HookEntry::Structured(StructuredHook {
1007 cmd: "echo HOOKSTDOUTLINE".to_string(),
1008 output: Some(true),
1009 ..Default::default()
1010 })];
1011 let vars = TemplateVars::new();
1012 run_hooks(
1013 &hooks,
1014 "test",
1015 HookRunContext::new(false, &log, Some(&vars)),
1016 )
1017 .expect("hook must run");
1018 let summarized = cap
1019 .all_messages()
1020 .into_iter()
1021 .any(|(_, m)| m.contains("[hook output]"));
1022 assert!(
1023 !summarized,
1024 "at verbose the live tee owns the hook stdout; the [hook output] \
1025 summary must be suppressed to avoid a double print"
1026 );
1027 }
1028
1029 #[cfg(feature = "test-helpers")]
1030 #[test]
1031 fn hook_output_summary_present_at_normal() {
1032 let (log, cap) = StageLogger::with_capture("test", Verbosity::Normal);
1036 let hooks = vec![HookEntry::Structured(StructuredHook {
1037 cmd: "echo HOOKSTDOUTLINE".to_string(),
1038 output: Some(true),
1039 ..Default::default()
1040 })];
1041 let vars = TemplateVars::new();
1042 run_hooks(
1043 &hooks,
1044 "test",
1045 HookRunContext::new(false, &log, Some(&vars)),
1046 )
1047 .expect("hook must run");
1048 let summarized = cap
1049 .all_messages()
1050 .into_iter()
1051 .any(|(_, m)| m.contains("[hook output]") && m.contains("HOOKSTDOUTLINE"));
1052 assert!(
1053 summarized,
1054 "at normal verbosity the [hook output] summary must carry the hook's stdout"
1055 );
1056 }
1057
1058 use crate::artifact::{Artifact, ArtifactKind};
1061 use crate::config::{Config, CrateConfig, HooksConfig};
1062 use crate::context::{Context, ContextOptions};
1063
1064 fn crate_with_before_publish(name: &str, out_file: &str) -> CrateConfig {
1069 let probe = format!("echo {name}:{{{{ Version }}}}:{{{{ ArtifactName }}}} >> {out_file}");
1070 CrateConfig {
1071 name: name.to_string(),
1072 path: ".".to_string(),
1073 tag_template: "v{{ .Version }}".to_string(),
1074 before_publish: Some(HooksConfig {
1075 hooks: Some(vec![HookEntry::Simple(probe)]),
1076 post: None,
1077 }),
1078 ..Default::default()
1079 }
1080 }
1081
1082 fn pkg_artifact(crate_name: &str, file_name: &str) -> Artifact {
1083 Artifact {
1084 kind: ArtifactKind::LinuxPackage,
1085 path: std::path::PathBuf::from(file_name),
1086 name: file_name.to_string(),
1087 target: None,
1088 crate_name: crate_name.to_string(),
1089 metadata: HashMap::new(),
1090 size: None,
1091 }
1092 }
1093
1094 fn fixed_tag(_ctx: &Context, c: &CrateConfig) -> Option<String> {
1097 match c.name.as_str() {
1098 "foo" => Some("v1.2.3".to_string()),
1099 "bar" => Some("v9.9.9".to_string()),
1100 _ => Some("v0.0.0".to_string()),
1101 }
1102 }
1103
1104 #[test]
1108 fn per_crate_before_publish_scopes_version_and_artifacts() {
1109 let dir = std::env::temp_dir().join(format!("anodizer-pcbp-{}", std::process::id()));
1110 std::fs::create_dir_all(&dir).unwrap();
1111 let out = dir.join("scoped.txt");
1112 let _ = std::fs::remove_file(&out);
1113 let out_s = out.display().to_string().replace('\\', "/");
1114
1115 let config = Config {
1116 crates: vec![
1117 crate_with_before_publish("foo", &out_s),
1118 crate_with_before_publish("bar", &out_s),
1119 ],
1120 ..Default::default()
1121 };
1122 let mut ctx = Context::new(config, ContextOptions::default());
1123 ctx.artifacts.add(pkg_artifact("foo", "foo_1.2.3.deb"));
1124 ctx.artifacts.add(pkg_artifact("bar", "bar_9.9.9.deb"));
1125
1126 let log = ctx.logger("before-publish");
1127 run_per_crate_before_publish_with_resolver(&mut ctx, false, &log, &fixed_tag)
1128 .expect("per-crate before_publish must run");
1129
1130 let contents = std::fs::read_to_string(&out).unwrap();
1131 assert!(
1133 contents.contains("foo:1.2.3:foo_1.2.3.deb"),
1134 "foo hook must be scoped to foo's version + artifact; got: {contents:?}"
1135 );
1136 assert!(
1138 contents.contains("bar:9.9.9:bar_9.9.9.deb"),
1139 "bar hook must be scoped to bar's version + artifact; got: {contents:?}"
1140 );
1141 assert!(
1143 !contents.contains("foo:1.2.3:bar_9.9.9.deb"),
1144 "foo hook must NOT see bar's artifact; got: {contents:?}"
1145 );
1146 assert!(
1147 !contents.contains("bar:9.9.9:foo_1.2.3.deb"),
1148 "bar hook must NOT see foo's artifact; got: {contents:?}"
1149 );
1150 let _ = std::fs::remove_file(&out);
1151 }
1152
1153 #[test]
1157 fn per_crate_before_publish_honors_selected_crates() {
1158 let dir = std::env::temp_dir().join(format!("anodizer-pcbp-sel-{}", std::process::id()));
1159 std::fs::create_dir_all(&dir).unwrap();
1160 let out = dir.join("selected.txt");
1161 let _ = std::fs::remove_file(&out);
1162 let out_s = out.display().to_string().replace('\\', "/");
1163
1164 let config = Config {
1165 crates: vec![
1166 crate_with_before_publish("foo", &out_s),
1167 crate_with_before_publish("bar", &out_s),
1168 ],
1169 ..Default::default()
1170 };
1171 let opts = ContextOptions {
1172 selected_crates: vec!["foo".to_string()],
1173 ..Default::default()
1174 };
1175 let mut ctx = Context::new(config, opts);
1176 ctx.artifacts.add(pkg_artifact("foo", "foo_1.2.3.deb"));
1177 ctx.artifacts.add(pkg_artifact("bar", "bar_9.9.9.deb"));
1178
1179 let log = ctx.logger("before-publish");
1180 run_per_crate_before_publish_with_resolver(&mut ctx, false, &log, &fixed_tag)
1181 .expect("selected per-crate before_publish must run");
1182
1183 let contents = std::fs::read_to_string(&out).unwrap();
1184 assert!(
1185 contents.contains("foo:1.2.3:foo_1.2.3.deb"),
1186 "selected crate foo's hook must fire; got: {contents:?}"
1187 );
1188 assert!(
1189 !contents.contains("bar:"),
1190 "unselected crate bar's hook must NOT fire; got: {contents:?}"
1191 );
1192 let _ = std::fs::remove_file(&out);
1193 }
1194
1195 #[test]
1199 fn per_crate_before_publish_noop_without_block() {
1200 let config = Config {
1201 crates: vec![CrateConfig {
1202 name: "foo".to_string(),
1203 path: ".".to_string(),
1204 tag_template: "v{{ .Version }}".to_string(),
1205 ..Default::default()
1206 }],
1207 ..Default::default()
1208 };
1209 let mut ctx = Context::new(config, ContextOptions::default());
1210 ctx.artifacts.add(pkg_artifact("foo", "foo.deb"));
1211 let log = ctx.logger("before-publish");
1212 run_per_crate_before_publish_with_resolver(&mut ctx, false, &log, &fixed_tag)
1214 .expect("absent per-crate before_publish must be a no-op");
1215 }
1216
1217 #[test]
1223 fn before_publish_stage_empty_selection_fires_every_crate() {
1224 let dir = std::env::temp_dir().join(format!("anodizer-bps-all-{}", std::process::id()));
1225 std::fs::create_dir_all(&dir).unwrap();
1226 let out = dir.join("all.txt");
1227 let _ = std::fs::remove_file(&out);
1228 let out_s = out.display().to_string().replace('\\', "/");
1229
1230 let config = Config {
1231 crates: vec![
1232 crate_with_before_publish("foo", &out_s),
1233 crate_with_before_publish("bar", &out_s),
1234 ],
1235 ..Default::default()
1236 };
1237 let mut ctx = Context::new(config, ContextOptions::default());
1239 assert!(
1240 ctx.options.selected_crates.is_empty(),
1241 "this test exercises the empty-selection (full-release) path"
1242 );
1243 ctx.artifacts.add(pkg_artifact("foo", "foo_1.2.3.deb"));
1244 ctx.artifacts.add(pkg_artifact("bar", "bar_9.9.9.deb"));
1245
1246 let log = ctx.logger("before-publish");
1247 before_publish_stage_inner(&mut ctx, false, &log, &fixed_tag)
1248 .expect("full before-publish stage must run with empty selection");
1249
1250 let contents = std::fs::read_to_string(&out).unwrap();
1251 assert!(
1252 contents.contains("foo:1.2.3:foo_1.2.3.deb"),
1253 "every crate's before_publish must fire on the full-release path; \
1254 foo missing in: {contents:?}"
1255 );
1256 assert!(
1257 contents.contains("bar:9.9.9:bar_9.9.9.deb"),
1258 "every crate's before_publish must fire on the full-release path; \
1259 bar missing in: {contents:?}"
1260 );
1261 let _ = std::fs::remove_file(&out);
1262 }
1263
1264 #[cfg(feature = "test-helpers")]
1265 #[test]
1266 fn before_publish_entry_emits_one_summary_not_per_artifact() {
1267 let (log, cap) = StageLogger::with_capture("before-publish", Verbosity::Verbose);
1268 let artifacts = vec![
1269 pkg_artifact("foo", "a.deb"),
1270 pkg_artifact("foo", "b.deb"),
1271 pkg_artifact("foo", "c.deb"),
1272 ];
1273 let base = TemplateVars::new();
1274 run_before_publish_entry(
1275 &HookEntry::Simple("true".to_string()),
1276 &artifacts,
1277 false,
1278 &log,
1279 &base,
1280 )
1281 .expect("entry must run over every artifact");
1282
1283 let msgs = cap.all_messages();
1284 let summary_status: Vec<&String> = msgs
1285 .iter()
1286 .filter(|(lvl, m)| {
1287 *lvl == LogLevel::Status && m.contains("before-publish hook") && m.contains('3')
1288 })
1289 .map(|(_, m)| m)
1290 .collect();
1291 assert_eq!(
1292 summary_status.len(),
1293 1,
1294 "exactly one default-level summary line for 3 artifacts; got: {:?}",
1295 msgs
1296 );
1297 let ran_status = msgs
1298 .iter()
1299 .filter(|(lvl, m)| *lvl == LogLevel::Status && m.starts_with("ran "))
1300 .count();
1301 assert_eq!(
1302 ran_status, 1,
1303 "no per-artifact 'ran' line may reach default level; got: {msgs:?}"
1304 );
1305 let per_artifact_verbose = msgs
1306 .iter()
1307 .filter(|(lvl, m)| {
1308 *lvl == LogLevel::Verbose && m.starts_with("ran ") && m.contains(".deb")
1309 })
1310 .count();
1311 assert_eq!(
1312 per_artifact_verbose, 3,
1313 "each artifact's 'ran' detail must be verbose-only and name the artifact; got: {msgs:?}"
1314 );
1315 }
1316
1317 #[cfg(feature = "test-helpers")]
1319 #[test]
1320 fn before_publish_summary_excludes_if_skipped_artifacts() {
1321 let (log, cap) = StageLogger::with_capture("before-publish", Verbosity::Verbose);
1322 let artifacts = vec![
1323 pkg_artifact("foo", "a.deb"),
1324 pkg_artifact("foo", "b.deb"),
1325 pkg_artifact("foo", "c.deb"),
1326 ];
1327 let base = TemplateVars::new();
1328 run_before_publish_entry(
1330 &HookEntry::Structured(StructuredHook {
1331 cmd: "true".to_string(),
1332 if_condition: Some("{{ ArtifactName == \"b.deb\" }}".to_string()),
1333 ..Default::default()
1334 }),
1335 &artifacts,
1336 false,
1337 &log,
1338 &base,
1339 )
1340 .expect("entry must run");
1341
1342 let msgs = cap.all_messages();
1343 let summary: Vec<&String> = msgs
1344 .iter()
1345 .filter(|(lvl, m)| *lvl == LogLevel::Status && m.contains("before-publish hook"))
1346 .map(|(_, m)| m)
1347 .collect();
1348 assert_eq!(
1349 summary.len(),
1350 1,
1351 "exactly one default-level summary; got: {msgs:?}"
1352 );
1353 assert!(
1354 summary[0].contains("over 1 artifact") && !summary[0].contains("over 1 artifacts"),
1355 "summary must count only the one executed artifact (singular), not the 3 filtered-in; \
1356 got: {:?}",
1357 summary[0]
1358 );
1359 let per_artifact_verbose: Vec<&String> = msgs
1360 .iter()
1361 .filter(|(lvl, m)| {
1362 *lvl == LogLevel::Verbose && m.starts_with("ran ") && m.contains(".deb")
1363 })
1364 .map(|(_, m)| m)
1365 .collect();
1366 assert_eq!(
1367 per_artifact_verbose.len(),
1368 1,
1369 "only the executed artifact emits a verbose 'ran ... on <name>' line; got: {msgs:?}"
1370 );
1371 assert!(
1372 per_artifact_verbose[0].contains("b.deb"),
1373 "the one verbose line must name the artifact that actually ran; got: {:?}",
1374 per_artifact_verbose[0]
1375 );
1376 }
1377
1378 #[test]
1380 fn before_publish_run_once_executes_a_single_time_for_many_artifacts() {
1381 let dir = std::env::temp_dir().join(format!("anodizer-bp-runonce-{}", std::process::id()));
1382 std::fs::create_dir_all(&dir).unwrap();
1383 let out = dir.join("runs.txt");
1384 let _ = std::fs::remove_file(&out);
1385 let out_s = out.display().to_string().replace('\\', "/");
1386
1387 let log = test_logger();
1388 let artifacts = vec![
1389 pkg_artifact("foo", "a.deb"),
1390 pkg_artifact("foo", "b.deb"),
1391 pkg_artifact("foo", "c.deb"),
1392 ];
1393 let mut base = TemplateVars::new();
1394 base.set("Version", "1.2.3");
1395 run_before_publish_entry(
1396 &HookEntry::Structured(StructuredHook {
1397 cmd: format!("echo once:{{{{ Version }}}} >> {out_s}"),
1398 run_once: true,
1399 ..Default::default()
1400 }),
1401 &artifacts,
1402 false,
1403 &log,
1404 &base,
1405 )
1406 .expect("run_once entry must execute");
1407
1408 let contents = std::fs::read_to_string(&out).unwrap();
1409 let lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect();
1410 assert_eq!(
1411 lines.len(),
1412 1,
1413 "run_once hook must fire exactly once for 3 artifacts; got: {contents:?}"
1414 );
1415 assert_eq!(
1416 lines[0], "once:1.2.3",
1417 "run_once hook must render run-level vars (Version), not per-artifact vars; got: {contents:?}"
1418 );
1419 let _ = std::fs::remove_file(&out);
1420 }
1421
1422 #[test]
1426 fn before_publish_run_once_nonzero_exit_fails_stage() {
1427 let log = test_logger();
1428 let artifacts = vec![pkg_artifact("foo", "a.deb"), pkg_artifact("foo", "b.deb")];
1429 let base = TemplateVars::new();
1430 let err = run_before_publish_entry(
1431 &HookEntry::Structured(StructuredHook {
1432 cmd: "exit 1".to_string(),
1433 run_once: true,
1434 ..Default::default()
1435 }),
1436 &artifacts,
1437 false,
1438 &log,
1439 &base,
1440 )
1441 .expect_err("a non-zero run_once command must fail the stage");
1442 let _ = err;
1443 }
1444
1445 #[test]
1447 fn before_publish_run_once_ignores_artifact_filters() {
1448 let dir =
1449 std::env::temp_dir().join(format!("anodizer-bp-runonce-flt-{}", std::process::id()));
1450 std::fs::create_dir_all(&dir).unwrap();
1451 let out = dir.join("flt.txt");
1452 let _ = std::fs::remove_file(&out);
1453 let out_s = out.display().to_string().replace('\\', "/");
1454
1455 let log = test_logger();
1456 let artifacts = vec![pkg_artifact("foo", "a.deb")];
1458 let base = TemplateVars::new();
1459 run_before_publish_entry(
1460 &HookEntry::Structured(StructuredHook {
1461 cmd: format!("echo ran >> {out_s}"),
1462 run_once: true,
1463 artifacts: Some(BeforePublishArtifactFilter::Checksum),
1464 ids: Some(vec!["nonexistent".to_string()]),
1465 ..Default::default()
1466 }),
1467 &artifacts,
1468 false,
1469 &log,
1470 &base,
1471 )
1472 .expect("run_once entry must run despite non-matching filters");
1473
1474 let contents = std::fs::read_to_string(&out).unwrap();
1475 assert_eq!(
1476 contents.lines().filter(|l| !l.is_empty()).count(),
1477 1,
1478 "run_once must fire exactly once regardless of artifact filters; got: {contents:?}"
1479 );
1480 let _ = std::fs::remove_file(&out);
1481 }
1482
1483 #[test]
1485 fn before_publish_run_once_false_stays_per_artifact() {
1486 let dir = std::env::temp_dir().join(format!("anodizer-bp-perart-{}", std::process::id()));
1487 std::fs::create_dir_all(&dir).unwrap();
1488 let out = dir.join("perart.txt");
1489 let _ = std::fs::remove_file(&out);
1490 let out_s = out.display().to_string().replace('\\', "/");
1491
1492 let log = test_logger();
1493 let artifacts = vec![
1494 pkg_artifact("foo", "a.deb"),
1495 pkg_artifact("foo", "b.deb"),
1496 pkg_artifact("foo", "c.deb"),
1497 ];
1498 let base = TemplateVars::new();
1499 run_before_publish_entry(
1500 &HookEntry::Structured(StructuredHook {
1501 cmd: format!("echo {{{{ ArtifactName }}}} >> {out_s}"),
1502 run_once: false,
1503 ..Default::default()
1504 }),
1505 &artifacts,
1506 false,
1507 &log,
1508 &base,
1509 )
1510 .expect("per-artifact entry must run");
1511
1512 let contents = std::fs::read_to_string(&out).unwrap();
1513 let mut lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect();
1514 lines.sort_unstable();
1515 assert_eq!(
1516 lines,
1517 vec!["a.deb", "b.deb", "c.deb"],
1518 "run_once:false must fire once per artifact with per-artifact vars; got: {contents:?}"
1519 );
1520 let _ = std::fs::remove_file(&out);
1521 }
1522
1523 fn crate_with_lifecycle(name: &str, out_file: &str, set_before: bool) -> CrateConfig {
1532 let phase = if set_before { "before" } else { "after" };
1533 let probe = format!("echo {phase}:{name}:{{{{ Version }}}} >> {out_file}");
1534 let hooks = Some(HooksConfig {
1535 hooks: Some(vec![HookEntry::Simple(probe)]),
1536 post: None,
1537 });
1538 let mut c = CrateConfig {
1539 name: name.to_string(),
1540 path: ".".to_string(),
1541 tag_template: "v{{ .Version }}".to_string(),
1542 ..Default::default()
1543 };
1544 if set_before {
1545 c.before = hooks;
1546 } else {
1547 c.after = hooks;
1548 }
1549 c
1550 }
1551
1552 #[test]
1557 fn per_crate_before_fires_once_per_crate_full_release() {
1558 let dir = std::env::temp_dir().join(format!("anodizer-pcb-{}", std::process::id()));
1559 std::fs::create_dir_all(&dir).unwrap();
1560 let out = dir.join("before.txt");
1561 let _ = std::fs::remove_file(&out);
1562 let out_s = out.display().to_string().replace('\\', "/");
1563
1564 let config = Config {
1565 crates: vec![
1566 crate_with_lifecycle("foo", &out_s, true),
1567 crate_with_lifecycle("bar", &out_s, true),
1568 ],
1569 ..Default::default()
1570 };
1571 let mut ctx = Context::new(config, ContextOptions::default());
1572 let log = ctx.logger("before");
1573 run_per_crate_lifecycle_with_resolver(
1574 &mut ctx,
1575 CrateLifecycleKind::Before,
1576 false,
1577 &log,
1578 &fixed_tag,
1579 )
1580 .expect("per-crate before must run");
1581
1582 let contents = std::fs::read_to_string(&out).unwrap();
1583 assert_eq!(
1585 contents.matches("before:").count(),
1586 2,
1587 "before must fire exactly once per crate (no double-fire); got: {contents:?}"
1588 );
1589 assert!(
1590 contents.contains("before:foo:1.2.3"),
1591 "foo's before must render under foo's version; got: {contents:?}"
1592 );
1593 assert!(
1594 contents.contains("before:bar:9.9.9"),
1595 "bar's before must render under bar's version; got: {contents:?}"
1596 );
1597 let _ = std::fs::remove_file(&out);
1598 }
1599
1600 #[test]
1603 fn per_crate_after_fires_once_per_crate_full_release() {
1604 let dir = std::env::temp_dir().join(format!("anodizer-pca-{}", std::process::id()));
1605 std::fs::create_dir_all(&dir).unwrap();
1606 let out = dir.join("after.txt");
1607 let _ = std::fs::remove_file(&out);
1608 let out_s = out.display().to_string().replace('\\', "/");
1609
1610 let config = Config {
1611 crates: vec![
1612 crate_with_lifecycle("foo", &out_s, false),
1613 crate_with_lifecycle("bar", &out_s, false),
1614 ],
1615 ..Default::default()
1616 };
1617 let mut ctx = Context::new(config, ContextOptions::default());
1618 let log = ctx.logger("after");
1619 run_per_crate_lifecycle_with_resolver(
1620 &mut ctx,
1621 CrateLifecycleKind::After,
1622 false,
1623 &log,
1624 &fixed_tag,
1625 )
1626 .expect("per-crate after must run");
1627
1628 let contents = std::fs::read_to_string(&out).unwrap();
1629 assert_eq!(
1630 contents.matches("after:").count(),
1631 2,
1632 "after must fire exactly once per crate; got: {contents:?}"
1633 );
1634 assert!(
1635 contents.contains("after:foo:1.2.3") && contents.contains("after:bar:9.9.9"),
1636 "each crate's after must render under its own version; got: {contents:?}"
1637 );
1638 let _ = std::fs::remove_file(&out);
1639 }
1640
1641 #[test]
1646 fn per_crate_before_fires_per_crate_in_lockstep() {
1647 fn fixed_lockstep(_ctx: &Context, _c: &CrateConfig) -> Option<String> {
1648 Some("v2.0.0".to_string())
1649 }
1650 let dir = std::env::temp_dir().join(format!("anodizer-pcl-{}", std::process::id()));
1651 std::fs::create_dir_all(&dir).unwrap();
1652 let out = dir.join("lockstep.txt");
1653 let _ = std::fs::remove_file(&out);
1654 let out_s = out.display().to_string().replace('\\', "/");
1655
1656 let config = Config {
1657 crates: vec![
1658 crate_with_lifecycle("foo", &out_s, true),
1659 crate_with_lifecycle("bar", &out_s, true),
1660 ],
1661 ..Default::default()
1662 };
1663 let mut ctx = Context::new(config, ContextOptions::default());
1664 let log = ctx.logger("before");
1665 run_per_crate_lifecycle_with_resolver(
1666 &mut ctx,
1667 CrateLifecycleKind::Before,
1668 false,
1669 &log,
1670 &fixed_lockstep,
1671 )
1672 .expect("per-crate before must run in lockstep");
1673
1674 let contents = std::fs::read_to_string(&out).unwrap();
1675 assert_eq!(
1676 contents.matches("before:").count(),
1677 2,
1678 "lockstep multi-crate must still fire before once per crate; got: {contents:?}"
1679 );
1680 assert!(
1681 contents.contains("before:foo:2.0.0") && contents.contains("before:bar:2.0.0"),
1682 "both crates share the lockstep version; got: {contents:?}"
1683 );
1684 let _ = std::fs::remove_file(&out);
1685 }
1686
1687 #[test]
1694 fn per_crate_before_honors_selected_crates_single_fire() {
1695 let dir = std::env::temp_dir().join(format!("anodizer-pcb-sel-{}", std::process::id()));
1696 std::fs::create_dir_all(&dir).unwrap();
1697 let out = dir.join("selected.txt");
1698 let _ = std::fs::remove_file(&out);
1699 let out_s = out.display().to_string().replace('\\', "/");
1700
1701 let config = Config {
1702 crates: vec![
1703 crate_with_lifecycle("foo", &out_s, true),
1704 crate_with_lifecycle("bar", &out_s, true),
1705 ],
1706 ..Default::default()
1707 };
1708 let opts = ContextOptions {
1709 selected_crates: vec!["foo".to_string()],
1710 ..Default::default()
1711 };
1712 let mut ctx = Context::new(config, opts);
1713 let log = ctx.logger("before");
1714 run_per_crate_lifecycle_with_resolver(
1715 &mut ctx,
1716 CrateLifecycleKind::Before,
1717 false,
1718 &log,
1719 &fixed_tag,
1720 )
1721 .expect("selected per-crate before must run");
1722
1723 let contents = std::fs::read_to_string(&out).unwrap();
1724 assert_eq!(
1725 contents.matches("before:").count(),
1726 1,
1727 "exactly the single selected crate's hook fires once; got: {contents:?}"
1728 );
1729 assert!(
1730 contents.contains("before:foo:1.2.3"),
1731 "selected crate foo's before must fire; got: {contents:?}"
1732 );
1733 assert!(
1734 !contents.contains("bar"),
1735 "unselected crate bar's before must NOT fire; got: {contents:?}"
1736 );
1737 let _ = std::fs::remove_file(&out);
1738 }
1739
1740 #[test]
1744 fn per_crate_lifecycle_noop_without_block() {
1745 let config = Config {
1746 crates: vec![CrateConfig {
1747 name: "foo".to_string(),
1748 path: ".".to_string(),
1749 tag_template: "v{{ .Version }}".to_string(),
1750 ..Default::default()
1751 }],
1752 ..Default::default()
1753 };
1754 let mut ctx = Context::new(config, ContextOptions::default());
1755 let log = ctx.logger("before");
1756 run_per_crate_lifecycle_with_resolver(
1757 &mut ctx,
1758 CrateLifecycleKind::Before,
1759 false,
1760 &log,
1761 &fixed_tag,
1762 )
1763 .expect("absent per-crate before must be a no-op");
1764 run_per_crate_lifecycle_with_resolver(
1765 &mut ctx,
1766 CrateLifecycleKind::After,
1767 false,
1768 &log,
1769 &fixed_tag,
1770 )
1771 .expect("absent per-crate after must be a no-op");
1772 }
1773
1774 #[test]
1775 fn hook_cmd_summary_takes_first_line_and_truncates() {
1776 assert_eq!(
1778 hook_cmd_summary(" cargo fmt --check "),
1779 "cargo fmt --check"
1780 );
1781 assert_eq!(
1783 hook_cmd_summary("cargo build\ncargo test\ncargo clippy"),
1784 "cargo build"
1785 );
1786 let long = "x".repeat(200);
1788 let summary = hook_cmd_summary(&long);
1789 assert!(
1790 summary.ends_with('…'),
1791 "long command must be elided: {summary}"
1792 );
1793 assert_eq!(summary.chars().count(), 81, "80 chars + the ellipsis");
1794 }
1795}