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,
156 pub project_root: Option<PathBuf>,
159 pub strict: bool,
161 pub resume_release: bool,
166 pub replace_existing_artifacts: bool,
170 pub skip_post_publish_poll: bool,
178 pub gate_submitter: Option<bool>,
186 pub rollback_mode: Option<RollbackMode>,
191 pub simulate_failure_publishers: Vec<String>,
199 pub rollback_only: bool,
205 pub allow_rerun: bool,
220 pub show_skipped: bool,
229 pub from_run: Option<String>,
233 pub runtime_nondeterministic_allowlist: Vec<(String, String)>,
240 pub summary_json_path: Option<PathBuf>,
244 pub allow_ai_failure: bool,
251 pub changelog_from: Option<String>,
258 pub changelog_full_history: bool,
265 pub changelog_to: Option<String>,
274 pub changelog_preview: bool,
290 pub allow_snapshot_publish: bool,
304}
305
306impl Default for ContextOptions {
307 fn default() -> Self {
308 Self {
309 snapshot: false,
310 nightly: false,
311 dry_run: false,
312 quiet: false,
313 verbose: false,
314 debug: false,
315 skip_stages: Vec::new(),
316 publisher_allowlist: Vec::new(),
317 selected_crates: Vec::new(),
318 token: None,
319 parallelism: 4,
320 single_target: None,
321 release_notes_path: None,
322 fail_fast: false,
323 partial_target: None,
324 merge: false,
325 publish_only: false,
326 project_root: None,
327 strict: false,
328 resume_release: false,
329 replace_existing_artifacts: false,
330 skip_post_publish_poll: false,
331 gate_submitter: None,
332 rollback_mode: None,
333 simulate_failure_publishers: Vec::new(),
334 rollback_only: false,
335 allow_rerun: false,
336 show_skipped: false,
337 from_run: None,
338 runtime_nondeterministic_allowlist: Vec::new(),
339 summary_json_path: None,
340 allow_ai_failure: false,
341 changelog_from: None,
342 changelog_full_history: false,
343 changelog_to: None,
344 changelog_preview: false,
345 allow_snapshot_publish: false,
346 }
347 }
348}
349
350#[derive(Debug, Default)]
355pub struct StageOutputs {
356 pub github_native_changelog: bool,
360 pub changelogs: HashMap<String, String>,
362 pub changelog_header: Option<String>,
367 pub changelog_footer: Option<String>,
370 pub post_publish_results: Vec<serde_json::Value>,
378}
379
380pub struct Context {
381 pub config: Config,
382 pub artifacts: ArtifactRegistry,
383 pub options: ContextOptions,
384 pub stage_outputs: StageOutputs,
386 template_vars: TemplateVars,
387 pub git_info: Option<GitInfo>,
388 pub token_type: ScmTokenType,
390 pub skip_memento: crate::pipe_skip::SkipMemento,
396 pub publish_report: Option<PublishReport>,
404 pub publish_attempted: bool,
410 pub verify_release: Option<VerifyReleaseSummary>,
421 pub determinism: Option<crate::DeterminismState>,
428 pub pending_outcome: Option<crate::PublisherOutcome>,
438 pub pending_evidence: Option<crate::PublishEvidence>,
451 built_crate_names: Option<std::collections::HashSet<String>>,
462 env_source: Arc<dyn EnvSource>,
470 #[cfg(feature = "test-helpers")]
478 pub log_capture: Option<crate::log::LogCapture>,
479 render_strict: std::cell::Cell<bool>,
494 pub literal_message: bool,
501 pub redact_body: bool,
508 process_env_cache: std::cell::OnceCell<std::collections::HashMap<String, String>>,
517}
518
519impl Context {
520 pub fn new(config: Config, options: ContextOptions) -> Self {
521 let mut vars = TemplateVars::new();
522 vars.set("ProjectName", &config.project_name);
523 Self {
524 config,
525 artifacts: ArtifactRegistry::new(),
526 options,
527 stage_outputs: StageOutputs::default(),
528 template_vars: vars,
529 git_info: None,
530 token_type: ScmTokenType::GitHub,
531 skip_memento: crate::pipe_skip::SkipMemento::new(),
532 publish_report: None,
533 publish_attempted: false,
534 verify_release: None,
535 determinism: None,
536 pending_outcome: None,
537 pending_evidence: None,
538 built_crate_names: None,
539 env_source: Arc::new(ProcessEnvSource),
540 #[cfg(feature = "test-helpers")]
541 log_capture: None,
542 render_strict: std::cell::Cell::new(false),
543 literal_message: false,
544 redact_body: true,
545 process_env_cache: std::cell::OnceCell::new(),
546 }
547 }
548
549 pub fn redact(&self, s: &str) -> String {
554 crate::redact::with_env(s, &self.env_for_redact())
555 }
556
557 pub fn env_var(&self, name: &str) -> Option<String> {
565 self.env_source.var(name)
566 }
567
568 pub fn set_env_source<S: EnvSource + 'static>(&mut self, src: S) {
574 self.env_source = Arc::new(src);
575 }
576
577 pub fn env_source(&self) -> &dyn EnvSource {
582 self.env_source.as_ref()
583 }
584
585 pub fn env_source_arc(&self) -> Arc<dyn EnvSource> {
591 Arc::clone(&self.env_source)
592 }
593
594 #[cfg(feature = "test-helpers")]
600 pub fn with_log_capture(&mut self, capture: crate::log::LogCapture) {
601 self.log_capture = Some(capture);
602 }
603
604 pub fn record_publisher_outcome(&mut self, outcome: crate::PublisherOutcome) {
612 self.pending_outcome = Some(outcome);
613 }
614
615 pub fn take_pending_outcome(&mut self) -> Option<crate::PublisherOutcome> {
619 self.pending_outcome.take()
620 }
621
622 pub fn record_pending_evidence(&mut self, evidence: crate::PublishEvidence) {
627 self.pending_evidence = Some(evidence);
628 }
629
630 pub fn take_pending_evidence(&mut self) -> Option<crate::PublishEvidence> {
634 self.pending_evidence.take()
635 }
636
637 pub fn publish_report(&self) -> Option<&PublishReport> {
640 self.publish_report.as_ref()
641 }
642
643 pub fn publish_attempted(&self) -> bool {
646 self.publish_attempted
647 }
648
649 pub fn set_publish_attempted(&mut self) {
653 self.publish_attempted = true;
654 }
655
656 pub fn set_publish_report(&mut self, r: PublishReport) {
662 self.publish_report = Some(r);
663 }
664
665 pub fn built_crate_names(&self) -> Option<&std::collections::HashSet<String>> {
668 self.built_crate_names.as_ref()
669 }
670
671 pub fn set_built_crate_names(&mut self, names: std::collections::HashSet<String>) {
674 self.built_crate_names = Some(names);
675 }
676
677 pub fn remember_skip(&self, stage: &str, label: &str, reason: &str) {
684 self.skip_memento.remember(stage, label, reason);
685 }
686
687 pub fn template_vars(&self) -> &TemplateVars {
688 &self.template_vars
689 }
690
691 pub fn template_vars_mut(&mut self) -> &mut TemplateVars {
692 &mut self.template_vars
693 }
694
695 pub fn render_template(&self, template: &str) -> anyhow::Result<String> {
696 crate::template::render(template, &self.template_vars)
697 }
698
699 pub fn render_template_opt(&self, template: Option<&str>) -> anyhow::Result<Option<String>> {
701 template.map(|t| self.render_template(t)).transpose()
702 }
703
704 pub fn skip_with_log(
713 &self,
714 skip: &Option<crate::config::StringOrBool>,
715 log: &StageLogger,
716 label: &str,
717 ) -> anyhow::Result<bool> {
718 let Some(d) = skip else {
719 return Ok(false);
720 };
721 let should_skip = d
722 .try_evaluates_to_true(|s| self.render_template(s))
723 .with_context(|| format!("evaluate skip expression for {label}"))?;
724 if should_skip {
725 log.status(&format!("{} skipped", label));
726 }
727 Ok(should_skip)
728 }
729
730 pub fn should_skip(&self, stage_name: &str) -> bool {
733 self.options.skip_stages.iter().any(|s| s == stage_name)
734 }
735
736 pub fn publisher_deselected(&self, name: &str) -> bool {
750 self.should_skip(name)
751 || (!self.options.publisher_allowlist.is_empty()
752 && !self.options.publisher_allowlist.iter().any(|s| s == name))
753 }
754
755 pub fn deselected_reason(&self, name: &str) -> String {
766 let reason = if self.should_skip(name) {
767 "excluded via --skip"
768 } else {
769 "not in --publishers allowlist"
770 };
771 format!("skipped {name} — {reason}")
772 }
773
774 pub fn skip_validate(&self) -> bool {
776 self.should_skip("validate")
777 }
778
779 pub fn is_dry_run(&self) -> bool {
780 self.options.dry_run
781 }
782
783 pub fn is_snapshot(&self) -> bool {
784 self.options.snapshot
785 }
786
787 pub fn is_strict(&self) -> bool {
788 self.options.strict
789 }
790
791 pub fn set_render_strict(&self, on: bool) -> bool {
799 self.render_strict.replace(on)
800 }
801
802 pub fn render_is_strict(&self) -> bool {
809 self.render_strict.get() || self.is_strict()
810 }
811
812 pub fn strict_guard(&self, log: &crate::log::StageLogger, msg: &str) -> anyhow::Result<()> {
815 if self.options.strict {
816 anyhow::bail!("{} (strict mode)", msg);
817 }
818 log.warn(msg);
819 Ok(())
820 }
821
822 pub fn skip_in_snapshot(&self, log: &crate::log::StageLogger, stage: &str) -> bool {
831 if self.is_snapshot() {
832 log.status(&format!("skipped {stage} — snapshot mode"));
836 true
837 } else {
838 false
839 }
840 }
841
842 pub fn render_template_strict(
844 &self,
845 template: &str,
846 label: &str,
847 log: &crate::log::StageLogger,
848 ) -> anyhow::Result<String> {
849 match self.render_template(template) {
850 Ok(rendered) => Ok(rendered),
851 Err(e) => {
852 if self.options.strict {
853 anyhow::bail!("{}: failed to render template: {} (strict mode)", label, e);
854 }
855 log.warn(&format!("failed to render template for {}: {}", label, e));
856 Ok(template.to_string())
857 }
858 }
859 }
860
861 pub fn is_nightly(&self) -> bool {
862 self.options.nightly
863 }
864
865 pub fn set_release_url(&mut self, url: &str) {
870 self.template_vars.set("ReleaseURL", url);
871 }
872
873 pub fn version(&self) -> String {
876 self.template_vars
877 .get("Version")
878 .cloned()
879 .unwrap_or_default()
880 }
881
882 pub fn verbosity(&self) -> Verbosity {
884 Verbosity::from_flags(self.options.quiet, self.options.verbose, self.options.debug)
885 }
886
887 pub fn retry_policy(&self) -> crate::retry::RetryPolicy {
893 self.config.retry.unwrap_or_default().to_policy()
894 }
895
896 pub fn logger(&self, stage: &'static str) -> StageLogger {
904 #[allow(unused_mut)]
905 let mut log = StageLogger::new(stage, self.verbosity()).with_env(self.env_for_redact());
906 #[cfg(feature = "test-helpers")]
907 if let Some(cap) = &self.log_capture {
908 log = log.with_capture_handle(cap.clone());
909 }
910 log
911 }
912
913 fn env_for_redact(&self) -> Vec<(String, String)> {
924 use std::collections::HashMap;
925 let mut map: HashMap<String, String> = self
926 .process_env_cache
927 .get_or_init(|| std::env::vars().collect())
928 .clone();
929 for (k, v) in self.template_vars.all_env() {
930 map.insert(k.clone(), v.clone());
931 }
932 map.into_iter().collect()
933 }
934
935 pub fn populate_git_vars(&mut self) {
977 if let Some(ref info) = self.git_info {
978 let raw_version = info.semver.raw_version_string();
980
981 let version = info.semver.version_string();
987
988 self.template_vars.set("Tag", &info.tag);
989 self.template_vars.set("Version", &version);
990 self.template_vars.set("RawVersion", &raw_version);
991 self.template_vars.set("Base", &raw_version);
996 self.template_vars
997 .set("Major", &info.semver.major.to_string());
998 self.template_vars
999 .set("Minor", &info.semver.minor.to_string());
1000 self.template_vars
1001 .set("Patch", &info.semver.patch.to_string());
1002 self.template_vars.set(
1003 "Prerelease",
1004 info.semver.prerelease.as_deref().unwrap_or(""),
1005 );
1006 self.template_vars.set(
1007 "BuildMetadata",
1008 info.semver.build_metadata.as_deref().unwrap_or(""),
1009 );
1010 self.template_vars.set("FullCommit", &info.commit);
1011 self.template_vars.set("Commit", &info.commit);
1012 self.template_vars.set("ShortCommit", &info.short_commit);
1013 self.template_vars.set("Branch", &info.branch);
1014 self.template_vars.set("CommitDate", &info.commit_date);
1015 self.template_vars
1016 .set("CommitTimestamp", &info.commit_timestamp);
1017 self.template_vars.set_bool("IsGitDirty", info.dirty);
1018 self.template_vars.set_bool("IsGitClean", !info.dirty);
1019 self.template_vars
1020 .set("GitTreeState", if info.dirty { "dirty" } else { "clean" });
1021 self.template_vars.set("GitURL", &info.remote_url);
1022 self.template_vars.set("Summary", &info.summary);
1023 self.template_vars.set("TagSubject", &info.tag_subject);
1024 self.template_vars.set("TagContents", &info.tag_contents);
1025 self.template_vars.set("TagBody", &info.tag_body);
1026 self.template_vars
1027 .set("PreviousTag", info.previous_tag.as_deref().unwrap_or(""));
1028 self.template_vars
1029 .set("FirstCommit", info.first_commit.as_deref().unwrap_or(""));
1030
1031 let monorepo_prefix = self.config.monorepo_tag_prefix();
1042
1043 if let Some(prefix) = monorepo_prefix {
1049 self.template_vars.set("PrefixedTag", &info.tag);
1052
1053 let stripped_tag = crate::git::strip_monorepo_prefix(&info.tag, prefix);
1055 self.template_vars.set("Tag", stripped_tag);
1056
1057 let version = info.semver.version_string();
1067 self.template_vars.set("Version", &version);
1068
1069 let prev_tag = info.previous_tag.as_deref().unwrap_or("");
1071 self.template_vars.set("PrefixedPreviousTag", prev_tag);
1072
1073 let stripped_prev = crate::git::strip_monorepo_prefix(prev_tag, prefix);
1075 self.template_vars.set("PreviousTag", stripped_prev);
1076
1077 self.template_vars.set("PrefixedSummary", &info.summary);
1081 let stripped_summary = crate::git::strip_monorepo_prefix(&info.summary, prefix);
1083 self.template_vars.set("Summary", stripped_summary);
1084 } else {
1085 let tag_prefix = self
1087 .config
1088 .tag
1089 .as_ref()
1090 .and_then(|t| t.tag_prefix.as_deref())
1091 .unwrap_or("");
1092 self.template_vars
1093 .set("PrefixedTag", &format!("{}{}", tag_prefix, info.tag));
1094 let prev_tag = info.previous_tag.as_deref().unwrap_or("");
1095 let prefixed_prev = if prev_tag.is_empty() {
1096 String::new()
1097 } else {
1098 format!("{}{}", tag_prefix, prev_tag)
1099 };
1100 self.template_vars
1101 .set("PrefixedPreviousTag", &prefixed_prev);
1102 self.template_vars.set(
1103 "PrefixedSummary",
1104 &format!("{}{}", tag_prefix, info.summary),
1105 );
1106 }
1107 }
1108
1109 let nightly_build = if self.git_info.is_some() {
1122 let root = self
1123 .options
1124 .project_root
1125 .clone()
1126 .unwrap_or_else(|| PathBuf::from("."));
1127 let monorepo_prefix = self.config.monorepo_tag_prefix();
1128 crate::git::count_commits_since_last_tag_in(&root, monorepo_prefix).unwrap_or(0)
1129 } else {
1130 0
1131 };
1132 self.template_vars
1133 .set_structured("NightlyBuild", tera::Value::from(nightly_build));
1134
1135 self.template_vars
1140 .set_bool("IsSnapshot", self.options.snapshot);
1141 self.template_vars
1142 .set_bool("IsNightly", self.options.nightly);
1143 self.template_vars.set_bool(
1147 "IsHarness",
1148 self.env_var("ANODIZER_IN_DETERMINISM_HARNESS").is_some(),
1149 );
1150 let is_draft = self
1152 .config
1153 .release
1154 .as_ref()
1155 .and_then(|r| r.draft)
1156 .unwrap_or(false);
1157 self.template_vars.set_bool("IsDraft", is_draft);
1158 self.template_vars
1159 .set_bool("IsSingleTarget", self.options.single_target.is_some());
1160
1161 let is_release = !self.options.snapshot && !self.options.nightly;
1163 self.template_vars.set_bool("IsRelease", is_release);
1164
1165 self.template_vars.set_bool("IsMerging", self.options.merge);
1167 }
1168
1169 pub fn populate_time_vars(&mut self) {
1197 let now = crate::sde::resolve_now_with_env(self.env_source());
1205 self.template_vars.set("Date", &now.to_rfc3339());
1206 self.template_vars
1207 .set("Timestamp", &now.timestamp().to_string());
1208 self.template_vars.set("Now", &now.to_rfc3339());
1209 self.template_vars
1210 .set("Year", &now.format("%Y").to_string());
1211 self.template_vars
1212 .set("Month", &now.format("%m").to_string());
1213 self.template_vars.set("Day", &now.format("%d").to_string());
1214 self.template_vars
1215 .set("Hour", &now.format("%H").to_string());
1216 self.template_vars
1217 .set("Minute", &now.format("%M").to_string());
1218 }
1219
1220 pub fn populate_runtime_vars(&mut self) {
1229 let goos = map_os_to_goos(std::env::consts::OS);
1230 let goarch = map_arch_to_goarch(std::env::consts::ARCH);
1231 self.template_vars.set("RuntimeGoos", goos);
1232 self.template_vars.set("RuntimeGoarch", goarch);
1233 self.template_vars.set("Runtime_Goos", goos);
1236 self.template_vars.set("Runtime_Goarch", goarch);
1237 self.populate_rustc_vars();
1241 }
1242
1243 fn populate_rustc_vars(&mut self) {
1250 let ver = crate::partial::detect_rustc_version().unwrap_or_default();
1251 self.template_vars.set("RustcVersion", &ver);
1252 }
1253
1254 pub fn populate_release_notes_var(&mut self) {
1262 let notes = self
1264 .config
1265 .crates
1266 .iter()
1267 .find_map(|c| self.stage_outputs.changelogs.get(&c.name))
1268 .cloned()
1269 .unwrap_or_default();
1270 self.template_vars.set("ReleaseNotes", ¬es);
1271 }
1272
1273 pub fn refresh_artifacts_var(&mut self) {
1288 const CSV_LIST_KEYS: &[&str] = &["extra_binaries", "extra_files"];
1293 const JSON_LIST_KEYS: &[&str] = &["Platforms"];
1299
1300 let artifacts_value: Vec<serde_json::Value> = self
1301 .artifacts
1302 .all()
1303 .iter()
1304 .map(|a| {
1305 let mut metadata_map = serde_json::Map::with_capacity(a.metadata.len());
1307 for (k, v) in &a.metadata {
1308 if CSV_LIST_KEYS.contains(&k.as_str()) {
1309 let items: Vec<serde_json::Value> = if v.is_empty() {
1310 Vec::new()
1311 } else {
1312 v.split(',')
1313 .map(|s| serde_json::Value::String(s.to_string()))
1314 .collect()
1315 };
1316 metadata_map.insert(k.clone(), serde_json::Value::Array(items));
1317 } else if JSON_LIST_KEYS.contains(&k.as_str()) {
1318 let parsed = serde_json::from_str::<serde_json::Value>(v)
1322 .unwrap_or_else(|_| serde_json::Value::String(v.clone()));
1323 metadata_map.insert(k.clone(), parsed);
1324 } else {
1325 metadata_map.insert(k.clone(), serde_json::Value::String(v.clone()));
1326 }
1327 }
1328 serde_json::json!({
1329 "name": a.name,
1330 "path": a.path.to_string_lossy(),
1331 "target": a.target.as_deref().unwrap_or(""),
1332 "kind": a.kind.as_str(),
1333 "crate_name": a.crate_name,
1334 "metadata": serde_json::Value::Object(metadata_map),
1335 })
1336 })
1337 .collect();
1338 let tera_value = tera::Value::Array(artifacts_value);
1341 self.template_vars.set_structured("Artifacts", tera_value);
1342 }
1343
1344 pub fn populate_metadata_var(&mut self) -> anyhow::Result<()> {
1358 let (
1361 description,
1362 homepage,
1363 documentation,
1364 license,
1365 maintainers,
1366 mod_timestamp,
1367 full_desc_src,
1368 commit_author,
1369 ) = {
1370 let meta = self.config.metadata.as_ref();
1371 let description = self
1378 .config
1379 .meta_description_project()
1380 .unwrap_or("")
1381 .to_string();
1382 let homepage = self
1383 .config
1384 .meta_homepage_project()
1385 .unwrap_or("")
1386 .to_string();
1387 let documentation = self
1388 .config
1389 .meta_documentation_project()
1390 .unwrap_or("")
1391 .to_string();
1392 let license = self.config.meta_license_project().unwrap_or("").to_string();
1393 let maintainers: Vec<String> = meta
1394 .and_then(|m| m.maintainers.as_ref())
1395 .cloned()
1396 .unwrap_or_default();
1397 let mod_timestamp = meta
1398 .and_then(|m| m.mod_timestamp.as_deref())
1399 .unwrap_or("")
1400 .to_string();
1401 let full_desc_src = meta.and_then(|m| m.full_description.clone());
1402 let commit_author = meta.and_then(|m| m.commit_author.clone());
1403 (
1404 description,
1405 homepage,
1406 documentation,
1407 license,
1408 maintainers,
1409 mod_timestamp,
1410 full_desc_src,
1411 commit_author,
1412 )
1413 };
1414
1415 let full_description = match full_desc_src {
1421 None => String::new(),
1422 Some(src) => crate::content_source::resolve(&src, "metadata.full_description", self)?,
1423 };
1424
1425 let commit_author_map = serde_json::json!({
1426 "Name": commit_author.as_ref().and_then(|c| c.name.clone()).unwrap_or_default(),
1427 "Email": commit_author.as_ref().and_then(|c| c.email.clone()).unwrap_or_default(),
1428 });
1429
1430 let meta_map = serde_json::json!({
1431 "Description": description,
1432 "Homepage": homepage,
1433 "Documentation": documentation,
1434 "License": license,
1435 "Maintainers": maintainers,
1436 "ModTimestamp": mod_timestamp,
1437 "FullDescription": full_description,
1438 "CommitAuthor": commit_author_map,
1439 });
1440 self.template_vars.set_structured("Metadata", meta_map);
1442 Ok(())
1443 }
1444}
1445
1446pub fn map_os_to_goos(os: &str) -> &str {
1449 match os {
1450 "macos" => "darwin",
1451 other => other, }
1453}
1454
1455pub fn map_arch_to_goarch(arch: &str) -> &str {
1458 match arch {
1459 "x86_64" => "amd64",
1460 "x86" => "386",
1461 "aarch64" => "arm64",
1462 "powerpc64" => "ppc64",
1463 "s390x" => "s390x",
1464 "mips" => "mips",
1465 "mips64" => "mips64",
1466 "riscv64" => "riscv64",
1467 other => other,
1468 }
1469}
1470
1471#[cfg(test)]
1472#[allow(clippy::field_reassign_with_default)]
1473mod tests {
1474 use super::*;
1475 use crate::config::Config;
1476 use crate::git::{GitInfo, SemVer};
1477
1478 fn make_git_info(dirty: bool, prerelease: Option<&str>) -> GitInfo {
1479 let tag = match prerelease {
1480 Some(pre) => format!("v1.2.3-{pre}"),
1481 None => "v1.2.3".to_string(),
1482 };
1483 GitInfo {
1484 tag,
1485 commit: "abc123def456abc123def456abc123def456abc1".to_string(),
1486 short_commit: "abc123d".to_string(),
1487 branch: "main".to_string(),
1488 dirty,
1489 semver: SemVer {
1490 major: 1,
1491 minor: 2,
1492 patch: 3,
1493 prerelease: prerelease.map(|s| s.to_string()),
1494 build_metadata: None,
1495 },
1496 commit_date: "2026-03-25T10:30:00+00:00".to_string(),
1497 commit_timestamp: "1774463400".to_string(),
1498 previous_tag: Some("v1.2.2".to_string()),
1499 remote_url: "https://github.com/test/repo.git".to_string(),
1500 summary: "v1.2.3-0-gabc123d".to_string(),
1501 tag_subject: "Release v1.2.3".to_string(),
1502 tag_contents: "Release v1.2.3\n\nFull release notes here.".to_string(),
1503 tag_body: "Full release notes here.".to_string(),
1504 first_commit: None,
1505 }
1506 }
1507
1508 #[test]
1509 fn test_context_template_vars() {
1510 let mut config = Config::default();
1511 config.project_name = "test-project".to_string();
1512 let ctx = Context::new(config, ContextOptions::default());
1513 assert_eq!(
1514 ctx.template_vars().get("ProjectName"),
1515 Some(&"test-project".to_string())
1516 );
1517 }
1518
1519 #[test]
1520 fn test_context_should_skip() {
1521 let config = Config::default();
1522 let opts = ContextOptions {
1523 skip_stages: vec!["publish".to_string(), "announce".to_string()],
1524 ..Default::default()
1525 };
1526 let ctx = Context::new(config, opts);
1527 assert!(ctx.should_skip("publish"));
1528 assert!(ctx.should_skip("announce"));
1529 assert!(!ctx.should_skip("build"));
1530 }
1531
1532 #[test]
1533 fn publisher_deselected_empty_selectors_runs_everything() {
1534 let ctx = Context::new(Config::default(), ContextOptions::default());
1535 assert!(!ctx.publisher_deselected("npm"));
1536 assert!(!ctx.publisher_deselected("cargo"));
1537 assert!(!ctx.publisher_deselected("anything"));
1538 }
1539
1540 #[test]
1541 fn publisher_deselected_skip_denylists() {
1542 let opts = ContextOptions {
1543 skip_stages: vec!["npm".to_string()],
1544 ..Default::default()
1545 };
1546 let ctx = Context::new(Config::default(), opts);
1547 assert!(ctx.publisher_deselected("npm"));
1548 assert!(!ctx.publisher_deselected("cargo"));
1549 }
1550
1551 #[test]
1552 fn publisher_deselected_allowlist_excludes_unlisted() {
1553 let opts = ContextOptions {
1554 publisher_allowlist: vec!["cargo".to_string()],
1555 ..Default::default()
1556 };
1557 let ctx = Context::new(Config::default(), opts);
1558 assert!(!ctx.publisher_deselected("cargo"));
1559 assert!(ctx.publisher_deselected("npm"));
1560 }
1561
1562 #[test]
1563 fn publisher_deselected_skip_wins_over_allowlist() {
1564 let opts = ContextOptions {
1565 skip_stages: vec!["cargo".to_string()],
1566 publisher_allowlist: vec!["cargo".to_string()],
1567 ..Default::default()
1568 };
1569 let ctx = Context::new(Config::default(), opts);
1570 assert!(ctx.publisher_deselected("cargo"));
1571 }
1572
1573 #[test]
1574 fn test_context_render_template() {
1575 let mut config = Config::default();
1576 config.project_name = "myapp".to_string();
1577 let ctx = Context::new(config, ContextOptions::default());
1578 let result = ctx.render_template("{{ .ProjectName }}-release").unwrap();
1579 assert_eq!(result, "myapp-release");
1580 }
1581
1582 #[test]
1583 fn test_populate_git_vars_sets_all_expected_vars() {
1584 let config = Config::default();
1585 let mut ctx = Context::new(config, ContextOptions::default());
1586 ctx.git_info = Some(make_git_info(false, None));
1587 ctx.populate_git_vars();
1588
1589 let v = ctx.template_vars();
1590 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
1591 assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
1592 assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
1593 assert_eq!(v.get("Major"), Some(&"1".to_string()));
1594 assert_eq!(v.get("Minor"), Some(&"2".to_string()));
1595 assert_eq!(v.get("Patch"), Some(&"3".to_string()));
1596 assert_eq!(v.get("Prerelease"), Some(&"".to_string()));
1597 assert_eq!(
1598 v.get("FullCommit"),
1599 Some(&"abc123def456abc123def456abc123def456abc1".to_string())
1600 );
1601 assert_eq!(v.get("ShortCommit"), Some(&"abc123d".to_string()));
1602 assert_eq!(v.get("Branch"), Some(&"main".to_string()));
1603 assert_eq!(
1604 v.get("CommitDate"),
1605 Some(&"2026-03-25T10:30:00+00:00".to_string())
1606 );
1607 assert_eq!(v.get("CommitTimestamp"), Some(&"1774463400".to_string()));
1608 assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
1609 assert_eq!(v.get("Base"), Some(&"1.2.3".to_string()));
1612 }
1613
1614 #[test]
1615 fn test_nightly_build_defaults_to_zero_without_git_info() {
1616 let config = Config::default();
1619 let mut ctx = Context::new(config, ContextOptions::default());
1620 ctx.git_info = None;
1621 ctx.populate_git_vars();
1622 assert_eq!(
1623 ctx.template_vars().get_structured("NightlyBuild"),
1624 Some(&tera::Value::from(0u64))
1625 );
1626 }
1627
1628 #[test]
1629 fn test_commit_is_alias_for_full_commit() {
1630 let config = Config::default();
1631 let mut ctx = Context::new(config, ContextOptions::default());
1632 ctx.git_info = Some(make_git_info(false, None));
1633 ctx.populate_git_vars();
1634
1635 let v = ctx.template_vars();
1636 assert_eq!(v.get("Commit"), v.get("FullCommit"));
1637 }
1638
1639 #[test]
1640 fn test_populate_git_vars_prerelease() {
1641 let config = Config::default();
1642 let mut ctx = Context::new(config, ContextOptions::default());
1643 ctx.git_info = Some(make_git_info(false, Some("rc.1")));
1644 ctx.populate_git_vars();
1645
1646 let v = ctx.template_vars();
1647 assert_eq!(v.get("Version"), Some(&"1.2.3-rc.1".to_string()));
1648 assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
1649 assert_eq!(v.get("Prerelease"), Some(&"rc.1".to_string()));
1650 }
1651
1652 #[test]
1653 fn test_build_metadata_template_var() {
1654 let config = Config::default();
1655 let mut ctx = Context::new(config, ContextOptions::default());
1656 let mut info = make_git_info(false, None);
1657 info.tag = "v1.2.3+build.42".to_string();
1658 info.semver.build_metadata = Some("build.42".to_string());
1659 ctx.git_info = Some(info);
1660 ctx.populate_git_vars();
1661
1662 let v = ctx.template_vars();
1663 assert_eq!(v.get("BuildMetadata"), Some(&"build.42".to_string()));
1664 assert_eq!(v.get("Version"), Some(&"1.2.3+build.42".to_string()));
1666 }
1667
1668 #[test]
1669 fn test_build_metadata_empty_when_none() {
1670 let config = Config::default();
1671 let mut ctx = Context::new(config, ContextOptions::default());
1672 ctx.git_info = Some(make_git_info(false, None));
1673 ctx.populate_git_vars();
1674
1675 assert_eq!(
1676 ctx.template_vars().get("BuildMetadata"),
1677 Some(&"".to_string())
1678 );
1679 }
1680
1681 #[test]
1682 fn test_populate_git_vars_monorepo_prefixed_tag() {
1683 let config = Config::default();
1686 let mut ctx = Context::new(config, ContextOptions::default());
1687 let mut info = make_git_info(false, None);
1688 info.tag = "core-v0.3.2".to_string();
1689 info.semver = SemVer {
1690 major: 0,
1691 minor: 3,
1692 patch: 2,
1693 prerelease: None,
1694 build_metadata: None,
1695 };
1696 ctx.git_info = Some(info);
1697 ctx.populate_git_vars();
1698
1699 let v = ctx.template_vars();
1700 assert_eq!(v.get("Tag"), Some(&"core-v0.3.2".to_string()));
1701 assert_eq!(v.get("Version"), Some(&"0.3.2".to_string()));
1702 assert_eq!(v.get("RawVersion"), Some(&"0.3.2".to_string()));
1703 assert_eq!(v.get("Major"), Some(&"0".to_string()));
1704 assert_eq!(v.get("Minor"), Some(&"3".to_string()));
1705 assert_eq!(v.get("Patch"), Some(&"2".to_string()));
1706 }
1707
1708 #[test]
1709 fn test_populate_git_vars_monorepo_prefixed_tag_with_prerelease() {
1710 let config = Config::default();
1711 let mut ctx = Context::new(config, ContextOptions::default());
1712 let mut info = make_git_info(false, None);
1713 info.tag = "operator-v1.0.0-rc.1".to_string();
1714 info.semver = SemVer {
1715 major: 1,
1716 minor: 0,
1717 patch: 0,
1718 prerelease: Some("rc.1".to_string()),
1719 build_metadata: None,
1720 };
1721 ctx.git_info = Some(info);
1722 ctx.populate_git_vars();
1723
1724 let v = ctx.template_vars();
1725 assert_eq!(v.get("Tag"), Some(&"operator-v1.0.0-rc.1".to_string()));
1726 assert_eq!(v.get("Version"), Some(&"1.0.0-rc.1".to_string()));
1727 assert_eq!(v.get("RawVersion"), Some(&"1.0.0".to_string()));
1728 }
1729
1730 #[test]
1731 fn test_git_tree_state_clean() {
1732 let config = Config::default();
1733 let mut ctx = Context::new(config, ContextOptions::default());
1734 ctx.git_info = Some(make_git_info(false, None));
1735 ctx.populate_git_vars();
1736
1737 let v = ctx.template_vars();
1738 assert_eq!(
1739 v.get_structured("IsGitDirty"),
1740 Some(&tera::Value::Bool(false))
1741 );
1742 assert_eq!(v.get("GitTreeState"), Some(&"clean".to_string()));
1743 }
1744
1745 #[test]
1746 fn test_git_tree_state_dirty() {
1747 let config = Config::default();
1748 let mut ctx = Context::new(config, ContextOptions::default());
1749 ctx.git_info = Some(make_git_info(true, None));
1750 ctx.populate_git_vars();
1751
1752 let v = ctx.template_vars();
1753 assert_eq!(
1754 v.get_structured("IsGitDirty"),
1755 Some(&tera::Value::Bool(true))
1756 );
1757 assert_eq!(v.get("GitTreeState"), Some(&"dirty".to_string()));
1758 }
1759
1760 #[test]
1761 fn test_is_snapshot_reflects_context_options() {
1762 let config = Config::default();
1763 let opts = ContextOptions {
1764 snapshot: true,
1765 ..Default::default()
1766 };
1767 let mut ctx = Context::new(config, opts);
1768 ctx.git_info = Some(make_git_info(false, None));
1769 ctx.populate_git_vars();
1770
1771 assert_eq!(
1772 ctx.template_vars().get_structured("IsSnapshot"),
1773 Some(&tera::Value::Bool(true))
1774 );
1775
1776 let config2 = Config::default();
1778 let opts2 = ContextOptions {
1779 snapshot: false,
1780 ..Default::default()
1781 };
1782 let mut ctx2 = Context::new(config2, opts2);
1783 ctx2.git_info = Some(make_git_info(false, None));
1784 ctx2.populate_git_vars();
1785
1786 assert_eq!(
1787 ctx2.template_vars().get_structured("IsSnapshot"),
1788 Some(&tera::Value::Bool(false))
1789 );
1790 }
1791
1792 #[test]
1793 fn test_is_draft_defaults_to_false() {
1794 let config = Config::default();
1795 let mut ctx = Context::new(config, ContextOptions::default());
1796 ctx.git_info = Some(make_git_info(false, None));
1797 ctx.populate_git_vars();
1798
1799 assert_eq!(
1800 ctx.template_vars().get_structured("IsDraft"),
1801 Some(&tera::Value::Bool(false))
1802 );
1803 }
1804
1805 #[test]
1806 fn test_previous_tag_empty_when_none() {
1807 let config = Config::default();
1808 let mut ctx = Context::new(config, ContextOptions::default());
1809 let mut info = make_git_info(false, None);
1810 info.previous_tag = None;
1811 ctx.git_info = Some(info);
1812 ctx.populate_git_vars();
1813
1814 assert_eq!(
1815 ctx.template_vars().get("PreviousTag"),
1816 Some(&"".to_string())
1817 );
1818 }
1819
1820 #[test]
1829 fn populate_time_vars_uses_source_date_epoch_when_set() {
1830 let env = crate::MapEnvSource::new().with("SOURCE_DATE_EPOCH", "1715000000");
1834 let config = Config::default();
1835 let mut ctx = Context::new(config, ContextOptions::default());
1836 ctx.set_env_source(env);
1837 ctx.populate_time_vars();
1838
1839 let v = ctx.template_vars();
1840 assert_eq!(
1841 v.get("Timestamp"),
1842 Some(&"1715000000".to_string()),
1843 "Timestamp must equal SOURCE_DATE_EPOCH seconds"
1844 );
1845 assert_eq!(
1846 v.get("Date"),
1847 Some(&"2024-05-06T12:53:20+00:00".to_string()),
1848 "Date must be RFC 3339 derived from SDE"
1849 );
1850 assert_eq!(v.get("Year"), Some(&"2024".to_string()));
1851 assert_eq!(v.get("Month"), Some(&"05".to_string()));
1852 assert_eq!(v.get("Day"), Some(&"06".to_string()));
1853 }
1854
1855 #[test]
1856 fn test_populate_time_vars() {
1857 let env = crate::MapEnvSource::new();
1860 let config = Config::default();
1861 let mut ctx = Context::new(config, ContextOptions::default());
1862 ctx.set_env_source(env);
1863 ctx.populate_time_vars();
1864
1865 let v = ctx.template_vars();
1866
1867 let date = v
1869 .get("Date")
1870 .unwrap_or_else(|| panic!("Date should be set"));
1871 assert!(
1872 date.contains('T') && date.len() > 10,
1873 "Date should be RFC 3339, got: {date}"
1874 );
1875
1876 let ts = v
1878 .get("Timestamp")
1879 .unwrap_or_else(|| panic!("Timestamp should be set"));
1880 assert!(
1881 ts.parse::<i64>().is_ok(),
1882 "Timestamp should be a numeric string, got: {ts}"
1883 );
1884
1885 let now = v.get("Now").unwrap_or_else(|| panic!("Now should be set"));
1887 assert!(now.contains('T'), "Now should be ISO 8601, got: {now}");
1888 }
1889
1890 #[test]
1891 fn test_env_vars_accessible_in_templates() {
1892 let mut config = Config::default();
1893 config.project_name = "myapp".to_string();
1894 let mut ctx = Context::new(config, ContextOptions::default());
1895 ctx.template_vars_mut().set_env("MY_VAR", "hello-world");
1896 ctx.template_vars_mut().set_env("DEPLOY_ENV", "staging");
1897
1898 let result = ctx
1899 .render_template("{{ .Env.MY_VAR }}-{{ .Env.DEPLOY_ENV }}")
1900 .unwrap();
1901 assert_eq!(result, "hello-world-staging");
1902 }
1903
1904 #[test]
1905 fn test_populate_git_vars_without_git_info_still_sets_snapshot() {
1906 let config = Config::default();
1907 let opts = ContextOptions {
1908 snapshot: true,
1909 ..Default::default()
1910 };
1911 let mut ctx = Context::new(config, opts);
1912 ctx.populate_git_vars();
1914
1915 assert_eq!(
1916 ctx.template_vars().get_structured("IsSnapshot"),
1917 Some(&tera::Value::Bool(true))
1918 );
1919 assert_eq!(
1920 ctx.template_vars().get_structured("IsDraft"),
1921 Some(&tera::Value::Bool(false))
1922 );
1923 assert_eq!(ctx.template_vars().get("Tag"), None);
1925 }
1926
1927 #[test]
1928 fn test_is_nightly_set_when_nightly_mode_active() {
1929 let config = Config::default();
1930 let opts = ContextOptions {
1931 nightly: true,
1932 ..Default::default()
1933 };
1934 let mut ctx = Context::new(config, opts);
1935 ctx.git_info = Some(make_git_info(false, None));
1936 ctx.populate_git_vars();
1937
1938 assert_eq!(
1939 ctx.template_vars().get_structured("IsNightly"),
1940 Some(&tera::Value::Bool(true)),
1941 "IsNightly should be 'true' when nightly mode is active"
1942 );
1943 assert!(ctx.is_nightly(), "is_nightly() should return true");
1944 }
1945
1946 #[test]
1947 fn test_is_nightly_false_by_default() {
1948 let config = Config::default();
1949 let mut ctx = Context::new(config, ContextOptions::default());
1950 ctx.git_info = Some(make_git_info(false, None));
1951 ctx.populate_git_vars();
1952
1953 assert_eq!(
1954 ctx.template_vars().get_structured("IsNightly"),
1955 Some(&tera::Value::Bool(false)),
1956 "IsNightly should default to 'false'"
1957 );
1958 assert!(
1959 !ctx.is_nightly(),
1960 "is_nightly() should return false by default"
1961 );
1962 }
1963
1964 #[test]
1965 fn test_version_returns_populated_value() {
1966 let config = Config::default();
1967 let mut ctx = Context::new(config, ContextOptions::default());
1968 ctx.git_info = Some(make_git_info(false, None));
1969 ctx.populate_git_vars();
1970
1971 assert_eq!(ctx.version(), "1.2.3");
1972 }
1973
1974 #[test]
1975 fn test_version_returns_empty_when_not_set() {
1976 let config = Config::default();
1977 let ctx = Context::new(config, ContextOptions::default());
1978 assert_eq!(ctx.version(), "");
1979 }
1980
1981 #[test]
1982 fn test_is_nightly_without_git_info() {
1983 let config = Config::default();
1984 let opts = ContextOptions {
1985 nightly: true,
1986 ..Default::default()
1987 };
1988 let mut ctx = Context::new(config, opts);
1989 ctx.populate_git_vars();
1991
1992 assert_eq!(
1993 ctx.template_vars().get_structured("IsNightly"),
1994 Some(&tera::Value::Bool(true)),
1995 "IsNightly should be set even without git info"
1996 );
1997 }
1998
1999 #[test]
2000 fn test_is_git_clean_when_not_dirty() {
2001 let config = Config::default();
2002 let mut ctx = Context::new(config, ContextOptions::default());
2003 ctx.git_info = Some(make_git_info(false, None));
2004 ctx.populate_git_vars();
2005
2006 assert_eq!(
2007 ctx.template_vars().get_structured("IsGitClean"),
2008 Some(&tera::Value::Bool(true))
2009 );
2010 }
2011
2012 #[test]
2013 fn test_is_git_clean_when_dirty() {
2014 let config = Config::default();
2015 let mut ctx = Context::new(config, ContextOptions::default());
2016 ctx.git_info = Some(make_git_info(true, None));
2017 ctx.populate_git_vars();
2018
2019 assert_eq!(
2020 ctx.template_vars().get_structured("IsGitClean"),
2021 Some(&tera::Value::Bool(false))
2022 );
2023 }
2024
2025 #[test]
2026 fn test_git_url_set_from_git_info() {
2027 let config = Config::default();
2028 let mut ctx = Context::new(config, ContextOptions::default());
2029 ctx.git_info = Some(make_git_info(false, None));
2030 ctx.populate_git_vars();
2031
2032 assert_eq!(
2033 ctx.template_vars().get("GitURL"),
2034 Some(&"https://github.com/test/repo.git".to_string())
2035 );
2036 }
2037
2038 #[test]
2039 fn test_summary_set_from_git_info() {
2040 let config = Config::default();
2041 let mut ctx = Context::new(config, ContextOptions::default());
2042 ctx.git_info = Some(make_git_info(false, None));
2043 ctx.populate_git_vars();
2044
2045 assert_eq!(
2046 ctx.template_vars().get("Summary"),
2047 Some(&"v1.2.3-0-gabc123d".to_string())
2048 );
2049 }
2050
2051 #[test]
2052 fn test_tag_subject_set_from_git_info() {
2053 let config = Config::default();
2054 let mut ctx = Context::new(config, ContextOptions::default());
2055 ctx.git_info = Some(make_git_info(false, None));
2056 ctx.populate_git_vars();
2057
2058 assert_eq!(
2059 ctx.template_vars().get("TagSubject"),
2060 Some(&"Release v1.2.3".to_string())
2061 );
2062 }
2063
2064 #[test]
2065 fn test_tag_contents_set_from_git_info() {
2066 let config = Config::default();
2067 let mut ctx = Context::new(config, ContextOptions::default());
2068 ctx.git_info = Some(make_git_info(false, None));
2069 ctx.populate_git_vars();
2070
2071 assert_eq!(
2072 ctx.template_vars().get("TagContents"),
2073 Some(&"Release v1.2.3\n\nFull release notes here.".to_string())
2074 );
2075 }
2076
2077 #[test]
2078 fn test_tag_body_set_from_git_info() {
2079 let config = Config::default();
2080 let mut ctx = Context::new(config, ContextOptions::default());
2081 ctx.git_info = Some(make_git_info(false, None));
2082 ctx.populate_git_vars();
2083
2084 assert_eq!(
2085 ctx.template_vars().get("TagBody"),
2086 Some(&"Full release notes here.".to_string())
2087 );
2088 }
2089
2090 #[test]
2091 fn test_is_single_target_false_by_default() {
2092 let config = Config::default();
2093 let mut ctx = Context::new(config, ContextOptions::default());
2094 ctx.git_info = Some(make_git_info(false, None));
2095 ctx.populate_git_vars();
2096
2097 assert_eq!(
2098 ctx.template_vars().get_structured("IsSingleTarget"),
2099 Some(&tera::Value::Bool(false))
2100 );
2101 }
2102
2103 #[test]
2104 fn test_is_single_target_true_when_set() {
2105 let config = Config::default();
2106 let opts = ContextOptions {
2107 single_target: Some("x86_64-unknown-linux-gnu".to_string()),
2108 ..Default::default()
2109 };
2110 let mut ctx = Context::new(config, opts);
2111 ctx.git_info = Some(make_git_info(false, None));
2112 ctx.populate_git_vars();
2113
2114 assert_eq!(
2115 ctx.template_vars().get_structured("IsSingleTarget"),
2116 Some(&tera::Value::Bool(true))
2117 );
2118 }
2119
2120 #[test]
2121 #[serial_test::serial]
2122 fn test_populate_runtime_vars() {
2123 let config = Config::default();
2124 let mut ctx = Context::new(config, ContextOptions::default());
2125 ctx.populate_runtime_vars();
2126
2127 let v = ctx.template_vars();
2128
2129 let goos = v
2130 .get("RuntimeGoos")
2131 .unwrap_or_else(|| panic!("RuntimeGoos should be set"));
2132 assert!(
2133 !goos.is_empty(),
2134 "RuntimeGoos should not be empty, got: {goos}"
2135 );
2136 assert_eq!(goos, map_os_to_goos(std::env::consts::OS));
2138
2139 let goarch = v
2140 .get("RuntimeGoarch")
2141 .unwrap_or_else(|| panic!("RuntimeGoarch should be set"));
2142 assert!(
2143 !goarch.is_empty(),
2144 "RuntimeGoarch should not be empty, got: {goarch}"
2145 );
2146 assert_eq!(goarch, map_arch_to_goarch(std::env::consts::ARCH));
2148 }
2149
2150 #[test]
2151 fn test_populate_release_notes_var_with_changelogs() {
2152 let mut config = Config::default();
2153 config.crates.push(crate::config::CrateConfig {
2154 name: "my-crate".to_string(),
2155 ..Default::default()
2156 });
2157 let mut ctx = Context::new(config, ContextOptions::default());
2158 ctx.stage_outputs
2159 .changelogs
2160 .insert("my-crate".to_string(), "## Changes\n- fix bug".to_string());
2161 ctx.populate_release_notes_var();
2162
2163 assert_eq!(
2164 ctx.template_vars().get("ReleaseNotes"),
2165 Some(&"## Changes\n- fix bug".to_string())
2166 );
2167 }
2168
2169 #[test]
2170 fn test_populate_release_notes_var_empty_when_no_changelogs() {
2171 let config = Config::default();
2172 let mut ctx = Context::new(config, ContextOptions::default());
2173 ctx.populate_release_notes_var();
2174
2175 assert_eq!(
2176 ctx.template_vars().get("ReleaseNotes"),
2177 Some(&"".to_string())
2178 );
2179 }
2180
2181 #[test]
2182 fn test_populate_release_notes_var_deterministic_with_multiple_crates() {
2183 let mut config = Config::default();
2184 config.crates.push(crate::config::CrateConfig {
2185 name: "crate-a".to_string(),
2186 ..Default::default()
2187 });
2188 config.crates.push(crate::config::CrateConfig {
2189 name: "crate-b".to_string(),
2190 ..Default::default()
2191 });
2192 let mut ctx = Context::new(config, ContextOptions::default());
2193 ctx.stage_outputs
2194 .changelogs
2195 .insert("crate-a".to_string(), "notes-a".to_string());
2196 ctx.stage_outputs
2197 .changelogs
2198 .insert("crate-b".to_string(), "notes-b".to_string());
2199 ctx.populate_release_notes_var();
2200
2201 assert_eq!(
2203 ctx.template_vars().get("ReleaseNotes"),
2204 Some(&"notes-a".to_string())
2205 );
2206 }
2207
2208 #[test]
2209 fn test_outputs_accessible_in_templates() {
2210 let mut config = Config::default();
2211 config.project_name = "myapp".to_string();
2212 let mut ctx = Context::new(config, ContextOptions::default());
2213 ctx.template_vars_mut().set_output("build_id", "abc123");
2214 ctx.template_vars_mut()
2215 .set_output("deploy_url", "https://example.com");
2216
2217 let result = ctx
2218 .render_template("{{ .Outputs.build_id }}-{{ .Outputs.deploy_url }}")
2219 .unwrap();
2220 assert_eq!(result, "abc123-https://example.com");
2221 }
2222
2223 #[test]
2224 fn test_artifact_ext_and_target_template_vars() {
2225 let mut config = Config::default();
2226 config.project_name = "myapp".to_string();
2227 let mut ctx = Context::new(config, ContextOptions::default());
2228 ctx.template_vars_mut().set("ArtifactName", "myapp.tar.gz");
2229 ctx.template_vars_mut().set("ArtifactExt", ".tar.gz");
2230 ctx.template_vars_mut()
2231 .set("Target", "x86_64-unknown-linux-gnu");
2232
2233 let result = ctx
2234 .render_template("{{ .ArtifactExt }}_{{ .Target }}")
2235 .unwrap();
2236 assert_eq!(result, ".tar.gz_x86_64-unknown-linux-gnu");
2237 }
2238
2239 #[test]
2240 fn test_checksums_template_var() {
2241 let mut config = Config::default();
2242 config.project_name = "myapp".to_string();
2243 let mut ctx = Context::new(config, ContextOptions::default());
2244 let checksum_text = "abc123 myapp.tar.gz\ndef456 myapp.zip\n";
2245 ctx.template_vars_mut().set("Checksums", checksum_text);
2246
2247 let result = ctx.render_template("{{ .Checksums }}").unwrap();
2248 assert_eq!(result, checksum_text);
2249 }
2250
2251 #[test]
2254 fn test_prefixed_tag_with_tag_prefix() {
2255 let mut config = Config::default();
2256 config.tag = Some(crate::config::TagConfig {
2257 tag_prefix: Some("api/".to_string()),
2258 ..Default::default()
2259 });
2260 let mut ctx = Context::new(config, ContextOptions::default());
2261 ctx.git_info = Some(make_git_info(false, None));
2262 ctx.populate_git_vars();
2263
2264 assert_eq!(
2265 ctx.template_vars().get("PrefixedTag"),
2266 Some(&"api/v1.2.3".to_string())
2267 );
2268 }
2269
2270 #[test]
2271 fn test_prefixed_tag_without_tag_prefix() {
2272 let config = Config::default();
2273 let mut ctx = Context::new(config, ContextOptions::default());
2274 ctx.git_info = Some(make_git_info(false, None));
2275 ctx.populate_git_vars();
2276
2277 assert_eq!(
2279 ctx.template_vars().get("PrefixedTag"),
2280 Some(&"v1.2.3".to_string())
2281 );
2282 }
2283
2284 #[test]
2285 fn test_prefixed_previous_tag_with_tag_prefix() {
2286 let mut config = Config::default();
2287 config.tag = Some(crate::config::TagConfig {
2288 tag_prefix: Some("api/".to_string()),
2289 ..Default::default()
2290 });
2291 let mut ctx = Context::new(config, ContextOptions::default());
2292 ctx.git_info = Some(make_git_info(false, None));
2293 ctx.populate_git_vars();
2294
2295 assert_eq!(
2296 ctx.template_vars().get("PrefixedPreviousTag"),
2297 Some(&"api/v1.2.2".to_string())
2298 );
2299 }
2300
2301 #[test]
2302 fn test_prefixed_previous_tag_empty_when_no_previous() {
2303 let mut config = Config::default();
2304 config.tag = Some(crate::config::TagConfig {
2305 tag_prefix: Some("api/".to_string()),
2306 ..Default::default()
2307 });
2308 let mut ctx = Context::new(config, ContextOptions::default());
2309 let mut info = make_git_info(false, None);
2310 info.previous_tag = None;
2311 ctx.git_info = Some(info);
2312 ctx.populate_git_vars();
2313
2314 assert_eq!(
2317 ctx.template_vars().get("PrefixedPreviousTag"),
2318 Some(&"".to_string())
2319 );
2320 }
2321
2322 #[test]
2323 fn test_prefixed_summary_with_tag_prefix() {
2324 let mut config = Config::default();
2325 config.tag = Some(crate::config::TagConfig {
2326 tag_prefix: Some("api/".to_string()),
2327 ..Default::default()
2328 });
2329 let mut ctx = Context::new(config, ContextOptions::default());
2330 ctx.git_info = Some(make_git_info(false, None));
2331 ctx.populate_git_vars();
2332
2333 assert_eq!(
2334 ctx.template_vars().get("PrefixedSummary"),
2335 Some(&"api/v1.2.3-0-gabc123d".to_string())
2336 );
2337 }
2338
2339 #[test]
2340 fn test_is_release_true_for_normal_release() {
2341 let config = Config::default();
2342 let opts = ContextOptions {
2343 snapshot: false,
2344 nightly: false,
2345 ..Default::default()
2346 };
2347 let mut ctx = Context::new(config, opts);
2348 ctx.git_info = Some(make_git_info(false, None));
2349 ctx.populate_git_vars();
2350
2351 assert_eq!(
2352 ctx.template_vars().get_structured("IsRelease"),
2353 Some(&tera::Value::Bool(true))
2354 );
2355 }
2356
2357 #[test]
2358 fn test_is_release_false_for_snapshot() {
2359 let config = Config::default();
2360 let opts = ContextOptions {
2361 snapshot: true,
2362 ..Default::default()
2363 };
2364 let mut ctx = Context::new(config, opts);
2365 ctx.git_info = Some(make_git_info(false, None));
2366 ctx.populate_git_vars();
2367
2368 assert_eq!(
2369 ctx.template_vars().get_structured("IsRelease"),
2370 Some(&tera::Value::Bool(false))
2371 );
2372 }
2373
2374 #[test]
2375 fn test_is_release_false_for_nightly() {
2376 let config = Config::default();
2377 let opts = ContextOptions {
2378 nightly: true,
2379 ..Default::default()
2380 };
2381 let mut ctx = Context::new(config, opts);
2382 ctx.git_info = Some(make_git_info(false, None));
2383 ctx.populate_git_vars();
2384
2385 assert_eq!(
2386 ctx.template_vars().get_structured("IsRelease"),
2387 Some(&tera::Value::Bool(false))
2388 );
2389 }
2390
2391 #[test]
2392 fn test_is_merging_true_when_merge_flag_set() {
2393 let config = Config::default();
2394 let opts = ContextOptions {
2395 merge: true,
2396 ..Default::default()
2397 };
2398 let mut ctx = Context::new(config, opts);
2399 ctx.git_info = Some(make_git_info(false, None));
2400 ctx.populate_git_vars();
2401
2402 assert_eq!(
2403 ctx.template_vars().get_structured("IsMerging"),
2404 Some(&tera::Value::Bool(true))
2405 );
2406 }
2407
2408 #[test]
2409 fn test_is_merging_false_by_default() {
2410 let config = Config::default();
2411 let mut ctx = Context::new(config, ContextOptions::default());
2412 ctx.git_info = Some(make_git_info(false, None));
2413 ctx.populate_git_vars();
2414
2415 assert_eq!(
2416 ctx.template_vars().get_structured("IsMerging"),
2417 Some(&tera::Value::Bool(false))
2418 );
2419 }
2420
2421 #[test]
2422 fn test_refresh_artifacts_var_empty() {
2423 let config = Config::default();
2424 let mut ctx = Context::new(config, ContextOptions::default());
2425 ctx.refresh_artifacts_var();
2426
2427 let result = ctx
2429 .render_template("{% for a in Artifacts %}{{ a.name }}{% endfor %}")
2430 .unwrap();
2431 assert_eq!(result, "");
2432 }
2433
2434 #[test]
2435 fn test_refresh_artifacts_var_with_artifacts() {
2436 use crate::artifact::{Artifact, ArtifactKind};
2437 use std::collections::HashMap;
2438 use std::path::PathBuf;
2439
2440 let config = Config::default();
2441 let mut ctx = Context::new(config, ContextOptions::default());
2442 ctx.artifacts.add(Artifact {
2446 kind: ArtifactKind::Archive,
2447 name: String::new(),
2448 path: PathBuf::from("dist/myapp-1.0.0-linux-amd64.tar.gz"),
2449 target: Some("x86_64-unknown-linux-gnu".to_string()),
2450 crate_name: "myapp".to_string(),
2451 metadata: HashMap::from([("format".to_string(), "tar.gz".to_string())]),
2452 size: None,
2453 });
2454 ctx.artifacts.add(Artifact {
2455 kind: ArtifactKind::Binary,
2456 name: String::new(),
2457 path: PathBuf::from("dist/myapp"),
2458 target: Some("x86_64-unknown-linux-gnu".to_string()),
2459 crate_name: "myapp".to_string(),
2460 metadata: HashMap::new(),
2461 size: None,
2462 });
2463 ctx.refresh_artifacts_var();
2464
2465 let result = ctx
2467 .render_template("{% for a in Artifacts %}{{ a.name }},{% endfor %}")
2468 .unwrap();
2469 assert!(result.contains("myapp-1.0.0-linux-amd64.tar.gz"));
2470 assert!(result.contains("myapp"));
2471
2472 let result_kinds = ctx
2474 .render_template("{% for a in Artifacts %}{{ a.kind }},{% endfor %}")
2475 .unwrap();
2476 assert!(result_kinds.contains("archive"));
2477 assert!(result_kinds.contains("binary"));
2478 }
2479
2480 #[test]
2481 fn test_populate_metadata_var_with_mod_timestamp() {
2482 let mut config = Config::default();
2483 config.metadata = Some(crate::config::MetadataConfig {
2484 mod_timestamp: Some("{{ .CommitTimestamp }}".to_string()),
2485 ..Default::default()
2486 });
2487 let mut ctx = Context::new(config, ContextOptions::default());
2488 ctx.populate_metadata_var().unwrap();
2489
2490 let result = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
2492 assert_eq!(result, "{{ .CommitTimestamp }}");
2493 }
2494
2495 #[test]
2496 fn test_populate_metadata_var_empty_when_no_config() {
2497 let config = Config::default();
2498 let mut ctx = Context::new(config, ContextOptions::default());
2499 ctx.populate_metadata_var().unwrap();
2500
2501 let result = ctx.render_template("{{ Metadata.Description }}").unwrap();
2503 assert_eq!(result, "");
2504 }
2505
2506 #[test]
2507 fn test_populate_metadata_var_reads_from_config() {
2508 let mut config = Config::default();
2509 config.metadata = Some(crate::config::MetadataConfig {
2510 description: Some("A test project".to_string()),
2511 homepage: Some("https://example.com".to_string()),
2512 documentation: Some("https://docs.example.com".to_string()),
2513 license: Some("MIT".to_string()),
2514 maintainers: Some(vec!["Alice".to_string(), "Bob".to_string()]),
2515 mod_timestamp: Some("1234567890".to_string()),
2516 ..Default::default()
2517 });
2518 let mut ctx = Context::new(config, ContextOptions::default());
2519 ctx.populate_metadata_var().unwrap();
2520
2521 let desc = ctx.render_template("{{ Metadata.Description }}").unwrap();
2522 assert_eq!(desc, "A test project");
2523
2524 let home = ctx.render_template("{{ Metadata.Homepage }}").unwrap();
2525 assert_eq!(home, "https://example.com");
2526
2527 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2528 assert_eq!(docs, "https://docs.example.com");
2529
2530 let lic = ctx.render_template("{{ Metadata.License }}").unwrap();
2531 assert_eq!(lic, "MIT");
2532
2533 let ts = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
2534 assert_eq!(ts, "1234567890");
2535 }
2536
2537 #[test]
2538 fn test_populate_metadata_var_license_falls_back_to_derived() {
2539 let mut config = Config::default();
2543 config.crates = vec![crate::config::CrateConfig {
2544 name: "anodizer".to_string(),
2545 ..Default::default()
2546 }];
2547 config.derived_metadata.insert(
2548 "anodizer".to_string(),
2549 crate::config::MetadataConfig {
2550 description: Some("Derived desc".to_string()),
2551 homepage: Some("https://derived.example".to_string()),
2552 documentation: Some("https://derived.docs".to_string()),
2553 license: Some("MIT OR Apache-2.0".to_string()),
2554 ..Default::default()
2555 },
2556 );
2557 let mut ctx = Context::new(config, ContextOptions::default());
2558 ctx.populate_metadata_var().unwrap();
2559
2560 assert_eq!(
2561 ctx.render_template("{{ Metadata.License }}").unwrap(),
2562 "MIT OR Apache-2.0"
2563 );
2564 assert_eq!(
2565 ctx.render_template("{{ Metadata.Description }}").unwrap(),
2566 "Derived desc"
2567 );
2568 assert_eq!(
2569 ctx.render_template("{{ Metadata.Homepage }}").unwrap(),
2570 "https://derived.example"
2571 );
2572 assert_eq!(
2573 ctx.render_template("{{ Metadata.Documentation }}").unwrap(),
2574 "https://derived.docs"
2575 );
2576 }
2577
2578 #[test]
2579 fn test_populate_metadata_var_top_level_license_wins_over_derived() {
2580 let mut config = Config::default();
2583 config.crates = vec![crate::config::CrateConfig {
2584 name: "anodizer".to_string(),
2585 ..Default::default()
2586 }];
2587 config.derived_metadata.insert(
2588 "anodizer".to_string(),
2589 crate::config::MetadataConfig {
2590 license: Some("MIT OR Apache-2.0".to_string()),
2591 ..Default::default()
2592 },
2593 );
2594 config.metadata = Some(crate::config::MetadataConfig {
2595 license: Some("GPL-3.0".to_string()),
2596 ..Default::default()
2597 });
2598 let mut ctx = Context::new(config, ContextOptions::default());
2599 ctx.populate_metadata_var().unwrap();
2600
2601 assert_eq!(
2602 ctx.render_template("{{ Metadata.License }}").unwrap(),
2603 "GPL-3.0"
2604 );
2605 }
2606
2607 #[test]
2608 fn test_populate_metadata_var_documentation_renders() {
2609 let mut config = Config::default();
2610 config.metadata = Some(crate::config::MetadataConfig {
2611 documentation: Some("https://docs.rs/anodizer".to_string()),
2612 ..Default::default()
2613 });
2614 let mut ctx = Context::new(config, ContextOptions::default());
2615 ctx.populate_metadata_var().unwrap();
2616
2617 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2618 assert_eq!(docs, "https://docs.rs/anodizer");
2619 }
2620
2621 #[test]
2622 fn test_populate_metadata_var_documentation_empty_when_unset() {
2623 let mut ctx = Context::new(Config::default(), ContextOptions::default());
2624 ctx.populate_metadata_var().unwrap();
2625
2626 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2627 assert_eq!(docs, "");
2628 }
2629
2630 #[test]
2631 fn test_populate_metadata_var_full_description_inline() {
2632 use crate::config::ContentSource;
2633 let mut config = Config::default();
2634 config.metadata = Some(crate::config::MetadataConfig {
2635 full_description: Some(ContentSource::Inline(
2636 "A long-form description of the project.".to_string(),
2637 )),
2638 ..Default::default()
2639 });
2640 let mut ctx = Context::new(config, ContextOptions::default());
2641 ctx.populate_metadata_var().unwrap();
2642 let rendered = ctx
2643 .render_template("{{ Metadata.FullDescription }}")
2644 .unwrap();
2645 assert_eq!(rendered, "A long-form description of the project.");
2646 }
2647
2648 #[test]
2649 fn test_populate_metadata_var_full_description_from_file() {
2650 use crate::config::ContentSource;
2651 let tmp = tempfile::tempdir().unwrap();
2652 let desc_path = tmp.path().join("DESCRIPTION.md");
2653 std::fs::write(&desc_path, "read from disk").unwrap();
2654 let mut config = Config::default();
2655 config.metadata = Some(crate::config::MetadataConfig {
2656 full_description: Some(ContentSource::FromFile {
2657 from_file: desc_path.to_string_lossy().into_owned(),
2658 }),
2659 ..Default::default()
2660 });
2661 let mut ctx = Context::new(config, ContextOptions::default());
2662 ctx.populate_metadata_var().unwrap();
2663 let rendered = ctx
2664 .render_template("{{ Metadata.FullDescription }}")
2665 .unwrap();
2666 assert_eq!(rendered, "read from disk");
2667 }
2668
2669 #[test]
2670 fn test_populate_metadata_var_full_description_from_url_resolves() {
2671 use crate::config::ContentSource;
2676 use crate::test_helpers::responder::spawn_oneshot_http_responder;
2677
2678 let body = "long form description body";
2679 let body_len = body.len();
2680 let response: &'static str = Box::leak(
2681 format!("HTTP/1.1 200 OK\r\nContent-Length: {body_len}\r\n\r\n{body}").into_boxed_str(),
2682 );
2683 let (addr, _calls) = spawn_oneshot_http_responder(vec![response]);
2684
2685 let mut config = Config::default();
2686 config.metadata = Some(crate::config::MetadataConfig {
2687 full_description: Some(ContentSource::FromUrl {
2688 from_url: format!("http://{addr}/description.md"),
2689 headers: None,
2690 }),
2691 ..Default::default()
2692 });
2693 let mut ctx = Context::new(config, ContextOptions::default());
2694 ctx.populate_metadata_var()
2695 .expect("from_url should resolve through content_source");
2696 let rendered = ctx
2697 .render_template("{{ Metadata.FullDescription }}")
2698 .unwrap();
2699 assert_eq!(rendered, body);
2700 }
2701
2702 #[test]
2703 fn test_populate_metadata_var_commit_author() {
2704 use crate::config::CommitAuthorConfig;
2705 let mut config = Config::default();
2706 config.metadata = Some(crate::config::MetadataConfig {
2707 commit_author: Some(CommitAuthorConfig {
2708 name: Some("Alice Developer".to_string()),
2709 email: Some("alice@example.com".to_string()),
2710 signing: None,
2711 use_github_app_token: false,
2712 }),
2713 ..Default::default()
2714 });
2715 let mut ctx = Context::new(config, ContextOptions::default());
2716 ctx.populate_metadata_var().unwrap();
2717 let name = ctx
2718 .render_template("{{ Metadata.CommitAuthor.Name }}")
2719 .unwrap();
2720 assert_eq!(name, "Alice Developer");
2721 let email = ctx
2722 .render_template("{{ Metadata.CommitAuthor.Email }}")
2723 .unwrap();
2724 assert_eq!(email, "alice@example.com");
2725 }
2726
2727 #[test]
2728 fn test_artifact_id_template_var() {
2729 let mut config = Config::default();
2730 config.project_name = "myapp".to_string();
2731 let mut ctx = Context::new(config, ContextOptions::default());
2732 ctx.template_vars_mut().set("ArtifactID", "default");
2733
2734 let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
2735 assert_eq!(result, "default");
2736 }
2737
2738 #[test]
2739 fn test_artifact_id_empty_when_not_set() {
2740 let mut config = Config::default();
2741 config.project_name = "myapp".to_string();
2742 let mut ctx = Context::new(config, ContextOptions::default());
2743 ctx.template_vars_mut().set("ArtifactID", "");
2744
2745 let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
2746 assert_eq!(result, "");
2747 }
2748
2749 #[test]
2750 fn test_pro_vars_rendered_in_templates() {
2751 let mut config = Config::default();
2753 config.tag = Some(crate::config::TagConfig {
2754 tag_prefix: Some("api/".to_string()),
2755 ..Default::default()
2756 });
2757 let opts = ContextOptions {
2758 snapshot: false,
2759 nightly: false,
2760 merge: true,
2761 ..Default::default()
2762 };
2763 let mut ctx = Context::new(config, opts);
2764 ctx.git_info = Some(make_git_info(false, None));
2765 ctx.populate_git_vars();
2766
2767 let result = ctx
2768 .render_template(
2769 "{% if IsRelease %}release{% endif %}-{% if IsMerging %}merge{% endif %}-{{ .PrefixedTag }}",
2770 )
2771 .unwrap();
2772 assert_eq!(result, "release-merge-api/v1.2.3");
2773 }
2774
2775 #[test]
2776 fn test_is_release_without_git_info() {
2777 let config = Config::default();
2779 let opts = ContextOptions {
2780 snapshot: false,
2781 nightly: false,
2782 ..Default::default()
2783 };
2784 let mut ctx = Context::new(config, opts);
2785 ctx.populate_git_vars();
2786
2787 assert_eq!(
2788 ctx.template_vars().get_structured("IsRelease"),
2789 Some(&tera::Value::Bool(true))
2790 );
2791 }
2792
2793 #[test]
2794 fn test_is_merging_without_git_info() {
2795 let config = Config::default();
2797 let opts = ContextOptions {
2798 merge: true,
2799 ..Default::default()
2800 };
2801 let mut ctx = Context::new(config, opts);
2802 ctx.populate_git_vars();
2803
2804 assert_eq!(
2805 ctx.template_vars().get_structured("IsMerging"),
2806 Some(&tera::Value::Bool(true))
2807 );
2808 }
2809
2810 #[test]
2820 fn test_monorepo_version_matches_shared_semver_helper() {
2821 let mut config = Config::default();
2822 config.monorepo = Some(crate::config::MonorepoConfig {
2823 tag_prefix: Some("core/".to_string()),
2824 dir: None,
2825 });
2826 let mut ctx = Context::new(config, ContextOptions::default());
2827
2828 let semver = SemVer {
2829 major: 2,
2830 minor: 1,
2831 patch: 0,
2832 prerelease: Some("rc.1".to_string()),
2833 build_metadata: Some("build.7".to_string()),
2834 };
2835 let mut info = make_git_info(false, None);
2836 info.tag = "core/v2.1.0-rc.1+build.7".to_string();
2837 info.semver = semver.clone();
2838 ctx.git_info = Some(info);
2839 ctx.populate_git_vars();
2840
2841 let v = ctx.template_vars();
2842 assert_eq!(v.get("Version"), Some(&semver.version_string()));
2845 assert_eq!(v.get("Version"), Some(&"2.1.0-rc.1+build.7".to_string()));
2846 assert_eq!(v.get("RawVersion"), Some(&semver.raw_version_string()));
2847 assert_eq!(v.get("RawVersion"), Some(&"2.1.0".to_string()));
2848 assert_eq!(v.get("Tag"), Some(&"v2.1.0-rc.1+build.7".to_string()));
2850 }
2851
2852 #[test]
2853 fn test_monorepo_tag_prefix_strips_tag_for_template_var() {
2854 let mut config = Config::default();
2855 config.monorepo = Some(crate::config::MonorepoConfig {
2856 tag_prefix: Some("subproject1/".to_string()),
2857 dir: None,
2858 });
2859 let mut ctx = Context::new(config, ContextOptions::default());
2860
2861 let mut info = make_git_info(false, None);
2863 info.tag = "subproject1/v1.2.3".to_string();
2864 info.previous_tag = Some("subproject1/v1.2.2".to_string());
2865 info.summary = "subproject1/v1.2.3-0-gabc123d".to_string();
2866 ctx.git_info = Some(info);
2867 ctx.populate_git_vars();
2868
2869 let v = ctx.template_vars();
2870 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2872 assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
2874 assert_eq!(
2876 v.get("PrefixedTag"),
2877 Some(&"subproject1/v1.2.3".to_string())
2878 );
2879 assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
2881 assert_eq!(
2883 v.get("PrefixedPreviousTag"),
2884 Some(&"subproject1/v1.2.2".to_string())
2885 );
2886 assert_eq!(v.get("Summary"), Some(&"v1.2.3-0-gabc123d".to_string()));
2888 assert_eq!(
2890 v.get("PrefixedSummary"),
2891 Some(&"subproject1/v1.2.3-0-gabc123d".to_string())
2892 );
2893 }
2894
2895 #[test]
2896 fn test_monorepo_prefixed_previous_tag() {
2897 let mut config = Config::default();
2898 config.monorepo = Some(crate::config::MonorepoConfig {
2899 tag_prefix: Some("svc/".to_string()),
2900 dir: None,
2901 });
2902 let mut ctx = Context::new(config, ContextOptions::default());
2903
2904 let mut info = make_git_info(false, None);
2905 info.tag = "svc/v2.0.0".to_string();
2906 info.previous_tag = Some("svc/v1.9.0".to_string());
2907 ctx.git_info = Some(info);
2908 ctx.populate_git_vars();
2909
2910 let v = ctx.template_vars();
2911 assert_eq!(
2913 v.get("PrefixedPreviousTag"),
2914 Some(&"svc/v1.9.0".to_string())
2915 );
2916 assert_eq!(v.get("PreviousTag"), Some(&"v1.9.0".to_string()));
2918 }
2919
2920 #[test]
2921 fn test_no_monorepo_falls_back_to_tag_prefix() {
2922 let mut config = Config::default();
2924 config.tag = Some(crate::config::TagConfig {
2925 tag_prefix: Some("release/".to_string()),
2926 ..Default::default()
2927 });
2928 let mut ctx = Context::new(config, ContextOptions::default());
2929 ctx.git_info = Some(make_git_info(false, None));
2930 ctx.populate_git_vars();
2931
2932 let v = ctx.template_vars();
2933 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2935 assert_eq!(v.get("PrefixedTag"), Some(&"release/v1.2.3".to_string()));
2937 assert_eq!(
2938 v.get("PrefixedPreviousTag"),
2939 Some(&"release/v1.2.2".to_string())
2940 );
2941 }
2942
2943 #[test]
2944 fn test_monorepo_overrides_tag_prefix_for_prefixed_vars() {
2945 let mut config = Config::default();
2948 config.tag = Some(crate::config::TagConfig {
2949 tag_prefix: Some("release/".to_string()),
2950 ..Default::default()
2951 });
2952 config.monorepo = Some(crate::config::MonorepoConfig {
2953 tag_prefix: Some("svc/".to_string()),
2954 dir: None,
2955 });
2956 let mut ctx = Context::new(config, ContextOptions::default());
2957
2958 let mut info = make_git_info(false, None);
2959 info.tag = "svc/v1.2.3".to_string();
2960 info.previous_tag = Some("svc/v1.2.2".to_string());
2961 ctx.git_info = Some(info);
2962 ctx.populate_git_vars();
2963
2964 let v = ctx.template_vars();
2965 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2967 assert_eq!(v.get("PrefixedTag"), Some(&"svc/v1.2.3".to_string()));
2969 }
2970
2971 #[test]
2972 fn test_monorepo_prefixed_summary() {
2973 let mut config = Config::default();
2974 config.monorepo = Some(crate::config::MonorepoConfig {
2975 tag_prefix: Some("pkg/".to_string()),
2976 dir: None,
2977 });
2978 let mut ctx = Context::new(config, ContextOptions::default());
2979
2980 let mut info = make_git_info(false, None);
2981 info.tag = "pkg/v1.2.3".to_string();
2982 info.summary = "pkg/v1.2.3-0-gabc123d".to_string();
2984 ctx.git_info = Some(info);
2985 ctx.populate_git_vars();
2986
2987 assert_eq!(
2989 ctx.template_vars().get("PrefixedSummary"),
2990 Some(&"pkg/v1.2.3-0-gabc123d".to_string())
2991 );
2992 assert_eq!(
2994 ctx.template_vars().get("Summary"),
2995 Some(&"v1.2.3-0-gabc123d".to_string())
2996 );
2997 }
2998
2999 #[test]
3000 fn test_monorepo_no_previous_tag() {
3001 let mut config = Config::default();
3002 config.monorepo = Some(crate::config::MonorepoConfig {
3003 tag_prefix: Some("svc/".to_string()),
3004 dir: None,
3005 });
3006 let mut ctx = Context::new(config, ContextOptions::default());
3007
3008 let mut info = make_git_info(false, None);
3009 info.tag = "svc/v1.0.0".to_string();
3010 info.previous_tag = None;
3011 ctx.git_info = Some(info);
3012 ctx.populate_git_vars();
3013
3014 let v = ctx.template_vars();
3015 assert_eq!(v.get("PrefixedPreviousTag"), Some(&"".to_string()));
3016 assert_eq!(v.get("PreviousTag"), Some(&"".to_string()));
3018 }
3019
3020 #[test]
3025 fn test_monorepo_full_flow_all_vars() {
3026 let mut config = Config::default();
3029 config.project_name = "mymonorepo".to_string();
3030 config.monorepo = Some(crate::config::MonorepoConfig {
3031 tag_prefix: Some("services/api/".to_string()),
3032 dir: Some("services/api".to_string()),
3033 });
3034
3035 assert_eq!(config.monorepo_tag_prefix(), Some("services/api/"));
3037 assert_eq!(config.monorepo_dir(), Some("services/api"));
3038
3039 let mut ctx = Context::new(config, ContextOptions::default());
3040
3041 let mut info = make_git_info(false, None);
3044 info.tag = "services/api/v2.1.0".to_string();
3045 info.previous_tag = Some("services/api/v2.0.5".to_string());
3046 info.summary = "services/api/v2.1.0-0-gabc123d".to_string();
3047 info.semver = crate::git::SemVer {
3048 major: 2,
3049 minor: 1,
3050 patch: 0,
3051 prerelease: None,
3052 build_metadata: None,
3053 };
3054 ctx.git_info = Some(info);
3055 ctx.populate_git_vars();
3056
3057 let v = ctx.template_vars();
3058
3059 assert_eq!(v.get("Tag"), Some(&"v2.1.0".to_string()));
3061 assert_eq!(v.get("Version"), Some(&"2.1.0".to_string()));
3062 assert_eq!(v.get("RawVersion"), Some(&"2.1.0".to_string()));
3063 assert_eq!(v.get("Major"), Some(&"2".to_string()));
3064 assert_eq!(v.get("Minor"), Some(&"1".to_string()));
3065 assert_eq!(v.get("Patch"), Some(&"0".to_string()));
3066 assert_eq!(v.get("PreviousTag"), Some(&"v2.0.5".to_string()));
3067 assert_eq!(v.get("Summary"), Some(&"v2.1.0-0-gabc123d".to_string()));
3068
3069 assert_eq!(
3071 v.get("PrefixedTag"),
3072 Some(&"services/api/v2.1.0".to_string())
3073 );
3074 assert_eq!(
3075 v.get("PrefixedPreviousTag"),
3076 Some(&"services/api/v2.0.5".to_string())
3077 );
3078 assert_eq!(
3079 v.get("PrefixedSummary"),
3080 Some(&"services/api/v2.1.0-0-gabc123d".to_string())
3081 );
3082
3083 assert_eq!(v.get("ProjectName"), Some(&"mymonorepo".to_string()));
3085 }
3086
3087 #[test]
3088 fn context_env_var_defaults_to_process_env_source() {
3089 let ctx = Context::new(Config::default(), ContextOptions::default());
3090 assert_eq!(ctx.env_var("ANODIZER_T3_UNSET_VAR"), None);
3092 }
3093
3094 #[test]
3095 fn context_env_var_routes_to_injected_source() {
3096 let mut ctx = Context::new(Config::default(), ContextOptions::default());
3097 ctx.set_env_source(crate::MapEnvSource::new().with("INJECTED", "yes"));
3098 assert_eq!(ctx.env_var("INJECTED"), Some("yes".to_string()));
3099 assert_eq!(ctx.env_var("PATH"), None);
3103 }
3104
3105 #[test]
3106 #[serial_test::serial]
3107 fn populate_runtime_vars_sets_rustc_version() {
3108 let config = Config::default();
3109 let mut ctx = Context::new(config, ContextOptions::default());
3110 ctx.populate_runtime_vars();
3113
3114 let ver = ctx
3115 .template_vars()
3116 .get("RustcVersion")
3117 .expect("RustcVersion should be set after populate_runtime_vars");
3118 if !ver.is_empty() {
3122 assert!(
3123 ver.chars().next().is_some_and(|c| c.is_ascii_digit()),
3124 "RustcVersion should start with a digit: {ver}"
3125 );
3126 }
3127 }
3128}