1use crate::artifact::ArtifactRegistry;
2use crate::config::Config;
3use crate::git::GitInfo;
4use crate::log::{StageLogger, Verbosity};
5use crate::partial::PartialTarget;
6use crate::scm::ScmTokenType;
7use crate::template::TemplateVars;
8use anyhow::Context as _;
9use chrono::Utc;
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13pub const VALID_RELEASE_SKIPS: &[&str] = &[
20 "publish",
21 "announce",
22 "sign",
23 "validate",
24 "sbom",
25 "docker",
26 "winget",
27 "choco",
28 "snapcraft",
29 "snapcraft-publish",
30 "scoop",
31 "brew",
32 "nix",
33 "aur",
34 "cargo",
35 "krew",
36 "nfpm",
37 "makeself",
38 "flatpak",
39 "srpm",
40 "before",
41 "notarize",
42 "archive",
43 "source",
44 "build",
45 "changelog",
46 "release",
47 "checksum",
48 "upx",
49 "blob",
50 "templatefiles",
51 "dmg",
52 "msi",
53 "nsis",
54 "pkg",
55 "appbundle",
56];
57
58pub const VALID_BUILD_SKIPS: &[&str] = &["pre-hooks", "post-hooks", "validate", "before"];
60
61pub fn validate_skip_values(skip: &[String], valid: &[&str]) -> Result<(), String> {
66 let invalid: Vec<&str> = skip
67 .iter()
68 .map(|s| s.as_str())
69 .filter(|s| !valid.contains(s))
70 .collect();
71 if invalid.is_empty() {
72 Ok(())
73 } else {
74 Err(format!(
75 "invalid --skip value(s): {}. Valid options: {}",
76 invalid.join(", "),
77 valid.join(", "),
78 ))
79 }
80}
81
82pub struct ContextOptions {
83 pub snapshot: bool,
84 pub nightly: bool,
85 pub dry_run: bool,
86 pub quiet: bool,
87 pub verbose: bool,
88 pub debug: bool,
89 pub skip_stages: Vec<String>,
90 pub selected_crates: Vec<String>,
91 pub token: Option<String>,
92 pub parallelism: usize,
94 pub single_target: Option<String>,
96 pub release_notes_path: Option<PathBuf>,
98 pub fail_fast: bool,
100 pub partial_target: Option<PartialTarget>,
103 pub merge: bool,
105 pub project_root: Option<PathBuf>,
108 pub strict: bool,
110}
111
112impl Default for ContextOptions {
113 fn default() -> Self {
114 Self {
115 snapshot: false,
116 nightly: false,
117 dry_run: false,
118 quiet: false,
119 verbose: false,
120 debug: false,
121 skip_stages: Vec::new(),
122 selected_crates: Vec::new(),
123 token: None,
124 parallelism: 4,
125 single_target: None,
126 release_notes_path: None,
127 fail_fast: false,
128 partial_target: None,
129 merge: false,
130 project_root: None,
131 strict: false,
132 }
133 }
134}
135
136#[derive(Debug, Default)]
142pub struct StageOutputs {
143 pub github_native_changelog: bool,
147 pub changelogs: HashMap<String, String>,
149 pub changelog_header: Option<String>,
154 pub changelog_footer: Option<String>,
157}
158
159pub struct Context {
160 pub config: Config,
161 pub artifacts: ArtifactRegistry,
162 pub options: ContextOptions,
163 pub stage_outputs: StageOutputs,
165 template_vars: TemplateVars,
166 pub git_info: Option<GitInfo>,
167 pub token_type: ScmTokenType,
169 pub skip_memento: crate::pipe_skip::SkipMemento,
175}
176
177impl Context {
178 pub fn new(config: Config, options: ContextOptions) -> Self {
179 let mut vars = TemplateVars::new();
180 vars.set("ProjectName", &config.project_name);
181 Self {
182 config,
183 artifacts: ArtifactRegistry::new(),
184 options,
185 stage_outputs: StageOutputs::default(),
186 template_vars: vars,
187 git_info: None,
188 token_type: ScmTokenType::GitHub,
189 skip_memento: crate::pipe_skip::SkipMemento::new(),
190 }
191 }
192
193 pub fn remember_skip(&self, stage: &str, label: &str, reason: &str) {
200 self.skip_memento.remember(stage, label, reason);
201 }
202
203 pub fn template_vars(&self) -> &TemplateVars {
204 &self.template_vars
205 }
206
207 pub fn template_vars_mut(&mut self) -> &mut TemplateVars {
208 &mut self.template_vars
209 }
210
211 pub fn render_template(&self, template: &str) -> anyhow::Result<String> {
212 crate::template::render(template, &self.template_vars)
213 }
214
215 pub fn render_template_opt(&self, template: Option<&str>) -> anyhow::Result<Option<String>> {
217 template.map(|t| self.render_template(t)).transpose()
218 }
219
220 pub fn skip_with_log(
229 &self,
230 skip: &Option<crate::config::StringOrBool>,
231 log: &StageLogger,
232 label: &str,
233 ) -> anyhow::Result<bool> {
234 let Some(d) = skip else {
235 return Ok(false);
236 };
237 let should_skip = d
238 .try_evaluates_to_true(|s| self.render_template(s))
239 .with_context(|| format!("evaluate skip expression for {label}"))?;
240 if should_skip {
241 log.status(&format!("{} skipped", label));
242 }
243 Ok(should_skip)
244 }
245
246 pub fn should_skip(&self, stage_name: &str) -> bool {
247 self.options.skip_stages.iter().any(|s| s == stage_name)
248 }
249
250 pub fn skip_validate(&self) -> bool {
252 self.should_skip("validate")
253 }
254
255 pub fn is_dry_run(&self) -> bool {
256 self.options.dry_run
257 }
258
259 pub fn is_snapshot(&self) -> bool {
260 self.options.snapshot
261 }
262
263 pub fn is_strict(&self) -> bool {
264 self.options.strict
265 }
266
267 pub fn strict_guard(&self, log: &crate::log::StageLogger, msg: &str) -> anyhow::Result<()> {
270 if self.options.strict {
271 anyhow::bail!("{} (strict mode)", msg);
272 }
273 log.warn(msg);
274 Ok(())
275 }
276
277 pub fn skip_in_snapshot(&self, log: &crate::log::StageLogger, stage: &str) -> bool {
286 if self.is_snapshot() {
287 log.status(&format!("{}: skipped (snapshot mode)", stage));
288 true
289 } else {
290 false
291 }
292 }
293
294 pub fn render_template_strict(
296 &self,
297 template: &str,
298 label: &str,
299 log: &crate::log::StageLogger,
300 ) -> anyhow::Result<String> {
301 match self.render_template(template) {
302 Ok(rendered) => Ok(rendered),
303 Err(e) => {
304 if self.options.strict {
305 anyhow::bail!("{}: failed to render template: {} (strict mode)", label, e);
306 }
307 log.warn(&format!("{}: failed to render template: {}", label, e));
308 Ok(template.to_string())
309 }
310 }
311 }
312
313 pub fn is_nightly(&self) -> bool {
314 self.options.nightly
315 }
316
317 pub fn set_release_url(&mut self, url: &str) {
322 self.template_vars.set("ReleaseURL", url);
323 }
324
325 pub fn version(&self) -> String {
328 self.template_vars
329 .get("Version")
330 .cloned()
331 .unwrap_or_default()
332 }
333
334 pub fn verbosity(&self) -> Verbosity {
336 Verbosity::from_flags(self.options.quiet, self.options.verbose, self.options.debug)
337 }
338
339 pub fn retry_policy(&self) -> crate::retry::RetryPolicy {
345 self.config.retry.unwrap_or_default().to_policy()
346 }
347
348 pub fn logger(&self, stage: &'static str) -> StageLogger {
356 StageLogger::new(stage, self.verbosity()).with_env(self.env_for_redact())
357 }
358
359 fn env_for_redact(&self) -> Vec<(String, String)> {
365 use std::collections::HashMap;
366 let mut map: HashMap<String, String> = std::env::vars().collect();
367 for (k, v) in self.template_vars.all_env() {
368 map.insert(k.clone(), v.clone());
369 }
370 map.into_iter().collect()
371 }
372
373 pub fn populate_git_vars(&mut self) {
415 if let Some(ref info) = self.git_info {
416 let raw_version = format!(
418 "{}.{}.{}",
419 info.semver.major, info.semver.minor, info.semver.patch
420 );
421
422 let mut version = raw_version.clone();
428 if let Some(ref pre) = info.semver.prerelease {
429 version.push('-');
430 version.push_str(pre);
431 }
432 if let Some(ref meta) = info.semver.build_metadata {
433 version.push('+');
434 version.push_str(meta);
435 }
436
437 self.template_vars.set("Tag", &info.tag);
438 self.template_vars.set("Version", &version);
439 self.template_vars.set("RawVersion", &raw_version);
440 self.template_vars
441 .set("Major", &info.semver.major.to_string());
442 self.template_vars
443 .set("Minor", &info.semver.minor.to_string());
444 self.template_vars
445 .set("Patch", &info.semver.patch.to_string());
446 self.template_vars.set(
447 "Prerelease",
448 info.semver.prerelease.as_deref().unwrap_or(""),
449 );
450 self.template_vars.set(
451 "BuildMetadata",
452 info.semver.build_metadata.as_deref().unwrap_or(""),
453 );
454 self.template_vars.set("FullCommit", &info.commit);
455 self.template_vars.set("Commit", &info.commit);
456 self.template_vars.set("ShortCommit", &info.short_commit);
457 self.template_vars.set("Branch", &info.branch);
458 self.template_vars.set("CommitDate", &info.commit_date);
459 self.template_vars
460 .set("CommitTimestamp", &info.commit_timestamp);
461 self.template_vars
462 .set("IsGitDirty", if info.dirty { "true" } else { "false" });
463 self.template_vars
464 .set("IsGitClean", if info.dirty { "false" } else { "true" });
465 self.template_vars
466 .set("GitTreeState", if info.dirty { "dirty" } else { "clean" });
467 self.template_vars.set("GitURL", &info.remote_url);
468 self.template_vars.set("Summary", &info.summary);
469 self.template_vars.set("TagSubject", &info.tag_subject);
470 self.template_vars.set("TagContents", &info.tag_contents);
471 self.template_vars.set("TagBody", &info.tag_body);
472 self.template_vars
473 .set("PreviousTag", info.previous_tag.as_deref().unwrap_or(""));
474 self.template_vars
475 .set("FirstCommit", info.first_commit.as_deref().unwrap_or(""));
476
477 let monorepo_prefix = self.config.monorepo_tag_prefix();
488
489 if let Some(prefix) = monorepo_prefix {
495 self.template_vars.set("PrefixedTag", &info.tag);
498
499 let stripped_tag = crate::git::strip_monorepo_prefix(&info.tag, prefix);
501 self.template_vars.set("Tag", stripped_tag);
502
503 let version = stripped_tag
507 .strip_prefix('v')
508 .unwrap_or(stripped_tag)
509 .to_string();
510 self.template_vars.set("Version", &version);
511
512 let prev_tag = info.previous_tag.as_deref().unwrap_or("");
514 self.template_vars.set("PrefixedPreviousTag", prev_tag);
515
516 let stripped_prev = crate::git::strip_monorepo_prefix(prev_tag, prefix);
518 self.template_vars.set("PreviousTag", stripped_prev);
519
520 self.template_vars.set("PrefixedSummary", &info.summary);
524 let stripped_summary = crate::git::strip_monorepo_prefix(&info.summary, prefix);
526 self.template_vars.set("Summary", stripped_summary);
527 } else {
528 let tag_prefix = self
530 .config
531 .tag
532 .as_ref()
533 .and_then(|t| t.tag_prefix.as_deref())
534 .unwrap_or("");
535 self.template_vars
536 .set("PrefixedTag", &format!("{}{}", tag_prefix, info.tag));
537 let prev_tag = info.previous_tag.as_deref().unwrap_or("");
538 let prefixed_prev = if prev_tag.is_empty() {
539 String::new()
540 } else {
541 format!("{}{}", tag_prefix, prev_tag)
542 };
543 self.template_vars
544 .set("PrefixedPreviousTag", &prefixed_prev);
545 self.template_vars.set(
546 "PrefixedSummary",
547 &format!("{}{}", tag_prefix, info.summary),
548 );
549 }
550 }
551
552 self.template_vars.set(
553 "IsSnapshot",
554 if self.options.snapshot {
555 "true"
556 } else {
557 "false"
558 },
559 );
560 self.template_vars.set(
561 "IsNightly",
562 if self.options.nightly {
563 "true"
564 } else {
565 "false"
566 },
567 );
568 let is_draft = self
570 .config
571 .release
572 .as_ref()
573 .and_then(|r| r.draft)
574 .unwrap_or(false);
575 self.template_vars
576 .set("IsDraft", if is_draft { "true" } else { "false" });
577 self.template_vars.set(
578 "IsSingleTarget",
579 if self.options.single_target.is_some() {
580 "true"
581 } else {
582 "false"
583 },
584 );
585
586 let is_release = !self.options.snapshot && !self.options.nightly;
588 self.template_vars
589 .set("IsRelease", if is_release { "true" } else { "false" });
590
591 self.template_vars.set(
593 "IsMerging",
594 if self.options.merge { "true" } else { "false" },
595 );
596 }
597
598 pub fn populate_time_vars(&mut self) {
610 let now = Utc::now();
611 self.template_vars.set("Date", &now.to_rfc3339());
612 self.template_vars
613 .set("Timestamp", &now.timestamp().to_string());
614 self.template_vars.set("Now", &now.to_rfc3339());
615 self.template_vars
616 .set("Year", &now.format("%Y").to_string());
617 self.template_vars
618 .set("Month", &now.format("%m").to_string());
619 self.template_vars.set("Day", &now.format("%d").to_string());
620 self.template_vars
621 .set("Hour", &now.format("%H").to_string());
622 self.template_vars
623 .set("Minute", &now.format("%M").to_string());
624 }
625
626 pub fn populate_runtime_vars(&mut self) {
633 let goos = map_os_to_goos(std::env::consts::OS);
634 let goarch = map_arch_to_goarch(std::env::consts::ARCH);
635 self.template_vars.set("RuntimeGoos", goos);
636 self.template_vars.set("RuntimeGoarch", goarch);
637 self.template_vars.set("Runtime_Goos", goos);
640 self.template_vars.set("Runtime_Goarch", goarch);
641 }
642
643 pub fn populate_release_notes_var(&mut self) {
651 let notes = self
653 .config
654 .crates
655 .iter()
656 .find_map(|c| self.stage_outputs.changelogs.get(&c.name))
657 .cloned()
658 .unwrap_or_default();
659 self.template_vars.set("ReleaseNotes", ¬es);
660 }
661
662 pub fn refresh_artifacts_var(&mut self) {
677 const CSV_LIST_KEYS: &[&str] = &["extra_binaries", "extra_files"];
682
683 let artifacts_value: Vec<serde_json::Value> = self
684 .artifacts
685 .all()
686 .iter()
687 .map(|a| {
688 let mut metadata_map = serde_json::Map::with_capacity(a.metadata.len());
690 for (k, v) in &a.metadata {
691 if CSV_LIST_KEYS.contains(&k.as_str()) {
692 let items: Vec<serde_json::Value> = if v.is_empty() {
693 Vec::new()
694 } else {
695 v.split(',')
696 .map(|s| serde_json::Value::String(s.to_string()))
697 .collect()
698 };
699 metadata_map.insert(k.clone(), serde_json::Value::Array(items));
700 } else {
701 metadata_map.insert(k.clone(), serde_json::Value::String(v.clone()));
702 }
703 }
704 serde_json::json!({
705 "name": a.name,
706 "path": a.path.to_string_lossy(),
707 "target": a.target.as_deref().unwrap_or(""),
708 "kind": a.kind.as_str(),
709 "crate_name": a.crate_name,
710 "metadata": serde_json::Value::Object(metadata_map),
711 })
712 })
713 .collect();
714 let tera_value = tera::Value::Array(artifacts_value);
717 self.template_vars.set_structured("Artifacts", tera_value);
718 }
719
720 pub fn populate_metadata_var(&mut self) -> anyhow::Result<()> {
732 use crate::config::ContentSource;
733
734 let (
737 description,
738 homepage,
739 license,
740 maintainers,
741 mod_timestamp,
742 full_desc_src,
743 commit_author,
744 ) = {
745 let meta = self.config.metadata.as_ref();
746 let description = meta
747 .and_then(|m| m.description.as_deref())
748 .unwrap_or("")
749 .to_string();
750 let homepage = meta
751 .and_then(|m| m.homepage.as_deref())
752 .unwrap_or("")
753 .to_string();
754 let license = meta
755 .and_then(|m| m.license.as_deref())
756 .unwrap_or("")
757 .to_string();
758 let maintainers: Vec<String> = meta
759 .and_then(|m| m.maintainers.as_ref())
760 .cloned()
761 .unwrap_or_default();
762 let mod_timestamp = meta
763 .and_then(|m| m.mod_timestamp.as_deref())
764 .unwrap_or("")
765 .to_string();
766 let full_desc_src = meta.and_then(|m| m.full_description.clone());
767 let commit_author = meta.and_then(|m| m.commit_author.clone());
768 (
769 description,
770 homepage,
771 license,
772 maintainers,
773 mod_timestamp,
774 full_desc_src,
775 commit_author,
776 )
777 };
778
779 let full_description = match full_desc_src {
781 None => String::new(),
782 Some(ContentSource::Inline(s)) => s,
783 Some(ContentSource::FromFile { from_file }) => {
784 let rendered_path = self.render_template(&from_file).with_context(|| {
785 format!("metadata.full_description: render path '{}'", from_file)
786 })?;
787 std::fs::read_to_string(&rendered_path).with_context(|| {
788 format!(
789 "metadata.full_description: read from_file '{}'",
790 rendered_path
791 )
792 })?
793 }
794 Some(ContentSource::FromUrl { .. }) => {
795 anyhow::bail!(
796 "metadata.full_description: `from_url` is not yet supported at metadata \
797 population time (core has no HTTP client). Use `from_file` with a \
798 pre-fetched file, or inline the content. Tracked for future: move \
799 URL resolution into a late-pipeline stage or add reqwest to core."
800 );
801 }
802 };
803
804 let commit_author_map = serde_json::json!({
805 "Name": commit_author.as_ref().and_then(|c| c.name.clone()).unwrap_or_default(),
806 "Email": commit_author.as_ref().and_then(|c| c.email.clone()).unwrap_or_default(),
807 });
808
809 let meta_map = serde_json::json!({
810 "Description": description,
811 "Homepage": homepage,
812 "License": license,
813 "Maintainers": maintainers,
814 "ModTimestamp": mod_timestamp,
815 "FullDescription": full_description,
816 "CommitAuthor": commit_author_map,
817 });
818 self.template_vars.set_structured("Metadata", meta_map);
820 Ok(())
821 }
822}
823
824pub fn map_os_to_goos(os: &str) -> &str {
827 match os {
828 "macos" => "darwin",
829 other => other, }
831}
832
833pub fn map_arch_to_goarch(arch: &str) -> &str {
836 match arch {
837 "x86_64" => "amd64",
838 "x86" => "386",
839 "aarch64" => "arm64",
840 "powerpc64" => "ppc64",
841 "s390x" => "s390x",
842 "mips" => "mips",
843 "mips64" => "mips64",
844 "riscv64" => "riscv64",
845 other => other,
846 }
847}
848
849#[cfg(test)]
850#[allow(clippy::field_reassign_with_default)]
851mod tests {
852 use super::*;
853 use crate::config::Config;
854 use crate::git::{GitInfo, SemVer};
855
856 fn make_git_info(dirty: bool, prerelease: Option<&str>) -> GitInfo {
857 let tag = match prerelease {
858 Some(pre) => format!("v1.2.3-{pre}"),
859 None => "v1.2.3".to_string(),
860 };
861 GitInfo {
862 tag,
863 commit: "abc123def456abc123def456abc123def456abc1".to_string(),
864 short_commit: "abc123d".to_string(),
865 branch: "main".to_string(),
866 dirty,
867 semver: SemVer {
868 major: 1,
869 minor: 2,
870 patch: 3,
871 prerelease: prerelease.map(|s| s.to_string()),
872 build_metadata: None,
873 },
874 commit_date: "2026-03-25T10:30:00+00:00".to_string(),
875 commit_timestamp: "1774463400".to_string(),
876 previous_tag: Some("v1.2.2".to_string()),
877 remote_url: "https://github.com/test/repo.git".to_string(),
878 summary: "v1.2.3-0-gabc123d".to_string(),
879 tag_subject: "Release v1.2.3".to_string(),
880 tag_contents: "Release v1.2.3\n\nFull release notes here.".to_string(),
881 tag_body: "Full release notes here.".to_string(),
882 first_commit: None,
883 }
884 }
885
886 #[test]
887 fn test_context_template_vars() {
888 let mut config = Config::default();
889 config.project_name = "test-project".to_string();
890 let ctx = Context::new(config, ContextOptions::default());
891 assert_eq!(
892 ctx.template_vars().get("ProjectName"),
893 Some(&"test-project".to_string())
894 );
895 }
896
897 #[test]
898 fn test_context_should_skip() {
899 let config = Config::default();
900 let opts = ContextOptions {
901 skip_stages: vec!["publish".to_string(), "announce".to_string()],
902 ..Default::default()
903 };
904 let ctx = Context::new(config, opts);
905 assert!(ctx.should_skip("publish"));
906 assert!(ctx.should_skip("announce"));
907 assert!(!ctx.should_skip("build"));
908 }
909
910 #[test]
911 fn test_context_render_template() {
912 let mut config = Config::default();
913 config.project_name = "myapp".to_string();
914 let ctx = Context::new(config, ContextOptions::default());
915 let result = ctx.render_template("{{ .ProjectName }}-release").unwrap();
916 assert_eq!(result, "myapp-release");
917 }
918
919 #[test]
920 fn test_populate_git_vars_sets_all_expected_vars() {
921 let config = Config::default();
922 let mut ctx = Context::new(config, ContextOptions::default());
923 ctx.git_info = Some(make_git_info(false, None));
924 ctx.populate_git_vars();
925
926 let v = ctx.template_vars();
927 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
928 assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
929 assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
930 assert_eq!(v.get("Major"), Some(&"1".to_string()));
931 assert_eq!(v.get("Minor"), Some(&"2".to_string()));
932 assert_eq!(v.get("Patch"), Some(&"3".to_string()));
933 assert_eq!(v.get("Prerelease"), Some(&"".to_string()));
934 assert_eq!(
935 v.get("FullCommit"),
936 Some(&"abc123def456abc123def456abc123def456abc1".to_string())
937 );
938 assert_eq!(v.get("ShortCommit"), Some(&"abc123d".to_string()));
939 assert_eq!(v.get("Branch"), Some(&"main".to_string()));
940 assert_eq!(
941 v.get("CommitDate"),
942 Some(&"2026-03-25T10:30:00+00:00".to_string())
943 );
944 assert_eq!(v.get("CommitTimestamp"), Some(&"1774463400".to_string()));
945 assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
946 }
947
948 #[test]
949 fn test_commit_is_alias_for_full_commit() {
950 let config = Config::default();
951 let mut ctx = Context::new(config, ContextOptions::default());
952 ctx.git_info = Some(make_git_info(false, None));
953 ctx.populate_git_vars();
954
955 let v = ctx.template_vars();
956 assert_eq!(v.get("Commit"), v.get("FullCommit"));
957 }
958
959 #[test]
960 fn test_populate_git_vars_prerelease() {
961 let config = Config::default();
962 let mut ctx = Context::new(config, ContextOptions::default());
963 ctx.git_info = Some(make_git_info(false, Some("rc.1")));
964 ctx.populate_git_vars();
965
966 let v = ctx.template_vars();
967 assert_eq!(v.get("Version"), Some(&"1.2.3-rc.1".to_string()));
968 assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
969 assert_eq!(v.get("Prerelease"), Some(&"rc.1".to_string()));
970 }
971
972 #[test]
973 fn test_build_metadata_template_var() {
974 let config = Config::default();
975 let mut ctx = Context::new(config, ContextOptions::default());
976 let mut info = make_git_info(false, None);
977 info.tag = "v1.2.3+build.42".to_string();
978 info.semver.build_metadata = Some("build.42".to_string());
979 ctx.git_info = Some(info);
980 ctx.populate_git_vars();
981
982 let v = ctx.template_vars();
983 assert_eq!(v.get("BuildMetadata"), Some(&"build.42".to_string()));
984 assert_eq!(v.get("Version"), Some(&"1.2.3+build.42".to_string()));
986 }
987
988 #[test]
989 fn test_build_metadata_empty_when_none() {
990 let config = Config::default();
991 let mut ctx = Context::new(config, ContextOptions::default());
992 ctx.git_info = Some(make_git_info(false, None));
993 ctx.populate_git_vars();
994
995 assert_eq!(
996 ctx.template_vars().get("BuildMetadata"),
997 Some(&"".to_string())
998 );
999 }
1000
1001 #[test]
1002 fn test_populate_git_vars_monorepo_prefixed_tag() {
1003 let config = Config::default();
1006 let mut ctx = Context::new(config, ContextOptions::default());
1007 let mut info = make_git_info(false, None);
1008 info.tag = "core-v0.3.2".to_string();
1009 info.semver = SemVer {
1010 major: 0,
1011 minor: 3,
1012 patch: 2,
1013 prerelease: None,
1014 build_metadata: None,
1015 };
1016 ctx.git_info = Some(info);
1017 ctx.populate_git_vars();
1018
1019 let v = ctx.template_vars();
1020 assert_eq!(v.get("Tag"), Some(&"core-v0.3.2".to_string()));
1021 assert_eq!(v.get("Version"), Some(&"0.3.2".to_string()));
1022 assert_eq!(v.get("RawVersion"), Some(&"0.3.2".to_string()));
1023 assert_eq!(v.get("Major"), Some(&"0".to_string()));
1024 assert_eq!(v.get("Minor"), Some(&"3".to_string()));
1025 assert_eq!(v.get("Patch"), Some(&"2".to_string()));
1026 }
1027
1028 #[test]
1029 fn test_populate_git_vars_monorepo_prefixed_tag_with_prerelease() {
1030 let config = Config::default();
1031 let mut ctx = Context::new(config, ContextOptions::default());
1032 let mut info = make_git_info(false, None);
1033 info.tag = "operator-v1.0.0-rc.1".to_string();
1034 info.semver = SemVer {
1035 major: 1,
1036 minor: 0,
1037 patch: 0,
1038 prerelease: Some("rc.1".to_string()),
1039 build_metadata: None,
1040 };
1041 ctx.git_info = Some(info);
1042 ctx.populate_git_vars();
1043
1044 let v = ctx.template_vars();
1045 assert_eq!(v.get("Tag"), Some(&"operator-v1.0.0-rc.1".to_string()));
1046 assert_eq!(v.get("Version"), Some(&"1.0.0-rc.1".to_string()));
1047 assert_eq!(v.get("RawVersion"), Some(&"1.0.0".to_string()));
1048 }
1049
1050 #[test]
1051 fn test_git_tree_state_clean() {
1052 let config = Config::default();
1053 let mut ctx = Context::new(config, ContextOptions::default());
1054 ctx.git_info = Some(make_git_info(false, None));
1055 ctx.populate_git_vars();
1056
1057 let v = ctx.template_vars();
1058 assert_eq!(v.get("IsGitDirty"), Some(&"false".to_string()));
1059 assert_eq!(v.get("GitTreeState"), Some(&"clean".to_string()));
1060 }
1061
1062 #[test]
1063 fn test_git_tree_state_dirty() {
1064 let config = Config::default();
1065 let mut ctx = Context::new(config, ContextOptions::default());
1066 ctx.git_info = Some(make_git_info(true, None));
1067 ctx.populate_git_vars();
1068
1069 let v = ctx.template_vars();
1070 assert_eq!(v.get("IsGitDirty"), Some(&"true".to_string()));
1071 assert_eq!(v.get("GitTreeState"), Some(&"dirty".to_string()));
1072 }
1073
1074 #[test]
1075 fn test_is_snapshot_reflects_context_options() {
1076 let config = Config::default();
1077 let opts = ContextOptions {
1078 snapshot: true,
1079 ..Default::default()
1080 };
1081 let mut ctx = Context::new(config, opts);
1082 ctx.git_info = Some(make_git_info(false, None));
1083 ctx.populate_git_vars();
1084
1085 assert_eq!(
1086 ctx.template_vars().get("IsSnapshot"),
1087 Some(&"true".to_string())
1088 );
1089
1090 let config2 = Config::default();
1092 let opts2 = ContextOptions {
1093 snapshot: false,
1094 ..Default::default()
1095 };
1096 let mut ctx2 = Context::new(config2, opts2);
1097 ctx2.git_info = Some(make_git_info(false, None));
1098 ctx2.populate_git_vars();
1099
1100 assert_eq!(
1101 ctx2.template_vars().get("IsSnapshot"),
1102 Some(&"false".to_string())
1103 );
1104 }
1105
1106 #[test]
1107 fn test_is_draft_defaults_to_false() {
1108 let config = Config::default();
1109 let mut ctx = Context::new(config, ContextOptions::default());
1110 ctx.git_info = Some(make_git_info(false, None));
1111 ctx.populate_git_vars();
1112
1113 assert_eq!(
1114 ctx.template_vars().get("IsDraft"),
1115 Some(&"false".to_string())
1116 );
1117 }
1118
1119 #[test]
1120 fn test_previous_tag_empty_when_none() {
1121 let config = Config::default();
1122 let mut ctx = Context::new(config, ContextOptions::default());
1123 let mut info = make_git_info(false, None);
1124 info.previous_tag = None;
1125 ctx.git_info = Some(info);
1126 ctx.populate_git_vars();
1127
1128 assert_eq!(
1129 ctx.template_vars().get("PreviousTag"),
1130 Some(&"".to_string())
1131 );
1132 }
1133
1134 #[test]
1135 fn test_populate_time_vars() {
1136 let config = Config::default();
1137 let mut ctx = Context::new(config, ContextOptions::default());
1138 ctx.populate_time_vars();
1139
1140 let v = ctx.template_vars();
1141
1142 let date = v
1144 .get("Date")
1145 .unwrap_or_else(|| panic!("Date should be set"));
1146 assert!(
1147 date.contains('T') && date.len() > 10,
1148 "Date should be RFC 3339, got: {date}"
1149 );
1150
1151 let ts = v
1153 .get("Timestamp")
1154 .unwrap_or_else(|| panic!("Timestamp should be set"));
1155 assert!(
1156 ts.parse::<i64>().is_ok(),
1157 "Timestamp should be a numeric string, got: {ts}"
1158 );
1159
1160 let now = v.get("Now").unwrap_or_else(|| panic!("Now should be set"));
1162 assert!(now.contains('T'), "Now should be ISO 8601, got: {now}");
1163 }
1164
1165 #[test]
1166 fn test_env_vars_accessible_in_templates() {
1167 let mut config = Config::default();
1168 config.project_name = "myapp".to_string();
1169 let mut ctx = Context::new(config, ContextOptions::default());
1170 ctx.template_vars_mut().set_env("MY_VAR", "hello-world");
1171 ctx.template_vars_mut().set_env("DEPLOY_ENV", "staging");
1172
1173 let result = ctx
1174 .render_template("{{ .Env.MY_VAR }}-{{ .Env.DEPLOY_ENV }}")
1175 .unwrap();
1176 assert_eq!(result, "hello-world-staging");
1177 }
1178
1179 #[test]
1180 fn test_populate_git_vars_without_git_info_still_sets_snapshot() {
1181 let config = Config::default();
1182 let opts = ContextOptions {
1183 snapshot: true,
1184 ..Default::default()
1185 };
1186 let mut ctx = Context::new(config, opts);
1187 ctx.populate_git_vars();
1189
1190 assert_eq!(
1191 ctx.template_vars().get("IsSnapshot"),
1192 Some(&"true".to_string())
1193 );
1194 assert_eq!(
1195 ctx.template_vars().get("IsDraft"),
1196 Some(&"false".to_string())
1197 );
1198 assert_eq!(ctx.template_vars().get("Tag"), None);
1200 }
1201
1202 #[test]
1203 fn test_is_nightly_set_when_nightly_mode_active() {
1204 let config = Config::default();
1205 let opts = ContextOptions {
1206 nightly: true,
1207 ..Default::default()
1208 };
1209 let mut ctx = Context::new(config, opts);
1210 ctx.git_info = Some(make_git_info(false, None));
1211 ctx.populate_git_vars();
1212
1213 assert_eq!(
1214 ctx.template_vars().get("IsNightly"),
1215 Some(&"true".to_string()),
1216 "IsNightly should be 'true' when nightly mode is active"
1217 );
1218 assert!(ctx.is_nightly(), "is_nightly() should return true");
1219 }
1220
1221 #[test]
1222 fn test_is_nightly_false_by_default() {
1223 let config = Config::default();
1224 let mut ctx = Context::new(config, ContextOptions::default());
1225 ctx.git_info = Some(make_git_info(false, None));
1226 ctx.populate_git_vars();
1227
1228 assert_eq!(
1229 ctx.template_vars().get("IsNightly"),
1230 Some(&"false".to_string()),
1231 "IsNightly should default to 'false'"
1232 );
1233 assert!(
1234 !ctx.is_nightly(),
1235 "is_nightly() should return false by default"
1236 );
1237 }
1238
1239 #[test]
1240 fn test_version_returns_populated_value() {
1241 let config = Config::default();
1242 let mut ctx = Context::new(config, ContextOptions::default());
1243 ctx.git_info = Some(make_git_info(false, None));
1244 ctx.populate_git_vars();
1245
1246 assert_eq!(ctx.version(), "1.2.3");
1247 }
1248
1249 #[test]
1250 fn test_version_returns_empty_when_not_set() {
1251 let config = Config::default();
1252 let ctx = Context::new(config, ContextOptions::default());
1253 assert_eq!(ctx.version(), "");
1254 }
1255
1256 #[test]
1257 fn test_is_nightly_without_git_info() {
1258 let config = Config::default();
1259 let opts = ContextOptions {
1260 nightly: true,
1261 ..Default::default()
1262 };
1263 let mut ctx = Context::new(config, opts);
1264 ctx.populate_git_vars();
1266
1267 assert_eq!(
1268 ctx.template_vars().get("IsNightly"),
1269 Some(&"true".to_string()),
1270 "IsNightly should be set even without git info"
1271 );
1272 }
1273
1274 #[test]
1275 fn test_is_git_clean_when_not_dirty() {
1276 let config = Config::default();
1277 let mut ctx = Context::new(config, ContextOptions::default());
1278 ctx.git_info = Some(make_git_info(false, None));
1279 ctx.populate_git_vars();
1280
1281 assert_eq!(
1282 ctx.template_vars().get("IsGitClean"),
1283 Some(&"true".to_string())
1284 );
1285 }
1286
1287 #[test]
1288 fn test_is_git_clean_when_dirty() {
1289 let config = Config::default();
1290 let mut ctx = Context::new(config, ContextOptions::default());
1291 ctx.git_info = Some(make_git_info(true, None));
1292 ctx.populate_git_vars();
1293
1294 assert_eq!(
1295 ctx.template_vars().get("IsGitClean"),
1296 Some(&"false".to_string())
1297 );
1298 }
1299
1300 #[test]
1301 fn test_git_url_set_from_git_info() {
1302 let config = Config::default();
1303 let mut ctx = Context::new(config, ContextOptions::default());
1304 ctx.git_info = Some(make_git_info(false, None));
1305 ctx.populate_git_vars();
1306
1307 assert_eq!(
1308 ctx.template_vars().get("GitURL"),
1309 Some(&"https://github.com/test/repo.git".to_string())
1310 );
1311 }
1312
1313 #[test]
1314 fn test_summary_set_from_git_info() {
1315 let config = Config::default();
1316 let mut ctx = Context::new(config, ContextOptions::default());
1317 ctx.git_info = Some(make_git_info(false, None));
1318 ctx.populate_git_vars();
1319
1320 assert_eq!(
1321 ctx.template_vars().get("Summary"),
1322 Some(&"v1.2.3-0-gabc123d".to_string())
1323 );
1324 }
1325
1326 #[test]
1327 fn test_tag_subject_set_from_git_info() {
1328 let config = Config::default();
1329 let mut ctx = Context::new(config, ContextOptions::default());
1330 ctx.git_info = Some(make_git_info(false, None));
1331 ctx.populate_git_vars();
1332
1333 assert_eq!(
1334 ctx.template_vars().get("TagSubject"),
1335 Some(&"Release v1.2.3".to_string())
1336 );
1337 }
1338
1339 #[test]
1340 fn test_tag_contents_set_from_git_info() {
1341 let config = Config::default();
1342 let mut ctx = Context::new(config, ContextOptions::default());
1343 ctx.git_info = Some(make_git_info(false, None));
1344 ctx.populate_git_vars();
1345
1346 assert_eq!(
1347 ctx.template_vars().get("TagContents"),
1348 Some(&"Release v1.2.3\n\nFull release notes here.".to_string())
1349 );
1350 }
1351
1352 #[test]
1353 fn test_tag_body_set_from_git_info() {
1354 let config = Config::default();
1355 let mut ctx = Context::new(config, ContextOptions::default());
1356 ctx.git_info = Some(make_git_info(false, None));
1357 ctx.populate_git_vars();
1358
1359 assert_eq!(
1360 ctx.template_vars().get("TagBody"),
1361 Some(&"Full release notes here.".to_string())
1362 );
1363 }
1364
1365 #[test]
1366 fn test_is_single_target_false_by_default() {
1367 let config = Config::default();
1368 let mut ctx = Context::new(config, ContextOptions::default());
1369 ctx.git_info = Some(make_git_info(false, None));
1370 ctx.populate_git_vars();
1371
1372 assert_eq!(
1373 ctx.template_vars().get("IsSingleTarget"),
1374 Some(&"false".to_string())
1375 );
1376 }
1377
1378 #[test]
1379 fn test_is_single_target_true_when_set() {
1380 let config = Config::default();
1381 let opts = ContextOptions {
1382 single_target: Some("x86_64-unknown-linux-gnu".to_string()),
1383 ..Default::default()
1384 };
1385 let mut ctx = Context::new(config, opts);
1386 ctx.git_info = Some(make_git_info(false, None));
1387 ctx.populate_git_vars();
1388
1389 assert_eq!(
1390 ctx.template_vars().get("IsSingleTarget"),
1391 Some(&"true".to_string())
1392 );
1393 }
1394
1395 #[test]
1396 fn test_populate_runtime_vars() {
1397 let config = Config::default();
1398 let mut ctx = Context::new(config, ContextOptions::default());
1399 ctx.populate_runtime_vars();
1400
1401 let v = ctx.template_vars();
1402
1403 let goos = v
1404 .get("RuntimeGoos")
1405 .unwrap_or_else(|| panic!("RuntimeGoos should be set"));
1406 assert!(
1407 !goos.is_empty(),
1408 "RuntimeGoos should not be empty, got: {goos}"
1409 );
1410 assert_eq!(goos, map_os_to_goos(std::env::consts::OS));
1412
1413 let goarch = v
1414 .get("RuntimeGoarch")
1415 .unwrap_or_else(|| panic!("RuntimeGoarch should be set"));
1416 assert!(
1417 !goarch.is_empty(),
1418 "RuntimeGoarch should not be empty, got: {goarch}"
1419 );
1420 assert_eq!(goarch, map_arch_to_goarch(std::env::consts::ARCH));
1422 }
1423
1424 #[test]
1425 fn test_populate_release_notes_var_with_changelogs() {
1426 let mut config = Config::default();
1427 config.crates.push(crate::config::CrateConfig {
1428 name: "my-crate".to_string(),
1429 ..Default::default()
1430 });
1431 let mut ctx = Context::new(config, ContextOptions::default());
1432 ctx.stage_outputs
1433 .changelogs
1434 .insert("my-crate".to_string(), "## Changes\n- fix bug".to_string());
1435 ctx.populate_release_notes_var();
1436
1437 assert_eq!(
1438 ctx.template_vars().get("ReleaseNotes"),
1439 Some(&"## Changes\n- fix bug".to_string())
1440 );
1441 }
1442
1443 #[test]
1444 fn test_populate_release_notes_var_empty_when_no_changelogs() {
1445 let config = Config::default();
1446 let mut ctx = Context::new(config, ContextOptions::default());
1447 ctx.populate_release_notes_var();
1448
1449 assert_eq!(
1450 ctx.template_vars().get("ReleaseNotes"),
1451 Some(&"".to_string())
1452 );
1453 }
1454
1455 #[test]
1456 fn test_populate_release_notes_var_deterministic_with_multiple_crates() {
1457 let mut config = Config::default();
1458 config.crates.push(crate::config::CrateConfig {
1459 name: "crate-a".to_string(),
1460 ..Default::default()
1461 });
1462 config.crates.push(crate::config::CrateConfig {
1463 name: "crate-b".to_string(),
1464 ..Default::default()
1465 });
1466 let mut ctx = Context::new(config, ContextOptions::default());
1467 ctx.stage_outputs
1468 .changelogs
1469 .insert("crate-a".to_string(), "notes-a".to_string());
1470 ctx.stage_outputs
1471 .changelogs
1472 .insert("crate-b".to_string(), "notes-b".to_string());
1473 ctx.populate_release_notes_var();
1474
1475 assert_eq!(
1477 ctx.template_vars().get("ReleaseNotes"),
1478 Some(&"notes-a".to_string())
1479 );
1480 }
1481
1482 #[test]
1483 fn test_outputs_accessible_in_templates() {
1484 let mut config = Config::default();
1485 config.project_name = "myapp".to_string();
1486 let mut ctx = Context::new(config, ContextOptions::default());
1487 ctx.template_vars_mut().set_output("build_id", "abc123");
1488 ctx.template_vars_mut()
1489 .set_output("deploy_url", "https://example.com");
1490
1491 let result = ctx
1492 .render_template("{{ .Outputs.build_id }}-{{ .Outputs.deploy_url }}")
1493 .unwrap();
1494 assert_eq!(result, "abc123-https://example.com");
1495 }
1496
1497 #[test]
1498 fn test_artifact_ext_and_target_template_vars() {
1499 let mut config = Config::default();
1500 config.project_name = "myapp".to_string();
1501 let mut ctx = Context::new(config, ContextOptions::default());
1502 ctx.template_vars_mut().set("ArtifactName", "myapp.tar.gz");
1503 ctx.template_vars_mut().set("ArtifactExt", ".tar.gz");
1504 ctx.template_vars_mut()
1505 .set("Target", "x86_64-unknown-linux-gnu");
1506
1507 let result = ctx
1508 .render_template("{{ .ArtifactExt }}_{{ .Target }}")
1509 .unwrap();
1510 assert_eq!(result, ".tar.gz_x86_64-unknown-linux-gnu");
1511 }
1512
1513 #[test]
1514 fn test_checksums_template_var() {
1515 let mut config = Config::default();
1516 config.project_name = "myapp".to_string();
1517 let mut ctx = Context::new(config, ContextOptions::default());
1518 let checksum_text = "abc123 myapp.tar.gz\ndef456 myapp.zip\n";
1519 ctx.template_vars_mut().set("Checksums", checksum_text);
1520
1521 let result = ctx.render_template("{{ .Checksums }}").unwrap();
1522 assert_eq!(result, checksum_text);
1523 }
1524
1525 #[test]
1528 fn test_prefixed_tag_with_tag_prefix() {
1529 let mut config = Config::default();
1530 config.tag = Some(crate::config::TagConfig {
1531 tag_prefix: Some("api/".to_string()),
1532 ..Default::default()
1533 });
1534 let mut ctx = Context::new(config, ContextOptions::default());
1535 ctx.git_info = Some(make_git_info(false, None));
1536 ctx.populate_git_vars();
1537
1538 assert_eq!(
1539 ctx.template_vars().get("PrefixedTag"),
1540 Some(&"api/v1.2.3".to_string())
1541 );
1542 }
1543
1544 #[test]
1545 fn test_prefixed_tag_without_tag_prefix() {
1546 let config = Config::default();
1547 let mut ctx = Context::new(config, ContextOptions::default());
1548 ctx.git_info = Some(make_git_info(false, None));
1549 ctx.populate_git_vars();
1550
1551 assert_eq!(
1553 ctx.template_vars().get("PrefixedTag"),
1554 Some(&"v1.2.3".to_string())
1555 );
1556 }
1557
1558 #[test]
1559 fn test_prefixed_previous_tag_with_tag_prefix() {
1560 let mut config = Config::default();
1561 config.tag = Some(crate::config::TagConfig {
1562 tag_prefix: Some("api/".to_string()),
1563 ..Default::default()
1564 });
1565 let mut ctx = Context::new(config, ContextOptions::default());
1566 ctx.git_info = Some(make_git_info(false, None));
1567 ctx.populate_git_vars();
1568
1569 assert_eq!(
1570 ctx.template_vars().get("PrefixedPreviousTag"),
1571 Some(&"api/v1.2.2".to_string())
1572 );
1573 }
1574
1575 #[test]
1576 fn test_prefixed_previous_tag_empty_when_no_previous() {
1577 let mut config = Config::default();
1578 config.tag = Some(crate::config::TagConfig {
1579 tag_prefix: Some("api/".to_string()),
1580 ..Default::default()
1581 });
1582 let mut ctx = Context::new(config, ContextOptions::default());
1583 let mut info = make_git_info(false, None);
1584 info.previous_tag = None;
1585 ctx.git_info = Some(info);
1586 ctx.populate_git_vars();
1587
1588 assert_eq!(
1591 ctx.template_vars().get("PrefixedPreviousTag"),
1592 Some(&"".to_string())
1593 );
1594 }
1595
1596 #[test]
1597 fn test_prefixed_summary_with_tag_prefix() {
1598 let mut config = Config::default();
1599 config.tag = Some(crate::config::TagConfig {
1600 tag_prefix: Some("api/".to_string()),
1601 ..Default::default()
1602 });
1603 let mut ctx = Context::new(config, ContextOptions::default());
1604 ctx.git_info = Some(make_git_info(false, None));
1605 ctx.populate_git_vars();
1606
1607 assert_eq!(
1608 ctx.template_vars().get("PrefixedSummary"),
1609 Some(&"api/v1.2.3-0-gabc123d".to_string())
1610 );
1611 }
1612
1613 #[test]
1614 fn test_is_release_true_for_normal_release() {
1615 let config = Config::default();
1616 let opts = ContextOptions {
1617 snapshot: false,
1618 nightly: false,
1619 ..Default::default()
1620 };
1621 let mut ctx = Context::new(config, opts);
1622 ctx.git_info = Some(make_git_info(false, None));
1623 ctx.populate_git_vars();
1624
1625 assert_eq!(
1626 ctx.template_vars().get("IsRelease"),
1627 Some(&"true".to_string())
1628 );
1629 }
1630
1631 #[test]
1632 fn test_is_release_false_for_snapshot() {
1633 let config = Config::default();
1634 let opts = ContextOptions {
1635 snapshot: true,
1636 ..Default::default()
1637 };
1638 let mut ctx = Context::new(config, opts);
1639 ctx.git_info = Some(make_git_info(false, None));
1640 ctx.populate_git_vars();
1641
1642 assert_eq!(
1643 ctx.template_vars().get("IsRelease"),
1644 Some(&"false".to_string())
1645 );
1646 }
1647
1648 #[test]
1649 fn test_is_release_false_for_nightly() {
1650 let config = Config::default();
1651 let opts = ContextOptions {
1652 nightly: true,
1653 ..Default::default()
1654 };
1655 let mut ctx = Context::new(config, opts);
1656 ctx.git_info = Some(make_git_info(false, None));
1657 ctx.populate_git_vars();
1658
1659 assert_eq!(
1660 ctx.template_vars().get("IsRelease"),
1661 Some(&"false".to_string())
1662 );
1663 }
1664
1665 #[test]
1666 fn test_is_merging_true_when_merge_flag_set() {
1667 let config = Config::default();
1668 let opts = ContextOptions {
1669 merge: true,
1670 ..Default::default()
1671 };
1672 let mut ctx = Context::new(config, opts);
1673 ctx.git_info = Some(make_git_info(false, None));
1674 ctx.populate_git_vars();
1675
1676 assert_eq!(
1677 ctx.template_vars().get("IsMerging"),
1678 Some(&"true".to_string())
1679 );
1680 }
1681
1682 #[test]
1683 fn test_is_merging_false_by_default() {
1684 let config = Config::default();
1685 let mut ctx = Context::new(config, ContextOptions::default());
1686 ctx.git_info = Some(make_git_info(false, None));
1687 ctx.populate_git_vars();
1688
1689 assert_eq!(
1690 ctx.template_vars().get("IsMerging"),
1691 Some(&"false".to_string())
1692 );
1693 }
1694
1695 #[test]
1696 fn test_refresh_artifacts_var_empty() {
1697 let config = Config::default();
1698 let mut ctx = Context::new(config, ContextOptions::default());
1699 ctx.refresh_artifacts_var();
1700
1701 let result = ctx
1703 .render_template("{% for a in Artifacts %}{{ a.name }}{% endfor %}")
1704 .unwrap();
1705 assert_eq!(result, "");
1706 }
1707
1708 #[test]
1709 fn test_refresh_artifacts_var_with_artifacts() {
1710 use crate::artifact::{Artifact, ArtifactKind};
1711 use std::collections::HashMap;
1712 use std::path::PathBuf;
1713
1714 let config = Config::default();
1715 let mut ctx = Context::new(config, ContextOptions::default());
1716 ctx.artifacts.add(Artifact {
1720 kind: ArtifactKind::Archive,
1721 name: String::new(),
1722 path: PathBuf::from("dist/myapp-1.0.0-linux-amd64.tar.gz"),
1723 target: Some("x86_64-unknown-linux-gnu".to_string()),
1724 crate_name: "myapp".to_string(),
1725 metadata: HashMap::from([("format".to_string(), "tar.gz".to_string())]),
1726 size: None,
1727 });
1728 ctx.artifacts.add(Artifact {
1729 kind: ArtifactKind::Binary,
1730 name: String::new(),
1731 path: PathBuf::from("dist/myapp"),
1732 target: Some("x86_64-unknown-linux-gnu".to_string()),
1733 crate_name: "myapp".to_string(),
1734 metadata: HashMap::new(),
1735 size: None,
1736 });
1737 ctx.refresh_artifacts_var();
1738
1739 let result = ctx
1741 .render_template("{% for a in Artifacts %}{{ a.name }},{% endfor %}")
1742 .unwrap();
1743 assert!(result.contains("myapp-1.0.0-linux-amd64.tar.gz"));
1744 assert!(result.contains("myapp"));
1745
1746 let result_kinds = ctx
1748 .render_template("{% for a in Artifacts %}{{ a.kind }},{% endfor %}")
1749 .unwrap();
1750 assert!(result_kinds.contains("archive"));
1751 assert!(result_kinds.contains("binary"));
1752 }
1753
1754 #[test]
1755 fn test_populate_metadata_var_with_mod_timestamp() {
1756 let mut config = Config::default();
1757 config.metadata = Some(crate::config::MetadataConfig {
1758 mod_timestamp: Some("{{ .CommitTimestamp }}".to_string()),
1759 ..Default::default()
1760 });
1761 let mut ctx = Context::new(config, ContextOptions::default());
1762 ctx.populate_metadata_var().unwrap();
1763
1764 let result = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
1766 assert_eq!(result, "{{ .CommitTimestamp }}");
1767 }
1768
1769 #[test]
1770 fn test_populate_metadata_var_empty_when_no_config() {
1771 let config = Config::default();
1772 let mut ctx = Context::new(config, ContextOptions::default());
1773 ctx.populate_metadata_var().unwrap();
1774
1775 let result = ctx.render_template("{{ Metadata.Description }}").unwrap();
1777 assert_eq!(result, "");
1778 }
1779
1780 #[test]
1781 fn test_populate_metadata_var_reads_from_config() {
1782 let mut config = Config::default();
1783 config.metadata = Some(crate::config::MetadataConfig {
1784 description: Some("A test project".to_string()),
1785 homepage: Some("https://example.com".to_string()),
1786 license: Some("MIT".to_string()),
1787 maintainers: Some(vec!["Alice".to_string(), "Bob".to_string()]),
1788 mod_timestamp: Some("1234567890".to_string()),
1789 ..Default::default()
1790 });
1791 let mut ctx = Context::new(config, ContextOptions::default());
1792 ctx.populate_metadata_var().unwrap();
1793
1794 let desc = ctx.render_template("{{ Metadata.Description }}").unwrap();
1795 assert_eq!(desc, "A test project");
1796
1797 let home = ctx.render_template("{{ Metadata.Homepage }}").unwrap();
1798 assert_eq!(home, "https://example.com");
1799
1800 let lic = ctx.render_template("{{ Metadata.License }}").unwrap();
1801 assert_eq!(lic, "MIT");
1802
1803 let ts = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
1804 assert_eq!(ts, "1234567890");
1805 }
1806
1807 #[test]
1808 fn test_populate_metadata_var_full_description_inline() {
1809 use crate::config::ContentSource;
1810 let mut config = Config::default();
1811 config.metadata = Some(crate::config::MetadataConfig {
1812 full_description: Some(ContentSource::Inline(
1813 "A long-form description of the project.".to_string(),
1814 )),
1815 ..Default::default()
1816 });
1817 let mut ctx = Context::new(config, ContextOptions::default());
1818 ctx.populate_metadata_var().unwrap();
1819 let rendered = ctx
1820 .render_template("{{ Metadata.FullDescription }}")
1821 .unwrap();
1822 assert_eq!(rendered, "A long-form description of the project.");
1823 }
1824
1825 #[test]
1826 fn test_populate_metadata_var_full_description_from_file() {
1827 use crate::config::ContentSource;
1828 let tmp = tempfile::tempdir().unwrap();
1829 let desc_path = tmp.path().join("DESCRIPTION.md");
1830 std::fs::write(&desc_path, "read from disk").unwrap();
1831 let mut config = Config::default();
1832 config.metadata = Some(crate::config::MetadataConfig {
1833 full_description: Some(ContentSource::FromFile {
1834 from_file: desc_path.to_string_lossy().into_owned(),
1835 }),
1836 ..Default::default()
1837 });
1838 let mut ctx = Context::new(config, ContextOptions::default());
1839 ctx.populate_metadata_var().unwrap();
1840 let rendered = ctx
1841 .render_template("{{ Metadata.FullDescription }}")
1842 .unwrap();
1843 assert_eq!(rendered, "read from disk");
1844 }
1845
1846 #[test]
1847 fn test_populate_metadata_var_full_description_from_url_errors() {
1848 use crate::config::ContentSource;
1852 let mut config = Config::default();
1853 config.metadata = Some(crate::config::MetadataConfig {
1854 full_description: Some(ContentSource::FromUrl {
1855 from_url: "https://example.com/description.md".to_string(),
1856 headers: None,
1857 }),
1858 ..Default::default()
1859 });
1860 let mut ctx = Context::new(config, ContextOptions::default());
1861 let err = ctx
1862 .populate_metadata_var()
1863 .expect_err("from_url must error");
1864 let msg = format!("{:#}", err);
1865 assert!(
1866 msg.contains("metadata.full_description") && msg.contains("from_url"),
1867 "error should mention the feature + limitation, got: {msg}"
1868 );
1869 }
1870
1871 #[test]
1872 fn test_populate_metadata_var_commit_author() {
1873 use crate::config::CommitAuthorConfig;
1874 let mut config = Config::default();
1875 config.metadata = Some(crate::config::MetadataConfig {
1876 commit_author: Some(CommitAuthorConfig {
1877 name: Some("Alice Developer".to_string()),
1878 email: Some("alice@example.com".to_string()),
1879 signing: None,
1880 use_github_app_token: false,
1881 }),
1882 ..Default::default()
1883 });
1884 let mut ctx = Context::new(config, ContextOptions::default());
1885 ctx.populate_metadata_var().unwrap();
1886 let name = ctx
1887 .render_template("{{ Metadata.CommitAuthor.Name }}")
1888 .unwrap();
1889 assert_eq!(name, "Alice Developer");
1890 let email = ctx
1891 .render_template("{{ Metadata.CommitAuthor.Email }}")
1892 .unwrap();
1893 assert_eq!(email, "alice@example.com");
1894 }
1895
1896 #[test]
1897 fn test_artifact_id_template_var() {
1898 let mut config = Config::default();
1899 config.project_name = "myapp".to_string();
1900 let mut ctx = Context::new(config, ContextOptions::default());
1901 ctx.template_vars_mut().set("ArtifactID", "default");
1902
1903 let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
1904 assert_eq!(result, "default");
1905 }
1906
1907 #[test]
1908 fn test_artifact_id_empty_when_not_set() {
1909 let mut config = Config::default();
1910 config.project_name = "myapp".to_string();
1911 let mut ctx = Context::new(config, ContextOptions::default());
1912 ctx.template_vars_mut().set("ArtifactID", "");
1913
1914 let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
1915 assert_eq!(result, "");
1916 }
1917
1918 #[test]
1919 fn test_pro_vars_rendered_in_templates() {
1920 let mut config = Config::default();
1922 config.tag = Some(crate::config::TagConfig {
1923 tag_prefix: Some("api/".to_string()),
1924 ..Default::default()
1925 });
1926 let opts = ContextOptions {
1927 snapshot: false,
1928 nightly: false,
1929 merge: true,
1930 ..Default::default()
1931 };
1932 let mut ctx = Context::new(config, opts);
1933 ctx.git_info = Some(make_git_info(false, None));
1934 ctx.populate_git_vars();
1935
1936 let result = ctx
1937 .render_template(
1938 "{% if IsRelease %}release{% endif %}-{% if IsMerging %}merge{% endif %}-{{ .PrefixedTag }}",
1939 )
1940 .unwrap();
1941 assert_eq!(result, "release-merge-api/v1.2.3");
1942 }
1943
1944 #[test]
1945 fn test_is_release_without_git_info() {
1946 let config = Config::default();
1948 let opts = ContextOptions {
1949 snapshot: false,
1950 nightly: false,
1951 ..Default::default()
1952 };
1953 let mut ctx = Context::new(config, opts);
1954 ctx.populate_git_vars();
1955
1956 assert_eq!(
1957 ctx.template_vars().get("IsRelease"),
1958 Some(&"true".to_string())
1959 );
1960 }
1961
1962 #[test]
1963 fn test_is_merging_without_git_info() {
1964 let config = Config::default();
1966 let opts = ContextOptions {
1967 merge: true,
1968 ..Default::default()
1969 };
1970 let mut ctx = Context::new(config, opts);
1971 ctx.populate_git_vars();
1972
1973 assert_eq!(
1974 ctx.template_vars().get("IsMerging"),
1975 Some(&"true".to_string())
1976 );
1977 }
1978
1979 #[test]
1984 fn test_monorepo_tag_prefix_strips_tag_for_template_var() {
1985 let mut config = Config::default();
1986 config.monorepo = Some(crate::config::MonorepoConfig {
1987 tag_prefix: Some("subproject1/".to_string()),
1988 dir: None,
1989 });
1990 let mut ctx = Context::new(config, ContextOptions::default());
1991
1992 let mut info = make_git_info(false, None);
1994 info.tag = "subproject1/v1.2.3".to_string();
1995 info.previous_tag = Some("subproject1/v1.2.2".to_string());
1996 info.summary = "subproject1/v1.2.3-0-gabc123d".to_string();
1997 ctx.git_info = Some(info);
1998 ctx.populate_git_vars();
1999
2000 let v = ctx.template_vars();
2001 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2003 assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
2005 assert_eq!(
2007 v.get("PrefixedTag"),
2008 Some(&"subproject1/v1.2.3".to_string())
2009 );
2010 assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
2012 assert_eq!(
2014 v.get("PrefixedPreviousTag"),
2015 Some(&"subproject1/v1.2.2".to_string())
2016 );
2017 assert_eq!(v.get("Summary"), Some(&"v1.2.3-0-gabc123d".to_string()));
2019 assert_eq!(
2021 v.get("PrefixedSummary"),
2022 Some(&"subproject1/v1.2.3-0-gabc123d".to_string())
2023 );
2024 }
2025
2026 #[test]
2027 fn test_monorepo_prefixed_previous_tag() {
2028 let mut config = Config::default();
2029 config.monorepo = Some(crate::config::MonorepoConfig {
2030 tag_prefix: Some("svc/".to_string()),
2031 dir: None,
2032 });
2033 let mut ctx = Context::new(config, ContextOptions::default());
2034
2035 let mut info = make_git_info(false, None);
2036 info.tag = "svc/v2.0.0".to_string();
2037 info.previous_tag = Some("svc/v1.9.0".to_string());
2038 ctx.git_info = Some(info);
2039 ctx.populate_git_vars();
2040
2041 let v = ctx.template_vars();
2042 assert_eq!(
2044 v.get("PrefixedPreviousTag"),
2045 Some(&"svc/v1.9.0".to_string())
2046 );
2047 assert_eq!(v.get("PreviousTag"), Some(&"v1.9.0".to_string()));
2049 }
2050
2051 #[test]
2052 fn test_no_monorepo_falls_back_to_tag_prefix() {
2053 let mut config = Config::default();
2055 config.tag = Some(crate::config::TagConfig {
2056 tag_prefix: Some("release/".to_string()),
2057 ..Default::default()
2058 });
2059 let mut ctx = Context::new(config, ContextOptions::default());
2060 ctx.git_info = Some(make_git_info(false, None));
2061 ctx.populate_git_vars();
2062
2063 let v = ctx.template_vars();
2064 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2066 assert_eq!(v.get("PrefixedTag"), Some(&"release/v1.2.3".to_string()));
2068 assert_eq!(
2069 v.get("PrefixedPreviousTag"),
2070 Some(&"release/v1.2.2".to_string())
2071 );
2072 }
2073
2074 #[test]
2075 fn test_monorepo_overrides_tag_prefix_for_prefixed_vars() {
2076 let mut config = Config::default();
2079 config.tag = Some(crate::config::TagConfig {
2080 tag_prefix: Some("release/".to_string()),
2081 ..Default::default()
2082 });
2083 config.monorepo = Some(crate::config::MonorepoConfig {
2084 tag_prefix: Some("svc/".to_string()),
2085 dir: None,
2086 });
2087 let mut ctx = Context::new(config, ContextOptions::default());
2088
2089 let mut info = make_git_info(false, None);
2090 info.tag = "svc/v1.2.3".to_string();
2091 info.previous_tag = Some("svc/v1.2.2".to_string());
2092 ctx.git_info = Some(info);
2093 ctx.populate_git_vars();
2094
2095 let v = ctx.template_vars();
2096 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2098 assert_eq!(v.get("PrefixedTag"), Some(&"svc/v1.2.3".to_string()));
2100 }
2101
2102 #[test]
2103 fn test_monorepo_prefixed_summary() {
2104 let mut config = Config::default();
2105 config.monorepo = Some(crate::config::MonorepoConfig {
2106 tag_prefix: Some("pkg/".to_string()),
2107 dir: None,
2108 });
2109 let mut ctx = Context::new(config, ContextOptions::default());
2110
2111 let mut info = make_git_info(false, None);
2112 info.tag = "pkg/v1.2.3".to_string();
2113 info.summary = "pkg/v1.2.3-0-gabc123d".to_string();
2115 ctx.git_info = Some(info);
2116 ctx.populate_git_vars();
2117
2118 assert_eq!(
2120 ctx.template_vars().get("PrefixedSummary"),
2121 Some(&"pkg/v1.2.3-0-gabc123d".to_string())
2122 );
2123 assert_eq!(
2125 ctx.template_vars().get("Summary"),
2126 Some(&"v1.2.3-0-gabc123d".to_string())
2127 );
2128 }
2129
2130 #[test]
2131 fn test_monorepo_no_previous_tag() {
2132 let mut config = Config::default();
2133 config.monorepo = Some(crate::config::MonorepoConfig {
2134 tag_prefix: Some("svc/".to_string()),
2135 dir: None,
2136 });
2137 let mut ctx = Context::new(config, ContextOptions::default());
2138
2139 let mut info = make_git_info(false, None);
2140 info.tag = "svc/v1.0.0".to_string();
2141 info.previous_tag = None;
2142 ctx.git_info = Some(info);
2143 ctx.populate_git_vars();
2144
2145 let v = ctx.template_vars();
2146 assert_eq!(v.get("PrefixedPreviousTag"), Some(&"".to_string()));
2147 assert_eq!(v.get("PreviousTag"), Some(&"".to_string()));
2149 }
2150
2151 #[test]
2156 fn test_monorepo_full_flow_all_vars() {
2157 let mut config = Config::default();
2160 config.project_name = "mymonorepo".to_string();
2161 config.monorepo = Some(crate::config::MonorepoConfig {
2162 tag_prefix: Some("services/api/".to_string()),
2163 dir: Some("services/api".to_string()),
2164 });
2165
2166 assert_eq!(config.monorepo_tag_prefix(), Some("services/api/"));
2168 assert_eq!(config.monorepo_dir(), Some("services/api"));
2169
2170 let mut ctx = Context::new(config, ContextOptions::default());
2171
2172 let mut info = make_git_info(false, None);
2175 info.tag = "services/api/v2.1.0".to_string();
2176 info.previous_tag = Some("services/api/v2.0.5".to_string());
2177 info.summary = "services/api/v2.1.0-0-gabc123d".to_string();
2178 info.semver = crate::git::SemVer {
2179 major: 2,
2180 minor: 1,
2181 patch: 0,
2182 prerelease: None,
2183 build_metadata: None,
2184 };
2185 ctx.git_info = Some(info);
2186 ctx.populate_git_vars();
2187
2188 let v = ctx.template_vars();
2189
2190 assert_eq!(v.get("Tag"), Some(&"v2.1.0".to_string()));
2192 assert_eq!(v.get("Version"), Some(&"2.1.0".to_string()));
2193 assert_eq!(v.get("RawVersion"), Some(&"2.1.0".to_string()));
2194 assert_eq!(v.get("Major"), Some(&"2".to_string()));
2195 assert_eq!(v.get("Minor"), Some(&"1".to_string()));
2196 assert_eq!(v.get("Patch"), Some(&"0".to_string()));
2197 assert_eq!(v.get("PreviousTag"), Some(&"v2.0.5".to_string()));
2198 assert_eq!(v.get("Summary"), Some(&"v2.1.0-0-gabc123d".to_string()));
2199
2200 assert_eq!(
2202 v.get("PrefixedTag"),
2203 Some(&"services/api/v2.1.0".to_string())
2204 );
2205 assert_eq!(
2206 v.get("PrefixedPreviousTag"),
2207 Some(&"services/api/v2.0.5".to_string())
2208 );
2209 assert_eq!(
2210 v.get("PrefixedSummary"),
2211 Some(&"services/api/v2.1.0-0-gabc123d".to_string())
2212 );
2213
2214 assert_eq!(v.get("ProjectName"), Some(&"mymonorepo".to_string()));
2216 }
2217}