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 allow_snapshot_publish: bool,
313}
314
315impl Default for ContextOptions {
316 fn default() -> Self {
317 Self {
318 snapshot: false,
319 nightly: false,
320 dry_run: false,
321 quiet: false,
322 verbose: false,
323 debug: false,
324 skip_stages: Vec::new(),
325 publisher_allowlist: Vec::new(),
326 selected_crates: Vec::new(),
327 token: None,
328 parallelism: 4,
329 single_target: None,
330 release_notes_path: None,
331 fail_fast: false,
332 partial_target: None,
333 merge: false,
334 publish_only: false,
335 preflight_secrets: false,
336 project_root: None,
337 strict: false,
338 resume_release: false,
339 replace_existing_artifacts: false,
340 skip_post_publish_poll: false,
341 gate_submitter: None,
342 rollback_mode: None,
343 simulate_failure_publishers: Vec::new(),
344 rollback_only: false,
345 allow_rerun: false,
346 show_skipped: false,
347 from_run: None,
348 runtime_nondeterministic_allowlist: Vec::new(),
349 summary_json_path: None,
350 allow_ai_failure: false,
351 changelog_from: None,
352 changelog_full_history: false,
353 changelog_to: None,
354 changelog_preview: false,
355 allow_snapshot_publish: false,
356 }
357 }
358}
359
360#[derive(Debug, Default)]
365pub struct StageOutputs {
366 pub github_native_changelog: bool,
370 pub changelogs: HashMap<String, String>,
372 pub changelog_header: Option<String>,
377 pub changelog_footer: Option<String>,
380 pub post_publish_results: Vec<serde_json::Value>,
388}
389
390pub struct Context {
391 pub config: Config,
392 pub artifacts: ArtifactRegistry,
393 pub options: ContextOptions,
394 pub stage_outputs: StageOutputs,
396 template_vars: TemplateVars,
397 pub git_info: Option<GitInfo>,
398 pub token_type: ScmTokenType,
400 pub skip_memento: crate::pipe_skip::SkipMemento,
406 pub publish_report: Option<PublishReport>,
414 pub publish_attempted: bool,
420 pub verify_release: Option<VerifyReleaseSummary>,
431 pub determinism: Option<crate::DeterminismState>,
438 pub pending_outcome: Option<crate::PublisherOutcome>,
448 pub pending_evidence: Option<crate::PublishEvidence>,
461 built_crate_names: Option<std::collections::HashSet<String>>,
472 env_source: Arc<dyn EnvSource>,
480 #[cfg(feature = "test-helpers")]
488 pub log_capture: Option<crate::log::LogCapture>,
489 render_strict: std::cell::Cell<bool>,
504 pub literal_message: bool,
511 pub redact_body: bool,
518 process_env_cache: std::cell::OnceCell<std::collections::HashMap<String, String>>,
527}
528
529impl Context {
530 pub fn new(config: Config, options: ContextOptions) -> Self {
531 let mut vars = TemplateVars::new();
532 vars.set("ProjectName", &config.project_name);
533 Self {
534 config,
535 artifacts: ArtifactRegistry::new(),
536 options,
537 stage_outputs: StageOutputs::default(),
538 template_vars: vars,
539 git_info: None,
540 token_type: ScmTokenType::GitHub,
541 skip_memento: crate::pipe_skip::SkipMemento::new(),
542 publish_report: None,
543 publish_attempted: false,
544 verify_release: None,
545 determinism: None,
546 pending_outcome: None,
547 pending_evidence: None,
548 built_crate_names: None,
549 env_source: Arc::new(ProcessEnvSource),
550 #[cfg(feature = "test-helpers")]
551 log_capture: None,
552 render_strict: std::cell::Cell::new(false),
553 literal_message: false,
554 redact_body: true,
555 process_env_cache: std::cell::OnceCell::new(),
556 }
557 }
558
559 pub fn redact(&self, s: &str) -> String {
564 crate::redact::with_env(s, &self.env_for_redact())
565 }
566
567 pub fn env_var(&self, name: &str) -> Option<String> {
575 self.env_source.var(name)
576 }
577
578 pub fn set_env_source<S: EnvSource + 'static>(&mut self, src: S) {
584 self.env_source = Arc::new(src);
585 }
586
587 pub fn env_source(&self) -> &dyn EnvSource {
592 self.env_source.as_ref()
593 }
594
595 pub fn env_source_arc(&self) -> Arc<dyn EnvSource> {
601 Arc::clone(&self.env_source)
602 }
603
604 #[cfg(feature = "test-helpers")]
610 pub fn with_log_capture(&mut self, capture: crate::log::LogCapture) {
611 self.log_capture = Some(capture);
612 }
613
614 pub fn record_publisher_outcome(&mut self, outcome: crate::PublisherOutcome) {
622 self.pending_outcome = Some(outcome);
623 }
624
625 pub fn take_pending_outcome(&mut self) -> Option<crate::PublisherOutcome> {
629 self.pending_outcome.take()
630 }
631
632 pub fn record_pending_evidence(&mut self, evidence: crate::PublishEvidence) {
637 self.pending_evidence = Some(evidence);
638 }
639
640 pub fn take_pending_evidence(&mut self) -> Option<crate::PublishEvidence> {
644 self.pending_evidence.take()
645 }
646
647 pub fn publish_report(&self) -> Option<&PublishReport> {
650 self.publish_report.as_ref()
651 }
652
653 pub fn publish_attempted(&self) -> bool {
656 self.publish_attempted
657 }
658
659 pub fn set_publish_attempted(&mut self) {
663 self.publish_attempted = true;
664 }
665
666 pub fn set_publish_report(&mut self, r: PublishReport) {
672 self.publish_report = Some(r);
673 }
674
675 pub fn built_crate_names(&self) -> Option<&std::collections::HashSet<String>> {
678 self.built_crate_names.as_ref()
679 }
680
681 pub fn set_built_crate_names(&mut self, names: std::collections::HashSet<String>) {
684 self.built_crate_names = Some(names);
685 }
686
687 pub fn remember_skip(&self, stage: &str, label: &str, reason: &str) {
694 self.skip_memento.remember(stage, label, reason);
695 }
696
697 pub fn template_vars(&self) -> &TemplateVars {
698 &self.template_vars
699 }
700
701 pub fn template_vars_mut(&mut self) -> &mut TemplateVars {
702 &mut self.template_vars
703 }
704
705 pub fn render_template(&self, template: &str) -> anyhow::Result<String> {
706 crate::template::render(template, &self.template_vars)
707 }
708
709 pub fn render_template_opt(&self, template: Option<&str>) -> anyhow::Result<Option<String>> {
711 template.map(|t| self.render_template(t)).transpose()
712 }
713
714 pub fn skip_with_log(
723 &self,
724 skip: &Option<crate::config::StringOrBool>,
725 log: &StageLogger,
726 label: &str,
727 ) -> anyhow::Result<bool> {
728 let Some(d) = skip else {
729 return Ok(false);
730 };
731 let should_skip = d
732 .try_evaluates_to_true(|s| self.render_template(s))
733 .with_context(|| format!("evaluate skip expression for {label}"))?;
734 if should_skip {
735 log.status(&format!("{} skipped", label));
736 }
737 Ok(should_skip)
738 }
739
740 pub fn should_skip(&self, stage_name: &str) -> bool {
743 self.options.skip_stages.iter().any(|s| s == stage_name)
744 }
745
746 pub fn publisher_deselected(&self, name: &str) -> bool {
760 self.should_skip(name)
761 || (!self.options.publisher_allowlist.is_empty()
762 && !self.options.publisher_allowlist.iter().any(|s| s == name))
763 }
764
765 pub fn deselected_reason(&self, name: &str) -> String {
776 let reason = if self.should_skip(name) {
777 "excluded via --skip"
778 } else {
779 "not in --publishers allowlist"
780 };
781 format!("skipped {name} — {reason}")
782 }
783
784 pub fn skip_validate(&self) -> bool {
786 self.should_skip("validate")
787 }
788
789 pub fn is_dry_run(&self) -> bool {
790 self.options.dry_run
791 }
792
793 pub fn is_snapshot(&self) -> bool {
794 self.options.snapshot
795 }
796
797 pub fn is_publish_only(&self) -> bool {
805 self.options.publish_only
806 }
807
808 pub fn is_strict(&self) -> bool {
809 self.options.strict
810 }
811
812 pub fn set_render_strict(&self, on: bool) -> bool {
820 self.render_strict.replace(on)
821 }
822
823 pub fn render_is_strict(&self) -> bool {
830 self.render_strict.get() || self.is_strict()
831 }
832
833 pub fn strict_guard(&self, log: &crate::log::StageLogger, msg: &str) -> anyhow::Result<()> {
836 if self.options.strict {
837 anyhow::bail!("{} (strict mode)", msg);
838 }
839 log.warn(msg);
840 Ok(())
841 }
842
843 pub fn skip_in_snapshot(&self, log: &crate::log::StageLogger, stage: &str) -> bool {
852 if self.is_snapshot() {
853 log.status(&format!("skipped {stage} — snapshot mode"));
857 true
858 } else {
859 false
860 }
861 }
862
863 pub fn render_template_strict(
865 &self,
866 template: &str,
867 label: &str,
868 log: &crate::log::StageLogger,
869 ) -> anyhow::Result<String> {
870 match self.render_template(template) {
871 Ok(rendered) => Ok(rendered),
872 Err(e) => {
873 if self.options.strict {
874 anyhow::bail!("{}: failed to render template: {} (strict mode)", label, e);
875 }
876 log.warn(&format!("failed to render template for {}: {}", label, e));
877 Ok(template.to_string())
878 }
879 }
880 }
881
882 pub fn is_nightly(&self) -> bool {
883 self.options.nightly
884 }
885
886 pub fn set_release_url(&mut self, url: &str) {
891 self.template_vars.set("ReleaseURL", url);
892 }
893
894 pub fn version(&self) -> String {
897 self.template_vars
898 .get("Version")
899 .cloned()
900 .unwrap_or_default()
901 }
902
903 pub fn verbosity(&self) -> Verbosity {
905 Verbosity::from_flags(self.options.quiet, self.options.verbose, self.options.debug)
906 }
907
908 pub fn retry_policy(&self) -> crate::retry::RetryPolicy {
914 self.config.retry.unwrap_or_default().to_policy()
915 }
916
917 pub fn logger(&self, stage: &'static str) -> StageLogger {
925 #[allow(unused_mut)]
926 let mut log = StageLogger::new(stage, self.verbosity()).with_env(self.env_for_redact());
927 #[cfg(feature = "test-helpers")]
928 if let Some(cap) = &self.log_capture {
929 log = log.with_capture_handle(cap.clone());
930 }
931 log
932 }
933
934 fn env_for_redact(&self) -> Vec<(String, String)> {
945 use std::collections::HashMap;
946 let mut map: HashMap<String, String> = self
947 .process_env_cache
948 .get_or_init(|| std::env::vars().collect())
949 .clone();
950 for (k, v) in self.template_vars.all_env() {
951 map.insert(k.clone(), v.clone());
952 }
953 map.into_iter().collect()
954 }
955
956 pub fn populate_git_vars(&mut self) {
998 if let Some(ref info) = self.git_info {
999 let raw_version = info.semver.raw_version_string();
1001
1002 let version = info.semver.version_string();
1008
1009 self.template_vars.set("Tag", &info.tag);
1010 self.template_vars.set("Version", &version);
1011 self.template_vars.set("RawVersion", &raw_version);
1012 self.template_vars.set("Base", &raw_version);
1017 self.template_vars
1018 .set("Major", &info.semver.major.to_string());
1019 self.template_vars
1020 .set("Minor", &info.semver.minor.to_string());
1021 self.template_vars
1022 .set("Patch", &info.semver.patch.to_string());
1023 self.template_vars.set(
1024 "Prerelease",
1025 info.semver.prerelease.as_deref().unwrap_or(""),
1026 );
1027 self.template_vars.set(
1028 "BuildMetadata",
1029 info.semver.build_metadata.as_deref().unwrap_or(""),
1030 );
1031 self.template_vars.set("FullCommit", &info.commit);
1032 self.template_vars.set("Commit", &info.commit);
1033 self.template_vars.set("ShortCommit", &info.short_commit);
1034 self.template_vars.set("Branch", &info.branch);
1035 self.template_vars.set("CommitDate", &info.commit_date);
1036 self.template_vars
1037 .set("CommitTimestamp", &info.commit_timestamp);
1038 self.template_vars.set_bool("IsGitDirty", info.dirty);
1039 self.template_vars.set_bool("IsGitClean", !info.dirty);
1040 self.template_vars
1041 .set("GitTreeState", if info.dirty { "dirty" } else { "clean" });
1042 self.template_vars.set("GitURL", &info.remote_url);
1043 self.template_vars.set("Summary", &info.summary);
1044 self.template_vars.set("TagSubject", &info.tag_subject);
1045 self.template_vars.set("TagContents", &info.tag_contents);
1046 self.template_vars.set("TagBody", &info.tag_body);
1047 self.template_vars
1048 .set("PreviousTag", info.previous_tag.as_deref().unwrap_or(""));
1049 self.template_vars
1050 .set("FirstCommit", info.first_commit.as_deref().unwrap_or(""));
1051
1052 let monorepo_prefix = self.config.monorepo_tag_prefix();
1063
1064 if let Some(prefix) = monorepo_prefix {
1070 self.template_vars.set("PrefixedTag", &info.tag);
1073
1074 let stripped_tag = crate::git::strip_monorepo_prefix(&info.tag, prefix);
1076 self.template_vars.set("Tag", stripped_tag);
1077
1078 let version = info.semver.version_string();
1088 self.template_vars.set("Version", &version);
1089
1090 let prev_tag = info.previous_tag.as_deref().unwrap_or("");
1092 self.template_vars.set("PrefixedPreviousTag", prev_tag);
1093
1094 let stripped_prev = crate::git::strip_monorepo_prefix(prev_tag, prefix);
1096 self.template_vars.set("PreviousTag", stripped_prev);
1097
1098 self.template_vars.set("PrefixedSummary", &info.summary);
1102 let stripped_summary = crate::git::strip_monorepo_prefix(&info.summary, prefix);
1104 self.template_vars.set("Summary", stripped_summary);
1105 } else {
1106 let tag_prefix = self
1108 .config
1109 .tag
1110 .as_ref()
1111 .and_then(|t| t.tag_prefix.as_deref())
1112 .unwrap_or("");
1113 self.template_vars
1114 .set("PrefixedTag", &format!("{}{}", tag_prefix, info.tag));
1115 let prev_tag = info.previous_tag.as_deref().unwrap_or("");
1116 let prefixed_prev = if prev_tag.is_empty() {
1117 String::new()
1118 } else {
1119 format!("{}{}", tag_prefix, prev_tag)
1120 };
1121 self.template_vars
1122 .set("PrefixedPreviousTag", &prefixed_prev);
1123 self.template_vars.set(
1124 "PrefixedSummary",
1125 &format!("{}{}", tag_prefix, info.summary),
1126 );
1127 }
1128 }
1129
1130 let nightly_build = if self.git_info.is_some() {
1143 let root = self
1144 .options
1145 .project_root
1146 .clone()
1147 .unwrap_or_else(|| PathBuf::from("."));
1148 let monorepo_prefix = self.config.monorepo_tag_prefix();
1149 crate::git::count_commits_since_last_tag_in(&root, monorepo_prefix).unwrap_or(0)
1150 } else {
1151 0
1152 };
1153 self.template_vars
1154 .set_structured("NightlyBuild", tera::Value::from(nightly_build));
1155
1156 self.template_vars
1161 .set_bool("IsSnapshot", self.options.snapshot);
1162 self.template_vars
1163 .set_bool("IsNightly", self.options.nightly);
1164 self.template_vars.set_bool(
1168 "IsHarness",
1169 self.env_var("ANODIZER_IN_DETERMINISM_HARNESS").is_some(),
1170 );
1171 let is_draft = self
1173 .config
1174 .release
1175 .as_ref()
1176 .and_then(|r| r.draft)
1177 .unwrap_or(false);
1178 self.template_vars.set_bool("IsDraft", is_draft);
1179 self.template_vars
1180 .set_bool("IsSingleTarget", self.options.single_target.is_some());
1181
1182 let is_release = !self.options.snapshot && !self.options.nightly;
1184 self.template_vars.set_bool("IsRelease", is_release);
1185
1186 self.template_vars.set_bool("IsMerging", self.options.merge);
1188 }
1189
1190 pub fn populate_time_vars(&mut self) {
1218 let now = crate::sde::resolve_now_with_env(self.env_source());
1226 self.template_vars.set("Date", &now.to_rfc3339());
1227 self.template_vars
1228 .set("Timestamp", &now.timestamp().to_string());
1229 self.template_vars.set("Now", &now.to_rfc3339());
1230 self.template_vars
1231 .set("Year", &now.format("%Y").to_string());
1232 self.template_vars
1233 .set("Month", &now.format("%m").to_string());
1234 self.template_vars.set("Day", &now.format("%d").to_string());
1235 self.template_vars
1236 .set("Hour", &now.format("%H").to_string());
1237 self.template_vars
1238 .set("Minute", &now.format("%M").to_string());
1239 }
1240
1241 pub fn populate_runtime_vars(&mut self) {
1250 let goos = map_os_to_goos(std::env::consts::OS);
1251 let goarch = map_arch_to_goarch(std::env::consts::ARCH);
1252 self.template_vars.set("RuntimeGoos", goos);
1253 self.template_vars.set("RuntimeGoarch", goarch);
1254 self.template_vars.set("Runtime_Goos", goos);
1257 self.template_vars.set("Runtime_Goarch", goarch);
1258 self.populate_rustc_vars();
1262 }
1263
1264 fn populate_rustc_vars(&mut self) {
1271 let ver = crate::partial::detect_rustc_version().unwrap_or_default();
1272 self.template_vars.set("RustcVersion", &ver);
1273 }
1274
1275 pub fn populate_release_notes_var(&mut self) {
1283 let notes = self
1285 .config
1286 .crates
1287 .iter()
1288 .find_map(|c| self.stage_outputs.changelogs.get(&c.name))
1289 .cloned()
1290 .unwrap_or_default();
1291 self.template_vars.set("ReleaseNotes", ¬es);
1292 }
1293
1294 pub fn refresh_artifacts_var(&mut self) {
1309 const CSV_LIST_KEYS: &[&str] = &["extra_binaries", "extra_files"];
1314 const JSON_LIST_KEYS: &[&str] = &["Platforms"];
1320
1321 let artifacts_value: Vec<serde_json::Value> = self
1322 .artifacts
1323 .all()
1324 .iter()
1325 .map(|a| {
1326 let mut metadata_map = serde_json::Map::with_capacity(a.metadata.len());
1328 for (k, v) in &a.metadata {
1329 if CSV_LIST_KEYS.contains(&k.as_str()) {
1330 let items: Vec<serde_json::Value> = if v.is_empty() {
1331 Vec::new()
1332 } else {
1333 v.split(',')
1334 .map(|s| serde_json::Value::String(s.to_string()))
1335 .collect()
1336 };
1337 metadata_map.insert(k.clone(), serde_json::Value::Array(items));
1338 } else if JSON_LIST_KEYS.contains(&k.as_str()) {
1339 let parsed = serde_json::from_str::<serde_json::Value>(v)
1343 .unwrap_or_else(|_| serde_json::Value::String(v.clone()));
1344 metadata_map.insert(k.clone(), parsed);
1345 } else {
1346 metadata_map.insert(k.clone(), serde_json::Value::String(v.clone()));
1347 }
1348 }
1349 serde_json::json!({
1350 "name": a.name,
1351 "path": a.path.to_string_lossy(),
1352 "target": a.target.as_deref().unwrap_or(""),
1353 "kind": a.kind.as_str(),
1354 "crate_name": a.crate_name,
1355 "metadata": serde_json::Value::Object(metadata_map),
1356 })
1357 })
1358 .collect();
1359 let tera_value = tera::Value::Array(artifacts_value);
1362 self.template_vars.set_structured("Artifacts", tera_value);
1363 }
1364
1365 pub fn populate_metadata_var(&mut self) -> anyhow::Result<()> {
1379 let (
1382 description,
1383 homepage,
1384 documentation,
1385 license,
1386 repository,
1387 maintainers,
1388 mod_timestamp,
1389 full_desc_src,
1390 commit_author,
1391 ) = {
1392 let meta = self.config.metadata.as_ref();
1393 let description = self
1400 .config
1401 .meta_description_project()
1402 .unwrap_or("")
1403 .to_string();
1404 let homepage = self
1405 .config
1406 .meta_homepage_project()
1407 .unwrap_or("")
1408 .to_string();
1409 let documentation = self
1410 .config
1411 .meta_documentation_project()
1412 .unwrap_or("")
1413 .to_string();
1414 let license = self.config.meta_license_project().unwrap_or("").to_string();
1415 let repository = self
1416 .config
1417 .meta_repository_project()
1418 .unwrap_or("")
1419 .to_string();
1420 let maintainers: Vec<String> = meta
1421 .and_then(|m| m.maintainers.as_ref())
1422 .cloned()
1423 .unwrap_or_default();
1424 let mod_timestamp = meta
1425 .and_then(|m| m.mod_timestamp.as_deref())
1426 .unwrap_or("")
1427 .to_string();
1428 let full_desc_src = meta.and_then(|m| m.full_description.clone());
1429 let commit_author = meta.and_then(|m| m.commit_author.clone());
1430 (
1431 description,
1432 homepage,
1433 documentation,
1434 license,
1435 repository,
1436 maintainers,
1437 mod_timestamp,
1438 full_desc_src,
1439 commit_author,
1440 )
1441 };
1442
1443 let full_description = match full_desc_src {
1449 None => String::new(),
1450 Some(src) => crate::content_source::resolve(&src, "metadata.full_description", self)?,
1451 };
1452
1453 let commit_author_map = serde_json::json!({
1454 "Name": commit_author.as_ref().and_then(|c| c.name.clone()).unwrap_or_default(),
1455 "Email": commit_author.as_ref().and_then(|c| c.email.clone()).unwrap_or_default(),
1456 });
1457
1458 let meta_map = serde_json::json!({
1459 "Description": description,
1460 "Homepage": homepage,
1461 "Documentation": documentation,
1462 "License": license,
1463 "Repository": repository,
1464 "Maintainers": maintainers,
1465 "ModTimestamp": mod_timestamp,
1466 "FullDescription": full_description,
1467 "CommitAuthor": commit_author_map,
1468 });
1469 self.template_vars.set_structured("Metadata", meta_map);
1471 Ok(())
1472 }
1473}
1474
1475pub fn map_os_to_goos(os: &str) -> &str {
1478 match os {
1479 "macos" => "darwin",
1480 other => other, }
1482}
1483
1484pub fn map_arch_to_goarch(arch: &str) -> &str {
1487 match arch {
1488 "x86_64" => "amd64",
1489 "x86" => "386",
1490 "aarch64" => "arm64",
1491 "powerpc64" => "ppc64",
1492 "s390x" => "s390x",
1493 "mips" => "mips",
1494 "mips64" => "mips64",
1495 "riscv64" => "riscv64",
1496 other => other,
1497 }
1498}
1499
1500#[cfg(test)]
1501#[allow(clippy::field_reassign_with_default)]
1502mod tests {
1503 use super::*;
1504 use crate::config::Config;
1505 use crate::git::{GitInfo, SemVer};
1506
1507 fn make_git_info(dirty: bool, prerelease: Option<&str>) -> GitInfo {
1508 let tag = match prerelease {
1509 Some(pre) => format!("v1.2.3-{pre}"),
1510 None => "v1.2.3".to_string(),
1511 };
1512 GitInfo {
1513 tag,
1514 commit: "abc123def456abc123def456abc123def456abc1".to_string(),
1515 short_commit: "abc123d".to_string(),
1516 branch: "main".to_string(),
1517 dirty,
1518 semver: SemVer {
1519 major: 1,
1520 minor: 2,
1521 patch: 3,
1522 prerelease: prerelease.map(|s| s.to_string()),
1523 build_metadata: None,
1524 },
1525 commit_date: "2026-03-25T10:30:00+00:00".to_string(),
1526 commit_timestamp: "1774463400".to_string(),
1527 previous_tag: Some("v1.2.2".to_string()),
1528 remote_url: "https://github.com/test/repo.git".to_string(),
1529 summary: "v1.2.3-0-gabc123d".to_string(),
1530 tag_subject: "Release v1.2.3".to_string(),
1531 tag_contents: "Release v1.2.3\n\nFull release notes here.".to_string(),
1532 tag_body: "Full release notes here.".to_string(),
1533 first_commit: None,
1534 }
1535 }
1536
1537 #[test]
1538 fn test_context_template_vars() {
1539 let mut config = Config::default();
1540 config.project_name = "test-project".to_string();
1541 let ctx = Context::new(config, ContextOptions::default());
1542 assert_eq!(
1543 ctx.template_vars().get("ProjectName"),
1544 Some(&"test-project".to_string())
1545 );
1546 }
1547
1548 #[test]
1549 fn test_context_should_skip() {
1550 let config = Config::default();
1551 let opts = ContextOptions {
1552 skip_stages: vec!["publish".to_string(), "announce".to_string()],
1553 ..Default::default()
1554 };
1555 let ctx = Context::new(config, opts);
1556 assert!(ctx.should_skip("publish"));
1557 assert!(ctx.should_skip("announce"));
1558 assert!(!ctx.should_skip("build"));
1559 }
1560
1561 #[test]
1562 fn publisher_deselected_empty_selectors_runs_everything() {
1563 let ctx = Context::new(Config::default(), ContextOptions::default());
1564 assert!(!ctx.publisher_deselected("npm"));
1565 assert!(!ctx.publisher_deselected("cargo"));
1566 assert!(!ctx.publisher_deselected("anything"));
1567 }
1568
1569 #[test]
1570 fn publisher_deselected_skip_denylists() {
1571 let opts = ContextOptions {
1572 skip_stages: vec!["npm".to_string()],
1573 ..Default::default()
1574 };
1575 let ctx = Context::new(Config::default(), opts);
1576 assert!(ctx.publisher_deselected("npm"));
1577 assert!(!ctx.publisher_deselected("cargo"));
1578 }
1579
1580 #[test]
1581 fn publisher_deselected_allowlist_excludes_unlisted() {
1582 let opts = ContextOptions {
1583 publisher_allowlist: vec!["cargo".to_string()],
1584 ..Default::default()
1585 };
1586 let ctx = Context::new(Config::default(), opts);
1587 assert!(!ctx.publisher_deselected("cargo"));
1588 assert!(ctx.publisher_deselected("npm"));
1589 }
1590
1591 #[test]
1592 fn publisher_deselected_skip_wins_over_allowlist() {
1593 let opts = ContextOptions {
1594 skip_stages: vec!["cargo".to_string()],
1595 publisher_allowlist: vec!["cargo".to_string()],
1596 ..Default::default()
1597 };
1598 let ctx = Context::new(Config::default(), opts);
1599 assert!(ctx.publisher_deselected("cargo"));
1600 }
1601
1602 #[test]
1603 fn test_context_render_template() {
1604 let mut config = Config::default();
1605 config.project_name = "myapp".to_string();
1606 let ctx = Context::new(config, ContextOptions::default());
1607 let result = ctx.render_template("{{ .ProjectName }}-release").unwrap();
1608 assert_eq!(result, "myapp-release");
1609 }
1610
1611 #[test]
1612 fn test_populate_git_vars_sets_all_expected_vars() {
1613 let config = Config::default();
1614 let mut ctx = Context::new(config, ContextOptions::default());
1615 ctx.git_info = Some(make_git_info(false, None));
1616 ctx.populate_git_vars();
1617
1618 let v = ctx.template_vars();
1619 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
1620 assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
1621 assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
1622 assert_eq!(v.get("Major"), Some(&"1".to_string()));
1623 assert_eq!(v.get("Minor"), Some(&"2".to_string()));
1624 assert_eq!(v.get("Patch"), Some(&"3".to_string()));
1625 assert_eq!(v.get("Prerelease"), Some(&"".to_string()));
1626 assert_eq!(
1627 v.get("FullCommit"),
1628 Some(&"abc123def456abc123def456abc123def456abc1".to_string())
1629 );
1630 assert_eq!(v.get("ShortCommit"), Some(&"abc123d".to_string()));
1631 assert_eq!(v.get("Branch"), Some(&"main".to_string()));
1632 assert_eq!(
1633 v.get("CommitDate"),
1634 Some(&"2026-03-25T10:30:00+00:00".to_string())
1635 );
1636 assert_eq!(v.get("CommitTimestamp"), Some(&"1774463400".to_string()));
1637 assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
1638 assert_eq!(v.get("Base"), Some(&"1.2.3".to_string()));
1641 }
1642
1643 #[test]
1644 fn test_nightly_build_defaults_to_zero_without_git_info() {
1645 let config = Config::default();
1648 let mut ctx = Context::new(config, ContextOptions::default());
1649 ctx.git_info = None;
1650 ctx.populate_git_vars();
1651 assert_eq!(
1652 ctx.template_vars().get_structured("NightlyBuild"),
1653 Some(&tera::Value::from(0u64))
1654 );
1655 }
1656
1657 #[test]
1658 fn test_commit_is_alias_for_full_commit() {
1659 let config = Config::default();
1660 let mut ctx = Context::new(config, ContextOptions::default());
1661 ctx.git_info = Some(make_git_info(false, None));
1662 ctx.populate_git_vars();
1663
1664 let v = ctx.template_vars();
1665 assert_eq!(v.get("Commit"), v.get("FullCommit"));
1666 }
1667
1668 #[test]
1669 fn test_populate_git_vars_prerelease() {
1670 let config = Config::default();
1671 let mut ctx = Context::new(config, ContextOptions::default());
1672 ctx.git_info = Some(make_git_info(false, Some("rc.1")));
1673 ctx.populate_git_vars();
1674
1675 let v = ctx.template_vars();
1676 assert_eq!(v.get("Version"), Some(&"1.2.3-rc.1".to_string()));
1677 assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
1678 assert_eq!(v.get("Prerelease"), Some(&"rc.1".to_string()));
1679 }
1680
1681 #[test]
1682 fn test_build_metadata_template_var() {
1683 let config = Config::default();
1684 let mut ctx = Context::new(config, ContextOptions::default());
1685 let mut info = make_git_info(false, None);
1686 info.tag = "v1.2.3+build.42".to_string();
1687 info.semver.build_metadata = Some("build.42".to_string());
1688 ctx.git_info = Some(info);
1689 ctx.populate_git_vars();
1690
1691 let v = ctx.template_vars();
1692 assert_eq!(v.get("BuildMetadata"), Some(&"build.42".to_string()));
1693 assert_eq!(v.get("Version"), Some(&"1.2.3+build.42".to_string()));
1695 }
1696
1697 #[test]
1698 fn test_build_metadata_empty_when_none() {
1699 let config = Config::default();
1700 let mut ctx = Context::new(config, ContextOptions::default());
1701 ctx.git_info = Some(make_git_info(false, None));
1702 ctx.populate_git_vars();
1703
1704 assert_eq!(
1705 ctx.template_vars().get("BuildMetadata"),
1706 Some(&"".to_string())
1707 );
1708 }
1709
1710 #[test]
1711 fn test_populate_git_vars_monorepo_prefixed_tag() {
1712 let config = Config::default();
1715 let mut ctx = Context::new(config, ContextOptions::default());
1716 let mut info = make_git_info(false, None);
1717 info.tag = "core-v0.3.2".to_string();
1718 info.semver = SemVer {
1719 major: 0,
1720 minor: 3,
1721 patch: 2,
1722 prerelease: None,
1723 build_metadata: None,
1724 };
1725 ctx.git_info = Some(info);
1726 ctx.populate_git_vars();
1727
1728 let v = ctx.template_vars();
1729 assert_eq!(v.get("Tag"), Some(&"core-v0.3.2".to_string()));
1730 assert_eq!(v.get("Version"), Some(&"0.3.2".to_string()));
1731 assert_eq!(v.get("RawVersion"), Some(&"0.3.2".to_string()));
1732 assert_eq!(v.get("Major"), Some(&"0".to_string()));
1733 assert_eq!(v.get("Minor"), Some(&"3".to_string()));
1734 assert_eq!(v.get("Patch"), Some(&"2".to_string()));
1735 }
1736
1737 #[test]
1738 fn test_populate_git_vars_monorepo_prefixed_tag_with_prerelease() {
1739 let config = Config::default();
1740 let mut ctx = Context::new(config, ContextOptions::default());
1741 let mut info = make_git_info(false, None);
1742 info.tag = "operator-v1.0.0-rc.1".to_string();
1743 info.semver = SemVer {
1744 major: 1,
1745 minor: 0,
1746 patch: 0,
1747 prerelease: Some("rc.1".to_string()),
1748 build_metadata: None,
1749 };
1750 ctx.git_info = Some(info);
1751 ctx.populate_git_vars();
1752
1753 let v = ctx.template_vars();
1754 assert_eq!(v.get("Tag"), Some(&"operator-v1.0.0-rc.1".to_string()));
1755 assert_eq!(v.get("Version"), Some(&"1.0.0-rc.1".to_string()));
1756 assert_eq!(v.get("RawVersion"), Some(&"1.0.0".to_string()));
1757 }
1758
1759 #[test]
1760 fn test_git_tree_state_clean() {
1761 let config = Config::default();
1762 let mut ctx = Context::new(config, ContextOptions::default());
1763 ctx.git_info = Some(make_git_info(false, None));
1764 ctx.populate_git_vars();
1765
1766 let v = ctx.template_vars();
1767 assert_eq!(
1768 v.get_structured("IsGitDirty"),
1769 Some(&tera::Value::Bool(false))
1770 );
1771 assert_eq!(v.get("GitTreeState"), Some(&"clean".to_string()));
1772 }
1773
1774 #[test]
1775 fn test_git_tree_state_dirty() {
1776 let config = Config::default();
1777 let mut ctx = Context::new(config, ContextOptions::default());
1778 ctx.git_info = Some(make_git_info(true, None));
1779 ctx.populate_git_vars();
1780
1781 let v = ctx.template_vars();
1782 assert_eq!(
1783 v.get_structured("IsGitDirty"),
1784 Some(&tera::Value::Bool(true))
1785 );
1786 assert_eq!(v.get("GitTreeState"), Some(&"dirty".to_string()));
1787 }
1788
1789 #[test]
1790 fn test_is_snapshot_reflects_context_options() {
1791 let config = Config::default();
1792 let opts = ContextOptions {
1793 snapshot: true,
1794 ..Default::default()
1795 };
1796 let mut ctx = Context::new(config, opts);
1797 ctx.git_info = Some(make_git_info(false, None));
1798 ctx.populate_git_vars();
1799
1800 assert_eq!(
1801 ctx.template_vars().get_structured("IsSnapshot"),
1802 Some(&tera::Value::Bool(true))
1803 );
1804
1805 let config2 = Config::default();
1807 let opts2 = ContextOptions {
1808 snapshot: false,
1809 ..Default::default()
1810 };
1811 let mut ctx2 = Context::new(config2, opts2);
1812 ctx2.git_info = Some(make_git_info(false, None));
1813 ctx2.populate_git_vars();
1814
1815 assert_eq!(
1816 ctx2.template_vars().get_structured("IsSnapshot"),
1817 Some(&tera::Value::Bool(false))
1818 );
1819 }
1820
1821 #[test]
1822 fn test_is_draft_defaults_to_false() {
1823 let config = Config::default();
1824 let mut ctx = Context::new(config, ContextOptions::default());
1825 ctx.git_info = Some(make_git_info(false, None));
1826 ctx.populate_git_vars();
1827
1828 assert_eq!(
1829 ctx.template_vars().get_structured("IsDraft"),
1830 Some(&tera::Value::Bool(false))
1831 );
1832 }
1833
1834 #[test]
1835 fn test_previous_tag_empty_when_none() {
1836 let config = Config::default();
1837 let mut ctx = Context::new(config, ContextOptions::default());
1838 let mut info = make_git_info(false, None);
1839 info.previous_tag = None;
1840 ctx.git_info = Some(info);
1841 ctx.populate_git_vars();
1842
1843 assert_eq!(
1844 ctx.template_vars().get("PreviousTag"),
1845 Some(&"".to_string())
1846 );
1847 }
1848
1849 #[test]
1858 fn populate_time_vars_uses_source_date_epoch_when_set() {
1859 let env = crate::MapEnvSource::new().with("SOURCE_DATE_EPOCH", "1715000000");
1863 let config = Config::default();
1864 let mut ctx = Context::new(config, ContextOptions::default());
1865 ctx.set_env_source(env);
1866 ctx.populate_time_vars();
1867
1868 let v = ctx.template_vars();
1869 assert_eq!(
1870 v.get("Timestamp"),
1871 Some(&"1715000000".to_string()),
1872 "Timestamp must equal SOURCE_DATE_EPOCH seconds"
1873 );
1874 assert_eq!(
1875 v.get("Date"),
1876 Some(&"2024-05-06T12:53:20+00:00".to_string()),
1877 "Date must be RFC 3339 derived from SDE"
1878 );
1879 assert_eq!(v.get("Year"), Some(&"2024".to_string()));
1880 assert_eq!(v.get("Month"), Some(&"05".to_string()));
1881 assert_eq!(v.get("Day"), Some(&"06".to_string()));
1882 }
1883
1884 #[test]
1885 fn test_populate_time_vars() {
1886 let env = crate::MapEnvSource::new();
1889 let config = Config::default();
1890 let mut ctx = Context::new(config, ContextOptions::default());
1891 ctx.set_env_source(env);
1892 ctx.populate_time_vars();
1893
1894 let v = ctx.template_vars();
1895
1896 let date = v
1898 .get("Date")
1899 .unwrap_or_else(|| panic!("Date should be set"));
1900 assert!(
1901 date.contains('T') && date.len() > 10,
1902 "Date should be RFC 3339, got: {date}"
1903 );
1904
1905 let ts = v
1907 .get("Timestamp")
1908 .unwrap_or_else(|| panic!("Timestamp should be set"));
1909 assert!(
1910 ts.parse::<i64>().is_ok(),
1911 "Timestamp should be a numeric string, got: {ts}"
1912 );
1913
1914 let now = v.get("Now").unwrap_or_else(|| panic!("Now should be set"));
1916 assert!(now.contains('T'), "Now should be ISO 8601, got: {now}");
1917 }
1918
1919 #[test]
1920 fn test_env_vars_accessible_in_templates() {
1921 let mut config = Config::default();
1922 config.project_name = "myapp".to_string();
1923 let mut ctx = Context::new(config, ContextOptions::default());
1924 ctx.template_vars_mut().set_env("MY_VAR", "hello-world");
1925 ctx.template_vars_mut().set_env("DEPLOY_ENV", "staging");
1926
1927 let result = ctx
1928 .render_template("{{ .Env.MY_VAR }}-{{ .Env.DEPLOY_ENV }}")
1929 .unwrap();
1930 assert_eq!(result, "hello-world-staging");
1931 }
1932
1933 #[test]
1934 fn test_populate_git_vars_without_git_info_still_sets_snapshot() {
1935 let config = Config::default();
1936 let opts = ContextOptions {
1937 snapshot: true,
1938 ..Default::default()
1939 };
1940 let mut ctx = Context::new(config, opts);
1941 ctx.populate_git_vars();
1943
1944 assert_eq!(
1945 ctx.template_vars().get_structured("IsSnapshot"),
1946 Some(&tera::Value::Bool(true))
1947 );
1948 assert_eq!(
1949 ctx.template_vars().get_structured("IsDraft"),
1950 Some(&tera::Value::Bool(false))
1951 );
1952 assert_eq!(ctx.template_vars().get("Tag"), None);
1954 }
1955
1956 #[test]
1957 fn test_is_nightly_set_when_nightly_mode_active() {
1958 let config = Config::default();
1959 let opts = ContextOptions {
1960 nightly: true,
1961 ..Default::default()
1962 };
1963 let mut ctx = Context::new(config, opts);
1964 ctx.git_info = Some(make_git_info(false, None));
1965 ctx.populate_git_vars();
1966
1967 assert_eq!(
1968 ctx.template_vars().get_structured("IsNightly"),
1969 Some(&tera::Value::Bool(true)),
1970 "IsNightly should be 'true' when nightly mode is active"
1971 );
1972 assert!(ctx.is_nightly(), "is_nightly() should return true");
1973 }
1974
1975 #[test]
1976 fn test_is_nightly_false_by_default() {
1977 let config = Config::default();
1978 let mut ctx = Context::new(config, ContextOptions::default());
1979 ctx.git_info = Some(make_git_info(false, None));
1980 ctx.populate_git_vars();
1981
1982 assert_eq!(
1983 ctx.template_vars().get_structured("IsNightly"),
1984 Some(&tera::Value::Bool(false)),
1985 "IsNightly should default to 'false'"
1986 );
1987 assert!(
1988 !ctx.is_nightly(),
1989 "is_nightly() should return false by default"
1990 );
1991 }
1992
1993 #[test]
1994 fn test_version_returns_populated_value() {
1995 let config = Config::default();
1996 let mut ctx = Context::new(config, ContextOptions::default());
1997 ctx.git_info = Some(make_git_info(false, None));
1998 ctx.populate_git_vars();
1999
2000 assert_eq!(ctx.version(), "1.2.3");
2001 }
2002
2003 #[test]
2004 fn test_version_returns_empty_when_not_set() {
2005 let config = Config::default();
2006 let ctx = Context::new(config, ContextOptions::default());
2007 assert_eq!(ctx.version(), "");
2008 }
2009
2010 #[test]
2011 fn test_is_nightly_without_git_info() {
2012 let config = Config::default();
2013 let opts = ContextOptions {
2014 nightly: true,
2015 ..Default::default()
2016 };
2017 let mut ctx = Context::new(config, opts);
2018 ctx.populate_git_vars();
2020
2021 assert_eq!(
2022 ctx.template_vars().get_structured("IsNightly"),
2023 Some(&tera::Value::Bool(true)),
2024 "IsNightly should be set even without git info"
2025 );
2026 }
2027
2028 #[test]
2029 fn test_is_git_clean_when_not_dirty() {
2030 let config = Config::default();
2031 let mut ctx = Context::new(config, ContextOptions::default());
2032 ctx.git_info = Some(make_git_info(false, None));
2033 ctx.populate_git_vars();
2034
2035 assert_eq!(
2036 ctx.template_vars().get_structured("IsGitClean"),
2037 Some(&tera::Value::Bool(true))
2038 );
2039 }
2040
2041 #[test]
2042 fn test_is_git_clean_when_dirty() {
2043 let config = Config::default();
2044 let mut ctx = Context::new(config, ContextOptions::default());
2045 ctx.git_info = Some(make_git_info(true, None));
2046 ctx.populate_git_vars();
2047
2048 assert_eq!(
2049 ctx.template_vars().get_structured("IsGitClean"),
2050 Some(&tera::Value::Bool(false))
2051 );
2052 }
2053
2054 #[test]
2055 fn test_git_url_set_from_git_info() {
2056 let config = Config::default();
2057 let mut ctx = Context::new(config, ContextOptions::default());
2058 ctx.git_info = Some(make_git_info(false, None));
2059 ctx.populate_git_vars();
2060
2061 assert_eq!(
2062 ctx.template_vars().get("GitURL"),
2063 Some(&"https://github.com/test/repo.git".to_string())
2064 );
2065 }
2066
2067 #[test]
2068 fn test_summary_set_from_git_info() {
2069 let config = Config::default();
2070 let mut ctx = Context::new(config, ContextOptions::default());
2071 ctx.git_info = Some(make_git_info(false, None));
2072 ctx.populate_git_vars();
2073
2074 assert_eq!(
2075 ctx.template_vars().get("Summary"),
2076 Some(&"v1.2.3-0-gabc123d".to_string())
2077 );
2078 }
2079
2080 #[test]
2081 fn test_tag_subject_set_from_git_info() {
2082 let config = Config::default();
2083 let mut ctx = Context::new(config, ContextOptions::default());
2084 ctx.git_info = Some(make_git_info(false, None));
2085 ctx.populate_git_vars();
2086
2087 assert_eq!(
2088 ctx.template_vars().get("TagSubject"),
2089 Some(&"Release v1.2.3".to_string())
2090 );
2091 }
2092
2093 #[test]
2094 fn test_tag_contents_set_from_git_info() {
2095 let config = Config::default();
2096 let mut ctx = Context::new(config, ContextOptions::default());
2097 ctx.git_info = Some(make_git_info(false, None));
2098 ctx.populate_git_vars();
2099
2100 assert_eq!(
2101 ctx.template_vars().get("TagContents"),
2102 Some(&"Release v1.2.3\n\nFull release notes here.".to_string())
2103 );
2104 }
2105
2106 #[test]
2107 fn test_tag_body_set_from_git_info() {
2108 let config = Config::default();
2109 let mut ctx = Context::new(config, ContextOptions::default());
2110 ctx.git_info = Some(make_git_info(false, None));
2111 ctx.populate_git_vars();
2112
2113 assert_eq!(
2114 ctx.template_vars().get("TagBody"),
2115 Some(&"Full release notes here.".to_string())
2116 );
2117 }
2118
2119 #[test]
2120 fn test_is_single_target_false_by_default() {
2121 let config = Config::default();
2122 let mut ctx = Context::new(config, ContextOptions::default());
2123 ctx.git_info = Some(make_git_info(false, None));
2124 ctx.populate_git_vars();
2125
2126 assert_eq!(
2127 ctx.template_vars().get_structured("IsSingleTarget"),
2128 Some(&tera::Value::Bool(false))
2129 );
2130 }
2131
2132 #[test]
2133 fn test_is_single_target_true_when_set() {
2134 let config = Config::default();
2135 let opts = ContextOptions {
2136 single_target: Some("x86_64-unknown-linux-gnu".to_string()),
2137 ..Default::default()
2138 };
2139 let mut ctx = Context::new(config, opts);
2140 ctx.git_info = Some(make_git_info(false, None));
2141 ctx.populate_git_vars();
2142
2143 assert_eq!(
2144 ctx.template_vars().get_structured("IsSingleTarget"),
2145 Some(&tera::Value::Bool(true))
2146 );
2147 }
2148
2149 #[test]
2150 #[serial_test::serial]
2151 fn test_populate_runtime_vars() {
2152 let config = Config::default();
2153 let mut ctx = Context::new(config, ContextOptions::default());
2154 ctx.populate_runtime_vars();
2155
2156 let v = ctx.template_vars();
2157
2158 let goos = v
2159 .get("RuntimeGoos")
2160 .unwrap_or_else(|| panic!("RuntimeGoos should be set"));
2161 assert!(
2162 !goos.is_empty(),
2163 "RuntimeGoos should not be empty, got: {goos}"
2164 );
2165 assert_eq!(goos, map_os_to_goos(std::env::consts::OS));
2167
2168 let goarch = v
2169 .get("RuntimeGoarch")
2170 .unwrap_or_else(|| panic!("RuntimeGoarch should be set"));
2171 assert!(
2172 !goarch.is_empty(),
2173 "RuntimeGoarch should not be empty, got: {goarch}"
2174 );
2175 assert_eq!(goarch, map_arch_to_goarch(std::env::consts::ARCH));
2177 }
2178
2179 #[test]
2180 fn test_populate_release_notes_var_with_changelogs() {
2181 let mut config = Config::default();
2182 config.crates.push(crate::config::CrateConfig {
2183 name: "my-crate".to_string(),
2184 ..Default::default()
2185 });
2186 let mut ctx = Context::new(config, ContextOptions::default());
2187 ctx.stage_outputs
2188 .changelogs
2189 .insert("my-crate".to_string(), "## Changes\n- fix bug".to_string());
2190 ctx.populate_release_notes_var();
2191
2192 assert_eq!(
2193 ctx.template_vars().get("ReleaseNotes"),
2194 Some(&"## Changes\n- fix bug".to_string())
2195 );
2196 }
2197
2198 #[test]
2199 fn test_populate_release_notes_var_empty_when_no_changelogs() {
2200 let config = Config::default();
2201 let mut ctx = Context::new(config, ContextOptions::default());
2202 ctx.populate_release_notes_var();
2203
2204 assert_eq!(
2205 ctx.template_vars().get("ReleaseNotes"),
2206 Some(&"".to_string())
2207 );
2208 }
2209
2210 #[test]
2211 fn test_populate_release_notes_var_deterministic_with_multiple_crates() {
2212 let mut config = Config::default();
2213 config.crates.push(crate::config::CrateConfig {
2214 name: "crate-a".to_string(),
2215 ..Default::default()
2216 });
2217 config.crates.push(crate::config::CrateConfig {
2218 name: "crate-b".to_string(),
2219 ..Default::default()
2220 });
2221 let mut ctx = Context::new(config, ContextOptions::default());
2222 ctx.stage_outputs
2223 .changelogs
2224 .insert("crate-a".to_string(), "notes-a".to_string());
2225 ctx.stage_outputs
2226 .changelogs
2227 .insert("crate-b".to_string(), "notes-b".to_string());
2228 ctx.populate_release_notes_var();
2229
2230 assert_eq!(
2232 ctx.template_vars().get("ReleaseNotes"),
2233 Some(&"notes-a".to_string())
2234 );
2235 }
2236
2237 #[test]
2238 fn test_outputs_accessible_in_templates() {
2239 let mut config = Config::default();
2240 config.project_name = "myapp".to_string();
2241 let mut ctx = Context::new(config, ContextOptions::default());
2242 ctx.template_vars_mut().set_output("build_id", "abc123");
2243 ctx.template_vars_mut()
2244 .set_output("deploy_url", "https://example.com");
2245
2246 let result = ctx
2247 .render_template("{{ .Outputs.build_id }}-{{ .Outputs.deploy_url }}")
2248 .unwrap();
2249 assert_eq!(result, "abc123-https://example.com");
2250 }
2251
2252 #[test]
2253 fn test_artifact_ext_and_target_template_vars() {
2254 let mut config = Config::default();
2255 config.project_name = "myapp".to_string();
2256 let mut ctx = Context::new(config, ContextOptions::default());
2257 ctx.template_vars_mut().set("ArtifactName", "myapp.tar.gz");
2258 ctx.template_vars_mut().set("ArtifactExt", ".tar.gz");
2259 ctx.template_vars_mut()
2260 .set("Target", "x86_64-unknown-linux-gnu");
2261
2262 let result = ctx
2263 .render_template("{{ .ArtifactExt }}_{{ .Target }}")
2264 .unwrap();
2265 assert_eq!(result, ".tar.gz_x86_64-unknown-linux-gnu");
2266 }
2267
2268 #[test]
2269 fn test_checksums_template_var() {
2270 let mut config = Config::default();
2271 config.project_name = "myapp".to_string();
2272 let mut ctx = Context::new(config, ContextOptions::default());
2273 let checksum_text = "abc123 myapp.tar.gz\ndef456 myapp.zip\n";
2274 ctx.template_vars_mut().set("Checksums", checksum_text);
2275
2276 let result = ctx.render_template("{{ .Checksums }}").unwrap();
2277 assert_eq!(result, checksum_text);
2278 }
2279
2280 #[test]
2283 fn test_prefixed_tag_with_tag_prefix() {
2284 let mut config = Config::default();
2285 config.tag = Some(crate::config::TagConfig {
2286 tag_prefix: Some("api/".to_string()),
2287 ..Default::default()
2288 });
2289 let mut ctx = Context::new(config, ContextOptions::default());
2290 ctx.git_info = Some(make_git_info(false, None));
2291 ctx.populate_git_vars();
2292
2293 assert_eq!(
2294 ctx.template_vars().get("PrefixedTag"),
2295 Some(&"api/v1.2.3".to_string())
2296 );
2297 }
2298
2299 #[test]
2300 fn test_prefixed_tag_without_tag_prefix() {
2301 let config = Config::default();
2302 let mut ctx = Context::new(config, ContextOptions::default());
2303 ctx.git_info = Some(make_git_info(false, None));
2304 ctx.populate_git_vars();
2305
2306 assert_eq!(
2308 ctx.template_vars().get("PrefixedTag"),
2309 Some(&"v1.2.3".to_string())
2310 );
2311 }
2312
2313 #[test]
2314 fn test_prefixed_previous_tag_with_tag_prefix() {
2315 let mut config = Config::default();
2316 config.tag = Some(crate::config::TagConfig {
2317 tag_prefix: Some("api/".to_string()),
2318 ..Default::default()
2319 });
2320 let mut ctx = Context::new(config, ContextOptions::default());
2321 ctx.git_info = Some(make_git_info(false, None));
2322 ctx.populate_git_vars();
2323
2324 assert_eq!(
2325 ctx.template_vars().get("PrefixedPreviousTag"),
2326 Some(&"api/v1.2.2".to_string())
2327 );
2328 }
2329
2330 #[test]
2331 fn test_prefixed_previous_tag_empty_when_no_previous() {
2332 let mut config = Config::default();
2333 config.tag = Some(crate::config::TagConfig {
2334 tag_prefix: Some("api/".to_string()),
2335 ..Default::default()
2336 });
2337 let mut ctx = Context::new(config, ContextOptions::default());
2338 let mut info = make_git_info(false, None);
2339 info.previous_tag = None;
2340 ctx.git_info = Some(info);
2341 ctx.populate_git_vars();
2342
2343 assert_eq!(
2346 ctx.template_vars().get("PrefixedPreviousTag"),
2347 Some(&"".to_string())
2348 );
2349 }
2350
2351 #[test]
2352 fn test_prefixed_summary_with_tag_prefix() {
2353 let mut config = Config::default();
2354 config.tag = Some(crate::config::TagConfig {
2355 tag_prefix: Some("api/".to_string()),
2356 ..Default::default()
2357 });
2358 let mut ctx = Context::new(config, ContextOptions::default());
2359 ctx.git_info = Some(make_git_info(false, None));
2360 ctx.populate_git_vars();
2361
2362 assert_eq!(
2363 ctx.template_vars().get("PrefixedSummary"),
2364 Some(&"api/v1.2.3-0-gabc123d".to_string())
2365 );
2366 }
2367
2368 #[test]
2369 fn test_is_release_true_for_normal_release() {
2370 let config = Config::default();
2371 let opts = ContextOptions {
2372 snapshot: false,
2373 nightly: false,
2374 ..Default::default()
2375 };
2376 let mut ctx = Context::new(config, opts);
2377 ctx.git_info = Some(make_git_info(false, None));
2378 ctx.populate_git_vars();
2379
2380 assert_eq!(
2381 ctx.template_vars().get_structured("IsRelease"),
2382 Some(&tera::Value::Bool(true))
2383 );
2384 }
2385
2386 #[test]
2387 fn test_is_release_false_for_snapshot() {
2388 let config = Config::default();
2389 let opts = ContextOptions {
2390 snapshot: true,
2391 ..Default::default()
2392 };
2393 let mut ctx = Context::new(config, opts);
2394 ctx.git_info = Some(make_git_info(false, None));
2395 ctx.populate_git_vars();
2396
2397 assert_eq!(
2398 ctx.template_vars().get_structured("IsRelease"),
2399 Some(&tera::Value::Bool(false))
2400 );
2401 }
2402
2403 #[test]
2404 fn test_is_release_false_for_nightly() {
2405 let config = Config::default();
2406 let opts = ContextOptions {
2407 nightly: true,
2408 ..Default::default()
2409 };
2410 let mut ctx = Context::new(config, opts);
2411 ctx.git_info = Some(make_git_info(false, None));
2412 ctx.populate_git_vars();
2413
2414 assert_eq!(
2415 ctx.template_vars().get_structured("IsRelease"),
2416 Some(&tera::Value::Bool(false))
2417 );
2418 }
2419
2420 #[test]
2421 fn test_is_merging_true_when_merge_flag_set() {
2422 let config = Config::default();
2423 let opts = ContextOptions {
2424 merge: true,
2425 ..Default::default()
2426 };
2427 let mut ctx = Context::new(config, opts);
2428 ctx.git_info = Some(make_git_info(false, None));
2429 ctx.populate_git_vars();
2430
2431 assert_eq!(
2432 ctx.template_vars().get_structured("IsMerging"),
2433 Some(&tera::Value::Bool(true))
2434 );
2435 }
2436
2437 #[test]
2438 fn test_is_merging_false_by_default() {
2439 let config = Config::default();
2440 let mut ctx = Context::new(config, ContextOptions::default());
2441 ctx.git_info = Some(make_git_info(false, None));
2442 ctx.populate_git_vars();
2443
2444 assert_eq!(
2445 ctx.template_vars().get_structured("IsMerging"),
2446 Some(&tera::Value::Bool(false))
2447 );
2448 }
2449
2450 #[test]
2451 fn test_refresh_artifacts_var_empty() {
2452 let config = Config::default();
2453 let mut ctx = Context::new(config, ContextOptions::default());
2454 ctx.refresh_artifacts_var();
2455
2456 let result = ctx
2458 .render_template("{% for a in Artifacts %}{{ a.name }}{% endfor %}")
2459 .unwrap();
2460 assert_eq!(result, "");
2461 }
2462
2463 #[test]
2464 fn test_refresh_artifacts_var_with_artifacts() {
2465 use crate::artifact::{Artifact, ArtifactKind};
2466 use std::collections::HashMap;
2467 use std::path::PathBuf;
2468
2469 let config = Config::default();
2470 let mut ctx = Context::new(config, ContextOptions::default());
2471 ctx.artifacts.add(Artifact {
2475 kind: ArtifactKind::Archive,
2476 name: String::new(),
2477 path: PathBuf::from("dist/myapp-1.0.0-linux-amd64.tar.gz"),
2478 target: Some("x86_64-unknown-linux-gnu".to_string()),
2479 crate_name: "myapp".to_string(),
2480 metadata: HashMap::from([("format".to_string(), "tar.gz".to_string())]),
2481 size: None,
2482 });
2483 ctx.artifacts.add(Artifact {
2484 kind: ArtifactKind::Binary,
2485 name: String::new(),
2486 path: PathBuf::from("dist/myapp"),
2487 target: Some("x86_64-unknown-linux-gnu".to_string()),
2488 crate_name: "myapp".to_string(),
2489 metadata: HashMap::new(),
2490 size: None,
2491 });
2492 ctx.refresh_artifacts_var();
2493
2494 let result = ctx
2496 .render_template("{% for a in Artifacts %}{{ a.name }},{% endfor %}")
2497 .unwrap();
2498 assert!(result.contains("myapp-1.0.0-linux-amd64.tar.gz"));
2499 assert!(result.contains("myapp"));
2500
2501 let result_kinds = ctx
2503 .render_template("{% for a in Artifacts %}{{ a.kind }},{% endfor %}")
2504 .unwrap();
2505 assert!(result_kinds.contains("archive"));
2506 assert!(result_kinds.contains("binary"));
2507 }
2508
2509 #[test]
2510 fn test_populate_metadata_var_with_mod_timestamp() {
2511 let mut config = Config::default();
2512 config.metadata = Some(crate::config::MetadataConfig {
2513 mod_timestamp: Some("{{ .CommitTimestamp }}".to_string()),
2514 ..Default::default()
2515 });
2516 let mut ctx = Context::new(config, ContextOptions::default());
2517 ctx.populate_metadata_var().unwrap();
2518
2519 let result = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
2521 assert_eq!(result, "{{ .CommitTimestamp }}");
2522 }
2523
2524 #[test]
2525 fn test_populate_metadata_var_empty_when_no_config() {
2526 let config = Config::default();
2527 let mut ctx = Context::new(config, ContextOptions::default());
2528 ctx.populate_metadata_var().unwrap();
2529
2530 let result = ctx.render_template("{{ Metadata.Description }}").unwrap();
2532 assert_eq!(result, "");
2533 }
2534
2535 #[test]
2536 fn test_populate_metadata_var_reads_from_config() {
2537 let mut config = Config::default();
2538 config.metadata = Some(crate::config::MetadataConfig {
2539 description: Some("A test project".to_string()),
2540 homepage: Some("https://example.com".to_string()),
2541 documentation: Some("https://docs.example.com".to_string()),
2542 license: Some("MIT".to_string()),
2543 repository: Some("https://github.com/example/test".to_string()),
2544 maintainers: Some(vec!["Alice".to_string(), "Bob".to_string()]),
2545 mod_timestamp: Some("1234567890".to_string()),
2546 ..Default::default()
2547 });
2548 let mut ctx = Context::new(config, ContextOptions::default());
2549 ctx.populate_metadata_var().unwrap();
2550
2551 let desc = ctx.render_template("{{ Metadata.Description }}").unwrap();
2552 assert_eq!(desc, "A test project");
2553
2554 let home = ctx.render_template("{{ Metadata.Homepage }}").unwrap();
2555 assert_eq!(home, "https://example.com");
2556
2557 let repo = ctx.render_template("{{ Metadata.Repository }}").unwrap();
2558 assert_eq!(repo, "https://github.com/example/test");
2559
2560 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2561 assert_eq!(docs, "https://docs.example.com");
2562
2563 let lic = ctx.render_template("{{ Metadata.License }}").unwrap();
2564 assert_eq!(lic, "MIT");
2565
2566 let ts = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
2567 assert_eq!(ts, "1234567890");
2568 }
2569
2570 #[test]
2571 fn test_populate_metadata_var_license_falls_back_to_derived() {
2572 let mut config = Config::default();
2576 config.crates = vec![crate::config::CrateConfig {
2577 name: "anodizer".to_string(),
2578 ..Default::default()
2579 }];
2580 config.derived_metadata.insert(
2581 "anodizer".to_string(),
2582 crate::config::MetadataConfig {
2583 description: Some("Derived desc".to_string()),
2584 homepage: Some("https://derived.example".to_string()),
2585 documentation: Some("https://derived.docs".to_string()),
2586 license: Some("MIT OR Apache-2.0".to_string()),
2587 ..Default::default()
2588 },
2589 );
2590 let mut ctx = Context::new(config, ContextOptions::default());
2591 ctx.populate_metadata_var().unwrap();
2592
2593 assert_eq!(
2594 ctx.render_template("{{ Metadata.License }}").unwrap(),
2595 "MIT OR Apache-2.0"
2596 );
2597 assert_eq!(
2598 ctx.render_template("{{ Metadata.Description }}").unwrap(),
2599 "Derived desc"
2600 );
2601 assert_eq!(
2602 ctx.render_template("{{ Metadata.Homepage }}").unwrap(),
2603 "https://derived.example"
2604 );
2605 assert_eq!(
2606 ctx.render_template("{{ Metadata.Documentation }}").unwrap(),
2607 "https://derived.docs"
2608 );
2609 }
2610
2611 #[test]
2612 fn test_populate_metadata_var_top_level_license_wins_over_derived() {
2613 let mut config = Config::default();
2616 config.crates = vec![crate::config::CrateConfig {
2617 name: "anodizer".to_string(),
2618 ..Default::default()
2619 }];
2620 config.derived_metadata.insert(
2621 "anodizer".to_string(),
2622 crate::config::MetadataConfig {
2623 license: Some("MIT OR Apache-2.0".to_string()),
2624 ..Default::default()
2625 },
2626 );
2627 config.metadata = Some(crate::config::MetadataConfig {
2628 license: Some("GPL-3.0".to_string()),
2629 ..Default::default()
2630 });
2631 let mut ctx = Context::new(config, ContextOptions::default());
2632 ctx.populate_metadata_var().unwrap();
2633
2634 assert_eq!(
2635 ctx.render_template("{{ Metadata.License }}").unwrap(),
2636 "GPL-3.0"
2637 );
2638 }
2639
2640 #[test]
2641 fn test_populate_metadata_var_documentation_renders() {
2642 let mut config = Config::default();
2643 config.metadata = Some(crate::config::MetadataConfig {
2644 documentation: Some("https://docs.rs/anodizer".to_string()),
2645 ..Default::default()
2646 });
2647 let mut ctx = Context::new(config, ContextOptions::default());
2648 ctx.populate_metadata_var().unwrap();
2649
2650 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2651 assert_eq!(docs, "https://docs.rs/anodizer");
2652 }
2653
2654 #[test]
2655 fn test_populate_metadata_var_documentation_empty_when_unset() {
2656 let mut ctx = Context::new(Config::default(), ContextOptions::default());
2657 ctx.populate_metadata_var().unwrap();
2658
2659 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2660 assert_eq!(docs, "");
2661 }
2662
2663 #[test]
2664 fn test_populate_metadata_var_full_description_inline() {
2665 use crate::config::ContentSource;
2666 let mut config = Config::default();
2667 config.metadata = Some(crate::config::MetadataConfig {
2668 full_description: Some(ContentSource::Inline(
2669 "A long-form description of the project.".to_string(),
2670 )),
2671 ..Default::default()
2672 });
2673 let mut ctx = Context::new(config, ContextOptions::default());
2674 ctx.populate_metadata_var().unwrap();
2675 let rendered = ctx
2676 .render_template("{{ Metadata.FullDescription }}")
2677 .unwrap();
2678 assert_eq!(rendered, "A long-form description of the project.");
2679 }
2680
2681 #[test]
2682 fn test_populate_metadata_var_full_description_from_file() {
2683 use crate::config::ContentSource;
2684 let tmp = tempfile::tempdir().unwrap();
2685 let desc_path = tmp.path().join("DESCRIPTION.md");
2686 std::fs::write(&desc_path, "read from disk").unwrap();
2687 let mut config = Config::default();
2688 config.metadata = Some(crate::config::MetadataConfig {
2689 full_description: Some(ContentSource::FromFile {
2690 from_file: desc_path.to_string_lossy().into_owned(),
2691 }),
2692 ..Default::default()
2693 });
2694 let mut ctx = Context::new(config, ContextOptions::default());
2695 ctx.populate_metadata_var().unwrap();
2696 let rendered = ctx
2697 .render_template("{{ Metadata.FullDescription }}")
2698 .unwrap();
2699 assert_eq!(rendered, "read from disk");
2700 }
2701
2702 #[test]
2703 fn test_populate_metadata_var_full_description_from_url_resolves() {
2704 use crate::config::ContentSource;
2709 use crate::test_helpers::responder::spawn_oneshot_http_responder;
2710
2711 let body = "long form description body";
2712 let body_len = body.len();
2713 let response: &'static str = Box::leak(
2714 format!("HTTP/1.1 200 OK\r\nContent-Length: {body_len}\r\n\r\n{body}").into_boxed_str(),
2715 );
2716 let (addr, _calls) = spawn_oneshot_http_responder(vec![response]);
2717
2718 let mut config = Config::default();
2719 config.metadata = Some(crate::config::MetadataConfig {
2720 full_description: Some(ContentSource::FromUrl {
2721 from_url: format!("http://{addr}/description.md"),
2722 headers: None,
2723 }),
2724 ..Default::default()
2725 });
2726 let mut ctx = Context::new(config, ContextOptions::default());
2727 ctx.populate_metadata_var()
2728 .expect("from_url should resolve through content_source");
2729 let rendered = ctx
2730 .render_template("{{ Metadata.FullDescription }}")
2731 .unwrap();
2732 assert_eq!(rendered, body);
2733 }
2734
2735 #[test]
2736 fn test_populate_metadata_var_commit_author() {
2737 use crate::config::CommitAuthorConfig;
2738 let mut config = Config::default();
2739 config.metadata = Some(crate::config::MetadataConfig {
2740 commit_author: Some(CommitAuthorConfig {
2741 name: Some("Alice Developer".to_string()),
2742 email: Some("alice@example.com".to_string()),
2743 signing: None,
2744 use_github_app_token: false,
2745 }),
2746 ..Default::default()
2747 });
2748 let mut ctx = Context::new(config, ContextOptions::default());
2749 ctx.populate_metadata_var().unwrap();
2750 let name = ctx
2751 .render_template("{{ Metadata.CommitAuthor.Name }}")
2752 .unwrap();
2753 assert_eq!(name, "Alice Developer");
2754 let email = ctx
2755 .render_template("{{ Metadata.CommitAuthor.Email }}")
2756 .unwrap();
2757 assert_eq!(email, "alice@example.com");
2758 }
2759
2760 #[test]
2761 fn test_artifact_id_template_var() {
2762 let mut config = Config::default();
2763 config.project_name = "myapp".to_string();
2764 let mut ctx = Context::new(config, ContextOptions::default());
2765 ctx.template_vars_mut().set("ArtifactID", "default");
2766
2767 let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
2768 assert_eq!(result, "default");
2769 }
2770
2771 #[test]
2772 fn test_artifact_id_empty_when_not_set() {
2773 let mut config = Config::default();
2774 config.project_name = "myapp".to_string();
2775 let mut ctx = Context::new(config, ContextOptions::default());
2776 ctx.template_vars_mut().set("ArtifactID", "");
2777
2778 let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
2779 assert_eq!(result, "");
2780 }
2781
2782 #[test]
2783 fn test_pro_vars_rendered_in_templates() {
2784 let mut config = Config::default();
2786 config.tag = Some(crate::config::TagConfig {
2787 tag_prefix: Some("api/".to_string()),
2788 ..Default::default()
2789 });
2790 let opts = ContextOptions {
2791 snapshot: false,
2792 nightly: false,
2793 merge: true,
2794 ..Default::default()
2795 };
2796 let mut ctx = Context::new(config, opts);
2797 ctx.git_info = Some(make_git_info(false, None));
2798 ctx.populate_git_vars();
2799
2800 let result = ctx
2801 .render_template(
2802 "{% if IsRelease %}release{% endif %}-{% if IsMerging %}merge{% endif %}-{{ .PrefixedTag }}",
2803 )
2804 .unwrap();
2805 assert_eq!(result, "release-merge-api/v1.2.3");
2806 }
2807
2808 #[test]
2809 fn test_is_release_without_git_info() {
2810 let config = Config::default();
2812 let opts = ContextOptions {
2813 snapshot: false,
2814 nightly: false,
2815 ..Default::default()
2816 };
2817 let mut ctx = Context::new(config, opts);
2818 ctx.populate_git_vars();
2819
2820 assert_eq!(
2821 ctx.template_vars().get_structured("IsRelease"),
2822 Some(&tera::Value::Bool(true))
2823 );
2824 }
2825
2826 #[test]
2827 fn test_is_merging_without_git_info() {
2828 let config = Config::default();
2830 let opts = ContextOptions {
2831 merge: true,
2832 ..Default::default()
2833 };
2834 let mut ctx = Context::new(config, opts);
2835 ctx.populate_git_vars();
2836
2837 assert_eq!(
2838 ctx.template_vars().get_structured("IsMerging"),
2839 Some(&tera::Value::Bool(true))
2840 );
2841 }
2842
2843 #[test]
2853 fn test_monorepo_version_matches_shared_semver_helper() {
2854 let mut config = Config::default();
2855 config.monorepo = Some(crate::config::MonorepoConfig {
2856 tag_prefix: Some("core/".to_string()),
2857 dir: None,
2858 });
2859 let mut ctx = Context::new(config, ContextOptions::default());
2860
2861 let semver = SemVer {
2862 major: 2,
2863 minor: 1,
2864 patch: 0,
2865 prerelease: Some("rc.1".to_string()),
2866 build_metadata: Some("build.7".to_string()),
2867 };
2868 let mut info = make_git_info(false, None);
2869 info.tag = "core/v2.1.0-rc.1+build.7".to_string();
2870 info.semver = semver.clone();
2871 ctx.git_info = Some(info);
2872 ctx.populate_git_vars();
2873
2874 let v = ctx.template_vars();
2875 assert_eq!(v.get("Version"), Some(&semver.version_string()));
2878 assert_eq!(v.get("Version"), Some(&"2.1.0-rc.1+build.7".to_string()));
2879 assert_eq!(v.get("RawVersion"), Some(&semver.raw_version_string()));
2880 assert_eq!(v.get("RawVersion"), Some(&"2.1.0".to_string()));
2881 assert_eq!(v.get("Tag"), Some(&"v2.1.0-rc.1+build.7".to_string()));
2883 }
2884
2885 #[test]
2886 fn test_monorepo_tag_prefix_strips_tag_for_template_var() {
2887 let mut config = Config::default();
2888 config.monorepo = Some(crate::config::MonorepoConfig {
2889 tag_prefix: Some("subproject1/".to_string()),
2890 dir: None,
2891 });
2892 let mut ctx = Context::new(config, ContextOptions::default());
2893
2894 let mut info = make_git_info(false, None);
2896 info.tag = "subproject1/v1.2.3".to_string();
2897 info.previous_tag = Some("subproject1/v1.2.2".to_string());
2898 info.summary = "subproject1/v1.2.3-0-gabc123d".to_string();
2899 ctx.git_info = Some(info);
2900 ctx.populate_git_vars();
2901
2902 let v = ctx.template_vars();
2903 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2905 assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
2907 assert_eq!(
2909 v.get("PrefixedTag"),
2910 Some(&"subproject1/v1.2.3".to_string())
2911 );
2912 assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
2914 assert_eq!(
2916 v.get("PrefixedPreviousTag"),
2917 Some(&"subproject1/v1.2.2".to_string())
2918 );
2919 assert_eq!(v.get("Summary"), Some(&"v1.2.3-0-gabc123d".to_string()));
2921 assert_eq!(
2923 v.get("PrefixedSummary"),
2924 Some(&"subproject1/v1.2.3-0-gabc123d".to_string())
2925 );
2926 }
2927
2928 #[test]
2929 fn test_monorepo_prefixed_previous_tag() {
2930 let mut config = Config::default();
2931 config.monorepo = Some(crate::config::MonorepoConfig {
2932 tag_prefix: Some("svc/".to_string()),
2933 dir: None,
2934 });
2935 let mut ctx = Context::new(config, ContextOptions::default());
2936
2937 let mut info = make_git_info(false, None);
2938 info.tag = "svc/v2.0.0".to_string();
2939 info.previous_tag = Some("svc/v1.9.0".to_string());
2940 ctx.git_info = Some(info);
2941 ctx.populate_git_vars();
2942
2943 let v = ctx.template_vars();
2944 assert_eq!(
2946 v.get("PrefixedPreviousTag"),
2947 Some(&"svc/v1.9.0".to_string())
2948 );
2949 assert_eq!(v.get("PreviousTag"), Some(&"v1.9.0".to_string()));
2951 }
2952
2953 #[test]
2954 fn test_no_monorepo_falls_back_to_tag_prefix() {
2955 let mut config = Config::default();
2957 config.tag = Some(crate::config::TagConfig {
2958 tag_prefix: Some("release/".to_string()),
2959 ..Default::default()
2960 });
2961 let mut ctx = Context::new(config, ContextOptions::default());
2962 ctx.git_info = Some(make_git_info(false, None));
2963 ctx.populate_git_vars();
2964
2965 let v = ctx.template_vars();
2966 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2968 assert_eq!(v.get("PrefixedTag"), Some(&"release/v1.2.3".to_string()));
2970 assert_eq!(
2971 v.get("PrefixedPreviousTag"),
2972 Some(&"release/v1.2.2".to_string())
2973 );
2974 }
2975
2976 #[test]
2977 fn test_monorepo_overrides_tag_prefix_for_prefixed_vars() {
2978 let mut config = Config::default();
2981 config.tag = Some(crate::config::TagConfig {
2982 tag_prefix: Some("release/".to_string()),
2983 ..Default::default()
2984 });
2985 config.monorepo = Some(crate::config::MonorepoConfig {
2986 tag_prefix: Some("svc/".to_string()),
2987 dir: None,
2988 });
2989 let mut ctx = Context::new(config, ContextOptions::default());
2990
2991 let mut info = make_git_info(false, None);
2992 info.tag = "svc/v1.2.3".to_string();
2993 info.previous_tag = Some("svc/v1.2.2".to_string());
2994 ctx.git_info = Some(info);
2995 ctx.populate_git_vars();
2996
2997 let v = ctx.template_vars();
2998 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
3000 assert_eq!(v.get("PrefixedTag"), Some(&"svc/v1.2.3".to_string()));
3002 }
3003
3004 #[test]
3005 fn test_monorepo_prefixed_summary() {
3006 let mut config = Config::default();
3007 config.monorepo = Some(crate::config::MonorepoConfig {
3008 tag_prefix: Some("pkg/".to_string()),
3009 dir: None,
3010 });
3011 let mut ctx = Context::new(config, ContextOptions::default());
3012
3013 let mut info = make_git_info(false, None);
3014 info.tag = "pkg/v1.2.3".to_string();
3015 info.summary = "pkg/v1.2.3-0-gabc123d".to_string();
3017 ctx.git_info = Some(info);
3018 ctx.populate_git_vars();
3019
3020 assert_eq!(
3022 ctx.template_vars().get("PrefixedSummary"),
3023 Some(&"pkg/v1.2.3-0-gabc123d".to_string())
3024 );
3025 assert_eq!(
3027 ctx.template_vars().get("Summary"),
3028 Some(&"v1.2.3-0-gabc123d".to_string())
3029 );
3030 }
3031
3032 #[test]
3033 fn test_monorepo_no_previous_tag() {
3034 let mut config = Config::default();
3035 config.monorepo = Some(crate::config::MonorepoConfig {
3036 tag_prefix: Some("svc/".to_string()),
3037 dir: None,
3038 });
3039 let mut ctx = Context::new(config, ContextOptions::default());
3040
3041 let mut info = make_git_info(false, None);
3042 info.tag = "svc/v1.0.0".to_string();
3043 info.previous_tag = None;
3044 ctx.git_info = Some(info);
3045 ctx.populate_git_vars();
3046
3047 let v = ctx.template_vars();
3048 assert_eq!(v.get("PrefixedPreviousTag"), Some(&"".to_string()));
3049 assert_eq!(v.get("PreviousTag"), Some(&"".to_string()));
3051 }
3052
3053 #[test]
3058 fn test_monorepo_full_flow_all_vars() {
3059 let mut config = Config::default();
3062 config.project_name = "mymonorepo".to_string();
3063 config.monorepo = Some(crate::config::MonorepoConfig {
3064 tag_prefix: Some("services/api/".to_string()),
3065 dir: Some("services/api".to_string()),
3066 });
3067
3068 assert_eq!(config.monorepo_tag_prefix(), Some("services/api/"));
3070 assert_eq!(config.monorepo_dir(), Some("services/api"));
3071
3072 let mut ctx = Context::new(config, ContextOptions::default());
3073
3074 let mut info = make_git_info(false, None);
3077 info.tag = "services/api/v2.1.0".to_string();
3078 info.previous_tag = Some("services/api/v2.0.5".to_string());
3079 info.summary = "services/api/v2.1.0-0-gabc123d".to_string();
3080 info.semver = crate::git::SemVer {
3081 major: 2,
3082 minor: 1,
3083 patch: 0,
3084 prerelease: None,
3085 build_metadata: None,
3086 };
3087 ctx.git_info = Some(info);
3088 ctx.populate_git_vars();
3089
3090 let v = ctx.template_vars();
3091
3092 assert_eq!(v.get("Tag"), Some(&"v2.1.0".to_string()));
3094 assert_eq!(v.get("Version"), Some(&"2.1.0".to_string()));
3095 assert_eq!(v.get("RawVersion"), Some(&"2.1.0".to_string()));
3096 assert_eq!(v.get("Major"), Some(&"2".to_string()));
3097 assert_eq!(v.get("Minor"), Some(&"1".to_string()));
3098 assert_eq!(v.get("Patch"), Some(&"0".to_string()));
3099 assert_eq!(v.get("PreviousTag"), Some(&"v2.0.5".to_string()));
3100 assert_eq!(v.get("Summary"), Some(&"v2.1.0-0-gabc123d".to_string()));
3101
3102 assert_eq!(
3104 v.get("PrefixedTag"),
3105 Some(&"services/api/v2.1.0".to_string())
3106 );
3107 assert_eq!(
3108 v.get("PrefixedPreviousTag"),
3109 Some(&"services/api/v2.0.5".to_string())
3110 );
3111 assert_eq!(
3112 v.get("PrefixedSummary"),
3113 Some(&"services/api/v2.1.0-0-gabc123d".to_string())
3114 );
3115
3116 assert_eq!(v.get("ProjectName"), Some(&"mymonorepo".to_string()));
3118 }
3119
3120 #[test]
3121 fn context_env_var_defaults_to_process_env_source() {
3122 let ctx = Context::new(Config::default(), ContextOptions::default());
3123 assert_eq!(ctx.env_var("ANODIZER_T3_UNSET_VAR"), None);
3125 }
3126
3127 #[test]
3128 fn context_env_var_routes_to_injected_source() {
3129 let mut ctx = Context::new(Config::default(), ContextOptions::default());
3130 ctx.set_env_source(crate::MapEnvSource::new().with("INJECTED", "yes"));
3131 assert_eq!(ctx.env_var("INJECTED"), Some("yes".to_string()));
3132 assert_eq!(ctx.env_var("PATH"), None);
3136 }
3137
3138 #[test]
3139 #[serial_test::serial]
3140 fn populate_runtime_vars_sets_rustc_version() {
3141 let config = Config::default();
3142 let mut ctx = Context::new(config, ContextOptions::default());
3143 ctx.populate_runtime_vars();
3146
3147 let ver = ctx
3148 .template_vars()
3149 .get("RustcVersion")
3150 .expect("RustcVersion should be set after populate_runtime_vars");
3151 if !ver.is_empty() {
3155 assert!(
3156 ver.chars().next().is_some_and(|c| c.is_ascii_digit()),
3157 "RustcVersion should start with a digit: {ver}"
3158 );
3159 }
3160 }
3161}