Skip to main content

anodizer_core/
context.rs

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
13/// Valid --skip values for the `release` command (matches GoReleaser).
14///
15/// Publisher skip names use the short canonical form (matching the CLI binary
16/// name and GoReleaser convention): `brew`, `choco`, `krew`, `cargo`, etc.
17/// Long aliases (e.g. `homebrew`, `chocolatey`) are NOT accepted — DEC-5 forbids
18/// aliases; use the short name everywhere (FOLL-1).
19pub 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
58/// Valid --skip values for the `build` command.
59pub const VALID_BUILD_SKIPS: &[&str] = &["pre-hooks", "post-hooks", "validate", "before"];
60
61/// Validate that all skip values are in the allowed set.
62///
63/// Returns `Ok(())` if all values are valid, or `Err` with a descriptive
64/// message listing the invalid value(s) and the full set of valid options.
65pub 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    /// Maximum number of parallel build jobs (minimum 1).
93    pub parallelism: usize,
94    /// When set, build only for this single host target triple.
95    pub single_target: Option<String>,
96    /// Path to a custom release notes file (overrides changelog).
97    pub release_notes_path: Option<PathBuf>,
98    /// When true, abort immediately on first error during publishing.
99    pub fail_fast: bool,
100    /// Partial build target for split/merge mode. When set, the build stage
101    /// filters targets to only those matching this partial target.
102    pub partial_target: Option<PartialTarget>,
103    /// When true, running with `--merge` flag (merging artifacts from split builds).
104    pub merge: bool,
105    /// Explicit project root directory. When set, stages use this instead of
106    /// discovering the repo root via `git rev-parse --show-toplevel`.
107    pub project_root: Option<PathBuf>,
108    /// Strict mode: configured features that would silently skip become errors.
109    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/// Stage→stage handoff state produced by stages and consumed by later
137/// stages (as opposed to `config` / `options` which are pipeline inputs,
138/// or `artifacts` which has its own registry). Closes the F·3 deferral
139/// (see `.claude/plans/archive/...`): the changelog stage writes here,
140/// the release stage reads here.
141#[derive(Debug, Default)]
142pub struct StageOutputs {
143    /// Set by the changelog stage when `use: github-native` is configured.
144    /// The release stage reads this to set `generate_release_notes(true)`
145    /// on the GitHub API.
146    pub github_native_changelog: bool,
147    /// Per-crate rendered changelog body, keyed by crate name.
148    pub changelogs: HashMap<String, String>,
149    /// Rendered `changelog.header` value, populated by the changelog stage.
150    /// The release stage uses it as a fallback when `release.header` is
151    /// unset so YAML-configured changelog headers reach the GitHub release
152    /// body (matching GoReleaser's `loadContent(ReleaseHeader…)` behaviour).
153    pub changelog_header: Option<String>,
154    /// Rendered `changelog.footer` value, populated by the changelog stage.
155    /// Same fallback semantics as `changelog_header`.
156    pub changelog_footer: Option<String>,
157}
158
159pub struct Context {
160    pub config: Config,
161    pub artifacts: ArtifactRegistry,
162    pub options: ContextOptions,
163    /// Stage→stage handoff outputs (changelog text, header/footer, etc.).
164    pub stage_outputs: StageOutputs,
165    template_vars: TemplateVars,
166    pub git_info: Option<GitInfo>,
167    /// The resolved SCM token type (GitHub, GitLab, or Gitea).
168    pub token_type: ScmTokenType,
169    /// Aggregated skips from per-sub-config loops (signs, docker_signs,
170    /// publishers, …). Drained by the pipeline runner at end-of-pipeline so
171    /// the summary shows what was intentionally skipped — mirroring
172    /// GoReleaser's `pipe.SkipMemento` pattern. The inner `Arc<Mutex<…>>`
173    /// lets parallel stage workers contribute without extra plumbing.
174    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    /// Record an intentional skip from a per-sub-config loop
194    /// (`signs`, `docker_signs`, `publishers`, …). `stage` identifies the
195    /// owning stage, `label` identifies the sub-config (id / name / index),
196    /// `reason` is short user-facing text. Duplicate (stage, label, reason)
197    /// tuples are dropped on insert so a per-artifact inner loop cannot emit
198    /// N copies of the same skip message.
199    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    /// Render a template if present, returning `None` for `None` input.
216    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    /// Evaluate a `skip` field, logging at INFO level when it resolves to true.
221    ///
222    /// Returns `Ok(false)` when `skip` is `None` or evaluates falsy. On
223    /// truthy, writes `"{label} skipped"` via `log.status` and returns
224    /// `Ok(true)`. A malformed `skip:` template propagates as `Err` so the
225    /// caller fails fast — silently treating a render error as "not skipped"
226    /// (the prior behavior) shipped configs that the user thought would
227    /// suppress a stage but actually ran it.
228    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    /// Check whether "validate" is in the skip list.
251    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    /// In strict mode, return an error. In normal mode, log a warning and continue.
268    /// Use this for any situation where a configured feature silently skips.
269    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    /// Defense-in-depth helper for upload-style stages.
278    ///
279    /// Returns `true` (after logging the skip) when the context is in snapshot
280    /// mode. Stages that perform external uploads (registries, package indexes,
281    /// object storage, snap store, …) call this at entry so they no-op even
282    /// when invoked directly without the orchestration layer's auto-skip.
283    /// Centralising the check keeps every publish stage consistent and avoids
284    /// per-stage copy-paste.
285    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    /// Render a template, failing in strict mode on error, or falling back to the raw string.
295    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    /// Set the `ReleaseURL` template variable.
318    ///
319    /// Should be called after a GitHub release is created, with the URL of
320    /// the created release (e.g. `https://github.com/owner/repo/releases/tag/v1.0.0`).
321    pub fn set_release_url(&mut self, url: &str) {
322        self.template_vars.set("ReleaseURL", url);
323    }
324
325    /// Return the current `Version` template variable, or an empty string if
326    /// not yet populated.
327    pub fn version(&self) -> String {
328        self.template_vars
329            .get("Version")
330            .cloned()
331            .unwrap_or_default()
332    }
333
334    /// Derive the verbosity level from context options.
335    pub fn verbosity(&self) -> Verbosity {
336        Verbosity::from_flags(self.options.quiet, self.options.verbose, self.options.debug)
337    }
338
339    /// Resolve the user's `retry:` block into a concrete [`RetryPolicy`],
340    /// applying defaults when `retry:` is unset. Equivalent to
341    /// `ctx.config.retry.unwrap_or_default().to_policy()` but centralizes
342    /// the lookup so a future refactor can hang validation / clamping off
343    /// a single seam.
344    pub fn retry_policy(&self) -> crate::retry::RetryPolicy {
345        self.config.retry.unwrap_or_default().to_policy()
346    }
347
348    /// Create a [`StageLogger`] for the given stage name, pre-attached to
349    /// the context's env-pairs list so that subprocess stderr / stdout
350    /// flowing through [`StageLogger::check_output`] is automatically
351    /// redacted. The env list combines the template-engine env
352    /// (process + config + `.env` files) and the current `std::env::vars`
353    /// snapshot, so any secret value reachable to a hook or subprocess is
354    /// available for scrubbing.
355    pub fn logger(&self, stage: &'static str) -> StageLogger {
356        StageLogger::new(stage, self.verbosity()).with_env(self.env_for_redact())
357    }
358
359    /// Build the env-pairs list used to seed every [`StageLogger`] created
360    /// via [`Context::logger`]. Combines the template-engine env map
361    /// (process env + config env + `.env` file values) with the current
362    /// `std::env::vars` snapshot, deduplicating by key (template-engine
363    /// values win because they reflect any user overrides).
364    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    /// Populate template variables from `self.git_info`.
374    ///
375    /// Must be called after `self.git_info` is set. Sets the following vars:
376    /// - `Tag`, `Version`, `RawVersion` — tag and version strings
377    /// - `Major`, `Minor`, `Patch` — semver components
378    /// - `Prerelease` — prerelease suffix (or empty)
379    /// - `BuildMetadata` — build metadata from semver tag (or empty)
380    /// - `FullCommit`, `Commit` — full commit SHA (`Commit` is alias for `FullCommit`)
381    /// - `ShortCommit` — abbreviated commit SHA
382    /// - `Branch` — current git branch
383    /// - `CommitDate` — ISO 8601 author date of HEAD commit
384    /// - `CommitTimestamp` — unix timestamp of HEAD commit
385    /// - `IsGitDirty` — "true"/"false"
386    /// - `IsGitClean` — "true"/"false" (inverse of `IsGitDirty`)
387    /// - `GitTreeState` — "clean"/"dirty"
388    /// - `GitURL` — git remote URL
389    /// - `Summary` — git describe summary
390    /// - `TagSubject` — annotated tag subject or commit subject
391    /// - `TagContents` — full annotated tag message or commit message
392    /// - `TagBody` — tag message body or commit message body
393    /// - `IsSnapshot` — from context options
394    /// - `IsNightly` — from context options
395    /// - `IsDraft` — "false" (stages may override to "true")
396    /// - `IsSingleTarget` — "true"/"false" based on single_target option
397    /// - `PreviousTag` — previous matching tag, stripped in monorepo mode (or empty)
398    /// - `PrefixedTag` — full tag with monorepo prefix, or tag_prefix-prepended (Pro addition)
399    /// - `PrefixedPreviousTag` — full previous tag with prefix (Pro addition)
400    /// - `PrefixedSummary` — full summary with prefix (Pro addition)
401    /// - `IsRelease` — "true" if not snapshot and not nightly (Pro addition)
402    /// - `IsMerging` — "true" if running with --merge flag (Pro addition)
403    ///
404    /// **Stage-scoped variables** (NOT set here; set per-artifact during stage execution):
405    /// - `Binary` — binary name, set by build stage per binary and archive stage per archive
406    /// - `ArtifactName` — output artifact filename, set by archive stage after creating each archive
407    /// - `ArtifactPath` — absolute path to artifact, set by archive stage after creating each archive
408    /// - `ArtifactExt` — artifact file extension (e.g. `.tar.gz`, `.exe`), set alongside ArtifactName
409    /// - `ArtifactID` — build config `id` field, set by build stage per build config
410    /// - `Os` — target OS, set by archive/nfpm stages per target
411    /// - `Arch` — target architecture, set by archive/nfpm stages per target
412    /// - `Target` — full target triple (e.g. `x86_64-unknown-linux-gnu`), set alongside Os/Arch
413    /// - `Checksums` — combined checksum file contents, set by checksum stage
414    pub fn populate_git_vars(&mut self) {
415        if let Some(ref info) = self.git_info {
416            // RawVersion: just major.minor.patch, no prerelease or build metadata.
417            let raw_version = format!(
418                "{}.{}.{}",
419                info.semver.major, info.semver.minor, info.semver.patch
420            );
421
422            // Version: clean semver derived from the parsed SemVer struct, not
423            // from the tag string.  The old `tag.strip_prefix('v')` approach
424            // broke for monorepo workspace tags like `core-v0.3.2` because it
425            // only stripped a leading 'v', leaving `core-v0.3.2` intact.
426            // Deriving from the struct handles all tag_template prefixes.
427            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            // Pro additions: PrefixedTag, PrefixedPreviousTag, PrefixedSummary
478            //
479            // When monorepo.tag_prefix is configured, the git tag already
480            // contains the prefix (e.g. "subproject1/v1.2.3"). In this case:
481            //   - Tag = prefix stripped (e.g. "v1.2.3")
482            //   - PrefixedTag = full tag (e.g. "subproject1/v1.2.3")
483            //   - PrefixedPreviousTag = full previous tag
484            //
485            // When monorepo is NOT configured, fall back to the original
486            // behavior: prepend tag.tag_prefix to construct PrefixedTag.
487            let monorepo_prefix = self.config.monorepo_tag_prefix();
488
489            // monorepo.tag_prefix takes precedence over tag.tag_prefix for
490            // PrefixedTag / PrefixedPreviousTag / PrefixedSummary behavior.
491            // When monorepo is configured, info.tag and info.summary already
492            // contain the prefix from git, so we strip for the base vars and
493            // use the raw values for the Prefixed variants.
494            if let Some(prefix) = monorepo_prefix {
495                // Monorepo mode: the tag in git_info is the FULL prefixed tag.
496                // PrefixedTag = full tag (already has prefix).
497                self.template_vars.set("PrefixedTag", &info.tag);
498
499                // Tag = prefix stripped. Override the Tag we set above.
500                let stripped_tag = crate::git::strip_monorepo_prefix(&info.tag, prefix);
501                self.template_vars.set("Tag", stripped_tag);
502
503                // Version: derive from the stripped tag (overrides the initial
504                // value set above from info.tag, which in monorepo mode still
505                // contains the prefix).
506                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                // PrefixedPreviousTag = full previous tag (already has prefix).
513                let prev_tag = info.previous_tag.as_deref().unwrap_or("");
514                self.template_vars.set("PrefixedPreviousTag", prev_tag);
515
516                // PreviousTag = prefix stripped, consistent with Tag being stripped.
517                let stripped_prev = crate::git::strip_monorepo_prefix(prev_tag, prefix);
518                self.template_vars.set("PreviousTag", stripped_prev);
519
520                // PrefixedSummary: info.summary from `git describe` already
521                // includes the monorepo prefix (e.g. "subproject1/v1.2.3-0-gabc123d"),
522                // so use it as-is for the prefixed variant.
523                self.template_vars.set("PrefixedSummary", &info.summary);
524                // Summary: strip the monorepo prefix for the base variant.
525                let stripped_summary = crate::git::strip_monorepo_prefix(&info.summary, prefix);
526                self.template_vars.set("Summary", stripped_summary);
527            } else {
528                // Non-monorepo: prepend tag.tag_prefix to construct PrefixedTag.
529                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        // Wire IsDraft from config (GoReleaser reads ctx.Config.Release.Draft).
569        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        // Pro addition: IsRelease — true if this is a regular release (not snapshot, not nightly).
587        let is_release = !self.options.snapshot && !self.options.nightly;
588        self.template_vars
589            .set("IsRelease", if is_release { "true" } else { "false" });
590
591        // Pro addition: IsMerging — true if running with --merge flag.
592        self.template_vars.set(
593            "IsMerging",
594            if self.options.merge { "true" } else { "false" },
595        );
596    }
597
598    /// Populate time-related template variables using the current UTC time.
599    ///
600    /// Sets:
601    /// - `Date` — current UTC time as RFC 3339
602    /// - `Timestamp` — current unix timestamp as string
603    /// - `Now` — current UTC time as RFC 3339
604    /// - `Year` — four-digit year (e.g. "2026")
605    /// - `Month` — zero-padded month (e.g. "03")
606    /// - `Day` — zero-padded day (e.g. "30")
607    /// - `Hour` — zero-padded hour (e.g. "14")
608    /// - `Minute` — zero-padded minute (e.g. "05")
609    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    /// Populate runtime environment variables.
627    ///
628    /// Sets:
629    /// - `RuntimeGoos` — host OS in Go-compatible naming (e.g. "linux", "darwin", "windows")
630    /// - `RuntimeGoarch` — host architecture in Go-compatible naming (e.g. "amd64", "arm64")
631    /// - `Runtime_Goos` / `Runtime_Goarch` — GoReleaser-compatible nested aliases
632    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        // GoReleaser uses Runtime.Goos / Runtime.Goarch — after preprocessing
638        // the dot becomes an underscore-separated flat key. We expose both forms.
639        self.template_vars.set("Runtime_Goos", goos);
640        self.template_vars.set("Runtime_Goarch", goarch);
641    }
642
643    /// Populate the `ReleaseNotes` template variable from stored changelogs.
644    ///
645    /// Should be called after the changelog stage has run and populated
646    /// `self.stage_outputs.changelogs`. Uses the first crate (by config
647    /// order) whose changelog is present, or an empty string if no
648    /// changelogs exist. Config order is deterministic, unlike HashMap
649    /// iteration order.
650    pub fn populate_release_notes_var(&mut self) {
651        // Look up changelogs in config-defined crate order for determinism.
652        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", &notes);
660    }
661
662    /// Refresh the `Artifacts` structured template variable from the current
663    /// artifact registry. Should be called before rendering release body and
664    /// announce templates so they can iterate over all artifacts.
665    ///
666    /// Each artifact is serialized as a map with keys: `name`, `path`, `target`,
667    /// `kind`, `crate_name`, and `metadata`.
668    ///
669    /// **Known metadata keys** (populated by individual stages):
670    /// - `format` — archive format (e.g. `"tar.gz"`, `"zip"`), set by archive stage
671    /// - `extra_file` — `"true"` when artifact is an extra file, set by checksum stage
672    /// - `extra_name_template` — name template override for extra files, set by checksum stage
673    /// - `digest` — docker image digest (e.g. `sha256:abc123...`), set by docker stage
674    /// - `id` — artifact ID from config, set by docker and build stages
675    /// - `binary` — binary name, set by build stage
676    pub fn refresh_artifacts_var(&mut self) {
677        // CSV metadata keys we expose as JSON arrays for template iteration.
678        // Storage remains HashMap<String,String> (flat); only the
679        // template-exposed view is expanded. Matches GoReleaser's
680        // ExtraBinaries / ExtraFiles list semantics.
681        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                // Rebuild metadata map converting known CSV keys into arrays.
689                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        // serde_json::Value and tera::Value are the same type under the hood,
715        // so no conversion is needed — pass values directly.
716        let tera_value = tera::Value::Array(artifacts_value);
717        self.template_vars.set_structured("Artifacts", tera_value);
718    }
719
720    /// Populate the `Metadata` structured template variable from config.metadata.
721    ///
722    /// Exposes the project metadata block as a nested map with PascalCase keys
723    /// matching GoReleaser's `.Metadata.*` namespace:
724    /// `Description`, `Homepage`, `License`, `Maintainers`, `ModTimestamp`,
725    /// `FullDescription` (resolved), `CommitAuthor.{Name,Email}`.
726    /// Missing fields default to empty strings / empty arrays.
727    ///
728    /// `full_description` with `from_url` is NOT resolved here (avoids a
729    /// reqwest dep in core); the FromUrl case returns an error and the caller
730    /// should surface it. Inline and FromFile are resolved synchronously.
731    pub fn populate_metadata_var(&mut self) -> anyhow::Result<()> {
732        use crate::config::ContentSource;
733
734        // Clone the small scalar fields so we don't hold a borrow on self.config
735        // across the render_template calls below.
736        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        // Resolve full_description (Inline + FromFile in-core; FromUrl errors here).
780        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        // serde_json::Value and tera::Value are the same type, so pass directly.
819        self.template_vars.set_structured("Metadata", meta_map);
820        Ok(())
821    }
822}
823
824/// Map Rust's `std::env::consts::OS` to Go-compatible GOOS naming.
825/// GoReleaser templates expect Go runtime names (e.g. "darwin" not "macos").
826pub fn map_os_to_goos(os: &str) -> &str {
827    match os {
828        "macos" => "darwin",
829        other => other, // linux, windows, freebsd, etc. already match
830    }
831}
832
833/// Map Rust's `std::env::consts::ARCH` to Go-compatible GOARCH naming.
834/// GoReleaser templates expect Go runtime names (e.g. "amd64" not "x86_64").
835pub 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        // Version should include build metadata (strip v prefix only)
985        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        // Workspace tags like "core-v0.3.2" should produce Version="0.3.2",
1004        // not "core-v0.3.2" (which breaks RPM Version fields and templates).
1005        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        // Non-snapshot
1091        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        // Date should be RFC 3339 format (e.g. 2026-03-30T12:00:00+00:00)
1143        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        // Timestamp should be numeric
1152        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        // Now should be ISO 8601
1161        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        // Don't set git_info — populate_git_vars should still set IsSnapshot/IsDraft
1188        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        // Git-specific vars should NOT be set
1199        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        // No git_info set — populate_git_vars still sets IsNightly
1265        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        // RuntimeGoos uses Go naming (e.g. "darwin" not "macos")
1411        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        // RuntimeGoarch uses Go naming (e.g. "amd64" not "x86_64")
1421        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        // Should always pick the first crate in config order, not arbitrary HashMap order
1476        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    // --- Pro template variable tests ---
1526
1527    #[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        // No tag_prefix configured — PrefixedTag should equal Tag
1552        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        // When there is no previous tag, PrefixedPreviousTag should be empty
1589        // (not just the prefix), matching GoReleaser behavior.
1590        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        // Should render as an empty array
1702        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        // Artifacts are created with empty `name` — ArtifactRegistry::add()
1717        // auto-derives the name from the path's filename component when name
1718        // is empty (see artifact.rs add() implementation).
1719        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        // Iterate over artifacts and collect names
1740        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        // Check kind field
1747        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        // Metadata should be accessible as a nested map with PascalCase keys
1765        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        // Should render empty strings for missing fields (PascalCase keys)
1776        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        // Avoids silent-skip footgun (see W1 in pro-features-audit.md). If the user
1849        // configures from_url for metadata.full_description, emit a clear, actionable
1850        // error at context-populate time rather than quietly shipping an empty string.
1851        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        // Test that all Pro vars can be used in templates together
1921        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        // IsRelease should still be set even without git info
1947        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        // IsMerging should still be set even without git info
1965        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    // -----------------------------------------------------------------------
1980    // Monorepo template variable tests
1981    // -----------------------------------------------------------------------
1982
1983    #[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        // Simulate a monorepo tag: the full prefixed tag is stored in git_info.
1993        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        // Tag should have the prefix stripped.
2002        assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2003        // Version should derive from stripped tag.
2004        assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
2005        // PrefixedTag should retain the full tag.
2006        assert_eq!(
2007            v.get("PrefixedTag"),
2008            Some(&"subproject1/v1.2.3".to_string())
2009        );
2010        // PreviousTag should be stripped (consistent with Tag).
2011        assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
2012        // PrefixedPreviousTag should retain the full tag.
2013        assert_eq!(
2014            v.get("PrefixedPreviousTag"),
2015            Some(&"subproject1/v1.2.2".to_string())
2016        );
2017        // Summary should be stripped.
2018        assert_eq!(v.get("Summary"), Some(&"v1.2.3-0-gabc123d".to_string()));
2019        // PrefixedSummary should retain the full summary.
2020        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        // PrefixedPreviousTag should be the full previous tag.
2043        assert_eq!(
2044            v.get("PrefixedPreviousTag"),
2045            Some(&"svc/v1.9.0".to_string())
2046        );
2047        // PreviousTag should be stripped (prefix removed), consistent with Tag.
2048        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        // When monorepo is not set, PrefixedTag should use tag.tag_prefix.
2054        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        // Tag is plain "v1.2.3" (not stripped because no monorepo).
2065        assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2066        // PrefixedTag should prepend tag_prefix.
2067        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        // When both monorepo.tag_prefix and tag.tag_prefix are set,
2077        // monorepo should take precedence for PrefixedTag.
2078        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        // Monorepo takes precedence: Tag is stripped.
2097        assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2098        // PrefixedTag is the full monorepo tag, NOT tag_prefix-prepended.
2099        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        // In a real monorepo, `git describe` already includes the prefix in the summary.
2114        info.summary = "pkg/v1.2.3-0-gabc123d".to_string();
2115        ctx.git_info = Some(info);
2116        ctx.populate_git_vars();
2117
2118        // PrefixedSummary is info.summary as-is (already contains prefix).
2119        assert_eq!(
2120            ctx.template_vars().get("PrefixedSummary"),
2121            Some(&"pkg/v1.2.3-0-gabc123d".to_string())
2122        );
2123        // Summary should have the prefix stripped.
2124        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        // PreviousTag should also be empty when no previous tag exists.
2148        assert_eq!(v.get("PreviousTag"), Some(&"".to_string()));
2149    }
2150
2151    // -----------------------------------------------------------------------
2152    // Integration test: full monorepo flow
2153    // -----------------------------------------------------------------------
2154
2155    #[test]
2156    fn test_monorepo_full_flow_all_vars() {
2157        // End-to-end test: config with monorepo.tag_prefix + dir
2158        // → context creation → populate_git_vars → verify ALL template vars.
2159        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        // Verify Config helper methods work
2167        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        // Simulate git info as it would appear in a monorepo:
2173        // tag and summary already contain the prefix from git.
2174        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        // Base vars should have the prefix STRIPPED.
2191        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        // Prefixed vars should retain the FULL prefix.
2201        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        // Project name should be available.
2215        assert_eq!(v.get("ProjectName"), Some(&"mymonorepo".to_string()));
2216    }
2217}