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] = &[
45 "publish",
46 "announce",
47 "sign",
48 "validate",
49 "sbom",
50 "attest",
51 "docker",
52 "docker-sign",
53 "winget",
54 "choco",
55 "snapcraft",
56 "snapcraft-publish",
57 "scoop",
58 "brew",
59 "nix",
60 "aur",
61 "cargo",
62 "krew",
63 "nfpm",
64 "makeself",
65 "appimage",
66 "flatpak",
67 "srpm",
68 "before",
69 "before-publish",
70 "notarize",
71 "archive",
72 "source",
73 "build",
74 "changelog",
75 "release",
76 "checksum",
77 "upx",
78 "blob",
79 "templatefiles",
80 "dmg",
81 "msi",
82 "nsis",
83 "pkg",
84 "appbundle",
85 "verify-release",
86];
87
88pub const VALID_BUILD_SKIPS: &[&str] = &["pre-hooks", "post-hooks", "validate", "before"];
90
91pub fn validate_skip_values(skip: &[String], valid: &[&str]) -> Result<(), String> {
96 let invalid: Vec<&str> = skip
97 .iter()
98 .map(|s| s.as_str())
99 .filter(|s| !valid.contains(s))
100 .collect();
101 if invalid.is_empty() {
102 Ok(())
103 } else {
104 Err(format!(
105 "invalid --skip value(s): {}. Valid options: {}",
106 invalid.join(", "),
107 valid.join(", "),
108 ))
109 }
110}
111
112pub struct ContextOptions {
113 pub snapshot: bool,
114 pub nightly: bool,
115 pub dry_run: bool,
116 pub quiet: bool,
117 pub verbose: bool,
118 pub debug: bool,
119 pub skip_stages: Vec<String>,
120 pub selected_crates: Vec<String>,
121 pub token: Option<String>,
122 pub parallelism: usize,
124 pub single_target: Option<String>,
126 pub release_notes_path: Option<PathBuf>,
128 pub fail_fast: bool,
130 pub partial_target: Option<PartialTarget>,
133 pub merge: bool,
135 pub publish_only: bool,
145 pub project_root: Option<PathBuf>,
148 pub strict: bool,
150 pub resume_release: bool,
155 pub replace_existing_artifacts: bool,
159 pub skip_post_publish_poll: bool,
167 pub gate_submitter: Option<bool>,
175 pub rollback_mode: Option<RollbackMode>,
180 pub simulate_failure_publishers: Vec<String>,
188 pub rollback_only: bool,
194 pub allow_rerun: bool,
209 pub show_skipped: bool,
218 pub from_run: Option<String>,
222 pub runtime_nondeterministic_allowlist: Vec<(String, String)>,
229 pub summary_json_path: Option<PathBuf>,
233 pub allow_ai_failure: bool,
240 pub changelog_from: Option<String>,
247 pub changelog_full_history: bool,
254 pub changelog_to: Option<String>,
263 pub changelog_preview: bool,
279 pub allow_snapshot_publish: bool,
293}
294
295impl Default for ContextOptions {
296 fn default() -> Self {
297 Self {
298 snapshot: false,
299 nightly: false,
300 dry_run: false,
301 quiet: false,
302 verbose: false,
303 debug: false,
304 skip_stages: Vec::new(),
305 selected_crates: Vec::new(),
306 token: None,
307 parallelism: 4,
308 single_target: None,
309 release_notes_path: None,
310 fail_fast: false,
311 partial_target: None,
312 merge: false,
313 publish_only: false,
314 project_root: None,
315 strict: false,
316 resume_release: false,
317 replace_existing_artifacts: false,
318 skip_post_publish_poll: false,
319 gate_submitter: None,
320 rollback_mode: None,
321 simulate_failure_publishers: Vec::new(),
322 rollback_only: false,
323 allow_rerun: false,
324 show_skipped: false,
325 from_run: None,
326 runtime_nondeterministic_allowlist: Vec::new(),
327 summary_json_path: None,
328 allow_ai_failure: false,
329 changelog_from: None,
330 changelog_full_history: false,
331 changelog_to: None,
332 changelog_preview: false,
333 allow_snapshot_publish: false,
334 }
335 }
336}
337
338#[derive(Debug, Default)]
343pub struct StageOutputs {
344 pub github_native_changelog: bool,
348 pub changelogs: HashMap<String, String>,
350 pub changelog_header: Option<String>,
355 pub changelog_footer: Option<String>,
358 pub post_publish_results: Vec<serde_json::Value>,
366}
367
368pub struct Context {
369 pub config: Config,
370 pub artifacts: ArtifactRegistry,
371 pub options: ContextOptions,
372 pub stage_outputs: StageOutputs,
374 template_vars: TemplateVars,
375 pub git_info: Option<GitInfo>,
376 pub token_type: ScmTokenType,
378 pub skip_memento: crate::pipe_skip::SkipMemento,
384 pub publish_report: Option<PublishReport>,
392 pub publish_attempted: bool,
398 pub verify_release: Option<VerifyReleaseSummary>,
409 pub determinism: Option<crate::DeterminismState>,
416 pub pending_outcome: Option<crate::PublisherOutcome>,
426 pub pending_evidence: Option<crate::PublishEvidence>,
439 built_crate_names: Option<std::collections::HashSet<String>>,
450 env_source: Arc<dyn EnvSource>,
458 #[cfg(feature = "test-helpers")]
466 pub log_capture: Option<crate::log::LogCapture>,
467 render_strict: std::cell::Cell<bool>,
482 pub literal_message: bool,
489 pub redact_body: bool,
496 process_env_cache: std::cell::OnceCell<std::collections::HashMap<String, String>>,
505}
506
507impl Context {
508 pub fn new(config: Config, options: ContextOptions) -> Self {
509 let mut vars = TemplateVars::new();
510 vars.set("ProjectName", &config.project_name);
511 Self {
512 config,
513 artifacts: ArtifactRegistry::new(),
514 options,
515 stage_outputs: StageOutputs::default(),
516 template_vars: vars,
517 git_info: None,
518 token_type: ScmTokenType::GitHub,
519 skip_memento: crate::pipe_skip::SkipMemento::new(),
520 publish_report: None,
521 publish_attempted: false,
522 verify_release: None,
523 determinism: None,
524 pending_outcome: None,
525 pending_evidence: None,
526 built_crate_names: None,
527 env_source: Arc::new(ProcessEnvSource),
528 #[cfg(feature = "test-helpers")]
529 log_capture: None,
530 render_strict: std::cell::Cell::new(false),
531 literal_message: false,
532 redact_body: true,
533 process_env_cache: std::cell::OnceCell::new(),
534 }
535 }
536
537 pub fn redact(&self, s: &str) -> String {
542 crate::redact::with_env(s, &self.env_for_redact())
543 }
544
545 pub fn env_var(&self, name: &str) -> Option<String> {
553 self.env_source.var(name)
554 }
555
556 pub fn set_env_source<S: EnvSource + 'static>(&mut self, src: S) {
562 self.env_source = Arc::new(src);
563 }
564
565 pub fn env_source(&self) -> &dyn EnvSource {
570 self.env_source.as_ref()
571 }
572
573 pub fn env_source_arc(&self) -> Arc<dyn EnvSource> {
579 Arc::clone(&self.env_source)
580 }
581
582 #[cfg(feature = "test-helpers")]
588 pub fn with_log_capture(&mut self, capture: crate::log::LogCapture) {
589 self.log_capture = Some(capture);
590 }
591
592 pub fn record_publisher_outcome(&mut self, outcome: crate::PublisherOutcome) {
600 self.pending_outcome = Some(outcome);
601 }
602
603 pub fn take_pending_outcome(&mut self) -> Option<crate::PublisherOutcome> {
607 self.pending_outcome.take()
608 }
609
610 pub fn record_pending_evidence(&mut self, evidence: crate::PublishEvidence) {
615 self.pending_evidence = Some(evidence);
616 }
617
618 pub fn take_pending_evidence(&mut self) -> Option<crate::PublishEvidence> {
622 self.pending_evidence.take()
623 }
624
625 pub fn publish_report(&self) -> Option<&PublishReport> {
628 self.publish_report.as_ref()
629 }
630
631 pub fn publish_attempted(&self) -> bool {
634 self.publish_attempted
635 }
636
637 pub fn set_publish_attempted(&mut self) {
641 self.publish_attempted = true;
642 }
643
644 pub fn set_publish_report(&mut self, r: PublishReport) {
650 self.publish_report = Some(r);
651 }
652
653 pub fn built_crate_names(&self) -> Option<&std::collections::HashSet<String>> {
656 self.built_crate_names.as_ref()
657 }
658
659 pub fn set_built_crate_names(&mut self, names: std::collections::HashSet<String>) {
662 self.built_crate_names = Some(names);
663 }
664
665 pub fn remember_skip(&self, stage: &str, label: &str, reason: &str) {
672 self.skip_memento.remember(stage, label, reason);
673 }
674
675 pub fn template_vars(&self) -> &TemplateVars {
676 &self.template_vars
677 }
678
679 pub fn template_vars_mut(&mut self) -> &mut TemplateVars {
680 &mut self.template_vars
681 }
682
683 pub fn render_template(&self, template: &str) -> anyhow::Result<String> {
684 crate::template::render(template, &self.template_vars)
685 }
686
687 pub fn render_template_opt(&self, template: Option<&str>) -> anyhow::Result<Option<String>> {
689 template.map(|t| self.render_template(t)).transpose()
690 }
691
692 pub fn skip_with_log(
701 &self,
702 skip: &Option<crate::config::StringOrBool>,
703 log: &StageLogger,
704 label: &str,
705 ) -> anyhow::Result<bool> {
706 let Some(d) = skip else {
707 return Ok(false);
708 };
709 let should_skip = d
710 .try_evaluates_to_true(|s| self.render_template(s))
711 .with_context(|| format!("evaluate skip expression for {label}"))?;
712 if should_skip {
713 log.status(&format!("{} skipped", label));
714 }
715 Ok(should_skip)
716 }
717
718 pub fn should_skip(&self, stage_name: &str) -> bool {
719 self.options.skip_stages.iter().any(|s| s == stage_name)
720 }
721
722 pub fn skip_validate(&self) -> bool {
724 self.should_skip("validate")
725 }
726
727 pub fn is_dry_run(&self) -> bool {
728 self.options.dry_run
729 }
730
731 pub fn is_snapshot(&self) -> bool {
732 self.options.snapshot
733 }
734
735 pub fn is_strict(&self) -> bool {
736 self.options.strict
737 }
738
739 pub fn set_render_strict(&self, on: bool) -> bool {
747 self.render_strict.replace(on)
748 }
749
750 pub fn render_is_strict(&self) -> bool {
757 self.render_strict.get() || self.is_strict()
758 }
759
760 pub fn strict_guard(&self, log: &crate::log::StageLogger, msg: &str) -> anyhow::Result<()> {
763 if self.options.strict {
764 anyhow::bail!("{} (strict mode)", msg);
765 }
766 log.warn(msg);
767 Ok(())
768 }
769
770 pub fn skip_in_snapshot(&self, log: &crate::log::StageLogger, stage: &str) -> bool {
779 if self.is_snapshot() {
780 log.status(&format!("skipped {stage} — snapshot mode"));
784 true
785 } else {
786 false
787 }
788 }
789
790 pub fn render_template_strict(
792 &self,
793 template: &str,
794 label: &str,
795 log: &crate::log::StageLogger,
796 ) -> anyhow::Result<String> {
797 match self.render_template(template) {
798 Ok(rendered) => Ok(rendered),
799 Err(e) => {
800 if self.options.strict {
801 anyhow::bail!("{}: failed to render template: {} (strict mode)", label, e);
802 }
803 log.warn(&format!("failed to render template for {}: {}", label, e));
804 Ok(template.to_string())
805 }
806 }
807 }
808
809 pub fn is_nightly(&self) -> bool {
810 self.options.nightly
811 }
812
813 pub fn set_release_url(&mut self, url: &str) {
818 self.template_vars.set("ReleaseURL", url);
819 }
820
821 pub fn version(&self) -> String {
824 self.template_vars
825 .get("Version")
826 .cloned()
827 .unwrap_or_default()
828 }
829
830 pub fn verbosity(&self) -> Verbosity {
832 Verbosity::from_flags(self.options.quiet, self.options.verbose, self.options.debug)
833 }
834
835 pub fn retry_policy(&self) -> crate::retry::RetryPolicy {
841 self.config.retry.unwrap_or_default().to_policy()
842 }
843
844 pub fn logger(&self, stage: &'static str) -> StageLogger {
852 #[allow(unused_mut)]
853 let mut log = StageLogger::new(stage, self.verbosity()).with_env(self.env_for_redact());
854 #[cfg(feature = "test-helpers")]
855 if let Some(cap) = &self.log_capture {
856 log = log.with_capture_handle(cap.clone());
857 }
858 log
859 }
860
861 fn env_for_redact(&self) -> Vec<(String, String)> {
872 use std::collections::HashMap;
873 let mut map: HashMap<String, String> = self
874 .process_env_cache
875 .get_or_init(|| std::env::vars().collect())
876 .clone();
877 for (k, v) in self.template_vars.all_env() {
878 map.insert(k.clone(), v.clone());
879 }
880 map.into_iter().collect()
881 }
882
883 pub fn populate_git_vars(&mut self) {
925 if let Some(ref info) = self.git_info {
926 let raw_version = info.semver.raw_version_string();
928
929 let version = info.semver.version_string();
935
936 self.template_vars.set("Tag", &info.tag);
937 self.template_vars.set("Version", &version);
938 self.template_vars.set("RawVersion", &raw_version);
939 self.template_vars.set("Base", &raw_version);
944 self.template_vars
945 .set("Major", &info.semver.major.to_string());
946 self.template_vars
947 .set("Minor", &info.semver.minor.to_string());
948 self.template_vars
949 .set("Patch", &info.semver.patch.to_string());
950 self.template_vars.set(
951 "Prerelease",
952 info.semver.prerelease.as_deref().unwrap_or(""),
953 );
954 self.template_vars.set(
955 "BuildMetadata",
956 info.semver.build_metadata.as_deref().unwrap_or(""),
957 );
958 self.template_vars.set("FullCommit", &info.commit);
959 self.template_vars.set("Commit", &info.commit);
960 self.template_vars.set("ShortCommit", &info.short_commit);
961 self.template_vars.set("Branch", &info.branch);
962 self.template_vars.set("CommitDate", &info.commit_date);
963 self.template_vars
964 .set("CommitTimestamp", &info.commit_timestamp);
965 self.template_vars.set_bool("IsGitDirty", info.dirty);
966 self.template_vars.set_bool("IsGitClean", !info.dirty);
967 self.template_vars
968 .set("GitTreeState", if info.dirty { "dirty" } else { "clean" });
969 self.template_vars.set("GitURL", &info.remote_url);
970 self.template_vars.set("Summary", &info.summary);
971 self.template_vars.set("TagSubject", &info.tag_subject);
972 self.template_vars.set("TagContents", &info.tag_contents);
973 self.template_vars.set("TagBody", &info.tag_body);
974 self.template_vars
975 .set("PreviousTag", info.previous_tag.as_deref().unwrap_or(""));
976 self.template_vars
977 .set("FirstCommit", info.first_commit.as_deref().unwrap_or(""));
978
979 let monorepo_prefix = self.config.monorepo_tag_prefix();
990
991 if let Some(prefix) = monorepo_prefix {
997 self.template_vars.set("PrefixedTag", &info.tag);
1000
1001 let stripped_tag = crate::git::strip_monorepo_prefix(&info.tag, prefix);
1003 self.template_vars.set("Tag", stripped_tag);
1004
1005 let version = info.semver.version_string();
1015 self.template_vars.set("Version", &version);
1016
1017 let prev_tag = info.previous_tag.as_deref().unwrap_or("");
1019 self.template_vars.set("PrefixedPreviousTag", prev_tag);
1020
1021 let stripped_prev = crate::git::strip_monorepo_prefix(prev_tag, prefix);
1023 self.template_vars.set("PreviousTag", stripped_prev);
1024
1025 self.template_vars.set("PrefixedSummary", &info.summary);
1029 let stripped_summary = crate::git::strip_monorepo_prefix(&info.summary, prefix);
1031 self.template_vars.set("Summary", stripped_summary);
1032 } else {
1033 let tag_prefix = self
1035 .config
1036 .tag
1037 .as_ref()
1038 .and_then(|t| t.tag_prefix.as_deref())
1039 .unwrap_or("");
1040 self.template_vars
1041 .set("PrefixedTag", &format!("{}{}", tag_prefix, info.tag));
1042 let prev_tag = info.previous_tag.as_deref().unwrap_or("");
1043 let prefixed_prev = if prev_tag.is_empty() {
1044 String::new()
1045 } else {
1046 format!("{}{}", tag_prefix, prev_tag)
1047 };
1048 self.template_vars
1049 .set("PrefixedPreviousTag", &prefixed_prev);
1050 self.template_vars.set(
1051 "PrefixedSummary",
1052 &format!("{}{}", tag_prefix, info.summary),
1053 );
1054 }
1055 }
1056
1057 let nightly_build = if self.git_info.is_some() {
1070 let root = self
1071 .options
1072 .project_root
1073 .clone()
1074 .unwrap_or_else(|| PathBuf::from("."));
1075 let monorepo_prefix = self.config.monorepo_tag_prefix();
1076 crate::git::count_commits_since_last_tag_in(&root, monorepo_prefix).unwrap_or(0)
1077 } else {
1078 0
1079 };
1080 self.template_vars
1081 .set_structured("NightlyBuild", tera::Value::from(nightly_build));
1082
1083 self.template_vars
1088 .set_bool("IsSnapshot", self.options.snapshot);
1089 self.template_vars
1090 .set_bool("IsNightly", self.options.nightly);
1091 self.template_vars.set_bool(
1095 "IsHarness",
1096 self.env_var("ANODIZER_IN_DETERMINISM_HARNESS").is_some(),
1097 );
1098 let is_draft = self
1100 .config
1101 .release
1102 .as_ref()
1103 .and_then(|r| r.draft)
1104 .unwrap_or(false);
1105 self.template_vars.set_bool("IsDraft", is_draft);
1106 self.template_vars
1107 .set_bool("IsSingleTarget", self.options.single_target.is_some());
1108
1109 let is_release = !self.options.snapshot && !self.options.nightly;
1111 self.template_vars.set_bool("IsRelease", is_release);
1112
1113 self.template_vars.set_bool("IsMerging", self.options.merge);
1115 }
1116
1117 pub fn populate_time_vars(&mut self) {
1145 let now = crate::sde::resolve_now_with_env(self.env_source());
1153 self.template_vars.set("Date", &now.to_rfc3339());
1154 self.template_vars
1155 .set("Timestamp", &now.timestamp().to_string());
1156 self.template_vars.set("Now", &now.to_rfc3339());
1157 self.template_vars
1158 .set("Year", &now.format("%Y").to_string());
1159 self.template_vars
1160 .set("Month", &now.format("%m").to_string());
1161 self.template_vars.set("Day", &now.format("%d").to_string());
1162 self.template_vars
1163 .set("Hour", &now.format("%H").to_string());
1164 self.template_vars
1165 .set("Minute", &now.format("%M").to_string());
1166 }
1167
1168 pub fn populate_runtime_vars(&mut self) {
1177 let goos = map_os_to_goos(std::env::consts::OS);
1178 let goarch = map_arch_to_goarch(std::env::consts::ARCH);
1179 self.template_vars.set("RuntimeGoos", goos);
1180 self.template_vars.set("RuntimeGoarch", goarch);
1181 self.template_vars.set("Runtime_Goos", goos);
1184 self.template_vars.set("Runtime_Goarch", goarch);
1185 self.populate_rustc_vars();
1189 }
1190
1191 fn populate_rustc_vars(&mut self) {
1198 let ver = crate::partial::detect_rustc_version().unwrap_or_default();
1199 self.template_vars.set("RustcVersion", &ver);
1200 }
1201
1202 pub fn populate_release_notes_var(&mut self) {
1210 let notes = self
1212 .config
1213 .crates
1214 .iter()
1215 .find_map(|c| self.stage_outputs.changelogs.get(&c.name))
1216 .cloned()
1217 .unwrap_or_default();
1218 self.template_vars.set("ReleaseNotes", ¬es);
1219 }
1220
1221 pub fn refresh_artifacts_var(&mut self) {
1236 const CSV_LIST_KEYS: &[&str] = &["extra_binaries", "extra_files"];
1241 const JSON_LIST_KEYS: &[&str] = &["Platforms"];
1247
1248 let artifacts_value: Vec<serde_json::Value> = self
1249 .artifacts
1250 .all()
1251 .iter()
1252 .map(|a| {
1253 let mut metadata_map = serde_json::Map::with_capacity(a.metadata.len());
1255 for (k, v) in &a.metadata {
1256 if CSV_LIST_KEYS.contains(&k.as_str()) {
1257 let items: Vec<serde_json::Value> = if v.is_empty() {
1258 Vec::new()
1259 } else {
1260 v.split(',')
1261 .map(|s| serde_json::Value::String(s.to_string()))
1262 .collect()
1263 };
1264 metadata_map.insert(k.clone(), serde_json::Value::Array(items));
1265 } else if JSON_LIST_KEYS.contains(&k.as_str()) {
1266 let parsed = serde_json::from_str::<serde_json::Value>(v)
1270 .unwrap_or_else(|_| serde_json::Value::String(v.clone()));
1271 metadata_map.insert(k.clone(), parsed);
1272 } else {
1273 metadata_map.insert(k.clone(), serde_json::Value::String(v.clone()));
1274 }
1275 }
1276 serde_json::json!({
1277 "name": a.name,
1278 "path": a.path.to_string_lossy(),
1279 "target": a.target.as_deref().unwrap_or(""),
1280 "kind": a.kind.as_str(),
1281 "crate_name": a.crate_name,
1282 "metadata": serde_json::Value::Object(metadata_map),
1283 })
1284 })
1285 .collect();
1286 let tera_value = tera::Value::Array(artifacts_value);
1289 self.template_vars.set_structured("Artifacts", tera_value);
1290 }
1291
1292 pub fn populate_metadata_var(&mut self) -> anyhow::Result<()> {
1306 let (
1309 description,
1310 homepage,
1311 documentation,
1312 license,
1313 maintainers,
1314 mod_timestamp,
1315 full_desc_src,
1316 commit_author,
1317 ) = {
1318 let meta = self.config.metadata.as_ref();
1319 let description = self
1326 .config
1327 .meta_description_project()
1328 .unwrap_or("")
1329 .to_string();
1330 let homepage = self
1331 .config
1332 .meta_homepage_project()
1333 .unwrap_or("")
1334 .to_string();
1335 let documentation = self
1336 .config
1337 .meta_documentation_project()
1338 .unwrap_or("")
1339 .to_string();
1340 let license = self.config.meta_license_project().unwrap_or("").to_string();
1341 let maintainers: Vec<String> = meta
1342 .and_then(|m| m.maintainers.as_ref())
1343 .cloned()
1344 .unwrap_or_default();
1345 let mod_timestamp = meta
1346 .and_then(|m| m.mod_timestamp.as_deref())
1347 .unwrap_or("")
1348 .to_string();
1349 let full_desc_src = meta.and_then(|m| m.full_description.clone());
1350 let commit_author = meta.and_then(|m| m.commit_author.clone());
1351 (
1352 description,
1353 homepage,
1354 documentation,
1355 license,
1356 maintainers,
1357 mod_timestamp,
1358 full_desc_src,
1359 commit_author,
1360 )
1361 };
1362
1363 let full_description = match full_desc_src {
1369 None => String::new(),
1370 Some(src) => crate::content_source::resolve(&src, "metadata.full_description", self)?,
1371 };
1372
1373 let commit_author_map = serde_json::json!({
1374 "Name": commit_author.as_ref().and_then(|c| c.name.clone()).unwrap_or_default(),
1375 "Email": commit_author.as_ref().and_then(|c| c.email.clone()).unwrap_or_default(),
1376 });
1377
1378 let meta_map = serde_json::json!({
1379 "Description": description,
1380 "Homepage": homepage,
1381 "Documentation": documentation,
1382 "License": license,
1383 "Maintainers": maintainers,
1384 "ModTimestamp": mod_timestamp,
1385 "FullDescription": full_description,
1386 "CommitAuthor": commit_author_map,
1387 });
1388 self.template_vars.set_structured("Metadata", meta_map);
1390 Ok(())
1391 }
1392}
1393
1394pub fn map_os_to_goos(os: &str) -> &str {
1397 match os {
1398 "macos" => "darwin",
1399 other => other, }
1401}
1402
1403pub fn map_arch_to_goarch(arch: &str) -> &str {
1406 match arch {
1407 "x86_64" => "amd64",
1408 "x86" => "386",
1409 "aarch64" => "arm64",
1410 "powerpc64" => "ppc64",
1411 "s390x" => "s390x",
1412 "mips" => "mips",
1413 "mips64" => "mips64",
1414 "riscv64" => "riscv64",
1415 other => other,
1416 }
1417}
1418
1419#[cfg(test)]
1420#[allow(clippy::field_reassign_with_default)]
1421mod tests {
1422 use super::*;
1423 use crate::config::Config;
1424 use crate::git::{GitInfo, SemVer};
1425
1426 fn make_git_info(dirty: bool, prerelease: Option<&str>) -> GitInfo {
1427 let tag = match prerelease {
1428 Some(pre) => format!("v1.2.3-{pre}"),
1429 None => "v1.2.3".to_string(),
1430 };
1431 GitInfo {
1432 tag,
1433 commit: "abc123def456abc123def456abc123def456abc1".to_string(),
1434 short_commit: "abc123d".to_string(),
1435 branch: "main".to_string(),
1436 dirty,
1437 semver: SemVer {
1438 major: 1,
1439 minor: 2,
1440 patch: 3,
1441 prerelease: prerelease.map(|s| s.to_string()),
1442 build_metadata: None,
1443 },
1444 commit_date: "2026-03-25T10:30:00+00:00".to_string(),
1445 commit_timestamp: "1774463400".to_string(),
1446 previous_tag: Some("v1.2.2".to_string()),
1447 remote_url: "https://github.com/test/repo.git".to_string(),
1448 summary: "v1.2.3-0-gabc123d".to_string(),
1449 tag_subject: "Release v1.2.3".to_string(),
1450 tag_contents: "Release v1.2.3\n\nFull release notes here.".to_string(),
1451 tag_body: "Full release notes here.".to_string(),
1452 first_commit: None,
1453 }
1454 }
1455
1456 #[test]
1457 fn test_context_template_vars() {
1458 let mut config = Config::default();
1459 config.project_name = "test-project".to_string();
1460 let ctx = Context::new(config, ContextOptions::default());
1461 assert_eq!(
1462 ctx.template_vars().get("ProjectName"),
1463 Some(&"test-project".to_string())
1464 );
1465 }
1466
1467 #[test]
1468 fn test_context_should_skip() {
1469 let config = Config::default();
1470 let opts = ContextOptions {
1471 skip_stages: vec!["publish".to_string(), "announce".to_string()],
1472 ..Default::default()
1473 };
1474 let ctx = Context::new(config, opts);
1475 assert!(ctx.should_skip("publish"));
1476 assert!(ctx.should_skip("announce"));
1477 assert!(!ctx.should_skip("build"));
1478 }
1479
1480 #[test]
1481 fn test_context_render_template() {
1482 let mut config = Config::default();
1483 config.project_name = "myapp".to_string();
1484 let ctx = Context::new(config, ContextOptions::default());
1485 let result = ctx.render_template("{{ .ProjectName }}-release").unwrap();
1486 assert_eq!(result, "myapp-release");
1487 }
1488
1489 #[test]
1490 fn test_populate_git_vars_sets_all_expected_vars() {
1491 let config = Config::default();
1492 let mut ctx = Context::new(config, ContextOptions::default());
1493 ctx.git_info = Some(make_git_info(false, None));
1494 ctx.populate_git_vars();
1495
1496 let v = ctx.template_vars();
1497 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
1498 assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
1499 assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
1500 assert_eq!(v.get("Major"), Some(&"1".to_string()));
1501 assert_eq!(v.get("Minor"), Some(&"2".to_string()));
1502 assert_eq!(v.get("Patch"), Some(&"3".to_string()));
1503 assert_eq!(v.get("Prerelease"), Some(&"".to_string()));
1504 assert_eq!(
1505 v.get("FullCommit"),
1506 Some(&"abc123def456abc123def456abc123def456abc1".to_string())
1507 );
1508 assert_eq!(v.get("ShortCommit"), Some(&"abc123d".to_string()));
1509 assert_eq!(v.get("Branch"), Some(&"main".to_string()));
1510 assert_eq!(
1511 v.get("CommitDate"),
1512 Some(&"2026-03-25T10:30:00+00:00".to_string())
1513 );
1514 assert_eq!(v.get("CommitTimestamp"), Some(&"1774463400".to_string()));
1515 assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
1516 assert_eq!(v.get("Base"), Some(&"1.2.3".to_string()));
1519 }
1520
1521 #[test]
1522 fn test_nightly_build_defaults_to_zero_without_git_info() {
1523 let config = Config::default();
1526 let mut ctx = Context::new(config, ContextOptions::default());
1527 ctx.git_info = None;
1528 ctx.populate_git_vars();
1529 assert_eq!(
1530 ctx.template_vars().get_structured("NightlyBuild"),
1531 Some(&tera::Value::from(0u64))
1532 );
1533 }
1534
1535 #[test]
1536 fn test_commit_is_alias_for_full_commit() {
1537 let config = Config::default();
1538 let mut ctx = Context::new(config, ContextOptions::default());
1539 ctx.git_info = Some(make_git_info(false, None));
1540 ctx.populate_git_vars();
1541
1542 let v = ctx.template_vars();
1543 assert_eq!(v.get("Commit"), v.get("FullCommit"));
1544 }
1545
1546 #[test]
1547 fn test_populate_git_vars_prerelease() {
1548 let config = Config::default();
1549 let mut ctx = Context::new(config, ContextOptions::default());
1550 ctx.git_info = Some(make_git_info(false, Some("rc.1")));
1551 ctx.populate_git_vars();
1552
1553 let v = ctx.template_vars();
1554 assert_eq!(v.get("Version"), Some(&"1.2.3-rc.1".to_string()));
1555 assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
1556 assert_eq!(v.get("Prerelease"), Some(&"rc.1".to_string()));
1557 }
1558
1559 #[test]
1560 fn test_build_metadata_template_var() {
1561 let config = Config::default();
1562 let mut ctx = Context::new(config, ContextOptions::default());
1563 let mut info = make_git_info(false, None);
1564 info.tag = "v1.2.3+build.42".to_string();
1565 info.semver.build_metadata = Some("build.42".to_string());
1566 ctx.git_info = Some(info);
1567 ctx.populate_git_vars();
1568
1569 let v = ctx.template_vars();
1570 assert_eq!(v.get("BuildMetadata"), Some(&"build.42".to_string()));
1571 assert_eq!(v.get("Version"), Some(&"1.2.3+build.42".to_string()));
1573 }
1574
1575 #[test]
1576 fn test_build_metadata_empty_when_none() {
1577 let config = Config::default();
1578 let mut ctx = Context::new(config, ContextOptions::default());
1579 ctx.git_info = Some(make_git_info(false, None));
1580 ctx.populate_git_vars();
1581
1582 assert_eq!(
1583 ctx.template_vars().get("BuildMetadata"),
1584 Some(&"".to_string())
1585 );
1586 }
1587
1588 #[test]
1589 fn test_populate_git_vars_monorepo_prefixed_tag() {
1590 let config = Config::default();
1593 let mut ctx = Context::new(config, ContextOptions::default());
1594 let mut info = make_git_info(false, None);
1595 info.tag = "core-v0.3.2".to_string();
1596 info.semver = SemVer {
1597 major: 0,
1598 minor: 3,
1599 patch: 2,
1600 prerelease: None,
1601 build_metadata: None,
1602 };
1603 ctx.git_info = Some(info);
1604 ctx.populate_git_vars();
1605
1606 let v = ctx.template_vars();
1607 assert_eq!(v.get("Tag"), Some(&"core-v0.3.2".to_string()));
1608 assert_eq!(v.get("Version"), Some(&"0.3.2".to_string()));
1609 assert_eq!(v.get("RawVersion"), Some(&"0.3.2".to_string()));
1610 assert_eq!(v.get("Major"), Some(&"0".to_string()));
1611 assert_eq!(v.get("Minor"), Some(&"3".to_string()));
1612 assert_eq!(v.get("Patch"), Some(&"2".to_string()));
1613 }
1614
1615 #[test]
1616 fn test_populate_git_vars_monorepo_prefixed_tag_with_prerelease() {
1617 let config = Config::default();
1618 let mut ctx = Context::new(config, ContextOptions::default());
1619 let mut info = make_git_info(false, None);
1620 info.tag = "operator-v1.0.0-rc.1".to_string();
1621 info.semver = SemVer {
1622 major: 1,
1623 minor: 0,
1624 patch: 0,
1625 prerelease: Some("rc.1".to_string()),
1626 build_metadata: None,
1627 };
1628 ctx.git_info = Some(info);
1629 ctx.populate_git_vars();
1630
1631 let v = ctx.template_vars();
1632 assert_eq!(v.get("Tag"), Some(&"operator-v1.0.0-rc.1".to_string()));
1633 assert_eq!(v.get("Version"), Some(&"1.0.0-rc.1".to_string()));
1634 assert_eq!(v.get("RawVersion"), Some(&"1.0.0".to_string()));
1635 }
1636
1637 #[test]
1638 fn test_git_tree_state_clean() {
1639 let config = Config::default();
1640 let mut ctx = Context::new(config, ContextOptions::default());
1641 ctx.git_info = Some(make_git_info(false, None));
1642 ctx.populate_git_vars();
1643
1644 let v = ctx.template_vars();
1645 assert_eq!(
1646 v.get_structured("IsGitDirty"),
1647 Some(&tera::Value::Bool(false))
1648 );
1649 assert_eq!(v.get("GitTreeState"), Some(&"clean".to_string()));
1650 }
1651
1652 #[test]
1653 fn test_git_tree_state_dirty() {
1654 let config = Config::default();
1655 let mut ctx = Context::new(config, ContextOptions::default());
1656 ctx.git_info = Some(make_git_info(true, None));
1657 ctx.populate_git_vars();
1658
1659 let v = ctx.template_vars();
1660 assert_eq!(
1661 v.get_structured("IsGitDirty"),
1662 Some(&tera::Value::Bool(true))
1663 );
1664 assert_eq!(v.get("GitTreeState"), Some(&"dirty".to_string()));
1665 }
1666
1667 #[test]
1668 fn test_is_snapshot_reflects_context_options() {
1669 let config = Config::default();
1670 let opts = ContextOptions {
1671 snapshot: true,
1672 ..Default::default()
1673 };
1674 let mut ctx = Context::new(config, opts);
1675 ctx.git_info = Some(make_git_info(false, None));
1676 ctx.populate_git_vars();
1677
1678 assert_eq!(
1679 ctx.template_vars().get_structured("IsSnapshot"),
1680 Some(&tera::Value::Bool(true))
1681 );
1682
1683 let config2 = Config::default();
1685 let opts2 = ContextOptions {
1686 snapshot: false,
1687 ..Default::default()
1688 };
1689 let mut ctx2 = Context::new(config2, opts2);
1690 ctx2.git_info = Some(make_git_info(false, None));
1691 ctx2.populate_git_vars();
1692
1693 assert_eq!(
1694 ctx2.template_vars().get_structured("IsSnapshot"),
1695 Some(&tera::Value::Bool(false))
1696 );
1697 }
1698
1699 #[test]
1700 fn test_is_draft_defaults_to_false() {
1701 let config = Config::default();
1702 let mut ctx = Context::new(config, ContextOptions::default());
1703 ctx.git_info = Some(make_git_info(false, None));
1704 ctx.populate_git_vars();
1705
1706 assert_eq!(
1707 ctx.template_vars().get_structured("IsDraft"),
1708 Some(&tera::Value::Bool(false))
1709 );
1710 }
1711
1712 #[test]
1713 fn test_previous_tag_empty_when_none() {
1714 let config = Config::default();
1715 let mut ctx = Context::new(config, ContextOptions::default());
1716 let mut info = make_git_info(false, None);
1717 info.previous_tag = None;
1718 ctx.git_info = Some(info);
1719 ctx.populate_git_vars();
1720
1721 assert_eq!(
1722 ctx.template_vars().get("PreviousTag"),
1723 Some(&"".to_string())
1724 );
1725 }
1726
1727 #[test]
1736 fn populate_time_vars_uses_source_date_epoch_when_set() {
1737 let env = crate::MapEnvSource::new().with("SOURCE_DATE_EPOCH", "1715000000");
1741 let config = Config::default();
1742 let mut ctx = Context::new(config, ContextOptions::default());
1743 ctx.set_env_source(env);
1744 ctx.populate_time_vars();
1745
1746 let v = ctx.template_vars();
1747 assert_eq!(
1748 v.get("Timestamp"),
1749 Some(&"1715000000".to_string()),
1750 "Timestamp must equal SOURCE_DATE_EPOCH seconds"
1751 );
1752 assert_eq!(
1753 v.get("Date"),
1754 Some(&"2024-05-06T12:53:20+00:00".to_string()),
1755 "Date must be RFC 3339 derived from SDE"
1756 );
1757 assert_eq!(v.get("Year"), Some(&"2024".to_string()));
1758 assert_eq!(v.get("Month"), Some(&"05".to_string()));
1759 assert_eq!(v.get("Day"), Some(&"06".to_string()));
1760 }
1761
1762 #[test]
1763 fn test_populate_time_vars() {
1764 let env = crate::MapEnvSource::new();
1767 let config = Config::default();
1768 let mut ctx = Context::new(config, ContextOptions::default());
1769 ctx.set_env_source(env);
1770 ctx.populate_time_vars();
1771
1772 let v = ctx.template_vars();
1773
1774 let date = v
1776 .get("Date")
1777 .unwrap_or_else(|| panic!("Date should be set"));
1778 assert!(
1779 date.contains('T') && date.len() > 10,
1780 "Date should be RFC 3339, got: {date}"
1781 );
1782
1783 let ts = v
1785 .get("Timestamp")
1786 .unwrap_or_else(|| panic!("Timestamp should be set"));
1787 assert!(
1788 ts.parse::<i64>().is_ok(),
1789 "Timestamp should be a numeric string, got: {ts}"
1790 );
1791
1792 let now = v.get("Now").unwrap_or_else(|| panic!("Now should be set"));
1794 assert!(now.contains('T'), "Now should be ISO 8601, got: {now}");
1795 }
1796
1797 #[test]
1798 fn test_env_vars_accessible_in_templates() {
1799 let mut config = Config::default();
1800 config.project_name = "myapp".to_string();
1801 let mut ctx = Context::new(config, ContextOptions::default());
1802 ctx.template_vars_mut().set_env("MY_VAR", "hello-world");
1803 ctx.template_vars_mut().set_env("DEPLOY_ENV", "staging");
1804
1805 let result = ctx
1806 .render_template("{{ .Env.MY_VAR }}-{{ .Env.DEPLOY_ENV }}")
1807 .unwrap();
1808 assert_eq!(result, "hello-world-staging");
1809 }
1810
1811 #[test]
1812 fn test_populate_git_vars_without_git_info_still_sets_snapshot() {
1813 let config = Config::default();
1814 let opts = ContextOptions {
1815 snapshot: true,
1816 ..Default::default()
1817 };
1818 let mut ctx = Context::new(config, opts);
1819 ctx.populate_git_vars();
1821
1822 assert_eq!(
1823 ctx.template_vars().get_structured("IsSnapshot"),
1824 Some(&tera::Value::Bool(true))
1825 );
1826 assert_eq!(
1827 ctx.template_vars().get_structured("IsDraft"),
1828 Some(&tera::Value::Bool(false))
1829 );
1830 assert_eq!(ctx.template_vars().get("Tag"), None);
1832 }
1833
1834 #[test]
1835 fn test_is_nightly_set_when_nightly_mode_active() {
1836 let config = Config::default();
1837 let opts = ContextOptions {
1838 nightly: true,
1839 ..Default::default()
1840 };
1841 let mut ctx = Context::new(config, opts);
1842 ctx.git_info = Some(make_git_info(false, None));
1843 ctx.populate_git_vars();
1844
1845 assert_eq!(
1846 ctx.template_vars().get_structured("IsNightly"),
1847 Some(&tera::Value::Bool(true)),
1848 "IsNightly should be 'true' when nightly mode is active"
1849 );
1850 assert!(ctx.is_nightly(), "is_nightly() should return true");
1851 }
1852
1853 #[test]
1854 fn test_is_nightly_false_by_default() {
1855 let config = Config::default();
1856 let mut ctx = Context::new(config, ContextOptions::default());
1857 ctx.git_info = Some(make_git_info(false, None));
1858 ctx.populate_git_vars();
1859
1860 assert_eq!(
1861 ctx.template_vars().get_structured("IsNightly"),
1862 Some(&tera::Value::Bool(false)),
1863 "IsNightly should default to 'false'"
1864 );
1865 assert!(
1866 !ctx.is_nightly(),
1867 "is_nightly() should return false by default"
1868 );
1869 }
1870
1871 #[test]
1872 fn test_version_returns_populated_value() {
1873 let config = Config::default();
1874 let mut ctx = Context::new(config, ContextOptions::default());
1875 ctx.git_info = Some(make_git_info(false, None));
1876 ctx.populate_git_vars();
1877
1878 assert_eq!(ctx.version(), "1.2.3");
1879 }
1880
1881 #[test]
1882 fn test_version_returns_empty_when_not_set() {
1883 let config = Config::default();
1884 let ctx = Context::new(config, ContextOptions::default());
1885 assert_eq!(ctx.version(), "");
1886 }
1887
1888 #[test]
1889 fn test_is_nightly_without_git_info() {
1890 let config = Config::default();
1891 let opts = ContextOptions {
1892 nightly: true,
1893 ..Default::default()
1894 };
1895 let mut ctx = Context::new(config, opts);
1896 ctx.populate_git_vars();
1898
1899 assert_eq!(
1900 ctx.template_vars().get_structured("IsNightly"),
1901 Some(&tera::Value::Bool(true)),
1902 "IsNightly should be set even without git info"
1903 );
1904 }
1905
1906 #[test]
1907 fn test_is_git_clean_when_not_dirty() {
1908 let config = Config::default();
1909 let mut ctx = Context::new(config, ContextOptions::default());
1910 ctx.git_info = Some(make_git_info(false, None));
1911 ctx.populate_git_vars();
1912
1913 assert_eq!(
1914 ctx.template_vars().get_structured("IsGitClean"),
1915 Some(&tera::Value::Bool(true))
1916 );
1917 }
1918
1919 #[test]
1920 fn test_is_git_clean_when_dirty() {
1921 let config = Config::default();
1922 let mut ctx = Context::new(config, ContextOptions::default());
1923 ctx.git_info = Some(make_git_info(true, None));
1924 ctx.populate_git_vars();
1925
1926 assert_eq!(
1927 ctx.template_vars().get_structured("IsGitClean"),
1928 Some(&tera::Value::Bool(false))
1929 );
1930 }
1931
1932 #[test]
1933 fn test_git_url_set_from_git_info() {
1934 let config = Config::default();
1935 let mut ctx = Context::new(config, ContextOptions::default());
1936 ctx.git_info = Some(make_git_info(false, None));
1937 ctx.populate_git_vars();
1938
1939 assert_eq!(
1940 ctx.template_vars().get("GitURL"),
1941 Some(&"https://github.com/test/repo.git".to_string())
1942 );
1943 }
1944
1945 #[test]
1946 fn test_summary_set_from_git_info() {
1947 let config = Config::default();
1948 let mut ctx = Context::new(config, ContextOptions::default());
1949 ctx.git_info = Some(make_git_info(false, None));
1950 ctx.populate_git_vars();
1951
1952 assert_eq!(
1953 ctx.template_vars().get("Summary"),
1954 Some(&"v1.2.3-0-gabc123d".to_string())
1955 );
1956 }
1957
1958 #[test]
1959 fn test_tag_subject_set_from_git_info() {
1960 let config = Config::default();
1961 let mut ctx = Context::new(config, ContextOptions::default());
1962 ctx.git_info = Some(make_git_info(false, None));
1963 ctx.populate_git_vars();
1964
1965 assert_eq!(
1966 ctx.template_vars().get("TagSubject"),
1967 Some(&"Release v1.2.3".to_string())
1968 );
1969 }
1970
1971 #[test]
1972 fn test_tag_contents_set_from_git_info() {
1973 let config = Config::default();
1974 let mut ctx = Context::new(config, ContextOptions::default());
1975 ctx.git_info = Some(make_git_info(false, None));
1976 ctx.populate_git_vars();
1977
1978 assert_eq!(
1979 ctx.template_vars().get("TagContents"),
1980 Some(&"Release v1.2.3\n\nFull release notes here.".to_string())
1981 );
1982 }
1983
1984 #[test]
1985 fn test_tag_body_set_from_git_info() {
1986 let config = Config::default();
1987 let mut ctx = Context::new(config, ContextOptions::default());
1988 ctx.git_info = Some(make_git_info(false, None));
1989 ctx.populate_git_vars();
1990
1991 assert_eq!(
1992 ctx.template_vars().get("TagBody"),
1993 Some(&"Full release notes here.".to_string())
1994 );
1995 }
1996
1997 #[test]
1998 fn test_is_single_target_false_by_default() {
1999 let config = Config::default();
2000 let mut ctx = Context::new(config, ContextOptions::default());
2001 ctx.git_info = Some(make_git_info(false, None));
2002 ctx.populate_git_vars();
2003
2004 assert_eq!(
2005 ctx.template_vars().get_structured("IsSingleTarget"),
2006 Some(&tera::Value::Bool(false))
2007 );
2008 }
2009
2010 #[test]
2011 fn test_is_single_target_true_when_set() {
2012 let config = Config::default();
2013 let opts = ContextOptions {
2014 single_target: Some("x86_64-unknown-linux-gnu".to_string()),
2015 ..Default::default()
2016 };
2017 let mut ctx = Context::new(config, opts);
2018 ctx.git_info = Some(make_git_info(false, None));
2019 ctx.populate_git_vars();
2020
2021 assert_eq!(
2022 ctx.template_vars().get_structured("IsSingleTarget"),
2023 Some(&tera::Value::Bool(true))
2024 );
2025 }
2026
2027 #[test]
2028 #[serial_test::serial]
2029 fn test_populate_runtime_vars() {
2030 let config = Config::default();
2031 let mut ctx = Context::new(config, ContextOptions::default());
2032 ctx.populate_runtime_vars();
2033
2034 let v = ctx.template_vars();
2035
2036 let goos = v
2037 .get("RuntimeGoos")
2038 .unwrap_or_else(|| panic!("RuntimeGoos should be set"));
2039 assert!(
2040 !goos.is_empty(),
2041 "RuntimeGoos should not be empty, got: {goos}"
2042 );
2043 assert_eq!(goos, map_os_to_goos(std::env::consts::OS));
2045
2046 let goarch = v
2047 .get("RuntimeGoarch")
2048 .unwrap_or_else(|| panic!("RuntimeGoarch should be set"));
2049 assert!(
2050 !goarch.is_empty(),
2051 "RuntimeGoarch should not be empty, got: {goarch}"
2052 );
2053 assert_eq!(goarch, map_arch_to_goarch(std::env::consts::ARCH));
2055 }
2056
2057 #[test]
2058 fn test_populate_release_notes_var_with_changelogs() {
2059 let mut config = Config::default();
2060 config.crates.push(crate::config::CrateConfig {
2061 name: "my-crate".to_string(),
2062 ..Default::default()
2063 });
2064 let mut ctx = Context::new(config, ContextOptions::default());
2065 ctx.stage_outputs
2066 .changelogs
2067 .insert("my-crate".to_string(), "## Changes\n- fix bug".to_string());
2068 ctx.populate_release_notes_var();
2069
2070 assert_eq!(
2071 ctx.template_vars().get("ReleaseNotes"),
2072 Some(&"## Changes\n- fix bug".to_string())
2073 );
2074 }
2075
2076 #[test]
2077 fn test_populate_release_notes_var_empty_when_no_changelogs() {
2078 let config = Config::default();
2079 let mut ctx = Context::new(config, ContextOptions::default());
2080 ctx.populate_release_notes_var();
2081
2082 assert_eq!(
2083 ctx.template_vars().get("ReleaseNotes"),
2084 Some(&"".to_string())
2085 );
2086 }
2087
2088 #[test]
2089 fn test_populate_release_notes_var_deterministic_with_multiple_crates() {
2090 let mut config = Config::default();
2091 config.crates.push(crate::config::CrateConfig {
2092 name: "crate-a".to_string(),
2093 ..Default::default()
2094 });
2095 config.crates.push(crate::config::CrateConfig {
2096 name: "crate-b".to_string(),
2097 ..Default::default()
2098 });
2099 let mut ctx = Context::new(config, ContextOptions::default());
2100 ctx.stage_outputs
2101 .changelogs
2102 .insert("crate-a".to_string(), "notes-a".to_string());
2103 ctx.stage_outputs
2104 .changelogs
2105 .insert("crate-b".to_string(), "notes-b".to_string());
2106 ctx.populate_release_notes_var();
2107
2108 assert_eq!(
2110 ctx.template_vars().get("ReleaseNotes"),
2111 Some(&"notes-a".to_string())
2112 );
2113 }
2114
2115 #[test]
2116 fn test_outputs_accessible_in_templates() {
2117 let mut config = Config::default();
2118 config.project_name = "myapp".to_string();
2119 let mut ctx = Context::new(config, ContextOptions::default());
2120 ctx.template_vars_mut().set_output("build_id", "abc123");
2121 ctx.template_vars_mut()
2122 .set_output("deploy_url", "https://example.com");
2123
2124 let result = ctx
2125 .render_template("{{ .Outputs.build_id }}-{{ .Outputs.deploy_url }}")
2126 .unwrap();
2127 assert_eq!(result, "abc123-https://example.com");
2128 }
2129
2130 #[test]
2131 fn test_artifact_ext_and_target_template_vars() {
2132 let mut config = Config::default();
2133 config.project_name = "myapp".to_string();
2134 let mut ctx = Context::new(config, ContextOptions::default());
2135 ctx.template_vars_mut().set("ArtifactName", "myapp.tar.gz");
2136 ctx.template_vars_mut().set("ArtifactExt", ".tar.gz");
2137 ctx.template_vars_mut()
2138 .set("Target", "x86_64-unknown-linux-gnu");
2139
2140 let result = ctx
2141 .render_template("{{ .ArtifactExt }}_{{ .Target }}")
2142 .unwrap();
2143 assert_eq!(result, ".tar.gz_x86_64-unknown-linux-gnu");
2144 }
2145
2146 #[test]
2147 fn test_checksums_template_var() {
2148 let mut config = Config::default();
2149 config.project_name = "myapp".to_string();
2150 let mut ctx = Context::new(config, ContextOptions::default());
2151 let checksum_text = "abc123 myapp.tar.gz\ndef456 myapp.zip\n";
2152 ctx.template_vars_mut().set("Checksums", checksum_text);
2153
2154 let result = ctx.render_template("{{ .Checksums }}").unwrap();
2155 assert_eq!(result, checksum_text);
2156 }
2157
2158 #[test]
2161 fn test_prefixed_tag_with_tag_prefix() {
2162 let mut config = Config::default();
2163 config.tag = Some(crate::config::TagConfig {
2164 tag_prefix: Some("api/".to_string()),
2165 ..Default::default()
2166 });
2167 let mut ctx = Context::new(config, ContextOptions::default());
2168 ctx.git_info = Some(make_git_info(false, None));
2169 ctx.populate_git_vars();
2170
2171 assert_eq!(
2172 ctx.template_vars().get("PrefixedTag"),
2173 Some(&"api/v1.2.3".to_string())
2174 );
2175 }
2176
2177 #[test]
2178 fn test_prefixed_tag_without_tag_prefix() {
2179 let config = Config::default();
2180 let mut ctx = Context::new(config, ContextOptions::default());
2181 ctx.git_info = Some(make_git_info(false, None));
2182 ctx.populate_git_vars();
2183
2184 assert_eq!(
2186 ctx.template_vars().get("PrefixedTag"),
2187 Some(&"v1.2.3".to_string())
2188 );
2189 }
2190
2191 #[test]
2192 fn test_prefixed_previous_tag_with_tag_prefix() {
2193 let mut config = Config::default();
2194 config.tag = Some(crate::config::TagConfig {
2195 tag_prefix: Some("api/".to_string()),
2196 ..Default::default()
2197 });
2198 let mut ctx = Context::new(config, ContextOptions::default());
2199 ctx.git_info = Some(make_git_info(false, None));
2200 ctx.populate_git_vars();
2201
2202 assert_eq!(
2203 ctx.template_vars().get("PrefixedPreviousTag"),
2204 Some(&"api/v1.2.2".to_string())
2205 );
2206 }
2207
2208 #[test]
2209 fn test_prefixed_previous_tag_empty_when_no_previous() {
2210 let mut config = Config::default();
2211 config.tag = Some(crate::config::TagConfig {
2212 tag_prefix: Some("api/".to_string()),
2213 ..Default::default()
2214 });
2215 let mut ctx = Context::new(config, ContextOptions::default());
2216 let mut info = make_git_info(false, None);
2217 info.previous_tag = None;
2218 ctx.git_info = Some(info);
2219 ctx.populate_git_vars();
2220
2221 assert_eq!(
2224 ctx.template_vars().get("PrefixedPreviousTag"),
2225 Some(&"".to_string())
2226 );
2227 }
2228
2229 #[test]
2230 fn test_prefixed_summary_with_tag_prefix() {
2231 let mut config = Config::default();
2232 config.tag = Some(crate::config::TagConfig {
2233 tag_prefix: Some("api/".to_string()),
2234 ..Default::default()
2235 });
2236 let mut ctx = Context::new(config, ContextOptions::default());
2237 ctx.git_info = Some(make_git_info(false, None));
2238 ctx.populate_git_vars();
2239
2240 assert_eq!(
2241 ctx.template_vars().get("PrefixedSummary"),
2242 Some(&"api/v1.2.3-0-gabc123d".to_string())
2243 );
2244 }
2245
2246 #[test]
2247 fn test_is_release_true_for_normal_release() {
2248 let config = Config::default();
2249 let opts = ContextOptions {
2250 snapshot: false,
2251 nightly: false,
2252 ..Default::default()
2253 };
2254 let mut ctx = Context::new(config, opts);
2255 ctx.git_info = Some(make_git_info(false, None));
2256 ctx.populate_git_vars();
2257
2258 assert_eq!(
2259 ctx.template_vars().get_structured("IsRelease"),
2260 Some(&tera::Value::Bool(true))
2261 );
2262 }
2263
2264 #[test]
2265 fn test_is_release_false_for_snapshot() {
2266 let config = Config::default();
2267 let opts = ContextOptions {
2268 snapshot: true,
2269 ..Default::default()
2270 };
2271 let mut ctx = Context::new(config, opts);
2272 ctx.git_info = Some(make_git_info(false, None));
2273 ctx.populate_git_vars();
2274
2275 assert_eq!(
2276 ctx.template_vars().get_structured("IsRelease"),
2277 Some(&tera::Value::Bool(false))
2278 );
2279 }
2280
2281 #[test]
2282 fn test_is_release_false_for_nightly() {
2283 let config = Config::default();
2284 let opts = ContextOptions {
2285 nightly: true,
2286 ..Default::default()
2287 };
2288 let mut ctx = Context::new(config, opts);
2289 ctx.git_info = Some(make_git_info(false, None));
2290 ctx.populate_git_vars();
2291
2292 assert_eq!(
2293 ctx.template_vars().get_structured("IsRelease"),
2294 Some(&tera::Value::Bool(false))
2295 );
2296 }
2297
2298 #[test]
2299 fn test_is_merging_true_when_merge_flag_set() {
2300 let config = Config::default();
2301 let opts = ContextOptions {
2302 merge: true,
2303 ..Default::default()
2304 };
2305 let mut ctx = Context::new(config, opts);
2306 ctx.git_info = Some(make_git_info(false, None));
2307 ctx.populate_git_vars();
2308
2309 assert_eq!(
2310 ctx.template_vars().get_structured("IsMerging"),
2311 Some(&tera::Value::Bool(true))
2312 );
2313 }
2314
2315 #[test]
2316 fn test_is_merging_false_by_default() {
2317 let config = Config::default();
2318 let mut ctx = Context::new(config, ContextOptions::default());
2319 ctx.git_info = Some(make_git_info(false, None));
2320 ctx.populate_git_vars();
2321
2322 assert_eq!(
2323 ctx.template_vars().get_structured("IsMerging"),
2324 Some(&tera::Value::Bool(false))
2325 );
2326 }
2327
2328 #[test]
2329 fn test_refresh_artifacts_var_empty() {
2330 let config = Config::default();
2331 let mut ctx = Context::new(config, ContextOptions::default());
2332 ctx.refresh_artifacts_var();
2333
2334 let result = ctx
2336 .render_template("{% for a in Artifacts %}{{ a.name }}{% endfor %}")
2337 .unwrap();
2338 assert_eq!(result, "");
2339 }
2340
2341 #[test]
2342 fn test_refresh_artifacts_var_with_artifacts() {
2343 use crate::artifact::{Artifact, ArtifactKind};
2344 use std::collections::HashMap;
2345 use std::path::PathBuf;
2346
2347 let config = Config::default();
2348 let mut ctx = Context::new(config, ContextOptions::default());
2349 ctx.artifacts.add(Artifact {
2353 kind: ArtifactKind::Archive,
2354 name: String::new(),
2355 path: PathBuf::from("dist/myapp-1.0.0-linux-amd64.tar.gz"),
2356 target: Some("x86_64-unknown-linux-gnu".to_string()),
2357 crate_name: "myapp".to_string(),
2358 metadata: HashMap::from([("format".to_string(), "tar.gz".to_string())]),
2359 size: None,
2360 });
2361 ctx.artifacts.add(Artifact {
2362 kind: ArtifactKind::Binary,
2363 name: String::new(),
2364 path: PathBuf::from("dist/myapp"),
2365 target: Some("x86_64-unknown-linux-gnu".to_string()),
2366 crate_name: "myapp".to_string(),
2367 metadata: HashMap::new(),
2368 size: None,
2369 });
2370 ctx.refresh_artifacts_var();
2371
2372 let result = ctx
2374 .render_template("{% for a in Artifacts %}{{ a.name }},{% endfor %}")
2375 .unwrap();
2376 assert!(result.contains("myapp-1.0.0-linux-amd64.tar.gz"));
2377 assert!(result.contains("myapp"));
2378
2379 let result_kinds = ctx
2381 .render_template("{% for a in Artifacts %}{{ a.kind }},{% endfor %}")
2382 .unwrap();
2383 assert!(result_kinds.contains("archive"));
2384 assert!(result_kinds.contains("binary"));
2385 }
2386
2387 #[test]
2388 fn test_populate_metadata_var_with_mod_timestamp() {
2389 let mut config = Config::default();
2390 config.metadata = Some(crate::config::MetadataConfig {
2391 mod_timestamp: Some("{{ .CommitTimestamp }}".to_string()),
2392 ..Default::default()
2393 });
2394 let mut ctx = Context::new(config, ContextOptions::default());
2395 ctx.populate_metadata_var().unwrap();
2396
2397 let result = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
2399 assert_eq!(result, "{{ .CommitTimestamp }}");
2400 }
2401
2402 #[test]
2403 fn test_populate_metadata_var_empty_when_no_config() {
2404 let config = Config::default();
2405 let mut ctx = Context::new(config, ContextOptions::default());
2406 ctx.populate_metadata_var().unwrap();
2407
2408 let result = ctx.render_template("{{ Metadata.Description }}").unwrap();
2410 assert_eq!(result, "");
2411 }
2412
2413 #[test]
2414 fn test_populate_metadata_var_reads_from_config() {
2415 let mut config = Config::default();
2416 config.metadata = Some(crate::config::MetadataConfig {
2417 description: Some("A test project".to_string()),
2418 homepage: Some("https://example.com".to_string()),
2419 documentation: Some("https://docs.example.com".to_string()),
2420 license: Some("MIT".to_string()),
2421 maintainers: Some(vec!["Alice".to_string(), "Bob".to_string()]),
2422 mod_timestamp: Some("1234567890".to_string()),
2423 ..Default::default()
2424 });
2425 let mut ctx = Context::new(config, ContextOptions::default());
2426 ctx.populate_metadata_var().unwrap();
2427
2428 let desc = ctx.render_template("{{ Metadata.Description }}").unwrap();
2429 assert_eq!(desc, "A test project");
2430
2431 let home = ctx.render_template("{{ Metadata.Homepage }}").unwrap();
2432 assert_eq!(home, "https://example.com");
2433
2434 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2435 assert_eq!(docs, "https://docs.example.com");
2436
2437 let lic = ctx.render_template("{{ Metadata.License }}").unwrap();
2438 assert_eq!(lic, "MIT");
2439
2440 let ts = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
2441 assert_eq!(ts, "1234567890");
2442 }
2443
2444 #[test]
2445 fn test_populate_metadata_var_license_falls_back_to_derived() {
2446 let mut config = Config::default();
2450 config.crates = vec![crate::config::CrateConfig {
2451 name: "anodizer".to_string(),
2452 ..Default::default()
2453 }];
2454 config.derived_metadata.insert(
2455 "anodizer".to_string(),
2456 crate::config::MetadataConfig {
2457 description: Some("Derived desc".to_string()),
2458 homepage: Some("https://derived.example".to_string()),
2459 documentation: Some("https://derived.docs".to_string()),
2460 license: Some("MIT OR Apache-2.0".to_string()),
2461 ..Default::default()
2462 },
2463 );
2464 let mut ctx = Context::new(config, ContextOptions::default());
2465 ctx.populate_metadata_var().unwrap();
2466
2467 assert_eq!(
2468 ctx.render_template("{{ Metadata.License }}").unwrap(),
2469 "MIT OR Apache-2.0"
2470 );
2471 assert_eq!(
2472 ctx.render_template("{{ Metadata.Description }}").unwrap(),
2473 "Derived desc"
2474 );
2475 assert_eq!(
2476 ctx.render_template("{{ Metadata.Homepage }}").unwrap(),
2477 "https://derived.example"
2478 );
2479 assert_eq!(
2480 ctx.render_template("{{ Metadata.Documentation }}").unwrap(),
2481 "https://derived.docs"
2482 );
2483 }
2484
2485 #[test]
2486 fn test_populate_metadata_var_top_level_license_wins_over_derived() {
2487 let mut config = Config::default();
2490 config.crates = vec![crate::config::CrateConfig {
2491 name: "anodizer".to_string(),
2492 ..Default::default()
2493 }];
2494 config.derived_metadata.insert(
2495 "anodizer".to_string(),
2496 crate::config::MetadataConfig {
2497 license: Some("MIT OR Apache-2.0".to_string()),
2498 ..Default::default()
2499 },
2500 );
2501 config.metadata = Some(crate::config::MetadataConfig {
2502 license: Some("GPL-3.0".to_string()),
2503 ..Default::default()
2504 });
2505 let mut ctx = Context::new(config, ContextOptions::default());
2506 ctx.populate_metadata_var().unwrap();
2507
2508 assert_eq!(
2509 ctx.render_template("{{ Metadata.License }}").unwrap(),
2510 "GPL-3.0"
2511 );
2512 }
2513
2514 #[test]
2515 fn test_populate_metadata_var_documentation_renders() {
2516 let mut config = Config::default();
2517 config.metadata = Some(crate::config::MetadataConfig {
2518 documentation: Some("https://docs.rs/anodizer".to_string()),
2519 ..Default::default()
2520 });
2521 let mut ctx = Context::new(config, ContextOptions::default());
2522 ctx.populate_metadata_var().unwrap();
2523
2524 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2525 assert_eq!(docs, "https://docs.rs/anodizer");
2526 }
2527
2528 #[test]
2529 fn test_populate_metadata_var_documentation_empty_when_unset() {
2530 let mut ctx = Context::new(Config::default(), ContextOptions::default());
2531 ctx.populate_metadata_var().unwrap();
2532
2533 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2534 assert_eq!(docs, "");
2535 }
2536
2537 #[test]
2538 fn test_populate_metadata_var_full_description_inline() {
2539 use crate::config::ContentSource;
2540 let mut config = Config::default();
2541 config.metadata = Some(crate::config::MetadataConfig {
2542 full_description: Some(ContentSource::Inline(
2543 "A long-form description of the project.".to_string(),
2544 )),
2545 ..Default::default()
2546 });
2547 let mut ctx = Context::new(config, ContextOptions::default());
2548 ctx.populate_metadata_var().unwrap();
2549 let rendered = ctx
2550 .render_template("{{ Metadata.FullDescription }}")
2551 .unwrap();
2552 assert_eq!(rendered, "A long-form description of the project.");
2553 }
2554
2555 #[test]
2556 fn test_populate_metadata_var_full_description_from_file() {
2557 use crate::config::ContentSource;
2558 let tmp = tempfile::tempdir().unwrap();
2559 let desc_path = tmp.path().join("DESCRIPTION.md");
2560 std::fs::write(&desc_path, "read from disk").unwrap();
2561 let mut config = Config::default();
2562 config.metadata = Some(crate::config::MetadataConfig {
2563 full_description: Some(ContentSource::FromFile {
2564 from_file: desc_path.to_string_lossy().into_owned(),
2565 }),
2566 ..Default::default()
2567 });
2568 let mut ctx = Context::new(config, ContextOptions::default());
2569 ctx.populate_metadata_var().unwrap();
2570 let rendered = ctx
2571 .render_template("{{ Metadata.FullDescription }}")
2572 .unwrap();
2573 assert_eq!(rendered, "read from disk");
2574 }
2575
2576 #[test]
2577 fn test_populate_metadata_var_full_description_from_url_resolves() {
2578 use crate::config::ContentSource;
2583 use crate::test_helpers::responder::spawn_oneshot_http_responder;
2584
2585 let body = "long form description body";
2586 let body_len = body.len();
2587 let response: &'static str = Box::leak(
2588 format!("HTTP/1.1 200 OK\r\nContent-Length: {body_len}\r\n\r\n{body}").into_boxed_str(),
2589 );
2590 let (addr, _calls) = spawn_oneshot_http_responder(vec![response]);
2591
2592 let mut config = Config::default();
2593 config.metadata = Some(crate::config::MetadataConfig {
2594 full_description: Some(ContentSource::FromUrl {
2595 from_url: format!("http://{addr}/description.md"),
2596 headers: None,
2597 }),
2598 ..Default::default()
2599 });
2600 let mut ctx = Context::new(config, ContextOptions::default());
2601 ctx.populate_metadata_var()
2602 .expect("from_url should resolve through content_source");
2603 let rendered = ctx
2604 .render_template("{{ Metadata.FullDescription }}")
2605 .unwrap();
2606 assert_eq!(rendered, body);
2607 }
2608
2609 #[test]
2610 fn test_populate_metadata_var_commit_author() {
2611 use crate::config::CommitAuthorConfig;
2612 let mut config = Config::default();
2613 config.metadata = Some(crate::config::MetadataConfig {
2614 commit_author: Some(CommitAuthorConfig {
2615 name: Some("Alice Developer".to_string()),
2616 email: Some("alice@example.com".to_string()),
2617 signing: None,
2618 use_github_app_token: false,
2619 }),
2620 ..Default::default()
2621 });
2622 let mut ctx = Context::new(config, ContextOptions::default());
2623 ctx.populate_metadata_var().unwrap();
2624 let name = ctx
2625 .render_template("{{ Metadata.CommitAuthor.Name }}")
2626 .unwrap();
2627 assert_eq!(name, "Alice Developer");
2628 let email = ctx
2629 .render_template("{{ Metadata.CommitAuthor.Email }}")
2630 .unwrap();
2631 assert_eq!(email, "alice@example.com");
2632 }
2633
2634 #[test]
2635 fn test_artifact_id_template_var() {
2636 let mut config = Config::default();
2637 config.project_name = "myapp".to_string();
2638 let mut ctx = Context::new(config, ContextOptions::default());
2639 ctx.template_vars_mut().set("ArtifactID", "default");
2640
2641 let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
2642 assert_eq!(result, "default");
2643 }
2644
2645 #[test]
2646 fn test_artifact_id_empty_when_not_set() {
2647 let mut config = Config::default();
2648 config.project_name = "myapp".to_string();
2649 let mut ctx = Context::new(config, ContextOptions::default());
2650 ctx.template_vars_mut().set("ArtifactID", "");
2651
2652 let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
2653 assert_eq!(result, "");
2654 }
2655
2656 #[test]
2657 fn test_pro_vars_rendered_in_templates() {
2658 let mut config = Config::default();
2660 config.tag = Some(crate::config::TagConfig {
2661 tag_prefix: Some("api/".to_string()),
2662 ..Default::default()
2663 });
2664 let opts = ContextOptions {
2665 snapshot: false,
2666 nightly: false,
2667 merge: true,
2668 ..Default::default()
2669 };
2670 let mut ctx = Context::new(config, opts);
2671 ctx.git_info = Some(make_git_info(false, None));
2672 ctx.populate_git_vars();
2673
2674 let result = ctx
2675 .render_template(
2676 "{% if IsRelease %}release{% endif %}-{% if IsMerging %}merge{% endif %}-{{ .PrefixedTag }}",
2677 )
2678 .unwrap();
2679 assert_eq!(result, "release-merge-api/v1.2.3");
2680 }
2681
2682 #[test]
2683 fn test_is_release_without_git_info() {
2684 let config = Config::default();
2686 let opts = ContextOptions {
2687 snapshot: false,
2688 nightly: false,
2689 ..Default::default()
2690 };
2691 let mut ctx = Context::new(config, opts);
2692 ctx.populate_git_vars();
2693
2694 assert_eq!(
2695 ctx.template_vars().get_structured("IsRelease"),
2696 Some(&tera::Value::Bool(true))
2697 );
2698 }
2699
2700 #[test]
2701 fn test_is_merging_without_git_info() {
2702 let config = Config::default();
2704 let opts = ContextOptions {
2705 merge: true,
2706 ..Default::default()
2707 };
2708 let mut ctx = Context::new(config, opts);
2709 ctx.populate_git_vars();
2710
2711 assert_eq!(
2712 ctx.template_vars().get_structured("IsMerging"),
2713 Some(&tera::Value::Bool(true))
2714 );
2715 }
2716
2717 #[test]
2727 fn test_monorepo_version_matches_shared_semver_helper() {
2728 let mut config = Config::default();
2729 config.monorepo = Some(crate::config::MonorepoConfig {
2730 tag_prefix: Some("core/".to_string()),
2731 dir: None,
2732 });
2733 let mut ctx = Context::new(config, ContextOptions::default());
2734
2735 let semver = SemVer {
2736 major: 2,
2737 minor: 1,
2738 patch: 0,
2739 prerelease: Some("rc.1".to_string()),
2740 build_metadata: Some("build.7".to_string()),
2741 };
2742 let mut info = make_git_info(false, None);
2743 info.tag = "core/v2.1.0-rc.1+build.7".to_string();
2744 info.semver = semver.clone();
2745 ctx.git_info = Some(info);
2746 ctx.populate_git_vars();
2747
2748 let v = ctx.template_vars();
2749 assert_eq!(v.get("Version"), Some(&semver.version_string()));
2752 assert_eq!(v.get("Version"), Some(&"2.1.0-rc.1+build.7".to_string()));
2753 assert_eq!(v.get("RawVersion"), Some(&semver.raw_version_string()));
2754 assert_eq!(v.get("RawVersion"), Some(&"2.1.0".to_string()));
2755 assert_eq!(v.get("Tag"), Some(&"v2.1.0-rc.1+build.7".to_string()));
2757 }
2758
2759 #[test]
2760 fn test_monorepo_tag_prefix_strips_tag_for_template_var() {
2761 let mut config = Config::default();
2762 config.monorepo = Some(crate::config::MonorepoConfig {
2763 tag_prefix: Some("subproject1/".to_string()),
2764 dir: None,
2765 });
2766 let mut ctx = Context::new(config, ContextOptions::default());
2767
2768 let mut info = make_git_info(false, None);
2770 info.tag = "subproject1/v1.2.3".to_string();
2771 info.previous_tag = Some("subproject1/v1.2.2".to_string());
2772 info.summary = "subproject1/v1.2.3-0-gabc123d".to_string();
2773 ctx.git_info = Some(info);
2774 ctx.populate_git_vars();
2775
2776 let v = ctx.template_vars();
2777 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2779 assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
2781 assert_eq!(
2783 v.get("PrefixedTag"),
2784 Some(&"subproject1/v1.2.3".to_string())
2785 );
2786 assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
2788 assert_eq!(
2790 v.get("PrefixedPreviousTag"),
2791 Some(&"subproject1/v1.2.2".to_string())
2792 );
2793 assert_eq!(v.get("Summary"), Some(&"v1.2.3-0-gabc123d".to_string()));
2795 assert_eq!(
2797 v.get("PrefixedSummary"),
2798 Some(&"subproject1/v1.2.3-0-gabc123d".to_string())
2799 );
2800 }
2801
2802 #[test]
2803 fn test_monorepo_prefixed_previous_tag() {
2804 let mut config = Config::default();
2805 config.monorepo = Some(crate::config::MonorepoConfig {
2806 tag_prefix: Some("svc/".to_string()),
2807 dir: None,
2808 });
2809 let mut ctx = Context::new(config, ContextOptions::default());
2810
2811 let mut info = make_git_info(false, None);
2812 info.tag = "svc/v2.0.0".to_string();
2813 info.previous_tag = Some("svc/v1.9.0".to_string());
2814 ctx.git_info = Some(info);
2815 ctx.populate_git_vars();
2816
2817 let v = ctx.template_vars();
2818 assert_eq!(
2820 v.get("PrefixedPreviousTag"),
2821 Some(&"svc/v1.9.0".to_string())
2822 );
2823 assert_eq!(v.get("PreviousTag"), Some(&"v1.9.0".to_string()));
2825 }
2826
2827 #[test]
2828 fn test_no_monorepo_falls_back_to_tag_prefix() {
2829 let mut config = Config::default();
2831 config.tag = Some(crate::config::TagConfig {
2832 tag_prefix: Some("release/".to_string()),
2833 ..Default::default()
2834 });
2835 let mut ctx = Context::new(config, ContextOptions::default());
2836 ctx.git_info = Some(make_git_info(false, None));
2837 ctx.populate_git_vars();
2838
2839 let v = ctx.template_vars();
2840 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2842 assert_eq!(v.get("PrefixedTag"), Some(&"release/v1.2.3".to_string()));
2844 assert_eq!(
2845 v.get("PrefixedPreviousTag"),
2846 Some(&"release/v1.2.2".to_string())
2847 );
2848 }
2849
2850 #[test]
2851 fn test_monorepo_overrides_tag_prefix_for_prefixed_vars() {
2852 let mut config = Config::default();
2855 config.tag = Some(crate::config::TagConfig {
2856 tag_prefix: Some("release/".to_string()),
2857 ..Default::default()
2858 });
2859 config.monorepo = Some(crate::config::MonorepoConfig {
2860 tag_prefix: Some("svc/".to_string()),
2861 dir: None,
2862 });
2863 let mut ctx = Context::new(config, ContextOptions::default());
2864
2865 let mut info = make_git_info(false, None);
2866 info.tag = "svc/v1.2.3".to_string();
2867 info.previous_tag = Some("svc/v1.2.2".to_string());
2868 ctx.git_info = Some(info);
2869 ctx.populate_git_vars();
2870
2871 let v = ctx.template_vars();
2872 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2874 assert_eq!(v.get("PrefixedTag"), Some(&"svc/v1.2.3".to_string()));
2876 }
2877
2878 #[test]
2879 fn test_monorepo_prefixed_summary() {
2880 let mut config = Config::default();
2881 config.monorepo = Some(crate::config::MonorepoConfig {
2882 tag_prefix: Some("pkg/".to_string()),
2883 dir: None,
2884 });
2885 let mut ctx = Context::new(config, ContextOptions::default());
2886
2887 let mut info = make_git_info(false, None);
2888 info.tag = "pkg/v1.2.3".to_string();
2889 info.summary = "pkg/v1.2.3-0-gabc123d".to_string();
2891 ctx.git_info = Some(info);
2892 ctx.populate_git_vars();
2893
2894 assert_eq!(
2896 ctx.template_vars().get("PrefixedSummary"),
2897 Some(&"pkg/v1.2.3-0-gabc123d".to_string())
2898 );
2899 assert_eq!(
2901 ctx.template_vars().get("Summary"),
2902 Some(&"v1.2.3-0-gabc123d".to_string())
2903 );
2904 }
2905
2906 #[test]
2907 fn test_monorepo_no_previous_tag() {
2908 let mut config = Config::default();
2909 config.monorepo = Some(crate::config::MonorepoConfig {
2910 tag_prefix: Some("svc/".to_string()),
2911 dir: None,
2912 });
2913 let mut ctx = Context::new(config, ContextOptions::default());
2914
2915 let mut info = make_git_info(false, None);
2916 info.tag = "svc/v1.0.0".to_string();
2917 info.previous_tag = None;
2918 ctx.git_info = Some(info);
2919 ctx.populate_git_vars();
2920
2921 let v = ctx.template_vars();
2922 assert_eq!(v.get("PrefixedPreviousTag"), Some(&"".to_string()));
2923 assert_eq!(v.get("PreviousTag"), Some(&"".to_string()));
2925 }
2926
2927 #[test]
2932 fn test_monorepo_full_flow_all_vars() {
2933 let mut config = Config::default();
2936 config.project_name = "mymonorepo".to_string();
2937 config.monorepo = Some(crate::config::MonorepoConfig {
2938 tag_prefix: Some("services/api/".to_string()),
2939 dir: Some("services/api".to_string()),
2940 });
2941
2942 assert_eq!(config.monorepo_tag_prefix(), Some("services/api/"));
2944 assert_eq!(config.monorepo_dir(), Some("services/api"));
2945
2946 let mut ctx = Context::new(config, ContextOptions::default());
2947
2948 let mut info = make_git_info(false, None);
2951 info.tag = "services/api/v2.1.0".to_string();
2952 info.previous_tag = Some("services/api/v2.0.5".to_string());
2953 info.summary = "services/api/v2.1.0-0-gabc123d".to_string();
2954 info.semver = crate::git::SemVer {
2955 major: 2,
2956 minor: 1,
2957 patch: 0,
2958 prerelease: None,
2959 build_metadata: None,
2960 };
2961 ctx.git_info = Some(info);
2962 ctx.populate_git_vars();
2963
2964 let v = ctx.template_vars();
2965
2966 assert_eq!(v.get("Tag"), Some(&"v2.1.0".to_string()));
2968 assert_eq!(v.get("Version"), Some(&"2.1.0".to_string()));
2969 assert_eq!(v.get("RawVersion"), Some(&"2.1.0".to_string()));
2970 assert_eq!(v.get("Major"), Some(&"2".to_string()));
2971 assert_eq!(v.get("Minor"), Some(&"1".to_string()));
2972 assert_eq!(v.get("Patch"), Some(&"0".to_string()));
2973 assert_eq!(v.get("PreviousTag"), Some(&"v2.0.5".to_string()));
2974 assert_eq!(v.get("Summary"), Some(&"v2.1.0-0-gabc123d".to_string()));
2975
2976 assert_eq!(
2978 v.get("PrefixedTag"),
2979 Some(&"services/api/v2.1.0".to_string())
2980 );
2981 assert_eq!(
2982 v.get("PrefixedPreviousTag"),
2983 Some(&"services/api/v2.0.5".to_string())
2984 );
2985 assert_eq!(
2986 v.get("PrefixedSummary"),
2987 Some(&"services/api/v2.1.0-0-gabc123d".to_string())
2988 );
2989
2990 assert_eq!(v.get("ProjectName"), Some(&"mymonorepo".to_string()));
2992 }
2993
2994 #[test]
2995 fn context_env_var_defaults_to_process_env_source() {
2996 let ctx = Context::new(Config::default(), ContextOptions::default());
2997 assert_eq!(ctx.env_var("ANODIZER_T3_UNSET_VAR"), None);
2999 }
3000
3001 #[test]
3002 fn context_env_var_routes_to_injected_source() {
3003 let mut ctx = Context::new(Config::default(), ContextOptions::default());
3004 ctx.set_env_source(crate::MapEnvSource::new().with("INJECTED", "yes"));
3005 assert_eq!(ctx.env_var("INJECTED"), Some("yes".to_string()));
3006 assert_eq!(ctx.env_var("PATH"), None);
3010 }
3011
3012 #[test]
3013 #[serial_test::serial]
3014 fn populate_runtime_vars_sets_rustc_version() {
3015 let config = Config::default();
3016 let mut ctx = Context::new(config, ContextOptions::default());
3017 ctx.populate_runtime_vars();
3020
3021 let ver = ctx
3022 .template_vars()
3023 .get("RustcVersion")
3024 .expect("RustcVersion should be set after populate_runtime_vars");
3025 if !ver.is_empty() {
3029 assert!(
3030 ver.chars().next().is_some_and(|c| c.is_ascii_digit()),
3031 "RustcVersion should start with a digit: {ver}"
3032 );
3033 }
3034 }
3035}