1use crate::artifact::ArtifactRegistry;
2use crate::config::Config;
3use crate::git::GitInfo;
4use crate::log::{StageLogger, Verbosity};
5use crate::partial::PartialTarget;
6use crate::publish_report::PublishReport;
7use crate::scm::ScmTokenType;
8use crate::template::TemplateVars;
9use anyhow::Context as _;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum RollbackMode {
22 None,
25 #[default]
30 BestEffort,
31}
32
33pub const VALID_RELEASE_SKIPS: &[&str] = &[
40 "publish",
41 "announce",
42 "sign",
43 "validate",
44 "sbom",
45 "docker",
46 "docker-sign",
47 "winget",
48 "choco",
49 "snapcraft",
50 "snapcraft-publish",
51 "scoop",
52 "brew",
53 "nix",
54 "aur",
55 "cargo",
56 "krew",
57 "nfpm",
58 "makeself",
59 "flatpak",
60 "srpm",
61 "before",
62 "notarize",
63 "archive",
64 "source",
65 "build",
66 "changelog",
67 "release",
68 "checksum",
69 "upx",
70 "blob",
71 "templatefiles",
72 "dmg",
73 "msi",
74 "nsis",
75 "pkg",
76 "appbundle",
77];
78
79pub const VALID_BUILD_SKIPS: &[&str] = &["pre-hooks", "post-hooks", "validate", "before"];
81
82pub fn validate_skip_values(skip: &[String], valid: &[&str]) -> Result<(), String> {
87 let invalid: Vec<&str> = skip
88 .iter()
89 .map(|s| s.as_str())
90 .filter(|s| !valid.contains(s))
91 .collect();
92 if invalid.is_empty() {
93 Ok(())
94 } else {
95 Err(format!(
96 "invalid --skip value(s): {}. Valid options: {}",
97 invalid.join(", "),
98 valid.join(", "),
99 ))
100 }
101}
102
103pub struct ContextOptions {
104 pub snapshot: bool,
105 pub nightly: bool,
106 pub dry_run: bool,
107 pub quiet: bool,
108 pub verbose: bool,
109 pub debug: bool,
110 pub skip_stages: Vec<String>,
111 pub selected_crates: Vec<String>,
112 pub token: Option<String>,
113 pub parallelism: usize,
115 pub single_target: Option<String>,
117 pub release_notes_path: Option<PathBuf>,
119 pub fail_fast: bool,
121 pub partial_target: Option<PartialTarget>,
124 pub merge: bool,
126 pub publish_only: bool,
136 pub project_root: Option<PathBuf>,
139 pub strict: bool,
141 pub resume_release: bool,
146 pub replace_existing_artifacts: bool,
150 pub skip_post_publish_poll: bool,
158 pub gate_submitter: Option<bool>,
166 pub rollback_mode: Option<RollbackMode>,
171 pub simulate_failure_publishers: Vec<String>,
179 pub rollback_only: bool,
185 pub allow_rerun: bool,
200 pub from_run: Option<String>,
204 pub runtime_nondeterministic_allowlist: Vec<(String, String)>,
211 pub summary_json_path: Option<PathBuf>,
215}
216
217impl Default for ContextOptions {
218 fn default() -> Self {
219 Self {
220 snapshot: false,
221 nightly: false,
222 dry_run: false,
223 quiet: false,
224 verbose: false,
225 debug: false,
226 skip_stages: Vec::new(),
227 selected_crates: Vec::new(),
228 token: None,
229 parallelism: 4,
230 single_target: None,
231 release_notes_path: None,
232 fail_fast: false,
233 partial_target: None,
234 merge: false,
235 publish_only: false,
236 project_root: None,
237 strict: false,
238 resume_release: false,
239 replace_existing_artifacts: false,
240 skip_post_publish_poll: false,
241 gate_submitter: None,
242 rollback_mode: None,
243 simulate_failure_publishers: Vec::new(),
244 rollback_only: false,
245 allow_rerun: false,
246 from_run: None,
247 runtime_nondeterministic_allowlist: Vec::new(),
248 summary_json_path: None,
249 }
250 }
251}
252
253#[derive(Debug, Default)]
258pub struct StageOutputs {
259 pub github_native_changelog: bool,
263 pub changelogs: HashMap<String, String>,
265 pub changelog_header: Option<String>,
270 pub changelog_footer: Option<String>,
273 pub post_publish_results: Vec<serde_json::Value>,
281}
282
283pub struct Context {
284 pub config: Config,
285 pub artifacts: ArtifactRegistry,
286 pub options: ContextOptions,
287 pub stage_outputs: StageOutputs,
289 template_vars: TemplateVars,
290 pub git_info: Option<GitInfo>,
291 pub token_type: ScmTokenType,
293 pub skip_memento: crate::pipe_skip::SkipMemento,
299 pub publish_report: Option<PublishReport>,
307 pub determinism: Option<crate::DeterminismState>,
314 pub pending_outcome: Option<crate::PublisherOutcome>,
324 #[cfg(feature = "test-helpers")]
332 pub log_capture: Option<crate::log::LogCapture>,
333}
334
335impl Context {
336 pub fn new(config: Config, options: ContextOptions) -> Self {
337 let mut vars = TemplateVars::new();
338 vars.set("ProjectName", &config.project_name);
339 Self {
340 config,
341 artifacts: ArtifactRegistry::new(),
342 options,
343 stage_outputs: StageOutputs::default(),
344 template_vars: vars,
345 git_info: None,
346 token_type: ScmTokenType::GitHub,
347 skip_memento: crate::pipe_skip::SkipMemento::new(),
348 publish_report: None,
349 determinism: None,
350 pending_outcome: None,
351 #[cfg(feature = "test-helpers")]
352 log_capture: None,
353 }
354 }
355
356 #[cfg(feature = "test-helpers")]
362 pub fn with_log_capture(&mut self, capture: crate::log::LogCapture) {
363 self.log_capture = Some(capture);
364 }
365
366 pub fn record_publisher_outcome(&mut self, outcome: crate::PublisherOutcome) {
374 self.pending_outcome = Some(outcome);
375 }
376
377 pub fn take_pending_outcome(&mut self) -> Option<crate::PublisherOutcome> {
381 self.pending_outcome.take()
382 }
383
384 pub fn publish_report(&self) -> Option<&PublishReport> {
387 self.publish_report.as_ref()
388 }
389
390 pub fn set_publish_report(&mut self, r: PublishReport) {
393 self.publish_report = Some(r);
394 }
395
396 pub fn remember_skip(&self, stage: &str, label: &str, reason: &str) {
403 self.skip_memento.remember(stage, label, reason);
404 }
405
406 pub fn template_vars(&self) -> &TemplateVars {
407 &self.template_vars
408 }
409
410 pub fn template_vars_mut(&mut self) -> &mut TemplateVars {
411 &mut self.template_vars
412 }
413
414 pub fn render_template(&self, template: &str) -> anyhow::Result<String> {
415 crate::template::render(template, &self.template_vars)
416 }
417
418 pub fn render_template_opt(&self, template: Option<&str>) -> anyhow::Result<Option<String>> {
420 template.map(|t| self.render_template(t)).transpose()
421 }
422
423 pub fn skip_with_log(
432 &self,
433 skip: &Option<crate::config::StringOrBool>,
434 log: &StageLogger,
435 label: &str,
436 ) -> anyhow::Result<bool> {
437 let Some(d) = skip else {
438 return Ok(false);
439 };
440 let should_skip = d
441 .try_evaluates_to_true(|s| self.render_template(s))
442 .with_context(|| format!("evaluate skip expression for {label}"))?;
443 if should_skip {
444 log.status(&format!("{} skipped", label));
445 }
446 Ok(should_skip)
447 }
448
449 pub fn should_skip(&self, stage_name: &str) -> bool {
450 self.options.skip_stages.iter().any(|s| s == stage_name)
451 }
452
453 pub fn skip_validate(&self) -> bool {
455 self.should_skip("validate")
456 }
457
458 pub fn is_dry_run(&self) -> bool {
459 self.options.dry_run
460 }
461
462 pub fn is_snapshot(&self) -> bool {
463 self.options.snapshot
464 }
465
466 pub fn is_strict(&self) -> bool {
467 self.options.strict
468 }
469
470 pub fn strict_guard(&self, log: &crate::log::StageLogger, msg: &str) -> anyhow::Result<()> {
473 if self.options.strict {
474 anyhow::bail!("{} (strict mode)", msg);
475 }
476 log.warn(msg);
477 Ok(())
478 }
479
480 pub fn skip_in_snapshot(&self, log: &crate::log::StageLogger, stage: &str) -> bool {
489 if self.is_snapshot() {
490 log.status(&format!("{}: skipped (snapshot mode)", stage));
491 true
492 } else {
493 false
494 }
495 }
496
497 pub fn render_template_strict(
499 &self,
500 template: &str,
501 label: &str,
502 log: &crate::log::StageLogger,
503 ) -> anyhow::Result<String> {
504 match self.render_template(template) {
505 Ok(rendered) => Ok(rendered),
506 Err(e) => {
507 if self.options.strict {
508 anyhow::bail!("{}: failed to render template: {} (strict mode)", label, e);
509 }
510 log.warn(&format!("{}: failed to render template: {}", label, e));
511 Ok(template.to_string())
512 }
513 }
514 }
515
516 pub fn is_nightly(&self) -> bool {
517 self.options.nightly
518 }
519
520 pub fn set_release_url(&mut self, url: &str) {
525 self.template_vars.set("ReleaseURL", url);
526 }
527
528 pub fn version(&self) -> String {
531 self.template_vars
532 .get("Version")
533 .cloned()
534 .unwrap_or_default()
535 }
536
537 pub fn verbosity(&self) -> Verbosity {
539 Verbosity::from_flags(self.options.quiet, self.options.verbose, self.options.debug)
540 }
541
542 pub fn retry_policy(&self) -> crate::retry::RetryPolicy {
548 self.config.retry.unwrap_or_default().to_policy()
549 }
550
551 pub fn logger(&self, stage: &'static str) -> StageLogger {
559 #[allow(unused_mut)]
560 let mut log = StageLogger::new(stage, self.verbosity()).with_env(self.env_for_redact());
561 #[cfg(feature = "test-helpers")]
562 if let Some(cap) = &self.log_capture {
563 log = log.with_capture_handle(cap.clone());
564 }
565 log
566 }
567
568 fn env_for_redact(&self) -> Vec<(String, String)> {
574 use std::collections::HashMap;
575 let mut map: HashMap<String, String> = std::env::vars().collect();
576 for (k, v) in self.template_vars.all_env() {
577 map.insert(k.clone(), v.clone());
578 }
579 map.into_iter().collect()
580 }
581
582 pub fn populate_git_vars(&mut self) {
624 if let Some(ref info) = self.git_info {
625 let raw_version = format!(
627 "{}.{}.{}",
628 info.semver.major, info.semver.minor, info.semver.patch
629 );
630
631 let mut version = raw_version.clone();
637 if let Some(ref pre) = info.semver.prerelease {
638 version.push('-');
639 version.push_str(pre);
640 }
641 if let Some(ref meta) = info.semver.build_metadata {
642 version.push('+');
643 version.push_str(meta);
644 }
645
646 self.template_vars.set("Tag", &info.tag);
647 self.template_vars.set("Version", &version);
648 self.template_vars.set("RawVersion", &raw_version);
649 self.template_vars
650 .set("Major", &info.semver.major.to_string());
651 self.template_vars
652 .set("Minor", &info.semver.minor.to_string());
653 self.template_vars
654 .set("Patch", &info.semver.patch.to_string());
655 self.template_vars.set(
656 "Prerelease",
657 info.semver.prerelease.as_deref().unwrap_or(""),
658 );
659 self.template_vars.set(
660 "BuildMetadata",
661 info.semver.build_metadata.as_deref().unwrap_or(""),
662 );
663 self.template_vars.set("FullCommit", &info.commit);
664 self.template_vars.set("Commit", &info.commit);
665 self.template_vars.set("ShortCommit", &info.short_commit);
666 self.template_vars.set("Branch", &info.branch);
667 self.template_vars.set("CommitDate", &info.commit_date);
668 self.template_vars
669 .set("CommitTimestamp", &info.commit_timestamp);
670 self.template_vars
671 .set("IsGitDirty", if info.dirty { "true" } else { "false" });
672 self.template_vars
673 .set("IsGitClean", if info.dirty { "false" } else { "true" });
674 self.template_vars
675 .set("GitTreeState", if info.dirty { "dirty" } else { "clean" });
676 self.template_vars.set("GitURL", &info.remote_url);
677 self.template_vars.set("Summary", &info.summary);
678 self.template_vars.set("TagSubject", &info.tag_subject);
679 self.template_vars.set("TagContents", &info.tag_contents);
680 self.template_vars.set("TagBody", &info.tag_body);
681 self.template_vars
682 .set("PreviousTag", info.previous_tag.as_deref().unwrap_or(""));
683 self.template_vars
684 .set("FirstCommit", info.first_commit.as_deref().unwrap_or(""));
685
686 let monorepo_prefix = self.config.monorepo_tag_prefix();
697
698 if let Some(prefix) = monorepo_prefix {
704 self.template_vars.set("PrefixedTag", &info.tag);
707
708 let stripped_tag = crate::git::strip_monorepo_prefix(&info.tag, prefix);
710 self.template_vars.set("Tag", stripped_tag);
711
712 let version = stripped_tag
716 .strip_prefix('v')
717 .unwrap_or(stripped_tag)
718 .to_string();
719 self.template_vars.set("Version", &version);
720
721 let prev_tag = info.previous_tag.as_deref().unwrap_or("");
723 self.template_vars.set("PrefixedPreviousTag", prev_tag);
724
725 let stripped_prev = crate::git::strip_monorepo_prefix(prev_tag, prefix);
727 self.template_vars.set("PreviousTag", stripped_prev);
728
729 self.template_vars.set("PrefixedSummary", &info.summary);
733 let stripped_summary = crate::git::strip_monorepo_prefix(&info.summary, prefix);
735 self.template_vars.set("Summary", stripped_summary);
736 } else {
737 let tag_prefix = self
739 .config
740 .tag
741 .as_ref()
742 .and_then(|t| t.tag_prefix.as_deref())
743 .unwrap_or("");
744 self.template_vars
745 .set("PrefixedTag", &format!("{}{}", tag_prefix, info.tag));
746 let prev_tag = info.previous_tag.as_deref().unwrap_or("");
747 let prefixed_prev = if prev_tag.is_empty() {
748 String::new()
749 } else {
750 format!("{}{}", tag_prefix, prev_tag)
751 };
752 self.template_vars
753 .set("PrefixedPreviousTag", &prefixed_prev);
754 self.template_vars.set(
755 "PrefixedSummary",
756 &format!("{}{}", tag_prefix, info.summary),
757 );
758 }
759 }
760
761 self.template_vars.set(
762 "IsSnapshot",
763 if self.options.snapshot {
764 "true"
765 } else {
766 "false"
767 },
768 );
769 self.template_vars.set(
770 "IsNightly",
771 if self.options.nightly {
772 "true"
773 } else {
774 "false"
775 },
776 );
777 self.template_vars.set(
781 "IsHarness",
782 if std::env::var_os("ANODIZER_IN_DETERMINISM_HARNESS").is_some() {
783 "true"
784 } else {
785 "false"
786 },
787 );
788 let is_draft = self
790 .config
791 .release
792 .as_ref()
793 .and_then(|r| r.draft)
794 .unwrap_or(false);
795 self.template_vars
796 .set("IsDraft", if is_draft { "true" } else { "false" });
797 self.template_vars.set(
798 "IsSingleTarget",
799 if self.options.single_target.is_some() {
800 "true"
801 } else {
802 "false"
803 },
804 );
805
806 let is_release = !self.options.snapshot && !self.options.nightly;
808 self.template_vars
809 .set("IsRelease", if is_release { "true" } else { "false" });
810
811 self.template_vars.set(
813 "IsMerging",
814 if self.options.merge { "true" } else { "false" },
815 );
816 }
817
818 pub fn populate_time_vars(&mut self) {
846 let now = crate::sde::resolve_now();
852 self.template_vars.set("Date", &now.to_rfc3339());
853 self.template_vars
854 .set("Timestamp", &now.timestamp().to_string());
855 self.template_vars.set("Now", &now.to_rfc3339());
856 self.template_vars
857 .set("Year", &now.format("%Y").to_string());
858 self.template_vars
859 .set("Month", &now.format("%m").to_string());
860 self.template_vars.set("Day", &now.format("%d").to_string());
861 self.template_vars
862 .set("Hour", &now.format("%H").to_string());
863 self.template_vars
864 .set("Minute", &now.format("%M").to_string());
865 }
866
867 pub fn populate_runtime_vars(&mut self) {
874 let goos = map_os_to_goos(std::env::consts::OS);
875 let goarch = map_arch_to_goarch(std::env::consts::ARCH);
876 self.template_vars.set("RuntimeGoos", goos);
877 self.template_vars.set("RuntimeGoarch", goarch);
878 self.template_vars.set("Runtime_Goos", goos);
881 self.template_vars.set("Runtime_Goarch", goarch);
882 }
883
884 pub fn populate_release_notes_var(&mut self) {
892 let notes = self
894 .config
895 .crates
896 .iter()
897 .find_map(|c| self.stage_outputs.changelogs.get(&c.name))
898 .cloned()
899 .unwrap_or_default();
900 self.template_vars.set("ReleaseNotes", ¬es);
901 }
902
903 pub fn refresh_artifacts_var(&mut self) {
918 const CSV_LIST_KEYS: &[&str] = &["extra_binaries", "extra_files"];
923
924 let artifacts_value: Vec<serde_json::Value> = self
925 .artifacts
926 .all()
927 .iter()
928 .map(|a| {
929 let mut metadata_map = serde_json::Map::with_capacity(a.metadata.len());
931 for (k, v) in &a.metadata {
932 if CSV_LIST_KEYS.contains(&k.as_str()) {
933 let items: Vec<serde_json::Value> = if v.is_empty() {
934 Vec::new()
935 } else {
936 v.split(',')
937 .map(|s| serde_json::Value::String(s.to_string()))
938 .collect()
939 };
940 metadata_map.insert(k.clone(), serde_json::Value::Array(items));
941 } else {
942 metadata_map.insert(k.clone(), serde_json::Value::String(v.clone()));
943 }
944 }
945 serde_json::json!({
946 "name": a.name,
947 "path": a.path.to_string_lossy(),
948 "target": a.target.as_deref().unwrap_or(""),
949 "kind": a.kind.as_str(),
950 "crate_name": a.crate_name,
951 "metadata": serde_json::Value::Object(metadata_map),
952 })
953 })
954 .collect();
955 let tera_value = tera::Value::Array(artifacts_value);
958 self.template_vars.set_structured("Artifacts", tera_value);
959 }
960
961 pub fn populate_metadata_var(&mut self) -> anyhow::Result<()> {
973 use crate::config::ContentSource;
974
975 let (
978 description,
979 homepage,
980 license,
981 maintainers,
982 mod_timestamp,
983 full_desc_src,
984 commit_author,
985 ) = {
986 let meta = self.config.metadata.as_ref();
987 let description = meta
988 .and_then(|m| m.description.as_deref())
989 .unwrap_or("")
990 .to_string();
991 let homepage = meta
992 .and_then(|m| m.homepage.as_deref())
993 .unwrap_or("")
994 .to_string();
995 let license = meta
996 .and_then(|m| m.license.as_deref())
997 .unwrap_or("")
998 .to_string();
999 let maintainers: Vec<String> = meta
1000 .and_then(|m| m.maintainers.as_ref())
1001 .cloned()
1002 .unwrap_or_default();
1003 let mod_timestamp = meta
1004 .and_then(|m| m.mod_timestamp.as_deref())
1005 .unwrap_or("")
1006 .to_string();
1007 let full_desc_src = meta.and_then(|m| m.full_description.clone());
1008 let commit_author = meta.and_then(|m| m.commit_author.clone());
1009 (
1010 description,
1011 homepage,
1012 license,
1013 maintainers,
1014 mod_timestamp,
1015 full_desc_src,
1016 commit_author,
1017 )
1018 };
1019
1020 let full_description = match full_desc_src {
1022 None => String::new(),
1023 Some(ContentSource::Inline(s)) => s,
1024 Some(ContentSource::FromFile { from_file }) => {
1025 let rendered_path = self.render_template(&from_file).with_context(|| {
1026 format!("metadata.full_description: render path '{}'", from_file)
1027 })?;
1028 std::fs::read_to_string(&rendered_path).with_context(|| {
1029 format!(
1030 "metadata.full_description: read from_file '{}'",
1031 rendered_path
1032 )
1033 })?
1034 }
1035 Some(ContentSource::FromUrl { .. }) => {
1036 anyhow::bail!(
1037 "metadata.full_description: `from_url` is not yet supported at metadata \
1038 population time (core has no HTTP client). Use `from_file` with a \
1039 pre-fetched file, or inline the content. Tracked for future: move \
1040 URL resolution into a late-pipeline stage or add reqwest to core."
1041 );
1042 }
1043 };
1044
1045 let commit_author_map = serde_json::json!({
1046 "Name": commit_author.as_ref().and_then(|c| c.name.clone()).unwrap_or_default(),
1047 "Email": commit_author.as_ref().and_then(|c| c.email.clone()).unwrap_or_default(),
1048 });
1049
1050 let meta_map = serde_json::json!({
1051 "Description": description,
1052 "Homepage": homepage,
1053 "License": license,
1054 "Maintainers": maintainers,
1055 "ModTimestamp": mod_timestamp,
1056 "FullDescription": full_description,
1057 "CommitAuthor": commit_author_map,
1058 });
1059 self.template_vars.set_structured("Metadata", meta_map);
1061 Ok(())
1062 }
1063}
1064
1065pub fn map_os_to_goos(os: &str) -> &str {
1068 match os {
1069 "macos" => "darwin",
1070 other => other, }
1072}
1073
1074pub fn map_arch_to_goarch(arch: &str) -> &str {
1077 match arch {
1078 "x86_64" => "amd64",
1079 "x86" => "386",
1080 "aarch64" => "arm64",
1081 "powerpc64" => "ppc64",
1082 "s390x" => "s390x",
1083 "mips" => "mips",
1084 "mips64" => "mips64",
1085 "riscv64" => "riscv64",
1086 other => other,
1087 }
1088}
1089
1090#[cfg(test)]
1091#[allow(clippy::field_reassign_with_default)]
1092mod tests {
1093 use super::*;
1094 use crate::config::Config;
1095 use crate::git::{GitInfo, SemVer};
1096
1097 fn make_git_info(dirty: bool, prerelease: Option<&str>) -> GitInfo {
1098 let tag = match prerelease {
1099 Some(pre) => format!("v1.2.3-{pre}"),
1100 None => "v1.2.3".to_string(),
1101 };
1102 GitInfo {
1103 tag,
1104 commit: "abc123def456abc123def456abc123def456abc1".to_string(),
1105 short_commit: "abc123d".to_string(),
1106 branch: "main".to_string(),
1107 dirty,
1108 semver: SemVer {
1109 major: 1,
1110 minor: 2,
1111 patch: 3,
1112 prerelease: prerelease.map(|s| s.to_string()),
1113 build_metadata: None,
1114 },
1115 commit_date: "2026-03-25T10:30:00+00:00".to_string(),
1116 commit_timestamp: "1774463400".to_string(),
1117 previous_tag: Some("v1.2.2".to_string()),
1118 remote_url: "https://github.com/test/repo.git".to_string(),
1119 summary: "v1.2.3-0-gabc123d".to_string(),
1120 tag_subject: "Release v1.2.3".to_string(),
1121 tag_contents: "Release v1.2.3\n\nFull release notes here.".to_string(),
1122 tag_body: "Full release notes here.".to_string(),
1123 first_commit: None,
1124 }
1125 }
1126
1127 #[test]
1128 fn test_context_template_vars() {
1129 let mut config = Config::default();
1130 config.project_name = "test-project".to_string();
1131 let ctx = Context::new(config, ContextOptions::default());
1132 assert_eq!(
1133 ctx.template_vars().get("ProjectName"),
1134 Some(&"test-project".to_string())
1135 );
1136 }
1137
1138 #[test]
1139 fn test_context_should_skip() {
1140 let config = Config::default();
1141 let opts = ContextOptions {
1142 skip_stages: vec!["publish".to_string(), "announce".to_string()],
1143 ..Default::default()
1144 };
1145 let ctx = Context::new(config, opts);
1146 assert!(ctx.should_skip("publish"));
1147 assert!(ctx.should_skip("announce"));
1148 assert!(!ctx.should_skip("build"));
1149 }
1150
1151 #[test]
1152 fn test_context_render_template() {
1153 let mut config = Config::default();
1154 config.project_name = "myapp".to_string();
1155 let ctx = Context::new(config, ContextOptions::default());
1156 let result = ctx.render_template("{{ .ProjectName }}-release").unwrap();
1157 assert_eq!(result, "myapp-release");
1158 }
1159
1160 #[test]
1161 fn test_populate_git_vars_sets_all_expected_vars() {
1162 let config = Config::default();
1163 let mut ctx = Context::new(config, ContextOptions::default());
1164 ctx.git_info = Some(make_git_info(false, None));
1165 ctx.populate_git_vars();
1166
1167 let v = ctx.template_vars();
1168 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
1169 assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
1170 assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
1171 assert_eq!(v.get("Major"), Some(&"1".to_string()));
1172 assert_eq!(v.get("Minor"), Some(&"2".to_string()));
1173 assert_eq!(v.get("Patch"), Some(&"3".to_string()));
1174 assert_eq!(v.get("Prerelease"), Some(&"".to_string()));
1175 assert_eq!(
1176 v.get("FullCommit"),
1177 Some(&"abc123def456abc123def456abc123def456abc1".to_string())
1178 );
1179 assert_eq!(v.get("ShortCommit"), Some(&"abc123d".to_string()));
1180 assert_eq!(v.get("Branch"), Some(&"main".to_string()));
1181 assert_eq!(
1182 v.get("CommitDate"),
1183 Some(&"2026-03-25T10:30:00+00:00".to_string())
1184 );
1185 assert_eq!(v.get("CommitTimestamp"), Some(&"1774463400".to_string()));
1186 assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
1187 }
1188
1189 #[test]
1190 fn test_commit_is_alias_for_full_commit() {
1191 let config = Config::default();
1192 let mut ctx = Context::new(config, ContextOptions::default());
1193 ctx.git_info = Some(make_git_info(false, None));
1194 ctx.populate_git_vars();
1195
1196 let v = ctx.template_vars();
1197 assert_eq!(v.get("Commit"), v.get("FullCommit"));
1198 }
1199
1200 #[test]
1201 fn test_populate_git_vars_prerelease() {
1202 let config = Config::default();
1203 let mut ctx = Context::new(config, ContextOptions::default());
1204 ctx.git_info = Some(make_git_info(false, Some("rc.1")));
1205 ctx.populate_git_vars();
1206
1207 let v = ctx.template_vars();
1208 assert_eq!(v.get("Version"), Some(&"1.2.3-rc.1".to_string()));
1209 assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
1210 assert_eq!(v.get("Prerelease"), Some(&"rc.1".to_string()));
1211 }
1212
1213 #[test]
1214 fn test_build_metadata_template_var() {
1215 let config = Config::default();
1216 let mut ctx = Context::new(config, ContextOptions::default());
1217 let mut info = make_git_info(false, None);
1218 info.tag = "v1.2.3+build.42".to_string();
1219 info.semver.build_metadata = Some("build.42".to_string());
1220 ctx.git_info = Some(info);
1221 ctx.populate_git_vars();
1222
1223 let v = ctx.template_vars();
1224 assert_eq!(v.get("BuildMetadata"), Some(&"build.42".to_string()));
1225 assert_eq!(v.get("Version"), Some(&"1.2.3+build.42".to_string()));
1227 }
1228
1229 #[test]
1230 fn test_build_metadata_empty_when_none() {
1231 let config = Config::default();
1232 let mut ctx = Context::new(config, ContextOptions::default());
1233 ctx.git_info = Some(make_git_info(false, None));
1234 ctx.populate_git_vars();
1235
1236 assert_eq!(
1237 ctx.template_vars().get("BuildMetadata"),
1238 Some(&"".to_string())
1239 );
1240 }
1241
1242 #[test]
1243 fn test_populate_git_vars_monorepo_prefixed_tag() {
1244 let config = Config::default();
1247 let mut ctx = Context::new(config, ContextOptions::default());
1248 let mut info = make_git_info(false, None);
1249 info.tag = "core-v0.3.2".to_string();
1250 info.semver = SemVer {
1251 major: 0,
1252 minor: 3,
1253 patch: 2,
1254 prerelease: None,
1255 build_metadata: None,
1256 };
1257 ctx.git_info = Some(info);
1258 ctx.populate_git_vars();
1259
1260 let v = ctx.template_vars();
1261 assert_eq!(v.get("Tag"), Some(&"core-v0.3.2".to_string()));
1262 assert_eq!(v.get("Version"), Some(&"0.3.2".to_string()));
1263 assert_eq!(v.get("RawVersion"), Some(&"0.3.2".to_string()));
1264 assert_eq!(v.get("Major"), Some(&"0".to_string()));
1265 assert_eq!(v.get("Minor"), Some(&"3".to_string()));
1266 assert_eq!(v.get("Patch"), Some(&"2".to_string()));
1267 }
1268
1269 #[test]
1270 fn test_populate_git_vars_monorepo_prefixed_tag_with_prerelease() {
1271 let config = Config::default();
1272 let mut ctx = Context::new(config, ContextOptions::default());
1273 let mut info = make_git_info(false, None);
1274 info.tag = "operator-v1.0.0-rc.1".to_string();
1275 info.semver = SemVer {
1276 major: 1,
1277 minor: 0,
1278 patch: 0,
1279 prerelease: Some("rc.1".to_string()),
1280 build_metadata: None,
1281 };
1282 ctx.git_info = Some(info);
1283 ctx.populate_git_vars();
1284
1285 let v = ctx.template_vars();
1286 assert_eq!(v.get("Tag"), Some(&"operator-v1.0.0-rc.1".to_string()));
1287 assert_eq!(v.get("Version"), Some(&"1.0.0-rc.1".to_string()));
1288 assert_eq!(v.get("RawVersion"), Some(&"1.0.0".to_string()));
1289 }
1290
1291 #[test]
1292 fn test_git_tree_state_clean() {
1293 let config = Config::default();
1294 let mut ctx = Context::new(config, ContextOptions::default());
1295 ctx.git_info = Some(make_git_info(false, None));
1296 ctx.populate_git_vars();
1297
1298 let v = ctx.template_vars();
1299 assert_eq!(v.get("IsGitDirty"), Some(&"false".to_string()));
1300 assert_eq!(v.get("GitTreeState"), Some(&"clean".to_string()));
1301 }
1302
1303 #[test]
1304 fn test_git_tree_state_dirty() {
1305 let config = Config::default();
1306 let mut ctx = Context::new(config, ContextOptions::default());
1307 ctx.git_info = Some(make_git_info(true, None));
1308 ctx.populate_git_vars();
1309
1310 let v = ctx.template_vars();
1311 assert_eq!(v.get("IsGitDirty"), Some(&"true".to_string()));
1312 assert_eq!(v.get("GitTreeState"), Some(&"dirty".to_string()));
1313 }
1314
1315 #[test]
1316 fn test_is_snapshot_reflects_context_options() {
1317 let config = Config::default();
1318 let opts = ContextOptions {
1319 snapshot: true,
1320 ..Default::default()
1321 };
1322 let mut ctx = Context::new(config, opts);
1323 ctx.git_info = Some(make_git_info(false, None));
1324 ctx.populate_git_vars();
1325
1326 assert_eq!(
1327 ctx.template_vars().get("IsSnapshot"),
1328 Some(&"true".to_string())
1329 );
1330
1331 let config2 = Config::default();
1333 let opts2 = ContextOptions {
1334 snapshot: false,
1335 ..Default::default()
1336 };
1337 let mut ctx2 = Context::new(config2, opts2);
1338 ctx2.git_info = Some(make_git_info(false, None));
1339 ctx2.populate_git_vars();
1340
1341 assert_eq!(
1342 ctx2.template_vars().get("IsSnapshot"),
1343 Some(&"false".to_string())
1344 );
1345 }
1346
1347 #[test]
1348 fn test_is_draft_defaults_to_false() {
1349 let config = Config::default();
1350 let mut ctx = Context::new(config, ContextOptions::default());
1351 ctx.git_info = Some(make_git_info(false, None));
1352 ctx.populate_git_vars();
1353
1354 assert_eq!(
1355 ctx.template_vars().get("IsDraft"),
1356 Some(&"false".to_string())
1357 );
1358 }
1359
1360 #[test]
1361 fn test_previous_tag_empty_when_none() {
1362 let config = Config::default();
1363 let mut ctx = Context::new(config, ContextOptions::default());
1364 let mut info = make_git_info(false, None);
1365 info.previous_tag = None;
1366 ctx.git_info = Some(info);
1367 ctx.populate_git_vars();
1368
1369 assert_eq!(
1370 ctx.template_vars().get("PreviousTag"),
1371 Some(&"".to_string())
1372 );
1373 }
1374
1375 #[test]
1384 #[serial_test::serial(env)]
1385 fn populate_time_vars_uses_source_date_epoch_when_set() {
1386 let key = "SOURCE_DATE_EPOCH";
1387 let prev = std::env::var(key).ok();
1388 unsafe { std::env::set_var(key, "1715000000") };
1393
1394 let config = Config::default();
1395 let mut ctx = Context::new(config, ContextOptions::default());
1396 ctx.populate_time_vars();
1397
1398 let v = ctx.template_vars();
1399 assert_eq!(
1400 v.get("Timestamp"),
1401 Some(&"1715000000".to_string()),
1402 "Timestamp must equal SOURCE_DATE_EPOCH seconds"
1403 );
1404 assert_eq!(
1405 v.get("Date"),
1406 Some(&"2024-05-06T12:53:20+00:00".to_string()),
1407 "Date must be RFC 3339 derived from SDE"
1408 );
1409 assert_eq!(v.get("Year"), Some(&"2024".to_string()));
1410 assert_eq!(v.get("Month"), Some(&"05".to_string()));
1411 assert_eq!(v.get("Day"), Some(&"06".to_string()));
1412
1413 unsafe {
1415 match prev {
1416 Some(v) => std::env::set_var(key, v),
1417 None => std::env::remove_var(key),
1418 }
1419 }
1420 }
1421
1422 #[test]
1423 #[serial_test::serial(env)]
1424 fn test_populate_time_vars() {
1425 let key = "SOURCE_DATE_EPOCH";
1428 let prev = std::env::var(key).ok();
1429 unsafe { std::env::remove_var(key) };
1431
1432 let config = Config::default();
1433 let mut ctx = Context::new(config, ContextOptions::default());
1434 ctx.populate_time_vars();
1435
1436 let v = ctx.template_vars();
1437
1438 let date = v
1440 .get("Date")
1441 .unwrap_or_else(|| panic!("Date should be set"));
1442 assert!(
1443 date.contains('T') && date.len() > 10,
1444 "Date should be RFC 3339, got: {date}"
1445 );
1446
1447 let ts = v
1449 .get("Timestamp")
1450 .unwrap_or_else(|| panic!("Timestamp should be set"));
1451 assert!(
1452 ts.parse::<i64>().is_ok(),
1453 "Timestamp should be a numeric string, got: {ts}"
1454 );
1455
1456 let now = v.get("Now").unwrap_or_else(|| panic!("Now should be set"));
1458 assert!(now.contains('T'), "Now should be ISO 8601, got: {now}");
1459
1460 unsafe {
1462 if let Some(v) = prev {
1463 std::env::set_var(key, v);
1464 }
1465 }
1466 }
1467
1468 #[test]
1469 fn test_env_vars_accessible_in_templates() {
1470 let mut config = Config::default();
1471 config.project_name = "myapp".to_string();
1472 let mut ctx = Context::new(config, ContextOptions::default());
1473 ctx.template_vars_mut().set_env("MY_VAR", "hello-world");
1474 ctx.template_vars_mut().set_env("DEPLOY_ENV", "staging");
1475
1476 let result = ctx
1477 .render_template("{{ .Env.MY_VAR }}-{{ .Env.DEPLOY_ENV }}")
1478 .unwrap();
1479 assert_eq!(result, "hello-world-staging");
1480 }
1481
1482 #[test]
1483 fn test_populate_git_vars_without_git_info_still_sets_snapshot() {
1484 let config = Config::default();
1485 let opts = ContextOptions {
1486 snapshot: true,
1487 ..Default::default()
1488 };
1489 let mut ctx = Context::new(config, opts);
1490 ctx.populate_git_vars();
1492
1493 assert_eq!(
1494 ctx.template_vars().get("IsSnapshot"),
1495 Some(&"true".to_string())
1496 );
1497 assert_eq!(
1498 ctx.template_vars().get("IsDraft"),
1499 Some(&"false".to_string())
1500 );
1501 assert_eq!(ctx.template_vars().get("Tag"), None);
1503 }
1504
1505 #[test]
1506 fn test_is_nightly_set_when_nightly_mode_active() {
1507 let config = Config::default();
1508 let opts = ContextOptions {
1509 nightly: true,
1510 ..Default::default()
1511 };
1512 let mut ctx = Context::new(config, opts);
1513 ctx.git_info = Some(make_git_info(false, None));
1514 ctx.populate_git_vars();
1515
1516 assert_eq!(
1517 ctx.template_vars().get("IsNightly"),
1518 Some(&"true".to_string()),
1519 "IsNightly should be 'true' when nightly mode is active"
1520 );
1521 assert!(ctx.is_nightly(), "is_nightly() should return true");
1522 }
1523
1524 #[test]
1525 fn test_is_nightly_false_by_default() {
1526 let config = Config::default();
1527 let mut ctx = Context::new(config, ContextOptions::default());
1528 ctx.git_info = Some(make_git_info(false, None));
1529 ctx.populate_git_vars();
1530
1531 assert_eq!(
1532 ctx.template_vars().get("IsNightly"),
1533 Some(&"false".to_string()),
1534 "IsNightly should default to 'false'"
1535 );
1536 assert!(
1537 !ctx.is_nightly(),
1538 "is_nightly() should return false by default"
1539 );
1540 }
1541
1542 #[test]
1543 fn test_version_returns_populated_value() {
1544 let config = Config::default();
1545 let mut ctx = Context::new(config, ContextOptions::default());
1546 ctx.git_info = Some(make_git_info(false, None));
1547 ctx.populate_git_vars();
1548
1549 assert_eq!(ctx.version(), "1.2.3");
1550 }
1551
1552 #[test]
1553 fn test_version_returns_empty_when_not_set() {
1554 let config = Config::default();
1555 let ctx = Context::new(config, ContextOptions::default());
1556 assert_eq!(ctx.version(), "");
1557 }
1558
1559 #[test]
1560 fn test_is_nightly_without_git_info() {
1561 let config = Config::default();
1562 let opts = ContextOptions {
1563 nightly: true,
1564 ..Default::default()
1565 };
1566 let mut ctx = Context::new(config, opts);
1567 ctx.populate_git_vars();
1569
1570 assert_eq!(
1571 ctx.template_vars().get("IsNightly"),
1572 Some(&"true".to_string()),
1573 "IsNightly should be set even without git info"
1574 );
1575 }
1576
1577 #[test]
1578 fn test_is_git_clean_when_not_dirty() {
1579 let config = Config::default();
1580 let mut ctx = Context::new(config, ContextOptions::default());
1581 ctx.git_info = Some(make_git_info(false, None));
1582 ctx.populate_git_vars();
1583
1584 assert_eq!(
1585 ctx.template_vars().get("IsGitClean"),
1586 Some(&"true".to_string())
1587 );
1588 }
1589
1590 #[test]
1591 fn test_is_git_clean_when_dirty() {
1592 let config = Config::default();
1593 let mut ctx = Context::new(config, ContextOptions::default());
1594 ctx.git_info = Some(make_git_info(true, None));
1595 ctx.populate_git_vars();
1596
1597 assert_eq!(
1598 ctx.template_vars().get("IsGitClean"),
1599 Some(&"false".to_string())
1600 );
1601 }
1602
1603 #[test]
1604 fn test_git_url_set_from_git_info() {
1605 let config = Config::default();
1606 let mut ctx = Context::new(config, ContextOptions::default());
1607 ctx.git_info = Some(make_git_info(false, None));
1608 ctx.populate_git_vars();
1609
1610 assert_eq!(
1611 ctx.template_vars().get("GitURL"),
1612 Some(&"https://github.com/test/repo.git".to_string())
1613 );
1614 }
1615
1616 #[test]
1617 fn test_summary_set_from_git_info() {
1618 let config = Config::default();
1619 let mut ctx = Context::new(config, ContextOptions::default());
1620 ctx.git_info = Some(make_git_info(false, None));
1621 ctx.populate_git_vars();
1622
1623 assert_eq!(
1624 ctx.template_vars().get("Summary"),
1625 Some(&"v1.2.3-0-gabc123d".to_string())
1626 );
1627 }
1628
1629 #[test]
1630 fn test_tag_subject_set_from_git_info() {
1631 let config = Config::default();
1632 let mut ctx = Context::new(config, ContextOptions::default());
1633 ctx.git_info = Some(make_git_info(false, None));
1634 ctx.populate_git_vars();
1635
1636 assert_eq!(
1637 ctx.template_vars().get("TagSubject"),
1638 Some(&"Release v1.2.3".to_string())
1639 );
1640 }
1641
1642 #[test]
1643 fn test_tag_contents_set_from_git_info() {
1644 let config = Config::default();
1645 let mut ctx = Context::new(config, ContextOptions::default());
1646 ctx.git_info = Some(make_git_info(false, None));
1647 ctx.populate_git_vars();
1648
1649 assert_eq!(
1650 ctx.template_vars().get("TagContents"),
1651 Some(&"Release v1.2.3\n\nFull release notes here.".to_string())
1652 );
1653 }
1654
1655 #[test]
1656 fn test_tag_body_set_from_git_info() {
1657 let config = Config::default();
1658 let mut ctx = Context::new(config, ContextOptions::default());
1659 ctx.git_info = Some(make_git_info(false, None));
1660 ctx.populate_git_vars();
1661
1662 assert_eq!(
1663 ctx.template_vars().get("TagBody"),
1664 Some(&"Full release notes here.".to_string())
1665 );
1666 }
1667
1668 #[test]
1669 fn test_is_single_target_false_by_default() {
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("IsSingleTarget"),
1677 Some(&"false".to_string())
1678 );
1679 }
1680
1681 #[test]
1682 fn test_is_single_target_true_when_set() {
1683 let config = Config::default();
1684 let opts = ContextOptions {
1685 single_target: Some("x86_64-unknown-linux-gnu".to_string()),
1686 ..Default::default()
1687 };
1688 let mut ctx = Context::new(config, opts);
1689 ctx.git_info = Some(make_git_info(false, None));
1690 ctx.populate_git_vars();
1691
1692 assert_eq!(
1693 ctx.template_vars().get("IsSingleTarget"),
1694 Some(&"true".to_string())
1695 );
1696 }
1697
1698 #[test]
1699 fn test_populate_runtime_vars() {
1700 let config = Config::default();
1701 let mut ctx = Context::new(config, ContextOptions::default());
1702 ctx.populate_runtime_vars();
1703
1704 let v = ctx.template_vars();
1705
1706 let goos = v
1707 .get("RuntimeGoos")
1708 .unwrap_or_else(|| panic!("RuntimeGoos should be set"));
1709 assert!(
1710 !goos.is_empty(),
1711 "RuntimeGoos should not be empty, got: {goos}"
1712 );
1713 assert_eq!(goos, map_os_to_goos(std::env::consts::OS));
1715
1716 let goarch = v
1717 .get("RuntimeGoarch")
1718 .unwrap_or_else(|| panic!("RuntimeGoarch should be set"));
1719 assert!(
1720 !goarch.is_empty(),
1721 "RuntimeGoarch should not be empty, got: {goarch}"
1722 );
1723 assert_eq!(goarch, map_arch_to_goarch(std::env::consts::ARCH));
1725 }
1726
1727 #[test]
1728 fn test_populate_release_notes_var_with_changelogs() {
1729 let mut config = Config::default();
1730 config.crates.push(crate::config::CrateConfig {
1731 name: "my-crate".to_string(),
1732 ..Default::default()
1733 });
1734 let mut ctx = Context::new(config, ContextOptions::default());
1735 ctx.stage_outputs
1736 .changelogs
1737 .insert("my-crate".to_string(), "## Changes\n- fix bug".to_string());
1738 ctx.populate_release_notes_var();
1739
1740 assert_eq!(
1741 ctx.template_vars().get("ReleaseNotes"),
1742 Some(&"## Changes\n- fix bug".to_string())
1743 );
1744 }
1745
1746 #[test]
1747 fn test_populate_release_notes_var_empty_when_no_changelogs() {
1748 let config = Config::default();
1749 let mut ctx = Context::new(config, ContextOptions::default());
1750 ctx.populate_release_notes_var();
1751
1752 assert_eq!(
1753 ctx.template_vars().get("ReleaseNotes"),
1754 Some(&"".to_string())
1755 );
1756 }
1757
1758 #[test]
1759 fn test_populate_release_notes_var_deterministic_with_multiple_crates() {
1760 let mut config = Config::default();
1761 config.crates.push(crate::config::CrateConfig {
1762 name: "crate-a".to_string(),
1763 ..Default::default()
1764 });
1765 config.crates.push(crate::config::CrateConfig {
1766 name: "crate-b".to_string(),
1767 ..Default::default()
1768 });
1769 let mut ctx = Context::new(config, ContextOptions::default());
1770 ctx.stage_outputs
1771 .changelogs
1772 .insert("crate-a".to_string(), "notes-a".to_string());
1773 ctx.stage_outputs
1774 .changelogs
1775 .insert("crate-b".to_string(), "notes-b".to_string());
1776 ctx.populate_release_notes_var();
1777
1778 assert_eq!(
1780 ctx.template_vars().get("ReleaseNotes"),
1781 Some(&"notes-a".to_string())
1782 );
1783 }
1784
1785 #[test]
1786 fn test_outputs_accessible_in_templates() {
1787 let mut config = Config::default();
1788 config.project_name = "myapp".to_string();
1789 let mut ctx = Context::new(config, ContextOptions::default());
1790 ctx.template_vars_mut().set_output("build_id", "abc123");
1791 ctx.template_vars_mut()
1792 .set_output("deploy_url", "https://example.com");
1793
1794 let result = ctx
1795 .render_template("{{ .Outputs.build_id }}-{{ .Outputs.deploy_url }}")
1796 .unwrap();
1797 assert_eq!(result, "abc123-https://example.com");
1798 }
1799
1800 #[test]
1801 fn test_artifact_ext_and_target_template_vars() {
1802 let mut config = Config::default();
1803 config.project_name = "myapp".to_string();
1804 let mut ctx = Context::new(config, ContextOptions::default());
1805 ctx.template_vars_mut().set("ArtifactName", "myapp.tar.gz");
1806 ctx.template_vars_mut().set("ArtifactExt", ".tar.gz");
1807 ctx.template_vars_mut()
1808 .set("Target", "x86_64-unknown-linux-gnu");
1809
1810 let result = ctx
1811 .render_template("{{ .ArtifactExt }}_{{ .Target }}")
1812 .unwrap();
1813 assert_eq!(result, ".tar.gz_x86_64-unknown-linux-gnu");
1814 }
1815
1816 #[test]
1817 fn test_checksums_template_var() {
1818 let mut config = Config::default();
1819 config.project_name = "myapp".to_string();
1820 let mut ctx = Context::new(config, ContextOptions::default());
1821 let checksum_text = "abc123 myapp.tar.gz\ndef456 myapp.zip\n";
1822 ctx.template_vars_mut().set("Checksums", checksum_text);
1823
1824 let result = ctx.render_template("{{ .Checksums }}").unwrap();
1825 assert_eq!(result, checksum_text);
1826 }
1827
1828 #[test]
1831 fn test_prefixed_tag_with_tag_prefix() {
1832 let mut config = Config::default();
1833 config.tag = Some(crate::config::TagConfig {
1834 tag_prefix: Some("api/".to_string()),
1835 ..Default::default()
1836 });
1837 let mut ctx = Context::new(config, ContextOptions::default());
1838 ctx.git_info = Some(make_git_info(false, None));
1839 ctx.populate_git_vars();
1840
1841 assert_eq!(
1842 ctx.template_vars().get("PrefixedTag"),
1843 Some(&"api/v1.2.3".to_string())
1844 );
1845 }
1846
1847 #[test]
1848 fn test_prefixed_tag_without_tag_prefix() {
1849 let config = Config::default();
1850 let mut ctx = Context::new(config, ContextOptions::default());
1851 ctx.git_info = Some(make_git_info(false, None));
1852 ctx.populate_git_vars();
1853
1854 assert_eq!(
1856 ctx.template_vars().get("PrefixedTag"),
1857 Some(&"v1.2.3".to_string())
1858 );
1859 }
1860
1861 #[test]
1862 fn test_prefixed_previous_tag_with_tag_prefix() {
1863 let mut config = Config::default();
1864 config.tag = Some(crate::config::TagConfig {
1865 tag_prefix: Some("api/".to_string()),
1866 ..Default::default()
1867 });
1868 let mut ctx = Context::new(config, ContextOptions::default());
1869 ctx.git_info = Some(make_git_info(false, None));
1870 ctx.populate_git_vars();
1871
1872 assert_eq!(
1873 ctx.template_vars().get("PrefixedPreviousTag"),
1874 Some(&"api/v1.2.2".to_string())
1875 );
1876 }
1877
1878 #[test]
1879 fn test_prefixed_previous_tag_empty_when_no_previous() {
1880 let mut config = Config::default();
1881 config.tag = Some(crate::config::TagConfig {
1882 tag_prefix: Some("api/".to_string()),
1883 ..Default::default()
1884 });
1885 let mut ctx = Context::new(config, ContextOptions::default());
1886 let mut info = make_git_info(false, None);
1887 info.previous_tag = None;
1888 ctx.git_info = Some(info);
1889 ctx.populate_git_vars();
1890
1891 assert_eq!(
1894 ctx.template_vars().get("PrefixedPreviousTag"),
1895 Some(&"".to_string())
1896 );
1897 }
1898
1899 #[test]
1900 fn test_prefixed_summary_with_tag_prefix() {
1901 let mut config = Config::default();
1902 config.tag = Some(crate::config::TagConfig {
1903 tag_prefix: Some("api/".to_string()),
1904 ..Default::default()
1905 });
1906 let mut ctx = Context::new(config, ContextOptions::default());
1907 ctx.git_info = Some(make_git_info(false, None));
1908 ctx.populate_git_vars();
1909
1910 assert_eq!(
1911 ctx.template_vars().get("PrefixedSummary"),
1912 Some(&"api/v1.2.3-0-gabc123d".to_string())
1913 );
1914 }
1915
1916 #[test]
1917 fn test_is_release_true_for_normal_release() {
1918 let config = Config::default();
1919 let opts = ContextOptions {
1920 snapshot: false,
1921 nightly: false,
1922 ..Default::default()
1923 };
1924 let mut ctx = Context::new(config, opts);
1925 ctx.git_info = Some(make_git_info(false, None));
1926 ctx.populate_git_vars();
1927
1928 assert_eq!(
1929 ctx.template_vars().get("IsRelease"),
1930 Some(&"true".to_string())
1931 );
1932 }
1933
1934 #[test]
1935 fn test_is_release_false_for_snapshot() {
1936 let config = Config::default();
1937 let opts = ContextOptions {
1938 snapshot: true,
1939 ..Default::default()
1940 };
1941 let mut ctx = Context::new(config, opts);
1942 ctx.git_info = Some(make_git_info(false, None));
1943 ctx.populate_git_vars();
1944
1945 assert_eq!(
1946 ctx.template_vars().get("IsRelease"),
1947 Some(&"false".to_string())
1948 );
1949 }
1950
1951 #[test]
1952 fn test_is_release_false_for_nightly() {
1953 let config = Config::default();
1954 let opts = ContextOptions {
1955 nightly: true,
1956 ..Default::default()
1957 };
1958 let mut ctx = Context::new(config, opts);
1959 ctx.git_info = Some(make_git_info(false, None));
1960 ctx.populate_git_vars();
1961
1962 assert_eq!(
1963 ctx.template_vars().get("IsRelease"),
1964 Some(&"false".to_string())
1965 );
1966 }
1967
1968 #[test]
1969 fn test_is_merging_true_when_merge_flag_set() {
1970 let config = Config::default();
1971 let opts = ContextOptions {
1972 merge: true,
1973 ..Default::default()
1974 };
1975 let mut ctx = Context::new(config, opts);
1976 ctx.git_info = Some(make_git_info(false, None));
1977 ctx.populate_git_vars();
1978
1979 assert_eq!(
1980 ctx.template_vars().get("IsMerging"),
1981 Some(&"true".to_string())
1982 );
1983 }
1984
1985 #[test]
1986 fn test_is_merging_false_by_default() {
1987 let config = Config::default();
1988 let mut ctx = Context::new(config, ContextOptions::default());
1989 ctx.git_info = Some(make_git_info(false, None));
1990 ctx.populate_git_vars();
1991
1992 assert_eq!(
1993 ctx.template_vars().get("IsMerging"),
1994 Some(&"false".to_string())
1995 );
1996 }
1997
1998 #[test]
1999 fn test_refresh_artifacts_var_empty() {
2000 let config = Config::default();
2001 let mut ctx = Context::new(config, ContextOptions::default());
2002 ctx.refresh_artifacts_var();
2003
2004 let result = ctx
2006 .render_template("{% for a in Artifacts %}{{ a.name }}{% endfor %}")
2007 .unwrap();
2008 assert_eq!(result, "");
2009 }
2010
2011 #[test]
2012 fn test_refresh_artifacts_var_with_artifacts() {
2013 use crate::artifact::{Artifact, ArtifactKind};
2014 use std::collections::HashMap;
2015 use std::path::PathBuf;
2016
2017 let config = Config::default();
2018 let mut ctx = Context::new(config, ContextOptions::default());
2019 ctx.artifacts.add(Artifact {
2023 kind: ArtifactKind::Archive,
2024 name: String::new(),
2025 path: PathBuf::from("dist/myapp-1.0.0-linux-amd64.tar.gz"),
2026 target: Some("x86_64-unknown-linux-gnu".to_string()),
2027 crate_name: "myapp".to_string(),
2028 metadata: HashMap::from([("format".to_string(), "tar.gz".to_string())]),
2029 size: None,
2030 });
2031 ctx.artifacts.add(Artifact {
2032 kind: ArtifactKind::Binary,
2033 name: String::new(),
2034 path: PathBuf::from("dist/myapp"),
2035 target: Some("x86_64-unknown-linux-gnu".to_string()),
2036 crate_name: "myapp".to_string(),
2037 metadata: HashMap::new(),
2038 size: None,
2039 });
2040 ctx.refresh_artifacts_var();
2041
2042 let result = ctx
2044 .render_template("{% for a in Artifacts %}{{ a.name }},{% endfor %}")
2045 .unwrap();
2046 assert!(result.contains("myapp-1.0.0-linux-amd64.tar.gz"));
2047 assert!(result.contains("myapp"));
2048
2049 let result_kinds = ctx
2051 .render_template("{% for a in Artifacts %}{{ a.kind }},{% endfor %}")
2052 .unwrap();
2053 assert!(result_kinds.contains("archive"));
2054 assert!(result_kinds.contains("binary"));
2055 }
2056
2057 #[test]
2058 fn test_populate_metadata_var_with_mod_timestamp() {
2059 let mut config = Config::default();
2060 config.metadata = Some(crate::config::MetadataConfig {
2061 mod_timestamp: Some("{{ .CommitTimestamp }}".to_string()),
2062 ..Default::default()
2063 });
2064 let mut ctx = Context::new(config, ContextOptions::default());
2065 ctx.populate_metadata_var().unwrap();
2066
2067 let result = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
2069 assert_eq!(result, "{{ .CommitTimestamp }}");
2070 }
2071
2072 #[test]
2073 fn test_populate_metadata_var_empty_when_no_config() {
2074 let config = Config::default();
2075 let mut ctx = Context::new(config, ContextOptions::default());
2076 ctx.populate_metadata_var().unwrap();
2077
2078 let result = ctx.render_template("{{ Metadata.Description }}").unwrap();
2080 assert_eq!(result, "");
2081 }
2082
2083 #[test]
2084 fn test_populate_metadata_var_reads_from_config() {
2085 let mut config = Config::default();
2086 config.metadata = Some(crate::config::MetadataConfig {
2087 description: Some("A test project".to_string()),
2088 homepage: Some("https://example.com".to_string()),
2089 license: Some("MIT".to_string()),
2090 maintainers: Some(vec!["Alice".to_string(), "Bob".to_string()]),
2091 mod_timestamp: Some("1234567890".to_string()),
2092 ..Default::default()
2093 });
2094 let mut ctx = Context::new(config, ContextOptions::default());
2095 ctx.populate_metadata_var().unwrap();
2096
2097 let desc = ctx.render_template("{{ Metadata.Description }}").unwrap();
2098 assert_eq!(desc, "A test project");
2099
2100 let home = ctx.render_template("{{ Metadata.Homepage }}").unwrap();
2101 assert_eq!(home, "https://example.com");
2102
2103 let lic = ctx.render_template("{{ Metadata.License }}").unwrap();
2104 assert_eq!(lic, "MIT");
2105
2106 let ts = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
2107 assert_eq!(ts, "1234567890");
2108 }
2109
2110 #[test]
2111 fn test_populate_metadata_var_full_description_inline() {
2112 use crate::config::ContentSource;
2113 let mut config = Config::default();
2114 config.metadata = Some(crate::config::MetadataConfig {
2115 full_description: Some(ContentSource::Inline(
2116 "A long-form description of the project.".to_string(),
2117 )),
2118 ..Default::default()
2119 });
2120 let mut ctx = Context::new(config, ContextOptions::default());
2121 ctx.populate_metadata_var().unwrap();
2122 let rendered = ctx
2123 .render_template("{{ Metadata.FullDescription }}")
2124 .unwrap();
2125 assert_eq!(rendered, "A long-form description of the project.");
2126 }
2127
2128 #[test]
2129 fn test_populate_metadata_var_full_description_from_file() {
2130 use crate::config::ContentSource;
2131 let tmp = tempfile::tempdir().unwrap();
2132 let desc_path = tmp.path().join("DESCRIPTION.md");
2133 std::fs::write(&desc_path, "read from disk").unwrap();
2134 let mut config = Config::default();
2135 config.metadata = Some(crate::config::MetadataConfig {
2136 full_description: Some(ContentSource::FromFile {
2137 from_file: desc_path.to_string_lossy().into_owned(),
2138 }),
2139 ..Default::default()
2140 });
2141 let mut ctx = Context::new(config, ContextOptions::default());
2142 ctx.populate_metadata_var().unwrap();
2143 let rendered = ctx
2144 .render_template("{{ Metadata.FullDescription }}")
2145 .unwrap();
2146 assert_eq!(rendered, "read from disk");
2147 }
2148
2149 #[test]
2150 fn test_populate_metadata_var_full_description_from_url_errors() {
2151 use crate::config::ContentSource;
2155 let mut config = Config::default();
2156 config.metadata = Some(crate::config::MetadataConfig {
2157 full_description: Some(ContentSource::FromUrl {
2158 from_url: "https://example.com/description.md".to_string(),
2159 headers: None,
2160 }),
2161 ..Default::default()
2162 });
2163 let mut ctx = Context::new(config, ContextOptions::default());
2164 let err = ctx
2165 .populate_metadata_var()
2166 .expect_err("from_url must error");
2167 let msg = format!("{:#}", err);
2168 assert!(
2169 msg.contains("metadata.full_description") && msg.contains("from_url"),
2170 "error should mention the feature + limitation, got: {msg}"
2171 );
2172 }
2173
2174 #[test]
2175 fn test_populate_metadata_var_commit_author() {
2176 use crate::config::CommitAuthorConfig;
2177 let mut config = Config::default();
2178 config.metadata = Some(crate::config::MetadataConfig {
2179 commit_author: Some(CommitAuthorConfig {
2180 name: Some("Alice Developer".to_string()),
2181 email: Some("alice@example.com".to_string()),
2182 signing: None,
2183 use_github_app_token: false,
2184 }),
2185 ..Default::default()
2186 });
2187 let mut ctx = Context::new(config, ContextOptions::default());
2188 ctx.populate_metadata_var().unwrap();
2189 let name = ctx
2190 .render_template("{{ Metadata.CommitAuthor.Name }}")
2191 .unwrap();
2192 assert_eq!(name, "Alice Developer");
2193 let email = ctx
2194 .render_template("{{ Metadata.CommitAuthor.Email }}")
2195 .unwrap();
2196 assert_eq!(email, "alice@example.com");
2197 }
2198
2199 #[test]
2200 fn test_artifact_id_template_var() {
2201 let mut config = Config::default();
2202 config.project_name = "myapp".to_string();
2203 let mut ctx = Context::new(config, ContextOptions::default());
2204 ctx.template_vars_mut().set("ArtifactID", "default");
2205
2206 let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
2207 assert_eq!(result, "default");
2208 }
2209
2210 #[test]
2211 fn test_artifact_id_empty_when_not_set() {
2212 let mut config = Config::default();
2213 config.project_name = "myapp".to_string();
2214 let mut ctx = Context::new(config, ContextOptions::default());
2215 ctx.template_vars_mut().set("ArtifactID", "");
2216
2217 let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
2218 assert_eq!(result, "");
2219 }
2220
2221 #[test]
2222 fn test_pro_vars_rendered_in_templates() {
2223 let mut config = Config::default();
2225 config.tag = Some(crate::config::TagConfig {
2226 tag_prefix: Some("api/".to_string()),
2227 ..Default::default()
2228 });
2229 let opts = ContextOptions {
2230 snapshot: false,
2231 nightly: false,
2232 merge: true,
2233 ..Default::default()
2234 };
2235 let mut ctx = Context::new(config, opts);
2236 ctx.git_info = Some(make_git_info(false, None));
2237 ctx.populate_git_vars();
2238
2239 let result = ctx
2240 .render_template(
2241 "{% if IsRelease %}release{% endif %}-{% if IsMerging %}merge{% endif %}-{{ .PrefixedTag }}",
2242 )
2243 .unwrap();
2244 assert_eq!(result, "release-merge-api/v1.2.3");
2245 }
2246
2247 #[test]
2248 fn test_is_release_without_git_info() {
2249 let config = Config::default();
2251 let opts = ContextOptions {
2252 snapshot: false,
2253 nightly: false,
2254 ..Default::default()
2255 };
2256 let mut ctx = Context::new(config, opts);
2257 ctx.populate_git_vars();
2258
2259 assert_eq!(
2260 ctx.template_vars().get("IsRelease"),
2261 Some(&"true".to_string())
2262 );
2263 }
2264
2265 #[test]
2266 fn test_is_merging_without_git_info() {
2267 let config = Config::default();
2269 let opts = ContextOptions {
2270 merge: true,
2271 ..Default::default()
2272 };
2273 let mut ctx = Context::new(config, opts);
2274 ctx.populate_git_vars();
2275
2276 assert_eq!(
2277 ctx.template_vars().get("IsMerging"),
2278 Some(&"true".to_string())
2279 );
2280 }
2281
2282 #[test]
2287 fn test_monorepo_tag_prefix_strips_tag_for_template_var() {
2288 let mut config = Config::default();
2289 config.monorepo = Some(crate::config::MonorepoConfig {
2290 tag_prefix: Some("subproject1/".to_string()),
2291 dir: None,
2292 });
2293 let mut ctx = Context::new(config, ContextOptions::default());
2294
2295 let mut info = make_git_info(false, None);
2297 info.tag = "subproject1/v1.2.3".to_string();
2298 info.previous_tag = Some("subproject1/v1.2.2".to_string());
2299 info.summary = "subproject1/v1.2.3-0-gabc123d".to_string();
2300 ctx.git_info = Some(info);
2301 ctx.populate_git_vars();
2302
2303 let v = ctx.template_vars();
2304 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2306 assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
2308 assert_eq!(
2310 v.get("PrefixedTag"),
2311 Some(&"subproject1/v1.2.3".to_string())
2312 );
2313 assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
2315 assert_eq!(
2317 v.get("PrefixedPreviousTag"),
2318 Some(&"subproject1/v1.2.2".to_string())
2319 );
2320 assert_eq!(v.get("Summary"), Some(&"v1.2.3-0-gabc123d".to_string()));
2322 assert_eq!(
2324 v.get("PrefixedSummary"),
2325 Some(&"subproject1/v1.2.3-0-gabc123d".to_string())
2326 );
2327 }
2328
2329 #[test]
2330 fn test_monorepo_prefixed_previous_tag() {
2331 let mut config = Config::default();
2332 config.monorepo = Some(crate::config::MonorepoConfig {
2333 tag_prefix: Some("svc/".to_string()),
2334 dir: None,
2335 });
2336 let mut ctx = Context::new(config, ContextOptions::default());
2337
2338 let mut info = make_git_info(false, None);
2339 info.tag = "svc/v2.0.0".to_string();
2340 info.previous_tag = Some("svc/v1.9.0".to_string());
2341 ctx.git_info = Some(info);
2342 ctx.populate_git_vars();
2343
2344 let v = ctx.template_vars();
2345 assert_eq!(
2347 v.get("PrefixedPreviousTag"),
2348 Some(&"svc/v1.9.0".to_string())
2349 );
2350 assert_eq!(v.get("PreviousTag"), Some(&"v1.9.0".to_string()));
2352 }
2353
2354 #[test]
2355 fn test_no_monorepo_falls_back_to_tag_prefix() {
2356 let mut config = Config::default();
2358 config.tag = Some(crate::config::TagConfig {
2359 tag_prefix: Some("release/".to_string()),
2360 ..Default::default()
2361 });
2362 let mut ctx = Context::new(config, ContextOptions::default());
2363 ctx.git_info = Some(make_git_info(false, None));
2364 ctx.populate_git_vars();
2365
2366 let v = ctx.template_vars();
2367 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2369 assert_eq!(v.get("PrefixedTag"), Some(&"release/v1.2.3".to_string()));
2371 assert_eq!(
2372 v.get("PrefixedPreviousTag"),
2373 Some(&"release/v1.2.2".to_string())
2374 );
2375 }
2376
2377 #[test]
2378 fn test_monorepo_overrides_tag_prefix_for_prefixed_vars() {
2379 let mut config = Config::default();
2382 config.tag = Some(crate::config::TagConfig {
2383 tag_prefix: Some("release/".to_string()),
2384 ..Default::default()
2385 });
2386 config.monorepo = Some(crate::config::MonorepoConfig {
2387 tag_prefix: Some("svc/".to_string()),
2388 dir: None,
2389 });
2390 let mut ctx = Context::new(config, ContextOptions::default());
2391
2392 let mut info = make_git_info(false, None);
2393 info.tag = "svc/v1.2.3".to_string();
2394 info.previous_tag = Some("svc/v1.2.2".to_string());
2395 ctx.git_info = Some(info);
2396 ctx.populate_git_vars();
2397
2398 let v = ctx.template_vars();
2399 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2401 assert_eq!(v.get("PrefixedTag"), Some(&"svc/v1.2.3".to_string()));
2403 }
2404
2405 #[test]
2406 fn test_monorepo_prefixed_summary() {
2407 let mut config = Config::default();
2408 config.monorepo = Some(crate::config::MonorepoConfig {
2409 tag_prefix: Some("pkg/".to_string()),
2410 dir: None,
2411 });
2412 let mut ctx = Context::new(config, ContextOptions::default());
2413
2414 let mut info = make_git_info(false, None);
2415 info.tag = "pkg/v1.2.3".to_string();
2416 info.summary = "pkg/v1.2.3-0-gabc123d".to_string();
2418 ctx.git_info = Some(info);
2419 ctx.populate_git_vars();
2420
2421 assert_eq!(
2423 ctx.template_vars().get("PrefixedSummary"),
2424 Some(&"pkg/v1.2.3-0-gabc123d".to_string())
2425 );
2426 assert_eq!(
2428 ctx.template_vars().get("Summary"),
2429 Some(&"v1.2.3-0-gabc123d".to_string())
2430 );
2431 }
2432
2433 #[test]
2434 fn test_monorepo_no_previous_tag() {
2435 let mut config = Config::default();
2436 config.monorepo = Some(crate::config::MonorepoConfig {
2437 tag_prefix: Some("svc/".to_string()),
2438 dir: None,
2439 });
2440 let mut ctx = Context::new(config, ContextOptions::default());
2441
2442 let mut info = make_git_info(false, None);
2443 info.tag = "svc/v1.0.0".to_string();
2444 info.previous_tag = None;
2445 ctx.git_info = Some(info);
2446 ctx.populate_git_vars();
2447
2448 let v = ctx.template_vars();
2449 assert_eq!(v.get("PrefixedPreviousTag"), Some(&"".to_string()));
2450 assert_eq!(v.get("PreviousTag"), Some(&"".to_string()));
2452 }
2453
2454 #[test]
2459 fn test_monorepo_full_flow_all_vars() {
2460 let mut config = Config::default();
2463 config.project_name = "mymonorepo".to_string();
2464 config.monorepo = Some(crate::config::MonorepoConfig {
2465 tag_prefix: Some("services/api/".to_string()),
2466 dir: Some("services/api".to_string()),
2467 });
2468
2469 assert_eq!(config.monorepo_tag_prefix(), Some("services/api/"));
2471 assert_eq!(config.monorepo_dir(), Some("services/api"));
2472
2473 let mut ctx = Context::new(config, ContextOptions::default());
2474
2475 let mut info = make_git_info(false, None);
2478 info.tag = "services/api/v2.1.0".to_string();
2479 info.previous_tag = Some("services/api/v2.0.5".to_string());
2480 info.summary = "services/api/v2.1.0-0-gabc123d".to_string();
2481 info.semver = crate::git::SemVer {
2482 major: 2,
2483 minor: 1,
2484 patch: 0,
2485 prerelease: None,
2486 build_metadata: None,
2487 };
2488 ctx.git_info = Some(info);
2489 ctx.populate_git_vars();
2490
2491 let v = ctx.template_vars();
2492
2493 assert_eq!(v.get("Tag"), Some(&"v2.1.0".to_string()));
2495 assert_eq!(v.get("Version"), Some(&"2.1.0".to_string()));
2496 assert_eq!(v.get("RawVersion"), Some(&"2.1.0".to_string()));
2497 assert_eq!(v.get("Major"), Some(&"2".to_string()));
2498 assert_eq!(v.get("Minor"), Some(&"1".to_string()));
2499 assert_eq!(v.get("Patch"), Some(&"0".to_string()));
2500 assert_eq!(v.get("PreviousTag"), Some(&"v2.0.5".to_string()));
2501 assert_eq!(v.get("Summary"), Some(&"v2.1.0-0-gabc123d".to_string()));
2502
2503 assert_eq!(
2505 v.get("PrefixedTag"),
2506 Some(&"services/api/v2.1.0".to_string())
2507 );
2508 assert_eq!(
2509 v.get("PrefixedPreviousTag"),
2510 Some(&"services/api/v2.0.5".to_string())
2511 );
2512 assert_eq!(
2513 v.get("PrefixedSummary"),
2514 Some(&"services/api/v2.1.0-0-gabc123d".to_string())
2515 );
2516
2517 assert_eq!(v.get("ProjectName"), Some(&"mymonorepo".to_string()));
2519 }
2520}