1use crate::artifact::ArtifactRegistry;
2use crate::config::Config;
3use crate::env_source::{EnvSource, ProcessEnvSource};
4use crate::git::GitInfo;
5use crate::log::{StageLogger, Verbosity};
6use crate::partial::PartialTarget;
7use crate::publish_report::PublishReport;
8use crate::scm::ScmTokenType;
9use crate::template::TemplateVars;
10use crate::verify_release_summary::VerifyReleaseSummary;
11use anyhow::Context as _;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::PathBuf;
15use std::sync::Arc;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum RollbackMode {
25 None,
28 #[default]
35 BestEffort,
36}
37
38pub const VALID_RELEASE_SKIPS: &[&str] = &[
48 "publish",
49 "announce",
50 "sign",
51 "validate",
52 "sbom",
53 "attest",
54 "docker",
55 "docker-sign",
56 "winget",
57 "chocolatey",
58 "snapcraft",
59 "snapcraft-publish",
60 "scoop",
61 "homebrew",
62 "nix",
63 "aur",
64 "cargo",
65 "krew",
66 "nfpm",
67 "makeself",
68 "appimage",
69 "flatpak",
70 "srpm",
71 "before",
72 "before-publish",
73 "notarize",
74 "archive",
75 "source",
76 "build",
77 "changelog",
78 "release",
79 "checksum",
80 "upx",
81 "blob",
82 "templatefiles",
83 "dmg",
84 "msi",
85 "nsis",
86 "pkg",
87 "appbundle",
88 "verify-release",
89];
90
91pub const VALID_BUILD_SKIPS: &[&str] = &["pre-hooks", "post-hooks", "validate", "before"];
93
94pub fn validate_skip_values(skip: &[String], valid: &[&str]) -> Result<(), String> {
99 let invalid: Vec<&str> = skip
100 .iter()
101 .map(|s| s.as_str())
102 .filter(|s| !valid.contains(s))
103 .collect();
104 if invalid.is_empty() {
105 Ok(())
106 } else {
107 Err(format!(
108 "invalid --skip value(s): {}. Valid options: {}",
109 invalid.join(", "),
110 valid.join(", "),
111 ))
112 }
113}
114
115pub struct ContextOptions {
116 pub snapshot: bool,
117 pub nightly: bool,
118 pub dry_run: bool,
119 pub quiet: bool,
120 pub verbose: bool,
121 pub debug: bool,
122 pub skip_stages: Vec<String>,
123 pub publisher_allowlist: Vec<String>,
131 pub selected_crates: Vec<String>,
132 pub token: Option<String>,
133 pub parallelism: usize,
135 pub single_target: Option<String>,
137 pub release_notes_path: Option<PathBuf>,
139 pub fail_fast: bool,
141 pub partial_target: Option<PartialTarget>,
144 pub merge: bool,
146 pub publish_only: bool,
157 pub preflight_secrets: bool,
165 pub project_root: Option<PathBuf>,
168 pub strict: bool,
170 pub resume_release: bool,
175 pub replace_existing_artifacts: bool,
179 pub skip_post_publish_poll: bool,
187 pub gate_submitter: Option<bool>,
195 pub rollback_mode: Option<RollbackMode>,
200 pub simulate_failure_publishers: Vec<String>,
208 pub rollback_only: bool,
214 pub allow_rerun: bool,
229 pub show_skipped: bool,
238 pub from_run: Option<String>,
242 pub runtime_nondeterministic_allowlist: Vec<(String, String)>,
249 pub summary_json_path: Option<PathBuf>,
253 pub allow_ai_failure: bool,
260 pub changelog_from: Option<String>,
267 pub changelog_full_history: bool,
274 pub changelog_to: Option<String>,
283 pub changelog_preview: bool,
299 pub notify: bool,
314 pub allow_snapshot_publish: bool,
328}
329
330impl Default for ContextOptions {
331 fn default() -> Self {
332 Self {
333 snapshot: false,
334 nightly: false,
335 dry_run: false,
336 quiet: false,
337 verbose: false,
338 debug: false,
339 skip_stages: Vec::new(),
340 publisher_allowlist: Vec::new(),
341 selected_crates: Vec::new(),
342 token: None,
343 parallelism: 4,
344 single_target: None,
345 release_notes_path: None,
346 fail_fast: false,
347 partial_target: None,
348 merge: false,
349 publish_only: false,
350 preflight_secrets: false,
351 project_root: None,
352 strict: false,
353 resume_release: false,
354 replace_existing_artifacts: false,
355 skip_post_publish_poll: false,
356 gate_submitter: None,
357 rollback_mode: None,
358 simulate_failure_publishers: Vec::new(),
359 rollback_only: false,
360 allow_rerun: false,
361 show_skipped: false,
362 from_run: None,
363 runtime_nondeterministic_allowlist: Vec::new(),
364 summary_json_path: None,
365 allow_ai_failure: false,
366 changelog_from: None,
367 changelog_full_history: false,
368 changelog_to: None,
369 changelog_preview: false,
370 notify: false,
371 allow_snapshot_publish: false,
372 }
373 }
374}
375
376#[derive(Debug, Default)]
381pub struct StageOutputs {
382 pub github_native_changelog: bool,
386 pub changelogs: HashMap<String, String>,
388 pub changelog_header: Option<String>,
393 pub changelog_footer: Option<String>,
396 pub post_publish_results: Vec<serde_json::Value>,
404}
405
406pub struct Context {
407 pub config: Config,
408 pub artifacts: ArtifactRegistry,
409 pub options: ContextOptions,
410 pub stage_outputs: StageOutputs,
412 template_vars: TemplateVars,
413 pub git_info: Option<GitInfo>,
414 pub token_type: ScmTokenType,
416 pub skip_memento: crate::pipe_skip::SkipMemento,
422 pub publish_report: Option<PublishReport>,
430 pub publish_attempted: bool,
436 pub verify_release: Option<VerifyReleaseSummary>,
447 pub determinism: Option<crate::DeterminismState>,
454 pub pending_outcome: Option<crate::PublisherOutcome>,
464 pub pending_evidence: Option<crate::PublishEvidence>,
477 built_crate_names: Option<std::collections::HashSet<String>>,
488 env_source: Arc<dyn EnvSource>,
496 #[cfg(feature = "test-helpers")]
504 pub log_capture: Option<crate::log::LogCapture>,
505 render_strict: std::cell::Cell<bool>,
520 pub literal_message: bool,
527 pub redact_body: bool,
534 process_env_cache: std::cell::OnceCell<std::collections::HashMap<String, String>>,
543}
544
545impl Context {
546 pub fn new(config: Config, options: ContextOptions) -> Self {
547 let mut vars = TemplateVars::new();
548 vars.set("ProjectName", &config.project_name);
549 Self {
550 config,
551 artifacts: ArtifactRegistry::new(),
552 options,
553 stage_outputs: StageOutputs::default(),
554 template_vars: vars,
555 git_info: None,
556 token_type: ScmTokenType::GitHub,
557 skip_memento: crate::pipe_skip::SkipMemento::new(),
558 publish_report: None,
559 publish_attempted: false,
560 verify_release: None,
561 determinism: None,
562 pending_outcome: None,
563 pending_evidence: None,
564 built_crate_names: None,
565 env_source: Arc::new(ProcessEnvSource),
566 #[cfg(feature = "test-helpers")]
567 log_capture: None,
568 render_strict: std::cell::Cell::new(false),
569 literal_message: false,
570 redact_body: true,
571 process_env_cache: std::cell::OnceCell::new(),
572 }
573 }
574
575 pub fn redact(&self, s: &str) -> String {
580 crate::redact::with_env(s, &self.env_for_redact())
581 }
582
583 pub fn env_var(&self, name: &str) -> Option<String> {
591 self.env_source.var(name)
592 }
593
594 pub fn set_env_source<S: EnvSource + 'static>(&mut self, src: S) {
600 self.env_source = Arc::new(src);
601 }
602
603 pub fn env_source(&self) -> &dyn EnvSource {
608 self.env_source.as_ref()
609 }
610
611 pub fn env_source_arc(&self) -> Arc<dyn EnvSource> {
617 Arc::clone(&self.env_source)
618 }
619
620 #[cfg(feature = "test-helpers")]
626 pub fn with_log_capture(&mut self, capture: crate::log::LogCapture) {
627 self.log_capture = Some(capture);
628 }
629
630 pub fn record_publisher_outcome(&mut self, outcome: crate::PublisherOutcome) {
638 self.pending_outcome = Some(outcome);
639 }
640
641 pub fn take_pending_outcome(&mut self) -> Option<crate::PublisherOutcome> {
645 self.pending_outcome.take()
646 }
647
648 pub fn record_pending_evidence(&mut self, evidence: crate::PublishEvidence) {
653 self.pending_evidence = Some(evidence);
654 }
655
656 pub fn take_pending_evidence(&mut self) -> Option<crate::PublishEvidence> {
660 self.pending_evidence.take()
661 }
662
663 pub fn publish_report(&self) -> Option<&PublishReport> {
666 self.publish_report.as_ref()
667 }
668
669 pub fn publish_attempted(&self) -> bool {
672 self.publish_attempted
673 }
674
675 pub fn set_publish_attempted(&mut self) {
679 self.publish_attempted = true;
680 }
681
682 pub fn set_publish_report(&mut self, r: PublishReport) {
688 self.publish_report = Some(r);
689 }
690
691 pub fn built_crate_names(&self) -> Option<&std::collections::HashSet<String>> {
694 self.built_crate_names.as_ref()
695 }
696
697 pub fn set_built_crate_names(&mut self, names: std::collections::HashSet<String>) {
700 self.built_crate_names = Some(names);
701 }
702
703 pub fn remember_skip(&self, stage: &str, label: &str, reason: &str) {
710 self.skip_memento.remember(stage, label, reason);
711 }
712
713 pub fn template_vars(&self) -> &TemplateVars {
714 &self.template_vars
715 }
716
717 pub fn template_vars_mut(&mut self) -> &mut TemplateVars {
718 &mut self.template_vars
719 }
720
721 pub fn render_template(&self, template: &str) -> anyhow::Result<String> {
722 crate::template::render(template, &self.template_vars)
723 }
724
725 pub fn render_template_opt(&self, template: Option<&str>) -> anyhow::Result<Option<String>> {
727 template.map(|t| self.render_template(t)).transpose()
728 }
729
730 pub fn skip_with_log(
739 &self,
740 skip: &Option<crate::config::StringOrBool>,
741 log: &StageLogger,
742 label: &str,
743 ) -> anyhow::Result<bool> {
744 let Some(d) = skip else {
745 return Ok(false);
746 };
747 let should_skip = d
748 .try_evaluates_to_true(|s| self.render_template(s))
749 .with_context(|| format!("evaluate skip expression for {label}"))?;
750 if should_skip {
751 log.status(&format!("{} skipped", label));
752 }
753 Ok(should_skip)
754 }
755
756 pub fn should_skip(&self, stage_name: &str) -> bool {
759 self.options.skip_stages.iter().any(|s| s == stage_name)
760 }
761
762 pub fn publisher_deselected(&self, name: &str) -> bool {
776 self.should_skip(name)
777 || (!self.options.publisher_allowlist.is_empty()
778 && !self.options.publisher_allowlist.iter().any(|s| s == name))
779 }
780
781 pub fn deselected_reason(&self, name: &str) -> String {
792 let reason = if self.should_skip(name) {
793 "excluded via --skip"
794 } else {
795 "not in --publishers allowlist"
796 };
797 format!("skipped {name} — {reason}")
798 }
799
800 pub fn skip_validate(&self) -> bool {
802 self.should_skip("validate")
803 }
804
805 pub fn is_dry_run(&self) -> bool {
806 self.options.dry_run
807 }
808
809 pub fn is_snapshot(&self) -> bool {
810 self.options.snapshot
811 }
812
813 pub fn is_publish_only(&self) -> bool {
821 self.options.publish_only
822 }
823
824 pub fn is_strict(&self) -> bool {
825 self.options.strict
826 }
827
828 pub fn set_render_strict(&self, on: bool) -> bool {
836 self.render_strict.replace(on)
837 }
838
839 pub fn render_is_strict(&self) -> bool {
846 self.render_strict.get() || self.is_strict()
847 }
848
849 pub fn strict_guard(&self, log: &crate::log::StageLogger, msg: &str) -> anyhow::Result<()> {
852 if self.options.strict {
853 anyhow::bail!("{} (strict mode)", msg);
854 }
855 log.warn(msg);
856 Ok(())
857 }
858
859 pub fn skip_in_snapshot(&self, log: &crate::log::StageLogger, stage: &str) -> bool {
868 if self.is_snapshot() {
869 log.status(&format!("skipped {stage} — snapshot mode"));
873 true
874 } else {
875 false
876 }
877 }
878
879 pub fn render_template_strict(
881 &self,
882 template: &str,
883 label: &str,
884 log: &crate::log::StageLogger,
885 ) -> anyhow::Result<String> {
886 match self.render_template(template) {
887 Ok(rendered) => Ok(rendered),
888 Err(e) => {
889 if self.options.strict {
890 anyhow::bail!("{}: failed to render template: {} (strict mode)", label, e);
891 }
892 log.warn(&format!("failed to render template for {}: {}", label, e));
893 Ok(template.to_string())
894 }
895 }
896 }
897
898 pub fn is_nightly(&self) -> bool {
899 self.options.nightly
900 }
901
902 pub fn set_release_url(&mut self, url: &str) {
907 self.template_vars.set("ReleaseURL", url);
908 }
909
910 pub fn version(&self) -> String {
913 self.template_vars
914 .get("Version")
915 .cloned()
916 .unwrap_or_default()
917 }
918
919 pub fn verbosity(&self) -> Verbosity {
921 Verbosity::from_flags(self.options.quiet, self.options.verbose, self.options.debug)
922 }
923
924 pub fn retry_policy(&self) -> crate::retry::RetryPolicy {
930 self.config.retry.unwrap_or_default().to_policy()
931 }
932
933 pub fn logger(&self, stage: &'static str) -> StageLogger {
941 #[allow(unused_mut)]
942 let mut log = StageLogger::new(stage, self.verbosity()).with_env(self.env_for_redact());
943 #[cfg(feature = "test-helpers")]
944 if let Some(cap) = &self.log_capture {
945 log = log.with_capture_handle(cap.clone());
946 }
947 log
948 }
949
950 fn env_for_redact(&self) -> Vec<(String, String)> {
961 use std::collections::HashMap;
962 let mut map: HashMap<String, String> = self
963 .process_env_cache
964 .get_or_init(|| std::env::vars().collect())
965 .clone();
966 for (k, v) in self.template_vars.all_env() {
967 map.insert(k.clone(), v.clone());
968 }
969 map.into_iter().collect()
970 }
971
972 pub fn populate_git_vars(&mut self) {
1014 if let Some(ref info) = self.git_info {
1015 let raw_version = info.semver.raw_version_string();
1017
1018 let version = info.semver.version_string();
1024
1025 self.template_vars.set("Tag", &info.tag);
1026 self.template_vars.set("Version", &version);
1027 self.template_vars.set("RawVersion", &raw_version);
1028 self.template_vars.set("Base", &raw_version);
1033 self.template_vars
1034 .set("Major", &info.semver.major.to_string());
1035 self.template_vars
1036 .set("Minor", &info.semver.minor.to_string());
1037 self.template_vars
1038 .set("Patch", &info.semver.patch.to_string());
1039 self.template_vars.set(
1040 "Prerelease",
1041 info.semver.prerelease.as_deref().unwrap_or(""),
1042 );
1043 self.template_vars.set(
1044 "BuildMetadata",
1045 info.semver.build_metadata.as_deref().unwrap_or(""),
1046 );
1047 self.template_vars.set("FullCommit", &info.commit);
1048 self.template_vars.set("Commit", &info.commit);
1049 self.template_vars.set("ShortCommit", &info.short_commit);
1050 self.template_vars.set("Branch", &info.branch);
1051 self.template_vars.set("CommitDate", &info.commit_date);
1052 self.template_vars
1053 .set("CommitTimestamp", &info.commit_timestamp);
1054 self.template_vars.set_bool("IsGitDirty", info.dirty);
1055 self.template_vars.set_bool("IsGitClean", !info.dirty);
1056 self.template_vars
1057 .set("GitTreeState", if info.dirty { "dirty" } else { "clean" });
1058 self.template_vars.set("GitURL", &info.remote_url);
1059 self.template_vars.set("Summary", &info.summary);
1060 self.template_vars.set("TagSubject", &info.tag_subject);
1061 self.template_vars.set("TagContents", &info.tag_contents);
1062 self.template_vars.set("TagBody", &info.tag_body);
1063 self.template_vars
1064 .set("PreviousTag", info.previous_tag.as_deref().unwrap_or(""));
1065 self.template_vars
1066 .set("FirstCommit", info.first_commit.as_deref().unwrap_or(""));
1067
1068 let monorepo_prefix = self.config.monorepo_tag_prefix();
1079
1080 if let Some(prefix) = monorepo_prefix {
1086 self.template_vars.set("PrefixedTag", &info.tag);
1089
1090 let stripped_tag = crate::git::strip_monorepo_prefix(&info.tag, prefix);
1092 self.template_vars.set("Tag", stripped_tag);
1093
1094 let version = info.semver.version_string();
1104 self.template_vars.set("Version", &version);
1105
1106 let prev_tag = info.previous_tag.as_deref().unwrap_or("");
1108 self.template_vars.set("PrefixedPreviousTag", prev_tag);
1109
1110 let stripped_prev = crate::git::strip_monorepo_prefix(prev_tag, prefix);
1112 self.template_vars.set("PreviousTag", stripped_prev);
1113
1114 self.template_vars.set("PrefixedSummary", &info.summary);
1118 let stripped_summary = crate::git::strip_monorepo_prefix(&info.summary, prefix);
1120 self.template_vars.set("Summary", stripped_summary);
1121 } else {
1122 let tag_prefix = self
1124 .config
1125 .tag
1126 .as_ref()
1127 .and_then(|t| t.tag_prefix.as_deref())
1128 .unwrap_or("");
1129 self.template_vars
1130 .set("PrefixedTag", &format!("{}{}", tag_prefix, info.tag));
1131 let prev_tag = info.previous_tag.as_deref().unwrap_or("");
1132 let prefixed_prev = if prev_tag.is_empty() {
1133 String::new()
1134 } else {
1135 format!("{}{}", tag_prefix, prev_tag)
1136 };
1137 self.template_vars
1138 .set("PrefixedPreviousTag", &prefixed_prev);
1139 self.template_vars.set(
1140 "PrefixedSummary",
1141 &format!("{}{}", tag_prefix, info.summary),
1142 );
1143 }
1144 }
1145
1146 let nightly_build = if self.git_info.is_some() {
1159 let root = self
1160 .options
1161 .project_root
1162 .clone()
1163 .unwrap_or_else(|| PathBuf::from("."));
1164 let monorepo_prefix = self.config.monorepo_tag_prefix();
1165 crate::git::count_commits_since_last_tag_in(&root, monorepo_prefix).unwrap_or(0)
1166 } else {
1167 0
1168 };
1169 self.template_vars
1170 .set_structured("NightlyBuild", tera::Value::from(nightly_build));
1171
1172 self.template_vars
1177 .set_bool("IsSnapshot", self.options.snapshot);
1178 self.template_vars
1179 .set_bool("IsNightly", self.options.nightly);
1180 self.template_vars.set_bool(
1184 "IsHarness",
1185 self.env_var("ANODIZER_IN_DETERMINISM_HARNESS").is_some(),
1186 );
1187 let is_draft = self
1189 .config
1190 .release
1191 .as_ref()
1192 .and_then(|r| r.draft)
1193 .unwrap_or(false);
1194 self.template_vars.set_bool("IsDraft", is_draft);
1195 self.template_vars
1196 .set_bool("IsSingleTarget", self.options.single_target.is_some());
1197
1198 let is_release = !self.options.snapshot && !self.options.nightly;
1200 self.template_vars.set_bool("IsRelease", is_release);
1201
1202 self.template_vars.set_bool("IsMerging", self.options.merge);
1204 }
1205
1206 pub fn populate_time_vars(&mut self) {
1234 let now = crate::sde::resolve_now_with_env(self.env_source());
1242 self.template_vars.set("Date", &now.to_rfc3339());
1243 self.template_vars
1244 .set("Timestamp", &now.timestamp().to_string());
1245 self.template_vars.set("Now", &now.to_rfc3339());
1246 self.template_vars
1247 .set("Year", &now.format("%Y").to_string());
1248 self.template_vars
1249 .set("Month", &now.format("%m").to_string());
1250 self.template_vars.set("Day", &now.format("%d").to_string());
1251 self.template_vars
1252 .set("Hour", &now.format("%H").to_string());
1253 self.template_vars
1254 .set("Minute", &now.format("%M").to_string());
1255 }
1256
1257 pub fn populate_runtime_vars(&mut self) {
1266 let goos = map_os_to_goos(std::env::consts::OS);
1267 let goarch = map_arch_to_goarch(std::env::consts::ARCH);
1268 self.template_vars.set("RuntimeGoos", goos);
1269 self.template_vars.set("RuntimeGoarch", goarch);
1270 self.template_vars.set("Runtime_Goos", goos);
1273 self.template_vars.set("Runtime_Goarch", goarch);
1274 self.populate_rustc_vars();
1278 }
1279
1280 fn populate_rustc_vars(&mut self) {
1287 let ver = crate::partial::detect_rustc_version().unwrap_or_default();
1288 self.template_vars.set("RustcVersion", &ver);
1289 }
1290
1291 pub fn populate_release_notes_var(&mut self) {
1299 let notes = self
1301 .config
1302 .crates
1303 .iter()
1304 .find_map(|c| self.stage_outputs.changelogs.get(&c.name))
1305 .cloned()
1306 .unwrap_or_default();
1307 self.template_vars.set("ReleaseNotes", ¬es);
1308 }
1309
1310 pub fn refresh_artifacts_var(&mut self) {
1325 const CSV_LIST_KEYS: &[&str] = &["extra_binaries", "extra_files"];
1330 const JSON_LIST_KEYS: &[&str] = &["Platforms"];
1336
1337 let artifacts_value: Vec<serde_json::Value> = self
1338 .artifacts
1339 .all()
1340 .iter()
1341 .map(|a| {
1342 let mut metadata_map = serde_json::Map::with_capacity(a.metadata.len());
1344 for (k, v) in &a.metadata {
1345 if CSV_LIST_KEYS.contains(&k.as_str()) {
1346 let items: Vec<serde_json::Value> = if v.is_empty() {
1347 Vec::new()
1348 } else {
1349 v.split(',')
1350 .map(|s| serde_json::Value::String(s.to_string()))
1351 .collect()
1352 };
1353 metadata_map.insert(k.clone(), serde_json::Value::Array(items));
1354 } else if JSON_LIST_KEYS.contains(&k.as_str()) {
1355 let parsed = serde_json::from_str::<serde_json::Value>(v)
1359 .unwrap_or_else(|_| serde_json::Value::String(v.clone()));
1360 metadata_map.insert(k.clone(), parsed);
1361 } else {
1362 metadata_map.insert(k.clone(), serde_json::Value::String(v.clone()));
1363 }
1364 }
1365 serde_json::json!({
1366 "name": a.name,
1367 "path": a.path.to_string_lossy(),
1368 "target": a.target.as_deref().unwrap_or(""),
1369 "kind": a.kind.as_str(),
1370 "crate_name": a.crate_name,
1371 "metadata": serde_json::Value::Object(metadata_map),
1372 })
1373 })
1374 .collect();
1375 let tera_value = tera::Value::Array(artifacts_value);
1378 self.template_vars.set_structured("Artifacts", tera_value);
1379 }
1380
1381 pub fn populate_metadata_var(&mut self) -> anyhow::Result<()> {
1395 let (
1398 description,
1399 homepage,
1400 documentation,
1401 license,
1402 repository,
1403 maintainers,
1404 mod_timestamp,
1405 full_desc_src,
1406 commit_author,
1407 ) = {
1408 let meta = self.config.metadata.as_ref();
1409 let description = self
1416 .config
1417 .meta_description_project()
1418 .unwrap_or("")
1419 .to_string();
1420 let homepage = self
1421 .config
1422 .meta_homepage_project()
1423 .unwrap_or("")
1424 .to_string();
1425 let documentation = self
1426 .config
1427 .meta_documentation_project()
1428 .unwrap_or("")
1429 .to_string();
1430 let license = self.config.meta_license_project().unwrap_or("").to_string();
1431 let repository = self
1432 .config
1433 .meta_repository_project()
1434 .unwrap_or("")
1435 .to_string();
1436 let maintainers: Vec<String> = meta
1437 .and_then(|m| m.maintainers.as_ref())
1438 .cloned()
1439 .unwrap_or_default();
1440 let mod_timestamp = meta
1441 .and_then(|m| m.mod_timestamp.as_deref())
1442 .unwrap_or("")
1443 .to_string();
1444 let full_desc_src = meta.and_then(|m| m.full_description.clone());
1445 let commit_author = meta.and_then(|m| m.commit_author.clone());
1446 (
1447 description,
1448 homepage,
1449 documentation,
1450 license,
1451 repository,
1452 maintainers,
1453 mod_timestamp,
1454 full_desc_src,
1455 commit_author,
1456 )
1457 };
1458
1459 let full_description = match full_desc_src {
1465 None => String::new(),
1466 Some(src) => crate::content_source::resolve(&src, "metadata.full_description", self)?,
1467 };
1468
1469 let commit_author_map = serde_json::json!({
1470 "Name": commit_author.as_ref().and_then(|c| c.name.clone()).unwrap_or_default(),
1471 "Email": commit_author.as_ref().and_then(|c| c.email.clone()).unwrap_or_default(),
1472 });
1473
1474 let meta_map = serde_json::json!({
1475 "Description": description,
1476 "Homepage": homepage,
1477 "Documentation": documentation,
1478 "License": license,
1479 "Repository": repository,
1480 "Maintainers": maintainers,
1481 "ModTimestamp": mod_timestamp,
1482 "FullDescription": full_description,
1483 "CommitAuthor": commit_author_map,
1484 });
1485 self.template_vars.set_structured("Metadata", meta_map);
1487 Ok(())
1488 }
1489}
1490
1491pub fn map_os_to_goos(os: &str) -> &str {
1494 match os {
1495 "macos" => "darwin",
1496 other => other, }
1498}
1499
1500pub fn map_arch_to_goarch(arch: &str) -> &str {
1503 match arch {
1504 "x86_64" => "amd64",
1505 "x86" => "386",
1506 "aarch64" => "arm64",
1507 "powerpc64" => "ppc64",
1508 "s390x" => "s390x",
1509 "mips" => "mips",
1510 "mips64" => "mips64",
1511 "riscv64" => "riscv64",
1512 other => other,
1513 }
1514}
1515
1516#[cfg(test)]
1517#[allow(clippy::field_reassign_with_default)]
1518mod tests {
1519 use super::*;
1520 use crate::config::Config;
1521 use crate::git::{GitInfo, SemVer};
1522
1523 fn make_git_info(dirty: bool, prerelease: Option<&str>) -> GitInfo {
1524 let tag = match prerelease {
1525 Some(pre) => format!("v1.2.3-{pre}"),
1526 None => "v1.2.3".to_string(),
1527 };
1528 GitInfo {
1529 tag,
1530 commit: "abc123def456abc123def456abc123def456abc1".to_string(),
1531 short_commit: "abc123d".to_string(),
1532 branch: "main".to_string(),
1533 dirty,
1534 semver: SemVer {
1535 major: 1,
1536 minor: 2,
1537 patch: 3,
1538 prerelease: prerelease.map(|s| s.to_string()),
1539 build_metadata: None,
1540 },
1541 commit_date: "2026-03-25T10:30:00+00:00".to_string(),
1542 commit_timestamp: "1774463400".to_string(),
1543 previous_tag: Some("v1.2.2".to_string()),
1544 remote_url: "https://github.com/test/repo.git".to_string(),
1545 summary: "v1.2.3-0-gabc123d".to_string(),
1546 tag_subject: "Release v1.2.3".to_string(),
1547 tag_contents: "Release v1.2.3\n\nFull release notes here.".to_string(),
1548 tag_body: "Full release notes here.".to_string(),
1549 first_commit: None,
1550 }
1551 }
1552
1553 #[test]
1554 fn test_context_template_vars() {
1555 let mut config = Config::default();
1556 config.project_name = "test-project".to_string();
1557 let ctx = Context::new(config, ContextOptions::default());
1558 assert_eq!(
1559 ctx.template_vars().get("ProjectName"),
1560 Some(&"test-project".to_string())
1561 );
1562 }
1563
1564 #[test]
1565 fn test_context_should_skip() {
1566 let config = Config::default();
1567 let opts = ContextOptions {
1568 skip_stages: vec!["publish".to_string(), "announce".to_string()],
1569 ..Default::default()
1570 };
1571 let ctx = Context::new(config, opts);
1572 assert!(ctx.should_skip("publish"));
1573 assert!(ctx.should_skip("announce"));
1574 assert!(!ctx.should_skip("build"));
1575 }
1576
1577 #[test]
1578 fn publisher_deselected_empty_selectors_runs_everything() {
1579 let ctx = Context::new(Config::default(), ContextOptions::default());
1580 assert!(!ctx.publisher_deselected("npm"));
1581 assert!(!ctx.publisher_deselected("cargo"));
1582 assert!(!ctx.publisher_deselected("anything"));
1583 }
1584
1585 #[test]
1586 fn publisher_deselected_skip_denylists() {
1587 let opts = ContextOptions {
1588 skip_stages: vec!["npm".to_string()],
1589 ..Default::default()
1590 };
1591 let ctx = Context::new(Config::default(), opts);
1592 assert!(ctx.publisher_deselected("npm"));
1593 assert!(!ctx.publisher_deselected("cargo"));
1594 }
1595
1596 #[test]
1597 fn publisher_deselected_allowlist_excludes_unlisted() {
1598 let opts = ContextOptions {
1599 publisher_allowlist: vec!["cargo".to_string()],
1600 ..Default::default()
1601 };
1602 let ctx = Context::new(Config::default(), opts);
1603 assert!(!ctx.publisher_deselected("cargo"));
1604 assert!(ctx.publisher_deselected("npm"));
1605 }
1606
1607 #[test]
1608 fn publisher_deselected_skip_wins_over_allowlist() {
1609 let opts = ContextOptions {
1610 skip_stages: vec!["cargo".to_string()],
1611 publisher_allowlist: vec!["cargo".to_string()],
1612 ..Default::default()
1613 };
1614 let ctx = Context::new(Config::default(), opts);
1615 assert!(ctx.publisher_deselected("cargo"));
1616 }
1617
1618 #[test]
1619 fn test_context_render_template() {
1620 let mut config = Config::default();
1621 config.project_name = "myapp".to_string();
1622 let ctx = Context::new(config, ContextOptions::default());
1623 let result = ctx.render_template("{{ .ProjectName }}-release").unwrap();
1624 assert_eq!(result, "myapp-release");
1625 }
1626
1627 #[test]
1628 fn test_populate_git_vars_sets_all_expected_vars() {
1629 let config = Config::default();
1630 let mut ctx = Context::new(config, ContextOptions::default());
1631 ctx.git_info = Some(make_git_info(false, None));
1632 ctx.populate_git_vars();
1633
1634 let v = ctx.template_vars();
1635 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
1636 assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
1637 assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
1638 assert_eq!(v.get("Major"), Some(&"1".to_string()));
1639 assert_eq!(v.get("Minor"), Some(&"2".to_string()));
1640 assert_eq!(v.get("Patch"), Some(&"3".to_string()));
1641 assert_eq!(v.get("Prerelease"), Some(&"".to_string()));
1642 assert_eq!(
1643 v.get("FullCommit"),
1644 Some(&"abc123def456abc123def456abc123def456abc1".to_string())
1645 );
1646 assert_eq!(v.get("ShortCommit"), Some(&"abc123d".to_string()));
1647 assert_eq!(v.get("Branch"), Some(&"main".to_string()));
1648 assert_eq!(
1649 v.get("CommitDate"),
1650 Some(&"2026-03-25T10:30:00+00:00".to_string())
1651 );
1652 assert_eq!(v.get("CommitTimestamp"), Some(&"1774463400".to_string()));
1653 assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
1654 assert_eq!(v.get("Base"), Some(&"1.2.3".to_string()));
1657 }
1658
1659 #[test]
1660 fn test_nightly_build_defaults_to_zero_without_git_info() {
1661 let config = Config::default();
1664 let mut ctx = Context::new(config, ContextOptions::default());
1665 ctx.git_info = None;
1666 ctx.populate_git_vars();
1667 assert_eq!(
1668 ctx.template_vars().get_structured("NightlyBuild"),
1669 Some(&tera::Value::from(0u64))
1670 );
1671 }
1672
1673 #[test]
1674 fn test_commit_is_alias_for_full_commit() {
1675 let config = Config::default();
1676 let mut ctx = Context::new(config, ContextOptions::default());
1677 ctx.git_info = Some(make_git_info(false, None));
1678 ctx.populate_git_vars();
1679
1680 let v = ctx.template_vars();
1681 assert_eq!(v.get("Commit"), v.get("FullCommit"));
1682 }
1683
1684 #[test]
1685 fn test_populate_git_vars_prerelease() {
1686 let config = Config::default();
1687 let mut ctx = Context::new(config, ContextOptions::default());
1688 ctx.git_info = Some(make_git_info(false, Some("rc.1")));
1689 ctx.populate_git_vars();
1690
1691 let v = ctx.template_vars();
1692 assert_eq!(v.get("Version"), Some(&"1.2.3-rc.1".to_string()));
1693 assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
1694 assert_eq!(v.get("Prerelease"), Some(&"rc.1".to_string()));
1695 }
1696
1697 #[test]
1698 fn test_build_metadata_template_var() {
1699 let config = Config::default();
1700 let mut ctx = Context::new(config, ContextOptions::default());
1701 let mut info = make_git_info(false, None);
1702 info.tag = "v1.2.3+build.42".to_string();
1703 info.semver.build_metadata = Some("build.42".to_string());
1704 ctx.git_info = Some(info);
1705 ctx.populate_git_vars();
1706
1707 let v = ctx.template_vars();
1708 assert_eq!(v.get("BuildMetadata"), Some(&"build.42".to_string()));
1709 assert_eq!(v.get("Version"), Some(&"1.2.3+build.42".to_string()));
1711 }
1712
1713 #[test]
1714 fn test_build_metadata_empty_when_none() {
1715 let config = Config::default();
1716 let mut ctx = Context::new(config, ContextOptions::default());
1717 ctx.git_info = Some(make_git_info(false, None));
1718 ctx.populate_git_vars();
1719
1720 assert_eq!(
1721 ctx.template_vars().get("BuildMetadata"),
1722 Some(&"".to_string())
1723 );
1724 }
1725
1726 #[test]
1727 fn test_populate_git_vars_monorepo_prefixed_tag() {
1728 let config = Config::default();
1731 let mut ctx = Context::new(config, ContextOptions::default());
1732 let mut info = make_git_info(false, None);
1733 info.tag = "core-v0.3.2".to_string();
1734 info.semver = SemVer {
1735 major: 0,
1736 minor: 3,
1737 patch: 2,
1738 prerelease: None,
1739 build_metadata: None,
1740 };
1741 ctx.git_info = Some(info);
1742 ctx.populate_git_vars();
1743
1744 let v = ctx.template_vars();
1745 assert_eq!(v.get("Tag"), Some(&"core-v0.3.2".to_string()));
1746 assert_eq!(v.get("Version"), Some(&"0.3.2".to_string()));
1747 assert_eq!(v.get("RawVersion"), Some(&"0.3.2".to_string()));
1748 assert_eq!(v.get("Major"), Some(&"0".to_string()));
1749 assert_eq!(v.get("Minor"), Some(&"3".to_string()));
1750 assert_eq!(v.get("Patch"), Some(&"2".to_string()));
1751 }
1752
1753 #[test]
1754 fn test_populate_git_vars_monorepo_prefixed_tag_with_prerelease() {
1755 let config = Config::default();
1756 let mut ctx = Context::new(config, ContextOptions::default());
1757 let mut info = make_git_info(false, None);
1758 info.tag = "operator-v1.0.0-rc.1".to_string();
1759 info.semver = SemVer {
1760 major: 1,
1761 minor: 0,
1762 patch: 0,
1763 prerelease: Some("rc.1".to_string()),
1764 build_metadata: None,
1765 };
1766 ctx.git_info = Some(info);
1767 ctx.populate_git_vars();
1768
1769 let v = ctx.template_vars();
1770 assert_eq!(v.get("Tag"), Some(&"operator-v1.0.0-rc.1".to_string()));
1771 assert_eq!(v.get("Version"), Some(&"1.0.0-rc.1".to_string()));
1772 assert_eq!(v.get("RawVersion"), Some(&"1.0.0".to_string()));
1773 }
1774
1775 #[test]
1776 fn test_git_tree_state_clean() {
1777 let config = Config::default();
1778 let mut ctx = Context::new(config, ContextOptions::default());
1779 ctx.git_info = Some(make_git_info(false, None));
1780 ctx.populate_git_vars();
1781
1782 let v = ctx.template_vars();
1783 assert_eq!(
1784 v.get_structured("IsGitDirty"),
1785 Some(&tera::Value::Bool(false))
1786 );
1787 assert_eq!(v.get("GitTreeState"), Some(&"clean".to_string()));
1788 }
1789
1790 #[test]
1791 fn test_git_tree_state_dirty() {
1792 let config = Config::default();
1793 let mut ctx = Context::new(config, ContextOptions::default());
1794 ctx.git_info = Some(make_git_info(true, None));
1795 ctx.populate_git_vars();
1796
1797 let v = ctx.template_vars();
1798 assert_eq!(
1799 v.get_structured("IsGitDirty"),
1800 Some(&tera::Value::Bool(true))
1801 );
1802 assert_eq!(v.get("GitTreeState"), Some(&"dirty".to_string()));
1803 }
1804
1805 #[test]
1806 fn test_is_snapshot_reflects_context_options() {
1807 let config = Config::default();
1808 let opts = ContextOptions {
1809 snapshot: true,
1810 ..Default::default()
1811 };
1812 let mut ctx = Context::new(config, opts);
1813 ctx.git_info = Some(make_git_info(false, None));
1814 ctx.populate_git_vars();
1815
1816 assert_eq!(
1817 ctx.template_vars().get_structured("IsSnapshot"),
1818 Some(&tera::Value::Bool(true))
1819 );
1820
1821 let config2 = Config::default();
1823 let opts2 = ContextOptions {
1824 snapshot: false,
1825 ..Default::default()
1826 };
1827 let mut ctx2 = Context::new(config2, opts2);
1828 ctx2.git_info = Some(make_git_info(false, None));
1829 ctx2.populate_git_vars();
1830
1831 assert_eq!(
1832 ctx2.template_vars().get_structured("IsSnapshot"),
1833 Some(&tera::Value::Bool(false))
1834 );
1835 }
1836
1837 #[test]
1838 fn test_is_draft_defaults_to_false() {
1839 let config = Config::default();
1840 let mut ctx = Context::new(config, ContextOptions::default());
1841 ctx.git_info = Some(make_git_info(false, None));
1842 ctx.populate_git_vars();
1843
1844 assert_eq!(
1845 ctx.template_vars().get_structured("IsDraft"),
1846 Some(&tera::Value::Bool(false))
1847 );
1848 }
1849
1850 #[test]
1851 fn test_previous_tag_empty_when_none() {
1852 let config = Config::default();
1853 let mut ctx = Context::new(config, ContextOptions::default());
1854 let mut info = make_git_info(false, None);
1855 info.previous_tag = None;
1856 ctx.git_info = Some(info);
1857 ctx.populate_git_vars();
1858
1859 assert_eq!(
1860 ctx.template_vars().get("PreviousTag"),
1861 Some(&"".to_string())
1862 );
1863 }
1864
1865 #[test]
1874 fn populate_time_vars_uses_source_date_epoch_when_set() {
1875 let env = crate::MapEnvSource::new().with("SOURCE_DATE_EPOCH", "1715000000");
1879 let config = Config::default();
1880 let mut ctx = Context::new(config, ContextOptions::default());
1881 ctx.set_env_source(env);
1882 ctx.populate_time_vars();
1883
1884 let v = ctx.template_vars();
1885 assert_eq!(
1886 v.get("Timestamp"),
1887 Some(&"1715000000".to_string()),
1888 "Timestamp must equal SOURCE_DATE_EPOCH seconds"
1889 );
1890 assert_eq!(
1891 v.get("Date"),
1892 Some(&"2024-05-06T12:53:20+00:00".to_string()),
1893 "Date must be RFC 3339 derived from SDE"
1894 );
1895 assert_eq!(v.get("Year"), Some(&"2024".to_string()));
1896 assert_eq!(v.get("Month"), Some(&"05".to_string()));
1897 assert_eq!(v.get("Day"), Some(&"06".to_string()));
1898 }
1899
1900 #[test]
1901 fn test_populate_time_vars() {
1902 let env = crate::MapEnvSource::new();
1905 let config = Config::default();
1906 let mut ctx = Context::new(config, ContextOptions::default());
1907 ctx.set_env_source(env);
1908 ctx.populate_time_vars();
1909
1910 let v = ctx.template_vars();
1911
1912 let date = v
1914 .get("Date")
1915 .unwrap_or_else(|| panic!("Date should be set"));
1916 assert!(
1917 date.contains('T') && date.len() > 10,
1918 "Date should be RFC 3339, got: {date}"
1919 );
1920
1921 let ts = v
1923 .get("Timestamp")
1924 .unwrap_or_else(|| panic!("Timestamp should be set"));
1925 assert!(
1926 ts.parse::<i64>().is_ok(),
1927 "Timestamp should be a numeric string, got: {ts}"
1928 );
1929
1930 let now = v.get("Now").unwrap_or_else(|| panic!("Now should be set"));
1932 assert!(now.contains('T'), "Now should be ISO 8601, got: {now}");
1933 }
1934
1935 #[test]
1936 fn test_env_vars_accessible_in_templates() {
1937 let mut config = Config::default();
1938 config.project_name = "myapp".to_string();
1939 let mut ctx = Context::new(config, ContextOptions::default());
1940 ctx.template_vars_mut().set_env("MY_VAR", "hello-world");
1941 ctx.template_vars_mut().set_env("DEPLOY_ENV", "staging");
1942
1943 let result = ctx
1944 .render_template("{{ .Env.MY_VAR }}-{{ .Env.DEPLOY_ENV }}")
1945 .unwrap();
1946 assert_eq!(result, "hello-world-staging");
1947 }
1948
1949 #[test]
1950 fn test_populate_git_vars_without_git_info_still_sets_snapshot() {
1951 let config = Config::default();
1952 let opts = ContextOptions {
1953 snapshot: true,
1954 ..Default::default()
1955 };
1956 let mut ctx = Context::new(config, opts);
1957 ctx.populate_git_vars();
1959
1960 assert_eq!(
1961 ctx.template_vars().get_structured("IsSnapshot"),
1962 Some(&tera::Value::Bool(true))
1963 );
1964 assert_eq!(
1965 ctx.template_vars().get_structured("IsDraft"),
1966 Some(&tera::Value::Bool(false))
1967 );
1968 assert_eq!(ctx.template_vars().get("Tag"), None);
1970 }
1971
1972 #[test]
1973 fn test_is_nightly_set_when_nightly_mode_active() {
1974 let config = Config::default();
1975 let opts = ContextOptions {
1976 nightly: true,
1977 ..Default::default()
1978 };
1979 let mut ctx = Context::new(config, opts);
1980 ctx.git_info = Some(make_git_info(false, None));
1981 ctx.populate_git_vars();
1982
1983 assert_eq!(
1984 ctx.template_vars().get_structured("IsNightly"),
1985 Some(&tera::Value::Bool(true)),
1986 "IsNightly should be 'true' when nightly mode is active"
1987 );
1988 assert!(ctx.is_nightly(), "is_nightly() should return true");
1989 }
1990
1991 #[test]
1992 fn test_is_nightly_false_by_default() {
1993 let config = Config::default();
1994 let mut ctx = Context::new(config, ContextOptions::default());
1995 ctx.git_info = Some(make_git_info(false, None));
1996 ctx.populate_git_vars();
1997
1998 assert_eq!(
1999 ctx.template_vars().get_structured("IsNightly"),
2000 Some(&tera::Value::Bool(false)),
2001 "IsNightly should default to 'false'"
2002 );
2003 assert!(
2004 !ctx.is_nightly(),
2005 "is_nightly() should return false by default"
2006 );
2007 }
2008
2009 #[test]
2010 fn test_version_returns_populated_value() {
2011 let config = Config::default();
2012 let mut ctx = Context::new(config, ContextOptions::default());
2013 ctx.git_info = Some(make_git_info(false, None));
2014 ctx.populate_git_vars();
2015
2016 assert_eq!(ctx.version(), "1.2.3");
2017 }
2018
2019 #[test]
2020 fn test_version_returns_empty_when_not_set() {
2021 let config = Config::default();
2022 let ctx = Context::new(config, ContextOptions::default());
2023 assert_eq!(ctx.version(), "");
2024 }
2025
2026 #[test]
2027 fn test_is_nightly_without_git_info() {
2028 let config = Config::default();
2029 let opts = ContextOptions {
2030 nightly: true,
2031 ..Default::default()
2032 };
2033 let mut ctx = Context::new(config, opts);
2034 ctx.populate_git_vars();
2036
2037 assert_eq!(
2038 ctx.template_vars().get_structured("IsNightly"),
2039 Some(&tera::Value::Bool(true)),
2040 "IsNightly should be set even without git info"
2041 );
2042 }
2043
2044 #[test]
2045 fn test_is_git_clean_when_not_dirty() {
2046 let config = Config::default();
2047 let mut ctx = Context::new(config, ContextOptions::default());
2048 ctx.git_info = Some(make_git_info(false, None));
2049 ctx.populate_git_vars();
2050
2051 assert_eq!(
2052 ctx.template_vars().get_structured("IsGitClean"),
2053 Some(&tera::Value::Bool(true))
2054 );
2055 }
2056
2057 #[test]
2058 fn test_is_git_clean_when_dirty() {
2059 let config = Config::default();
2060 let mut ctx = Context::new(config, ContextOptions::default());
2061 ctx.git_info = Some(make_git_info(true, None));
2062 ctx.populate_git_vars();
2063
2064 assert_eq!(
2065 ctx.template_vars().get_structured("IsGitClean"),
2066 Some(&tera::Value::Bool(false))
2067 );
2068 }
2069
2070 #[test]
2071 fn test_git_url_set_from_git_info() {
2072 let config = Config::default();
2073 let mut ctx = Context::new(config, ContextOptions::default());
2074 ctx.git_info = Some(make_git_info(false, None));
2075 ctx.populate_git_vars();
2076
2077 assert_eq!(
2078 ctx.template_vars().get("GitURL"),
2079 Some(&"https://github.com/test/repo.git".to_string())
2080 );
2081 }
2082
2083 #[test]
2084 fn test_summary_set_from_git_info() {
2085 let config = Config::default();
2086 let mut ctx = Context::new(config, ContextOptions::default());
2087 ctx.git_info = Some(make_git_info(false, None));
2088 ctx.populate_git_vars();
2089
2090 assert_eq!(
2091 ctx.template_vars().get("Summary"),
2092 Some(&"v1.2.3-0-gabc123d".to_string())
2093 );
2094 }
2095
2096 #[test]
2097 fn test_tag_subject_set_from_git_info() {
2098 let config = Config::default();
2099 let mut ctx = Context::new(config, ContextOptions::default());
2100 ctx.git_info = Some(make_git_info(false, None));
2101 ctx.populate_git_vars();
2102
2103 assert_eq!(
2104 ctx.template_vars().get("TagSubject"),
2105 Some(&"Release v1.2.3".to_string())
2106 );
2107 }
2108
2109 #[test]
2110 fn test_tag_contents_set_from_git_info() {
2111 let config = Config::default();
2112 let mut ctx = Context::new(config, ContextOptions::default());
2113 ctx.git_info = Some(make_git_info(false, None));
2114 ctx.populate_git_vars();
2115
2116 assert_eq!(
2117 ctx.template_vars().get("TagContents"),
2118 Some(&"Release v1.2.3\n\nFull release notes here.".to_string())
2119 );
2120 }
2121
2122 #[test]
2123 fn test_tag_body_set_from_git_info() {
2124 let config = Config::default();
2125 let mut ctx = Context::new(config, ContextOptions::default());
2126 ctx.git_info = Some(make_git_info(false, None));
2127 ctx.populate_git_vars();
2128
2129 assert_eq!(
2130 ctx.template_vars().get("TagBody"),
2131 Some(&"Full release notes here.".to_string())
2132 );
2133 }
2134
2135 #[test]
2136 fn test_is_single_target_false_by_default() {
2137 let config = Config::default();
2138 let mut ctx = Context::new(config, ContextOptions::default());
2139 ctx.git_info = Some(make_git_info(false, None));
2140 ctx.populate_git_vars();
2141
2142 assert_eq!(
2143 ctx.template_vars().get_structured("IsSingleTarget"),
2144 Some(&tera::Value::Bool(false))
2145 );
2146 }
2147
2148 #[test]
2149 fn test_is_single_target_true_when_set() {
2150 let config = Config::default();
2151 let opts = ContextOptions {
2152 single_target: Some("x86_64-unknown-linux-gnu".to_string()),
2153 ..Default::default()
2154 };
2155 let mut ctx = Context::new(config, opts);
2156 ctx.git_info = Some(make_git_info(false, None));
2157 ctx.populate_git_vars();
2158
2159 assert_eq!(
2160 ctx.template_vars().get_structured("IsSingleTarget"),
2161 Some(&tera::Value::Bool(true))
2162 );
2163 }
2164
2165 #[test]
2166 #[serial_test::serial]
2167 fn test_populate_runtime_vars() {
2168 let config = Config::default();
2169 let mut ctx = Context::new(config, ContextOptions::default());
2170 ctx.populate_runtime_vars();
2171
2172 let v = ctx.template_vars();
2173
2174 let goos = v
2175 .get("RuntimeGoos")
2176 .unwrap_or_else(|| panic!("RuntimeGoos should be set"));
2177 assert!(
2178 !goos.is_empty(),
2179 "RuntimeGoos should not be empty, got: {goos}"
2180 );
2181 assert_eq!(goos, map_os_to_goos(std::env::consts::OS));
2183
2184 let goarch = v
2185 .get("RuntimeGoarch")
2186 .unwrap_or_else(|| panic!("RuntimeGoarch should be set"));
2187 assert!(
2188 !goarch.is_empty(),
2189 "RuntimeGoarch should not be empty, got: {goarch}"
2190 );
2191 assert_eq!(goarch, map_arch_to_goarch(std::env::consts::ARCH));
2193 }
2194
2195 #[test]
2196 fn test_populate_release_notes_var_with_changelogs() {
2197 let mut config = Config::default();
2198 config.crates.push(crate::config::CrateConfig {
2199 name: "my-crate".to_string(),
2200 ..Default::default()
2201 });
2202 let mut ctx = Context::new(config, ContextOptions::default());
2203 ctx.stage_outputs
2204 .changelogs
2205 .insert("my-crate".to_string(), "## Changes\n- fix bug".to_string());
2206 ctx.populate_release_notes_var();
2207
2208 assert_eq!(
2209 ctx.template_vars().get("ReleaseNotes"),
2210 Some(&"## Changes\n- fix bug".to_string())
2211 );
2212 }
2213
2214 #[test]
2215 fn test_populate_release_notes_var_empty_when_no_changelogs() {
2216 let config = Config::default();
2217 let mut ctx = Context::new(config, ContextOptions::default());
2218 ctx.populate_release_notes_var();
2219
2220 assert_eq!(
2221 ctx.template_vars().get("ReleaseNotes"),
2222 Some(&"".to_string())
2223 );
2224 }
2225
2226 #[test]
2227 fn test_populate_release_notes_var_deterministic_with_multiple_crates() {
2228 let mut config = Config::default();
2229 config.crates.push(crate::config::CrateConfig {
2230 name: "crate-a".to_string(),
2231 ..Default::default()
2232 });
2233 config.crates.push(crate::config::CrateConfig {
2234 name: "crate-b".to_string(),
2235 ..Default::default()
2236 });
2237 let mut ctx = Context::new(config, ContextOptions::default());
2238 ctx.stage_outputs
2239 .changelogs
2240 .insert("crate-a".to_string(), "notes-a".to_string());
2241 ctx.stage_outputs
2242 .changelogs
2243 .insert("crate-b".to_string(), "notes-b".to_string());
2244 ctx.populate_release_notes_var();
2245
2246 assert_eq!(
2248 ctx.template_vars().get("ReleaseNotes"),
2249 Some(&"notes-a".to_string())
2250 );
2251 }
2252
2253 #[test]
2254 fn test_outputs_accessible_in_templates() {
2255 let mut config = Config::default();
2256 config.project_name = "myapp".to_string();
2257 let mut ctx = Context::new(config, ContextOptions::default());
2258 ctx.template_vars_mut().set_output("build_id", "abc123");
2259 ctx.template_vars_mut()
2260 .set_output("deploy_url", "https://example.com");
2261
2262 let result = ctx
2263 .render_template("{{ .Outputs.build_id }}-{{ .Outputs.deploy_url }}")
2264 .unwrap();
2265 assert_eq!(result, "abc123-https://example.com");
2266 }
2267
2268 #[test]
2269 fn test_artifact_ext_and_target_template_vars() {
2270 let mut config = Config::default();
2271 config.project_name = "myapp".to_string();
2272 let mut ctx = Context::new(config, ContextOptions::default());
2273 ctx.template_vars_mut().set("ArtifactName", "myapp.tar.gz");
2274 ctx.template_vars_mut().set("ArtifactExt", ".tar.gz");
2275 ctx.template_vars_mut()
2276 .set("Target", "x86_64-unknown-linux-gnu");
2277
2278 let result = ctx
2279 .render_template("{{ .ArtifactExt }}_{{ .Target }}")
2280 .unwrap();
2281 assert_eq!(result, ".tar.gz_x86_64-unknown-linux-gnu");
2282 }
2283
2284 #[test]
2285 fn test_checksums_template_var() {
2286 let mut config = Config::default();
2287 config.project_name = "myapp".to_string();
2288 let mut ctx = Context::new(config, ContextOptions::default());
2289 let checksum_text = "abc123 myapp.tar.gz\ndef456 myapp.zip\n";
2290 ctx.template_vars_mut().set("Checksums", checksum_text);
2291
2292 let result = ctx.render_template("{{ .Checksums }}").unwrap();
2293 assert_eq!(result, checksum_text);
2294 }
2295
2296 #[test]
2299 fn test_prefixed_tag_with_tag_prefix() {
2300 let mut config = Config::default();
2301 config.tag = Some(crate::config::TagConfig {
2302 tag_prefix: Some("api/".to_string()),
2303 ..Default::default()
2304 });
2305 let mut ctx = Context::new(config, ContextOptions::default());
2306 ctx.git_info = Some(make_git_info(false, None));
2307 ctx.populate_git_vars();
2308
2309 assert_eq!(
2310 ctx.template_vars().get("PrefixedTag"),
2311 Some(&"api/v1.2.3".to_string())
2312 );
2313 }
2314
2315 #[test]
2316 fn test_prefixed_tag_without_tag_prefix() {
2317 let config = Config::default();
2318 let mut ctx = Context::new(config, ContextOptions::default());
2319 ctx.git_info = Some(make_git_info(false, None));
2320 ctx.populate_git_vars();
2321
2322 assert_eq!(
2324 ctx.template_vars().get("PrefixedTag"),
2325 Some(&"v1.2.3".to_string())
2326 );
2327 }
2328
2329 #[test]
2330 fn test_prefixed_previous_tag_with_tag_prefix() {
2331 let mut config = Config::default();
2332 config.tag = Some(crate::config::TagConfig {
2333 tag_prefix: Some("api/".to_string()),
2334 ..Default::default()
2335 });
2336 let mut ctx = Context::new(config, ContextOptions::default());
2337 ctx.git_info = Some(make_git_info(false, None));
2338 ctx.populate_git_vars();
2339
2340 assert_eq!(
2341 ctx.template_vars().get("PrefixedPreviousTag"),
2342 Some(&"api/v1.2.2".to_string())
2343 );
2344 }
2345
2346 #[test]
2347 fn test_prefixed_previous_tag_empty_when_no_previous() {
2348 let mut config = Config::default();
2349 config.tag = Some(crate::config::TagConfig {
2350 tag_prefix: Some("api/".to_string()),
2351 ..Default::default()
2352 });
2353 let mut ctx = Context::new(config, ContextOptions::default());
2354 let mut info = make_git_info(false, None);
2355 info.previous_tag = None;
2356 ctx.git_info = Some(info);
2357 ctx.populate_git_vars();
2358
2359 assert_eq!(
2362 ctx.template_vars().get("PrefixedPreviousTag"),
2363 Some(&"".to_string())
2364 );
2365 }
2366
2367 #[test]
2368 fn test_prefixed_summary_with_tag_prefix() {
2369 let mut config = Config::default();
2370 config.tag = Some(crate::config::TagConfig {
2371 tag_prefix: Some("api/".to_string()),
2372 ..Default::default()
2373 });
2374 let mut ctx = Context::new(config, ContextOptions::default());
2375 ctx.git_info = Some(make_git_info(false, None));
2376 ctx.populate_git_vars();
2377
2378 assert_eq!(
2379 ctx.template_vars().get("PrefixedSummary"),
2380 Some(&"api/v1.2.3-0-gabc123d".to_string())
2381 );
2382 }
2383
2384 #[test]
2385 fn test_is_release_true_for_normal_release() {
2386 let config = Config::default();
2387 let opts = ContextOptions {
2388 snapshot: false,
2389 nightly: false,
2390 ..Default::default()
2391 };
2392 let mut ctx = Context::new(config, opts);
2393 ctx.git_info = Some(make_git_info(false, None));
2394 ctx.populate_git_vars();
2395
2396 assert_eq!(
2397 ctx.template_vars().get_structured("IsRelease"),
2398 Some(&tera::Value::Bool(true))
2399 );
2400 }
2401
2402 #[test]
2403 fn test_is_release_false_for_snapshot() {
2404 let config = Config::default();
2405 let opts = ContextOptions {
2406 snapshot: true,
2407 ..Default::default()
2408 };
2409 let mut ctx = Context::new(config, opts);
2410 ctx.git_info = Some(make_git_info(false, None));
2411 ctx.populate_git_vars();
2412
2413 assert_eq!(
2414 ctx.template_vars().get_structured("IsRelease"),
2415 Some(&tera::Value::Bool(false))
2416 );
2417 }
2418
2419 #[test]
2420 fn test_is_release_false_for_nightly() {
2421 let config = Config::default();
2422 let opts = ContextOptions {
2423 nightly: true,
2424 ..Default::default()
2425 };
2426 let mut ctx = Context::new(config, opts);
2427 ctx.git_info = Some(make_git_info(false, None));
2428 ctx.populate_git_vars();
2429
2430 assert_eq!(
2431 ctx.template_vars().get_structured("IsRelease"),
2432 Some(&tera::Value::Bool(false))
2433 );
2434 }
2435
2436 #[test]
2437 fn test_is_merging_true_when_merge_flag_set() {
2438 let config = Config::default();
2439 let opts = ContextOptions {
2440 merge: true,
2441 ..Default::default()
2442 };
2443 let mut ctx = Context::new(config, opts);
2444 ctx.git_info = Some(make_git_info(false, None));
2445 ctx.populate_git_vars();
2446
2447 assert_eq!(
2448 ctx.template_vars().get_structured("IsMerging"),
2449 Some(&tera::Value::Bool(true))
2450 );
2451 }
2452
2453 #[test]
2454 fn test_is_merging_false_by_default() {
2455 let config = Config::default();
2456 let mut ctx = Context::new(config, ContextOptions::default());
2457 ctx.git_info = Some(make_git_info(false, None));
2458 ctx.populate_git_vars();
2459
2460 assert_eq!(
2461 ctx.template_vars().get_structured("IsMerging"),
2462 Some(&tera::Value::Bool(false))
2463 );
2464 }
2465
2466 #[test]
2467 fn test_refresh_artifacts_var_empty() {
2468 let config = Config::default();
2469 let mut ctx = Context::new(config, ContextOptions::default());
2470 ctx.refresh_artifacts_var();
2471
2472 let result = ctx
2474 .render_template("{% for a in Artifacts %}{{ a.name }}{% endfor %}")
2475 .unwrap();
2476 assert_eq!(result, "");
2477 }
2478
2479 #[test]
2480 fn test_refresh_artifacts_var_with_artifacts() {
2481 use crate::artifact::{Artifact, ArtifactKind};
2482 use std::collections::HashMap;
2483 use std::path::PathBuf;
2484
2485 let config = Config::default();
2486 let mut ctx = Context::new(config, ContextOptions::default());
2487 ctx.artifacts.add(Artifact {
2491 kind: ArtifactKind::Archive,
2492 name: String::new(),
2493 path: PathBuf::from("dist/myapp-1.0.0-linux-amd64.tar.gz"),
2494 target: Some("x86_64-unknown-linux-gnu".to_string()),
2495 crate_name: "myapp".to_string(),
2496 metadata: HashMap::from([("format".to_string(), "tar.gz".to_string())]),
2497 size: None,
2498 });
2499 ctx.artifacts.add(Artifact {
2500 kind: ArtifactKind::Binary,
2501 name: String::new(),
2502 path: PathBuf::from("dist/myapp"),
2503 target: Some("x86_64-unknown-linux-gnu".to_string()),
2504 crate_name: "myapp".to_string(),
2505 metadata: HashMap::new(),
2506 size: None,
2507 });
2508 ctx.refresh_artifacts_var();
2509
2510 let result = ctx
2512 .render_template("{% for a in Artifacts %}{{ a.name }},{% endfor %}")
2513 .unwrap();
2514 assert!(result.contains("myapp-1.0.0-linux-amd64.tar.gz"));
2515 assert!(result.contains("myapp"));
2516
2517 let result_kinds = ctx
2519 .render_template("{% for a in Artifacts %}{{ a.kind }},{% endfor %}")
2520 .unwrap();
2521 assert!(result_kinds.contains("archive"));
2522 assert!(result_kinds.contains("binary"));
2523 }
2524
2525 #[test]
2526 fn test_populate_metadata_var_with_mod_timestamp() {
2527 let mut config = Config::default();
2528 config.metadata = Some(crate::config::MetadataConfig {
2529 mod_timestamp: Some("{{ .CommitTimestamp }}".to_string()),
2530 ..Default::default()
2531 });
2532 let mut ctx = Context::new(config, ContextOptions::default());
2533 ctx.populate_metadata_var().unwrap();
2534
2535 let result = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
2537 assert_eq!(result, "{{ .CommitTimestamp }}");
2538 }
2539
2540 #[test]
2541 fn test_populate_metadata_var_empty_when_no_config() {
2542 let config = Config::default();
2543 let mut ctx = Context::new(config, ContextOptions::default());
2544 ctx.populate_metadata_var().unwrap();
2545
2546 let result = ctx.render_template("{{ Metadata.Description }}").unwrap();
2548 assert_eq!(result, "");
2549 }
2550
2551 #[test]
2552 fn test_populate_metadata_var_reads_from_config() {
2553 let mut config = Config::default();
2554 config.metadata = Some(crate::config::MetadataConfig {
2555 description: Some("A test project".to_string()),
2556 homepage: Some("https://example.com".to_string()),
2557 documentation: Some("https://docs.example.com".to_string()),
2558 license: Some("MIT".to_string()),
2559 repository: Some("https://github.com/example/test".to_string()),
2560 maintainers: Some(vec!["Alice".to_string(), "Bob".to_string()]),
2561 mod_timestamp: Some("1234567890".to_string()),
2562 ..Default::default()
2563 });
2564 let mut ctx = Context::new(config, ContextOptions::default());
2565 ctx.populate_metadata_var().unwrap();
2566
2567 let desc = ctx.render_template("{{ Metadata.Description }}").unwrap();
2568 assert_eq!(desc, "A test project");
2569
2570 let home = ctx.render_template("{{ Metadata.Homepage }}").unwrap();
2571 assert_eq!(home, "https://example.com");
2572
2573 let repo = ctx.render_template("{{ Metadata.Repository }}").unwrap();
2574 assert_eq!(repo, "https://github.com/example/test");
2575
2576 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2577 assert_eq!(docs, "https://docs.example.com");
2578
2579 let lic = ctx.render_template("{{ Metadata.License }}").unwrap();
2580 assert_eq!(lic, "MIT");
2581
2582 let ts = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
2583 assert_eq!(ts, "1234567890");
2584 }
2585
2586 #[test]
2587 fn test_populate_metadata_var_license_falls_back_to_derived() {
2588 let mut config = Config::default();
2592 config.crates = vec![crate::config::CrateConfig {
2593 name: "anodizer".to_string(),
2594 ..Default::default()
2595 }];
2596 config.derived_metadata.insert(
2597 "anodizer".to_string(),
2598 crate::config::MetadataConfig {
2599 description: Some("Derived desc".to_string()),
2600 homepage: Some("https://derived.example".to_string()),
2601 documentation: Some("https://derived.docs".to_string()),
2602 license: Some("MIT OR Apache-2.0".to_string()),
2603 ..Default::default()
2604 },
2605 );
2606 let mut ctx = Context::new(config, ContextOptions::default());
2607 ctx.populate_metadata_var().unwrap();
2608
2609 assert_eq!(
2610 ctx.render_template("{{ Metadata.License }}").unwrap(),
2611 "MIT OR Apache-2.0"
2612 );
2613 assert_eq!(
2614 ctx.render_template("{{ Metadata.Description }}").unwrap(),
2615 "Derived desc"
2616 );
2617 assert_eq!(
2618 ctx.render_template("{{ Metadata.Homepage }}").unwrap(),
2619 "https://derived.example"
2620 );
2621 assert_eq!(
2622 ctx.render_template("{{ Metadata.Documentation }}").unwrap(),
2623 "https://derived.docs"
2624 );
2625 }
2626
2627 #[test]
2628 fn test_populate_metadata_var_top_level_license_wins_over_derived() {
2629 let mut config = Config::default();
2632 config.crates = vec![crate::config::CrateConfig {
2633 name: "anodizer".to_string(),
2634 ..Default::default()
2635 }];
2636 config.derived_metadata.insert(
2637 "anodizer".to_string(),
2638 crate::config::MetadataConfig {
2639 license: Some("MIT OR Apache-2.0".to_string()),
2640 ..Default::default()
2641 },
2642 );
2643 config.metadata = Some(crate::config::MetadataConfig {
2644 license: Some("GPL-3.0".to_string()),
2645 ..Default::default()
2646 });
2647 let mut ctx = Context::new(config, ContextOptions::default());
2648 ctx.populate_metadata_var().unwrap();
2649
2650 assert_eq!(
2651 ctx.render_template("{{ Metadata.License }}").unwrap(),
2652 "GPL-3.0"
2653 );
2654 }
2655
2656 #[test]
2657 fn test_populate_metadata_var_documentation_renders() {
2658 let mut config = Config::default();
2659 config.metadata = Some(crate::config::MetadataConfig {
2660 documentation: Some("https://docs.rs/anodizer".to_string()),
2661 ..Default::default()
2662 });
2663 let mut ctx = Context::new(config, ContextOptions::default());
2664 ctx.populate_metadata_var().unwrap();
2665
2666 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2667 assert_eq!(docs, "https://docs.rs/anodizer");
2668 }
2669
2670 #[test]
2671 fn test_populate_metadata_var_documentation_empty_when_unset() {
2672 let mut ctx = Context::new(Config::default(), ContextOptions::default());
2673 ctx.populate_metadata_var().unwrap();
2674
2675 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2676 assert_eq!(docs, "");
2677 }
2678
2679 #[test]
2680 fn test_populate_metadata_var_full_description_inline() {
2681 use crate::config::ContentSource;
2682 let mut config = Config::default();
2683 config.metadata = Some(crate::config::MetadataConfig {
2684 full_description: Some(ContentSource::Inline(
2685 "A long-form description of the project.".to_string(),
2686 )),
2687 ..Default::default()
2688 });
2689 let mut ctx = Context::new(config, ContextOptions::default());
2690 ctx.populate_metadata_var().unwrap();
2691 let rendered = ctx
2692 .render_template("{{ Metadata.FullDescription }}")
2693 .unwrap();
2694 assert_eq!(rendered, "A long-form description of the project.");
2695 }
2696
2697 #[test]
2698 fn test_populate_metadata_var_full_description_from_file() {
2699 use crate::config::ContentSource;
2700 let tmp = tempfile::tempdir().unwrap();
2701 let desc_path = tmp.path().join("DESCRIPTION.md");
2702 std::fs::write(&desc_path, "read from disk").unwrap();
2703 let mut config = Config::default();
2704 config.metadata = Some(crate::config::MetadataConfig {
2705 full_description: Some(ContentSource::FromFile {
2706 from_file: desc_path.to_string_lossy().into_owned(),
2707 }),
2708 ..Default::default()
2709 });
2710 let mut ctx = Context::new(config, ContextOptions::default());
2711 ctx.populate_metadata_var().unwrap();
2712 let rendered = ctx
2713 .render_template("{{ Metadata.FullDescription }}")
2714 .unwrap();
2715 assert_eq!(rendered, "read from disk");
2716 }
2717
2718 #[test]
2719 fn test_populate_metadata_var_full_description_from_url_resolves() {
2720 use crate::config::ContentSource;
2725 use crate::test_helpers::responder::spawn_oneshot_http_responder;
2726
2727 let body = "long form description body";
2728 let body_len = body.len();
2729 let response: &'static str = Box::leak(
2730 format!("HTTP/1.1 200 OK\r\nContent-Length: {body_len}\r\n\r\n{body}").into_boxed_str(),
2731 );
2732 let (addr, _calls) = spawn_oneshot_http_responder(vec![response]);
2733
2734 let mut config = Config::default();
2735 config.metadata = Some(crate::config::MetadataConfig {
2736 full_description: Some(ContentSource::FromUrl {
2737 from_url: format!("http://{addr}/description.md"),
2738 headers: None,
2739 }),
2740 ..Default::default()
2741 });
2742 let mut ctx = Context::new(config, ContextOptions::default());
2743 ctx.populate_metadata_var()
2744 .expect("from_url should resolve through content_source");
2745 let rendered = ctx
2746 .render_template("{{ Metadata.FullDescription }}")
2747 .unwrap();
2748 assert_eq!(rendered, body);
2749 }
2750
2751 #[test]
2752 fn test_populate_metadata_var_commit_author() {
2753 use crate::config::CommitAuthorConfig;
2754 let mut config = Config::default();
2755 config.metadata = Some(crate::config::MetadataConfig {
2756 commit_author: Some(CommitAuthorConfig {
2757 name: Some("Alice Developer".to_string()),
2758 email: Some("alice@example.com".to_string()),
2759 signing: None,
2760 use_github_app_token: false,
2761 }),
2762 ..Default::default()
2763 });
2764 let mut ctx = Context::new(config, ContextOptions::default());
2765 ctx.populate_metadata_var().unwrap();
2766 let name = ctx
2767 .render_template("{{ Metadata.CommitAuthor.Name }}")
2768 .unwrap();
2769 assert_eq!(name, "Alice Developer");
2770 let email = ctx
2771 .render_template("{{ Metadata.CommitAuthor.Email }}")
2772 .unwrap();
2773 assert_eq!(email, "alice@example.com");
2774 }
2775
2776 #[test]
2777 fn test_artifact_id_template_var() {
2778 let mut config = Config::default();
2779 config.project_name = "myapp".to_string();
2780 let mut ctx = Context::new(config, ContextOptions::default());
2781 ctx.template_vars_mut().set("ArtifactID", "default");
2782
2783 let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
2784 assert_eq!(result, "default");
2785 }
2786
2787 #[test]
2788 fn test_artifact_id_empty_when_not_set() {
2789 let mut config = Config::default();
2790 config.project_name = "myapp".to_string();
2791 let mut ctx = Context::new(config, ContextOptions::default());
2792 ctx.template_vars_mut().set("ArtifactID", "");
2793
2794 let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
2795 assert_eq!(result, "");
2796 }
2797
2798 #[test]
2799 fn test_pro_vars_rendered_in_templates() {
2800 let mut config = Config::default();
2802 config.tag = Some(crate::config::TagConfig {
2803 tag_prefix: Some("api/".to_string()),
2804 ..Default::default()
2805 });
2806 let opts = ContextOptions {
2807 snapshot: false,
2808 nightly: false,
2809 merge: true,
2810 ..Default::default()
2811 };
2812 let mut ctx = Context::new(config, opts);
2813 ctx.git_info = Some(make_git_info(false, None));
2814 ctx.populate_git_vars();
2815
2816 let result = ctx
2817 .render_template(
2818 "{% if IsRelease %}release{% endif %}-{% if IsMerging %}merge{% endif %}-{{ .PrefixedTag }}",
2819 )
2820 .unwrap();
2821 assert_eq!(result, "release-merge-api/v1.2.3");
2822 }
2823
2824 #[test]
2825 fn test_is_release_without_git_info() {
2826 let config = Config::default();
2828 let opts = ContextOptions {
2829 snapshot: false,
2830 nightly: false,
2831 ..Default::default()
2832 };
2833 let mut ctx = Context::new(config, opts);
2834 ctx.populate_git_vars();
2835
2836 assert_eq!(
2837 ctx.template_vars().get_structured("IsRelease"),
2838 Some(&tera::Value::Bool(true))
2839 );
2840 }
2841
2842 #[test]
2843 fn test_is_merging_without_git_info() {
2844 let config = Config::default();
2846 let opts = ContextOptions {
2847 merge: true,
2848 ..Default::default()
2849 };
2850 let mut ctx = Context::new(config, opts);
2851 ctx.populate_git_vars();
2852
2853 assert_eq!(
2854 ctx.template_vars().get_structured("IsMerging"),
2855 Some(&tera::Value::Bool(true))
2856 );
2857 }
2858
2859 #[test]
2869 fn test_monorepo_version_matches_shared_semver_helper() {
2870 let mut config = Config::default();
2871 config.monorepo = Some(crate::config::MonorepoConfig {
2872 tag_prefix: Some("core/".to_string()),
2873 dir: None,
2874 });
2875 let mut ctx = Context::new(config, ContextOptions::default());
2876
2877 let semver = SemVer {
2878 major: 2,
2879 minor: 1,
2880 patch: 0,
2881 prerelease: Some("rc.1".to_string()),
2882 build_metadata: Some("build.7".to_string()),
2883 };
2884 let mut info = make_git_info(false, None);
2885 info.tag = "core/v2.1.0-rc.1+build.7".to_string();
2886 info.semver = semver.clone();
2887 ctx.git_info = Some(info);
2888 ctx.populate_git_vars();
2889
2890 let v = ctx.template_vars();
2891 assert_eq!(v.get("Version"), Some(&semver.version_string()));
2894 assert_eq!(v.get("Version"), Some(&"2.1.0-rc.1+build.7".to_string()));
2895 assert_eq!(v.get("RawVersion"), Some(&semver.raw_version_string()));
2896 assert_eq!(v.get("RawVersion"), Some(&"2.1.0".to_string()));
2897 assert_eq!(v.get("Tag"), Some(&"v2.1.0-rc.1+build.7".to_string()));
2899 }
2900
2901 #[test]
2902 fn test_monorepo_tag_prefix_strips_tag_for_template_var() {
2903 let mut config = Config::default();
2904 config.monorepo = Some(crate::config::MonorepoConfig {
2905 tag_prefix: Some("subproject1/".to_string()),
2906 dir: None,
2907 });
2908 let mut ctx = Context::new(config, ContextOptions::default());
2909
2910 let mut info = make_git_info(false, None);
2912 info.tag = "subproject1/v1.2.3".to_string();
2913 info.previous_tag = Some("subproject1/v1.2.2".to_string());
2914 info.summary = "subproject1/v1.2.3-0-gabc123d".to_string();
2915 ctx.git_info = Some(info);
2916 ctx.populate_git_vars();
2917
2918 let v = ctx.template_vars();
2919 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2921 assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
2923 assert_eq!(
2925 v.get("PrefixedTag"),
2926 Some(&"subproject1/v1.2.3".to_string())
2927 );
2928 assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
2930 assert_eq!(
2932 v.get("PrefixedPreviousTag"),
2933 Some(&"subproject1/v1.2.2".to_string())
2934 );
2935 assert_eq!(v.get("Summary"), Some(&"v1.2.3-0-gabc123d".to_string()));
2937 assert_eq!(
2939 v.get("PrefixedSummary"),
2940 Some(&"subproject1/v1.2.3-0-gabc123d".to_string())
2941 );
2942 }
2943
2944 #[test]
2945 fn test_monorepo_prefixed_previous_tag() {
2946 let mut config = Config::default();
2947 config.monorepo = Some(crate::config::MonorepoConfig {
2948 tag_prefix: Some("svc/".to_string()),
2949 dir: None,
2950 });
2951 let mut ctx = Context::new(config, ContextOptions::default());
2952
2953 let mut info = make_git_info(false, None);
2954 info.tag = "svc/v2.0.0".to_string();
2955 info.previous_tag = Some("svc/v1.9.0".to_string());
2956 ctx.git_info = Some(info);
2957 ctx.populate_git_vars();
2958
2959 let v = ctx.template_vars();
2960 assert_eq!(
2962 v.get("PrefixedPreviousTag"),
2963 Some(&"svc/v1.9.0".to_string())
2964 );
2965 assert_eq!(v.get("PreviousTag"), Some(&"v1.9.0".to_string()));
2967 }
2968
2969 #[test]
2970 fn test_no_monorepo_falls_back_to_tag_prefix() {
2971 let mut config = Config::default();
2973 config.tag = Some(crate::config::TagConfig {
2974 tag_prefix: Some("release/".to_string()),
2975 ..Default::default()
2976 });
2977 let mut ctx = Context::new(config, ContextOptions::default());
2978 ctx.git_info = Some(make_git_info(false, None));
2979 ctx.populate_git_vars();
2980
2981 let v = ctx.template_vars();
2982 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2984 assert_eq!(v.get("PrefixedTag"), Some(&"release/v1.2.3".to_string()));
2986 assert_eq!(
2987 v.get("PrefixedPreviousTag"),
2988 Some(&"release/v1.2.2".to_string())
2989 );
2990 }
2991
2992 #[test]
2993 fn test_monorepo_overrides_tag_prefix_for_prefixed_vars() {
2994 let mut config = Config::default();
2997 config.tag = Some(crate::config::TagConfig {
2998 tag_prefix: Some("release/".to_string()),
2999 ..Default::default()
3000 });
3001 config.monorepo = Some(crate::config::MonorepoConfig {
3002 tag_prefix: Some("svc/".to_string()),
3003 dir: None,
3004 });
3005 let mut ctx = Context::new(config, ContextOptions::default());
3006
3007 let mut info = make_git_info(false, None);
3008 info.tag = "svc/v1.2.3".to_string();
3009 info.previous_tag = Some("svc/v1.2.2".to_string());
3010 ctx.git_info = Some(info);
3011 ctx.populate_git_vars();
3012
3013 let v = ctx.template_vars();
3014 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
3016 assert_eq!(v.get("PrefixedTag"), Some(&"svc/v1.2.3".to_string()));
3018 }
3019
3020 #[test]
3021 fn test_monorepo_prefixed_summary() {
3022 let mut config = Config::default();
3023 config.monorepo = Some(crate::config::MonorepoConfig {
3024 tag_prefix: Some("pkg/".to_string()),
3025 dir: None,
3026 });
3027 let mut ctx = Context::new(config, ContextOptions::default());
3028
3029 let mut info = make_git_info(false, None);
3030 info.tag = "pkg/v1.2.3".to_string();
3031 info.summary = "pkg/v1.2.3-0-gabc123d".to_string();
3033 ctx.git_info = Some(info);
3034 ctx.populate_git_vars();
3035
3036 assert_eq!(
3038 ctx.template_vars().get("PrefixedSummary"),
3039 Some(&"pkg/v1.2.3-0-gabc123d".to_string())
3040 );
3041 assert_eq!(
3043 ctx.template_vars().get("Summary"),
3044 Some(&"v1.2.3-0-gabc123d".to_string())
3045 );
3046 }
3047
3048 #[test]
3049 fn test_monorepo_no_previous_tag() {
3050 let mut config = Config::default();
3051 config.monorepo = Some(crate::config::MonorepoConfig {
3052 tag_prefix: Some("svc/".to_string()),
3053 dir: None,
3054 });
3055 let mut ctx = Context::new(config, ContextOptions::default());
3056
3057 let mut info = make_git_info(false, None);
3058 info.tag = "svc/v1.0.0".to_string();
3059 info.previous_tag = None;
3060 ctx.git_info = Some(info);
3061 ctx.populate_git_vars();
3062
3063 let v = ctx.template_vars();
3064 assert_eq!(v.get("PrefixedPreviousTag"), Some(&"".to_string()));
3065 assert_eq!(v.get("PreviousTag"), Some(&"".to_string()));
3067 }
3068
3069 #[test]
3074 fn test_monorepo_full_flow_all_vars() {
3075 let mut config = Config::default();
3078 config.project_name = "mymonorepo".to_string();
3079 config.monorepo = Some(crate::config::MonorepoConfig {
3080 tag_prefix: Some("services/api/".to_string()),
3081 dir: Some("services/api".to_string()),
3082 });
3083
3084 assert_eq!(config.monorepo_tag_prefix(), Some("services/api/"));
3086 assert_eq!(config.monorepo_dir(), Some("services/api"));
3087
3088 let mut ctx = Context::new(config, ContextOptions::default());
3089
3090 let mut info = make_git_info(false, None);
3093 info.tag = "services/api/v2.1.0".to_string();
3094 info.previous_tag = Some("services/api/v2.0.5".to_string());
3095 info.summary = "services/api/v2.1.0-0-gabc123d".to_string();
3096 info.semver = crate::git::SemVer {
3097 major: 2,
3098 minor: 1,
3099 patch: 0,
3100 prerelease: None,
3101 build_metadata: None,
3102 };
3103 ctx.git_info = Some(info);
3104 ctx.populate_git_vars();
3105
3106 let v = ctx.template_vars();
3107
3108 assert_eq!(v.get("Tag"), Some(&"v2.1.0".to_string()));
3110 assert_eq!(v.get("Version"), Some(&"2.1.0".to_string()));
3111 assert_eq!(v.get("RawVersion"), Some(&"2.1.0".to_string()));
3112 assert_eq!(v.get("Major"), Some(&"2".to_string()));
3113 assert_eq!(v.get("Minor"), Some(&"1".to_string()));
3114 assert_eq!(v.get("Patch"), Some(&"0".to_string()));
3115 assert_eq!(v.get("PreviousTag"), Some(&"v2.0.5".to_string()));
3116 assert_eq!(v.get("Summary"), Some(&"v2.1.0-0-gabc123d".to_string()));
3117
3118 assert_eq!(
3120 v.get("PrefixedTag"),
3121 Some(&"services/api/v2.1.0".to_string())
3122 );
3123 assert_eq!(
3124 v.get("PrefixedPreviousTag"),
3125 Some(&"services/api/v2.0.5".to_string())
3126 );
3127 assert_eq!(
3128 v.get("PrefixedSummary"),
3129 Some(&"services/api/v2.1.0-0-gabc123d".to_string())
3130 );
3131
3132 assert_eq!(v.get("ProjectName"), Some(&"mymonorepo".to_string()));
3134 }
3135
3136 #[test]
3137 fn context_env_var_defaults_to_process_env_source() {
3138 let ctx = Context::new(Config::default(), ContextOptions::default());
3139 assert_eq!(ctx.env_var("ANODIZER_T3_UNSET_VAR"), None);
3141 }
3142
3143 #[test]
3144 fn context_env_var_routes_to_injected_source() {
3145 let mut ctx = Context::new(Config::default(), ContextOptions::default());
3146 ctx.set_env_source(crate::MapEnvSource::new().with("INJECTED", "yes"));
3147 assert_eq!(ctx.env_var("INJECTED"), Some("yes".to_string()));
3148 assert_eq!(ctx.env_var("PATH"), None);
3152 }
3153
3154 #[test]
3155 #[serial_test::serial]
3156 fn populate_runtime_vars_sets_rustc_version() {
3157 let config = Config::default();
3158 let mut ctx = Context::new(config, ContextOptions::default());
3159 ctx.populate_runtime_vars();
3162
3163 let ver = ctx
3164 .template_vars()
3165 .get("RustcVersion")
3166 .expect("RustcVersion should be set after populate_runtime_vars");
3167 if !ver.is_empty() {
3171 assert!(
3172 ver.chars().next().is_some_and(|c| c.is_ascii_digit()),
3173 "RustcVersion should start with a digit: {ver}"
3174 );
3175 }
3176 }
3177}