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::publish_report::PublishReport;
7use crate::scm::ScmTokenType;
8use crate::template::TemplateVars;
9use anyhow::Context as _;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14/// Rollback policy after the publish stage. `BestEffort` is the default when
15/// pre-flight ran clean; `None` is the implicit default otherwise (callers
16/// should warn that rollback is disabled). The CLI flag `--rollback=<v>`
17/// sets `ContextOptions::rollback_mode` to `Some(v)` to override the
18/// default-resolution at the dispatch site.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum RollbackMode {
22    /// Do not attempt rollback. Useful when the operator wants to inspect
23    /// half-published state before deciding.
24    None,
25    /// Run best-effort rollback for every reversible publisher whose
26    /// evidence is present in the report. Irreversible publishers
27    /// (chocolatey moderation, winget PRs, AUR) are never rolled back —
28    /// the Submitter gate is the only protection.
29    #[default]
30    BestEffort,
31}
32
33/// Valid --skip values for the `release` command (matches GoReleaser).
34///
35/// Publisher skip names use the short canonical form (matching the CLI binary
36/// name and GoReleaser convention): `brew`, `choco`, `krew`, `cargo`, etc.
37/// Long aliases (e.g. `homebrew`, `chocolatey`) are NOT accepted forbids
38/// aliases; use the short name everywhere.
39pub const VALID_RELEASE_SKIPS: &[&str] = &[
40    "publish",
41    "announce",
42    "sign",
43    "validate",
44    "sbom",
45    "docker",
46    "docker-sign",
47    "winget",
48    "choco",
49    "snapcraft",
50    "snapcraft-publish",
51    "scoop",
52    "brew",
53    "nix",
54    "aur",
55    "cargo",
56    "krew",
57    "nfpm",
58    "makeself",
59    "flatpak",
60    "srpm",
61    "before",
62    "notarize",
63    "archive",
64    "source",
65    "build",
66    "changelog",
67    "release",
68    "checksum",
69    "upx",
70    "blob",
71    "templatefiles",
72    "dmg",
73    "msi",
74    "nsis",
75    "pkg",
76    "appbundle",
77];
78
79/// Valid --skip values for the `build` command.
80pub const VALID_BUILD_SKIPS: &[&str] = &["pre-hooks", "post-hooks", "validate", "before"];
81
82/// Validate that all skip values are in the allowed set.
83///
84/// Returns `Ok(())` if all values are valid, or `Err` with a descriptive
85/// message listing the invalid value(s) and the full set of valid options.
86pub fn validate_skip_values(skip: &[String], valid: &[&str]) -> Result<(), String> {
87    let invalid: Vec<&str> = skip
88        .iter()
89        .map(|s| s.as_str())
90        .filter(|s| !valid.contains(s))
91        .collect();
92    if invalid.is_empty() {
93        Ok(())
94    } else {
95        Err(format!(
96            "invalid --skip value(s): {}. Valid options: {}",
97            invalid.join(", "),
98            valid.join(", "),
99        ))
100    }
101}
102
103pub struct ContextOptions {
104    pub snapshot: bool,
105    pub nightly: bool,
106    pub dry_run: bool,
107    pub quiet: bool,
108    pub verbose: bool,
109    pub debug: bool,
110    pub skip_stages: Vec<String>,
111    pub selected_crates: Vec<String>,
112    pub token: Option<String>,
113    /// Maximum number of parallel build jobs (minimum 1).
114    pub parallelism: usize,
115    /// When set, build only for this single host target triple.
116    pub single_target: Option<String>,
117    /// Path to a custom release notes file (overrides changelog).
118    pub release_notes_path: Option<PathBuf>,
119    /// When true, abort immediately on first error during publishing.
120    pub fail_fast: bool,
121    /// Partial build target for split/merge mode. When set, the build stage
122    /// filters targets to only those matching this partial target.
123    pub partial_target: Option<PartialTarget>,
124    /// When true, running with `--merge` flag (merging artifacts from split builds).
125    pub merge: bool,
126    /// `--publish-only`: load artifacts from a preserved dist (written
127    /// by `anodize check determinism --preserve-dist=...`) and run
128    /// only the sign + publish pipeline. The CLI dispatcher uses this
129    /// flag in `setup_env` to defer the GitHub-token check to
130    /// `publish_only::preflight_credentials`, which owns the
131    /// combined token + sign-key check and bails fail-closed on
132    /// missing values. Without this gate, `setup_env`'s token check
133    /// would fire FIRST and pre-empt publish-only's own preflight
134    /// (which validates BOTH token AND sign key in one shot).
135    pub publish_only: bool,
136    /// Explicit project root directory. When set, stages use this instead of
137    /// discovering the repo root via `git rev-parse --show-toplevel`.
138    pub project_root: Option<PathBuf>,
139    /// Strict mode: configured features that would silently skip become errors.
140    pub strict: bool,
141    /// `--resume-release`: opt-in to continue into a release left over from
142    /// a prior failed attempt. Bypasses the leftover-assets pre-check that
143    /// bails when an existing release already has assets and
144    /// `replace_existing_artifacts` is false.
145    pub resume_release: bool,
146    /// `--replace-existing`: CLI override that forces
147    /// `release.replace_existing_artifacts: true` regardless of config.
148    /// The release stage ORs this with the config value.
149    pub replace_existing_artifacts: bool,
150    /// `--no-post-publish-poll`: skip post-publish polling for the
151    /// chocolatey moderation queue and the winget PR validation pipeline.
152    /// When `true`, the polling runner emits `PostPublishStatus::NotPolled`
153    /// (pending immediately) for every publisher rather than waiting on a
154    /// terminal state. Lets CI users with no patience for long-running
155    /// waits opt out without scattering `post_publish_poll.enabled: false`
156    /// across every publisher block.
157    pub skip_post_publish_poll: bool,
158    /// Whether the publisher dispatcher gates irreversible Submitter
159    /// publishers (chocolatey, winget, AUR-source, krew, snapcraft) on
160    /// the success of every required Assets/Manager publisher that ran
161    /// before them. `None` defaults to `Some(true)` (gate on). The CLI
162    /// flag `--no-gate-submitter` flips this to `Some(false)`. See
163    /// `stage-publish::dispatch::DispatchOptions::gate_submitter` for
164    /// the gating mechanics.
165    pub gate_submitter: Option<bool>,
166    /// `--rollback=<none|best-effort>`: post-publish rollback policy.
167    /// `None` means "resolve from preflight state at dispatch time"
168    /// (best-effort when preflight ran clean, none otherwise with a
169    /// warn). Consumed by the rollback-dispatch task.
170    pub rollback_mode: Option<RollbackMode>,
171    /// `--simulate-failure=<publisher>` (repeatable, hidden, env-gated
172    /// behind `ANODIZE_TEST_HARNESS=1`): names of publishers whose
173    /// `run()` should be skipped and a synthetic `Failed("simulated
174    /// failure: <name>")` recorded in the report instead. Lets the
175    /// failure-mode test harness exercise gate / rollback / report
176    /// paths deterministically without monkey-patching production
177    /// publisher code.
178    pub simulate_failure_publishers: Vec<String>,
179    /// `--rollback-only`: skip publish; re-attempt rollback from a
180    /// prior run report. Requires `from_run` to identify which prior
181    /// run's `report.json` to load. The actual replay logic lands in
182    /// a follow-up task; this field is plumbed so the flag is visible
183    /// in `--help` today.
184    pub rollback_only: bool,
185    /// `--allow-rerun`: force `PublishStage::run` to proceed even
186    /// when a prior `report.json` exists for the current `run_id`.
187    /// The default (false) refuses re-runs to prevent PR-based
188    /// publishers (homebrew / scoop / nix / krew / MCP) from
189    /// duplicating their pull requests against the same tag.
190    ///
191    /// Operators recovering from a partial failure should prefer
192    /// `--rollback-only --from-run=<id>` (which has its own
193    /// idempotency guard via `dist/run-<id>/rollback.json`). The
194    /// rerun flag is an escape hatch for advanced cases where the
195    /// operator has manually verified no duplicate-publish risk
196    /// exists.
197    ///
198    /// Audit ref: 2026-05-15 release-resilience-review finding I4.
199    pub allow_rerun: bool,
200    /// `--from-run=<id>`: prior run id whose `report.json` to load
201    /// when running in `--rollback-only` mode. clap enforces the
202    /// `requires = "rollback_only"` relationship at parse time.
203    pub from_run: Option<String>,
204    /// `--allow-nondeterministic <name>=<reason>` (repeatable):
205    /// runtime non-determinism opt-outs for specific artifacts. The
206    /// determinism stage suppresses its non-determinism error for
207    /// any matching artifact name, recording the supplied reason in
208    /// the report. Mutually exclusive with `--strict` at the clap
209    /// layer.
210    pub runtime_nondeterministic_allowlist: Vec<(String, String)>,
211    /// `--summary-json=<path>`: when set, the per-publisher run
212    /// summary is written to this path. Consumed by the run-summary
213    /// task.
214    pub summary_json_path: Option<PathBuf>,
215}
216
217impl Default for ContextOptions {
218    fn default() -> Self {
219        Self {
220            snapshot: false,
221            nightly: false,
222            dry_run: false,
223            quiet: false,
224            verbose: false,
225            debug: false,
226            skip_stages: Vec::new(),
227            selected_crates: Vec::new(),
228            token: None,
229            parallelism: 4,
230            single_target: None,
231            release_notes_path: None,
232            fail_fast: false,
233            partial_target: None,
234            merge: false,
235            publish_only: false,
236            project_root: None,
237            strict: false,
238            resume_release: false,
239            replace_existing_artifacts: false,
240            skip_post_publish_poll: false,
241            gate_submitter: None,
242            rollback_mode: None,
243            simulate_failure_publishers: Vec::new(),
244            rollback_only: false,
245            allow_rerun: false,
246            from_run: None,
247            runtime_nondeterministic_allowlist: Vec::new(),
248            summary_json_path: None,
249        }
250    }
251}
252
253/// Stage→stage handoff state produced by stages and consumed by later
254/// stages (as opposed to `config` / `options` which are pipeline inputs,
255/// or `artifacts` which has its own registry). The changelog stage
256/// writes here, the release stage reads here.
257#[derive(Debug, Default)]
258pub struct StageOutputs {
259    /// Set by the changelog stage when `use: github-native` is configured.
260    /// The release stage reads this to set `generate_release_notes(true)`
261    /// on the GitHub API.
262    pub github_native_changelog: bool,
263    /// Per-crate rendered changelog body, keyed by crate name.
264    pub changelogs: HashMap<String, String>,
265    /// Rendered `changelog.header` value, populated by the changelog stage.
266    /// The release stage uses it as a fallback when `release.header` is
267    /// unset so YAML-configured changelog headers reach the GitHub release
268    /// body (matching GoReleaser's `loadContent(ReleaseHeader…)` behaviour).
269    pub changelog_header: Option<String>,
270    /// Rendered `changelog.footer` value, populated by the changelog stage.
271    /// Same fallback semantics as `changelog_header`.
272    pub changelog_footer: Option<String>,
273    /// Per-publisher post-publish polling results, written by the publish
274    /// stage's chocolatey / winget polling fan-out and consumed by the
275    /// release-summary renderer. Stored as opaque JSON to keep core free
276    /// of stage-publish types (the `PostPublishResult` type lives in
277    /// `anodizer-stage-publish::post_publish::status` and serializes
278    /// stably). Empty when polling was disabled or no eligible
279    /// publishers ran.
280    pub post_publish_results: Vec<serde_json::Value>,
281}
282
283pub struct Context {
284    pub config: Config,
285    pub artifacts: ArtifactRegistry,
286    pub options: ContextOptions,
287    /// Stage→stage handoff outputs (changelog text, header/footer, etc.).
288    pub stage_outputs: StageOutputs,
289    template_vars: TemplateVars,
290    pub git_info: Option<GitInfo>,
291    /// The resolved SCM token type (GitHub, GitLab, or Gitea).
292    pub token_type: ScmTokenType,
293    /// Aggregated skips from per-sub-config loops (signs, docker_signs,
294    /// publishers, …). Drained by the pipeline runner at end-of-pipeline so
295    /// the summary shows what was intentionally skipped — mirroring
296    /// GoReleaser's `pipe.SkipMemento` pattern. The inner `Arc<Mutex<…>>`
297    /// lets parallel stage workers contribute without extra plumbing.
298    pub skip_memento: crate::pipe_skip::SkipMemento,
299    /// Trait-based publisher dispatch report, set by `PublishStage::run`
300    /// when the per-publisher dispatcher finishes. `None` until the
301    /// publish stage executes (or when publishing is skipped entirely
302    /// via snapshot mode / `--skip=publish`). Downstream stages
303    /// (SnapcraftPublishStage, AnnounceStage, future Submitter-group
304    /// stages) consult this to apply the submitter-gate / announce-gate
305    /// rules — see `PublishReport::any_failed`.
306    pub publish_report: Option<PublishReport>,
307    /// SOURCE_DATE_EPOCH seed + non-determinism allow-list state for the
308    /// run. `None` until a stage (typically `BuildStage`) seeds it from
309    /// `resolve_reproducible_epoch(commit_timestamp)`; downstream stages
310    /// (`stage-sbom`, `stage-archive`, `stage-sign`) read `sde` to derive
311    /// deterministic timestamps. Lazy-init by design: tests and snapshot
312    /// runs without a clean commit can still proceed.
313    pub determinism: Option<crate::DeterminismState>,
314    /// Per-publisher outcome override published by `Publisher::run` when
315    /// the artifact reached a non-`Succeeded` terminal state but `run`
316    /// still returned `Ok` (e.g. chocolatey moderation skip,
317    /// winget/krew/homebrew PR-already-exists skip). Dispatch consumes
318    /// this slot via `take_pending_outcome()` immediately after `run`
319    /// returns Ok so the per-publisher row in the summary table reads
320    /// `pending-moderation` / `pending-validation` instead of
321    /// `succeeded`. The slot is single-shot: any unread value is
322    /// cleared at the start of every `run` call.
323    pub pending_outcome: Option<crate::PublisherOutcome>,
324    /// Optional in-memory log-capture handle. When `Some`, every logger
325    /// produced by [`Context::logger`] attaches it so the test can read
326    /// back aggregated counts of `status` / `warn` / etc. calls without
327    /// having to intercept stderr.
328    ///
329    /// Gated behind the `test-helpers` Cargo feature — production
330    /// binaries do not carry the field at all.
331    #[cfg(feature = "test-helpers")]
332    pub log_capture: Option<crate::log::LogCapture>,
333}
334
335impl Context {
336    pub fn new(config: Config, options: ContextOptions) -> Self {
337        let mut vars = TemplateVars::new();
338        vars.set("ProjectName", &config.project_name);
339        Self {
340            config,
341            artifacts: ArtifactRegistry::new(),
342            options,
343            stage_outputs: StageOutputs::default(),
344            template_vars: vars,
345            git_info: None,
346            token_type: ScmTokenType::GitHub,
347            skip_memento: crate::pipe_skip::SkipMemento::new(),
348            publish_report: None,
349            determinism: None,
350            pending_outcome: None,
351            #[cfg(feature = "test-helpers")]
352            log_capture: None,
353        }
354    }
355
356    /// Attach an in-memory log-capture sink so every logger derived from
357    /// this context via [`Context::logger`] records to it. Intended for
358    /// tests; production callers leave this `None`.
359    ///
360    /// Gated behind the `test-helpers` Cargo feature.
361    #[cfg(feature = "test-helpers")]
362    pub fn with_log_capture(&mut self, capture: crate::log::LogCapture) {
363        self.log_capture = Some(capture);
364    }
365
366    /// Publisher-facing override: when `Publisher::run` returns `Ok`
367    /// but the terminal outcome is something other than `Succeeded`
368    /// (chocolatey moderation skip, winget/krew/homebrew
369    /// PR-already-exists skip, …) call this before returning so
370    /// dispatch records the correct `PublisherOutcome` on the report.
371    /// Without this, dispatch defaults to `Succeeded` on any Ok and
372    /// the summary table silently misreports the skip as success.
373    pub fn record_publisher_outcome(&mut self, outcome: crate::PublisherOutcome) {
374        self.pending_outcome = Some(outcome);
375    }
376
377    /// Dispatch-side consumer: take the pending outcome override (if
378    /// any) recorded by the publisher's `run`. Single-shot — the slot
379    /// is empty after this call.
380    pub fn take_pending_outcome(&mut self) -> Option<crate::PublisherOutcome> {
381        self.pending_outcome.take()
382    }
383
384    /// Borrow the publisher dispatch report set by `PublishStage::run`,
385    /// or `None` if the publish stage hasn't run yet (or was skipped).
386    pub fn publish_report(&self) -> Option<&PublishReport> {
387        self.publish_report.as_ref()
388    }
389
390    /// Store the publisher dispatch report. Overwrites any prior report;
391    /// the publish stage is the single writer.
392    pub fn set_publish_report(&mut self, r: PublishReport) {
393        self.publish_report = Some(r);
394    }
395
396    /// Record an intentional skip from a per-sub-config loop
397    /// (`signs`, `docker_signs`, `publishers`, …). `stage` identifies the
398    /// owning stage, `label` identifies the sub-config (id / name / index),
399    /// `reason` is short user-facing text. Duplicate (stage, label, reason)
400    /// tuples are dropped on insert so a per-artifact inner loop cannot emit
401    /// N copies of the same skip message.
402    pub fn remember_skip(&self, stage: &str, label: &str, reason: &str) {
403        self.skip_memento.remember(stage, label, reason);
404    }
405
406    pub fn template_vars(&self) -> &TemplateVars {
407        &self.template_vars
408    }
409
410    pub fn template_vars_mut(&mut self) -> &mut TemplateVars {
411        &mut self.template_vars
412    }
413
414    pub fn render_template(&self, template: &str) -> anyhow::Result<String> {
415        crate::template::render(template, &self.template_vars)
416    }
417
418    /// Render a template if present, returning `None` for `None` input.
419    pub fn render_template_opt(&self, template: Option<&str>) -> anyhow::Result<Option<String>> {
420        template.map(|t| self.render_template(t)).transpose()
421    }
422
423    /// Evaluate a `skip` field, logging at INFO level when it resolves to true.
424    ///
425    /// Returns `Ok(false)` when `skip` is `None` or evaluates falsy. On
426    /// truthy, writes `"{label} skipped"` via `log.status` and returns
427    /// `Ok(true)`. A malformed `skip:` template propagates as `Err` so the
428    /// caller fails fast — silently treating a render error as "not skipped"
429    /// (the prior behavior) shipped configs that the user thought would
430    /// suppress a stage but actually ran it.
431    pub fn skip_with_log(
432        &self,
433        skip: &Option<crate::config::StringOrBool>,
434        log: &StageLogger,
435        label: &str,
436    ) -> anyhow::Result<bool> {
437        let Some(d) = skip else {
438            return Ok(false);
439        };
440        let should_skip = d
441            .try_evaluates_to_true(|s| self.render_template(s))
442            .with_context(|| format!("evaluate skip expression for {label}"))?;
443        if should_skip {
444            log.status(&format!("{} skipped", label));
445        }
446        Ok(should_skip)
447    }
448
449    pub fn should_skip(&self, stage_name: &str) -> bool {
450        self.options.skip_stages.iter().any(|s| s == stage_name)
451    }
452
453    /// Check whether "validate" is in the skip list.
454    pub fn skip_validate(&self) -> bool {
455        self.should_skip("validate")
456    }
457
458    pub fn is_dry_run(&self) -> bool {
459        self.options.dry_run
460    }
461
462    pub fn is_snapshot(&self) -> bool {
463        self.options.snapshot
464    }
465
466    pub fn is_strict(&self) -> bool {
467        self.options.strict
468    }
469
470    /// In strict mode, return an error. In normal mode, log a warning and continue.
471    /// Use this for any situation where a configured feature silently skips.
472    pub fn strict_guard(&self, log: &crate::log::StageLogger, msg: &str) -> anyhow::Result<()> {
473        if self.options.strict {
474            anyhow::bail!("{} (strict mode)", msg);
475        }
476        log.warn(msg);
477        Ok(())
478    }
479
480    /// Defense-in-depth helper for upload-style stages.
481    ///
482    /// Returns `true` (after logging the skip) when the context is in snapshot
483    /// mode. Stages that perform external uploads (registries, package indexes,
484    /// object storage, snap store, …) call this at entry so they no-op even
485    /// when invoked directly without the orchestration layer's auto-skip.
486    /// Centralising the check keeps every publish stage consistent and avoids
487    /// per-stage copy-paste.
488    pub fn skip_in_snapshot(&self, log: &crate::log::StageLogger, stage: &str) -> bool {
489        if self.is_snapshot() {
490            log.status(&format!("{}: skipped (snapshot mode)", stage));
491            true
492        } else {
493            false
494        }
495    }
496
497    /// Render a template, failing in strict mode on error, or falling back to the raw string.
498    pub fn render_template_strict(
499        &self,
500        template: &str,
501        label: &str,
502        log: &crate::log::StageLogger,
503    ) -> anyhow::Result<String> {
504        match self.render_template(template) {
505            Ok(rendered) => Ok(rendered),
506            Err(e) => {
507                if self.options.strict {
508                    anyhow::bail!("{}: failed to render template: {} (strict mode)", label, e);
509                }
510                log.warn(&format!("{}: failed to render template: {}", label, e));
511                Ok(template.to_string())
512            }
513        }
514    }
515
516    pub fn is_nightly(&self) -> bool {
517        self.options.nightly
518    }
519
520    /// Set the `ReleaseURL` template variable.
521    ///
522    /// Should be called after a GitHub release is created, with the URL of
523    /// the created release (e.g. `https://github.com/owner/repo/releases/tag/v1.0.0`).
524    pub fn set_release_url(&mut self, url: &str) {
525        self.template_vars.set("ReleaseURL", url);
526    }
527
528    /// Return the current `Version` template variable, or an empty string if
529    /// not yet populated.
530    pub fn version(&self) -> String {
531        self.template_vars
532            .get("Version")
533            .cloned()
534            .unwrap_or_default()
535    }
536
537    /// Derive the verbosity level from context options.
538    pub fn verbosity(&self) -> Verbosity {
539        Verbosity::from_flags(self.options.quiet, self.options.verbose, self.options.debug)
540    }
541
542    /// Resolve the user's `retry:` block into a concrete [`RetryPolicy`],
543    /// applying defaults when `retry:` is unset. Equivalent to
544    /// `ctx.config.retry.unwrap_or_default().to_policy()` but centralizes
545    /// the lookup so a future refactor can hang validation / clamping off
546    /// a single seam.
547    pub fn retry_policy(&self) -> crate::retry::RetryPolicy {
548        self.config.retry.unwrap_or_default().to_policy()
549    }
550
551    /// Create a [`StageLogger`] for the given stage name, pre-attached to
552    /// the context's env-pairs list so that subprocess stderr / stdout
553    /// flowing through [`StageLogger::check_output`] is automatically
554    /// redacted. The env list combines the template-engine env
555    /// (process + config + `.env` files) and the current `std::env::vars`
556    /// snapshot, so any secret value reachable to a hook or subprocess is
557    /// available for scrubbing.
558    pub fn logger(&self, stage: &'static str) -> StageLogger {
559        #[allow(unused_mut)]
560        let mut log = StageLogger::new(stage, self.verbosity()).with_env(self.env_for_redact());
561        #[cfg(feature = "test-helpers")]
562        if let Some(cap) = &self.log_capture {
563            log = log.with_capture_handle(cap.clone());
564        }
565        log
566    }
567
568    /// Build the env-pairs list used to seed every [`StageLogger`] created
569    /// via [`Context::logger`]. Combines the template-engine env map
570    /// (process env + config env + `.env` file values) with the current
571    /// `std::env::vars` snapshot, deduplicating by key (template-engine
572    /// values win because they reflect any user overrides).
573    fn env_for_redact(&self) -> Vec<(String, String)> {
574        use std::collections::HashMap;
575        let mut map: HashMap<String, String> = std::env::vars().collect();
576        for (k, v) in self.template_vars.all_env() {
577            map.insert(k.clone(), v.clone());
578        }
579        map.into_iter().collect()
580    }
581
582    /// Populate template variables from `self.git_info`.
583    ///
584    /// Must be called after `self.git_info` is set. Sets the following vars:
585    /// - `Tag`, `Version`, `RawVersion` — tag and version strings
586    /// - `Major`, `Minor`, `Patch` — semver components
587    /// - `Prerelease` — prerelease suffix (or empty)
588    /// - `BuildMetadata` — build metadata from semver tag (or empty)
589    /// - `FullCommit`, `Commit` — full commit SHA (`Commit` is alias for `FullCommit`)
590    /// - `ShortCommit` — abbreviated commit SHA
591    /// - `Branch` — current git branch
592    /// - `CommitDate` — ISO 8601 author date of HEAD commit
593    /// - `CommitTimestamp` — unix timestamp of HEAD commit
594    /// - `IsGitDirty` — "true"/"false"
595    /// - `IsGitClean` — "true"/"false" (inverse of `IsGitDirty`)
596    /// - `GitTreeState` — "clean"/"dirty"
597    /// - `GitURL` — git remote URL
598    /// - `Summary` — git describe summary
599    /// - `TagSubject` — annotated tag subject or commit subject
600    /// - `TagContents` — full annotated tag message or commit message
601    /// - `TagBody` — tag message body or commit message body
602    /// - `IsSnapshot` — from context options
603    /// - `IsNightly` — from context options
604    /// - `IsDraft` — "false" (stages may override to "true")
605    /// - `IsSingleTarget` — "true"/"false" based on single_target option
606    /// - `PreviousTag` — previous matching tag, stripped in monorepo mode (or empty)
607    /// - `PrefixedTag` — full tag with monorepo prefix, or tag_prefix-prepended (Pro addition)
608    /// - `PrefixedPreviousTag` — full previous tag with prefix (Pro addition)
609    /// - `PrefixedSummary` — full summary with prefix (Pro addition)
610    /// - `IsRelease` — "true" if not snapshot and not nightly (Pro addition)
611    /// - `IsMerging` — "true" if running with --merge flag (Pro addition)
612    ///
613    /// **Stage-scoped variables** (NOT set here; set per-artifact during stage execution):
614    /// - `Binary` — binary name, set by build stage per binary and archive stage per archive
615    /// - `ArtifactName` — output artifact filename, set by archive stage after creating each archive
616    /// - `ArtifactPath` — absolute path to artifact, set by archive stage after creating each archive
617    /// - `ArtifactExt` — artifact file extension (e.g. `.tar.gz`, `.exe`), set alongside ArtifactName
618    /// - `ArtifactID` — build config `id` field, set by build stage per build config
619    /// - `Os` — target OS, set by archive/nfpm stages per target
620    /// - `Arch` — target architecture, set by archive/nfpm stages per target
621    /// - `Target` — full target triple (e.g. `x86_64-unknown-linux-gnu`), set alongside Os/Arch
622    /// - `Checksums` — combined checksum file contents, set by checksum stage
623    pub fn populate_git_vars(&mut self) {
624        if let Some(ref info) = self.git_info {
625            // RawVersion: just major.minor.patch, no prerelease or build metadata.
626            let raw_version = format!(
627                "{}.{}.{}",
628                info.semver.major, info.semver.minor, info.semver.patch
629            );
630
631            // Version: clean semver derived from the parsed SemVer struct, not
632            // from the tag string.  The old `tag.strip_prefix('v')` approach
633            // broke for monorepo workspace tags like `core-v0.3.2` because it
634            // only stripped a leading 'v', leaving `core-v0.3.2` intact.
635            // Deriving from the struct handles all tag_template prefixes.
636            let mut version = raw_version.clone();
637            if let Some(ref pre) = info.semver.prerelease {
638                version.push('-');
639                version.push_str(pre);
640            }
641            if let Some(ref meta) = info.semver.build_metadata {
642                version.push('+');
643                version.push_str(meta);
644            }
645
646            self.template_vars.set("Tag", &info.tag);
647            self.template_vars.set("Version", &version);
648            self.template_vars.set("RawVersion", &raw_version);
649            self.template_vars
650                .set("Major", &info.semver.major.to_string());
651            self.template_vars
652                .set("Minor", &info.semver.minor.to_string());
653            self.template_vars
654                .set("Patch", &info.semver.patch.to_string());
655            self.template_vars.set(
656                "Prerelease",
657                info.semver.prerelease.as_deref().unwrap_or(""),
658            );
659            self.template_vars.set(
660                "BuildMetadata",
661                info.semver.build_metadata.as_deref().unwrap_or(""),
662            );
663            self.template_vars.set("FullCommit", &info.commit);
664            self.template_vars.set("Commit", &info.commit);
665            self.template_vars.set("ShortCommit", &info.short_commit);
666            self.template_vars.set("Branch", &info.branch);
667            self.template_vars.set("CommitDate", &info.commit_date);
668            self.template_vars
669                .set("CommitTimestamp", &info.commit_timestamp);
670            self.template_vars
671                .set("IsGitDirty", if info.dirty { "true" } else { "false" });
672            self.template_vars
673                .set("IsGitClean", if info.dirty { "false" } else { "true" });
674            self.template_vars
675                .set("GitTreeState", if info.dirty { "dirty" } else { "clean" });
676            self.template_vars.set("GitURL", &info.remote_url);
677            self.template_vars.set("Summary", &info.summary);
678            self.template_vars.set("TagSubject", &info.tag_subject);
679            self.template_vars.set("TagContents", &info.tag_contents);
680            self.template_vars.set("TagBody", &info.tag_body);
681            self.template_vars
682                .set("PreviousTag", info.previous_tag.as_deref().unwrap_or(""));
683            self.template_vars
684                .set("FirstCommit", info.first_commit.as_deref().unwrap_or(""));
685
686            // Pro additions: PrefixedTag, PrefixedPreviousTag, PrefixedSummary
687            //
688            // When monorepo.tag_prefix is configured, the git tag already
689            // contains the prefix (e.g. "subproject1/v1.2.3"). In this case:
690            //   - Tag = prefix stripped (e.g. "v1.2.3")
691            //   - PrefixedTag = full tag (e.g. "subproject1/v1.2.3")
692            //   - PrefixedPreviousTag = full previous tag
693            //
694            // When monorepo is NOT configured, fall back to the original
695            // behavior: prepend tag.tag_prefix to construct PrefixedTag.
696            let monorepo_prefix = self.config.monorepo_tag_prefix();
697
698            // monorepo.tag_prefix takes precedence over tag.tag_prefix for
699            // PrefixedTag / PrefixedPreviousTag / PrefixedSummary behavior.
700            // When monorepo is configured, info.tag and info.summary already
701            // contain the prefix from git, so we strip for the base vars and
702            // use the raw values for the Prefixed variants.
703            if let Some(prefix) = monorepo_prefix {
704                // Monorepo mode: the tag in git_info is the FULL prefixed tag.
705                // PrefixedTag = full tag (already has prefix).
706                self.template_vars.set("PrefixedTag", &info.tag);
707
708                // Tag = prefix stripped. Override the Tag we set above.
709                let stripped_tag = crate::git::strip_monorepo_prefix(&info.tag, prefix);
710                self.template_vars.set("Tag", stripped_tag);
711
712                // Version: derive from the stripped tag (overrides the initial
713                // value set above from info.tag, which in monorepo mode still
714                // contains the prefix).
715                let version = stripped_tag
716                    .strip_prefix('v')
717                    .unwrap_or(stripped_tag)
718                    .to_string();
719                self.template_vars.set("Version", &version);
720
721                // PrefixedPreviousTag = full previous tag (already has prefix).
722                let prev_tag = info.previous_tag.as_deref().unwrap_or("");
723                self.template_vars.set("PrefixedPreviousTag", prev_tag);
724
725                // PreviousTag = prefix stripped, consistent with Tag being stripped.
726                let stripped_prev = crate::git::strip_monorepo_prefix(prev_tag, prefix);
727                self.template_vars.set("PreviousTag", stripped_prev);
728
729                // PrefixedSummary: info.summary from `git describe` already
730                // includes the monorepo prefix (e.g. "subproject1/v1.2.3-0-gabc123d"),
731                // so use it as-is for the prefixed variant.
732                self.template_vars.set("PrefixedSummary", &info.summary);
733                // Summary: strip the monorepo prefix for the base variant.
734                let stripped_summary = crate::git::strip_monorepo_prefix(&info.summary, prefix);
735                self.template_vars.set("Summary", stripped_summary);
736            } else {
737                // Non-monorepo: prepend tag.tag_prefix to construct PrefixedTag.
738                let tag_prefix = self
739                    .config
740                    .tag
741                    .as_ref()
742                    .and_then(|t| t.tag_prefix.as_deref())
743                    .unwrap_or("");
744                self.template_vars
745                    .set("PrefixedTag", &format!("{}{}", tag_prefix, info.tag));
746                let prev_tag = info.previous_tag.as_deref().unwrap_or("");
747                let prefixed_prev = if prev_tag.is_empty() {
748                    String::new()
749                } else {
750                    format!("{}{}", tag_prefix, prev_tag)
751                };
752                self.template_vars
753                    .set("PrefixedPreviousTag", &prefixed_prev);
754                self.template_vars.set(
755                    "PrefixedSummary",
756                    &format!("{}{}", tag_prefix, info.summary),
757                );
758            }
759        }
760
761        self.template_vars.set(
762            "IsSnapshot",
763            if self.options.snapshot {
764                "true"
765            } else {
766                "false"
767            },
768        );
769        self.template_vars.set(
770            "IsNightly",
771            if self.options.nightly {
772                "true"
773            } else {
774                "false"
775            },
776        );
777        // Surfaced to user `if_condition:` templates so stages can
778        // selectively run inside the determinism harness even when
779        // `IsSnapshot == "false"` would otherwise skip them.
780        self.template_vars.set(
781            "IsHarness",
782            if std::env::var_os("ANODIZER_IN_DETERMINISM_HARNESS").is_some() {
783                "true"
784            } else {
785                "false"
786            },
787        );
788        // Wire IsDraft from config (GoReleaser reads ctx.Config.Release.Draft).
789        let is_draft = self
790            .config
791            .release
792            .as_ref()
793            .and_then(|r| r.draft)
794            .unwrap_or(false);
795        self.template_vars
796            .set("IsDraft", if is_draft { "true" } else { "false" });
797        self.template_vars.set(
798            "IsSingleTarget",
799            if self.options.single_target.is_some() {
800                "true"
801            } else {
802                "false"
803            },
804        );
805
806        // Pro addition: IsRelease — true if this is a regular release (not snapshot, not nightly).
807        let is_release = !self.options.snapshot && !self.options.nightly;
808        self.template_vars
809            .set("IsRelease", if is_release { "true" } else { "false" });
810
811        // Pro addition: IsMerging — true if running with --merge flag.
812        self.template_vars.set(
813            "IsMerging",
814            if self.options.merge { "true" } else { "false" },
815        );
816    }
817
818    /// Populate time-related template variables.
819    ///
820    /// Sets:
821    /// - `Date` — UTC time as RFC 3339
822    /// - `Timestamp` — unix timestamp as string
823    /// - `Now` — UTC time as RFC 3339
824    /// - `Year` — four-digit year (e.g. "2026")
825    /// - `Month` — zero-padded month (e.g. "03")
826    /// - `Day` — zero-padded day (e.g. "30")
827    /// - `Hour` — zero-padded hour (e.g. "14")
828    /// - `Minute` — zero-padded minute (e.g. "05")
829    ///
830    /// Time source resolution (first match wins):
831    ///
832    /// 1. `SOURCE_DATE_EPOCH` env var — the standard reproducibility contract
833    ///    (set by the determinism harness on every child release subprocess,
834    ///    and the conventional way external CI / packagers signal a fixed
835    ///    epoch). This is load-bearing for byte-stability of `metadata.json`
836    ///    (which embeds `Date`) and any user template that consumes `Date` /
837    ///    `Timestamp` / `Now`. Without this branch, two from-clean runs of
838    ///    the same commit emit metadata.json files that differ in the `date`
839    ///    field, defeating release-asset idempotency.
840    /// 2. `chrono::Utc::now()` — wall-clock fallback. Matches GoReleaser's
841    ///    legacy semantics for runs without SDE wired in. Note that the
842    ///    GoReleaser template docs explicitly call `.Now` "not deterministic"
843    ///    — under SDE-aware reproducible builds we deviate from that
844    ///    behavior intentionally.
845    pub fn populate_time_vars(&mut self) {
846        // Resolution order (SDE first, else wall-clock) is centralized in
847        // `crate::sde::resolve_now` so any caller — `populate_time_vars`,
848        // Tera built-ins, stage-srpm's `%changelog` date, nightly
849        // `date_str` — sees identical "now" semantics. Earlier this
850        // function inlined the resolution and drifted from the helper.
851        let now = crate::sde::resolve_now();
852        self.template_vars.set("Date", &now.to_rfc3339());
853        self.template_vars
854            .set("Timestamp", &now.timestamp().to_string());
855        self.template_vars.set("Now", &now.to_rfc3339());
856        self.template_vars
857            .set("Year", &now.format("%Y").to_string());
858        self.template_vars
859            .set("Month", &now.format("%m").to_string());
860        self.template_vars.set("Day", &now.format("%d").to_string());
861        self.template_vars
862            .set("Hour", &now.format("%H").to_string());
863        self.template_vars
864            .set("Minute", &now.format("%M").to_string());
865    }
866
867    /// Populate runtime environment variables.
868    ///
869    /// Sets:
870    /// - `RuntimeGoos` — host OS in Go-compatible naming (e.g. "linux", "darwin", "windows")
871    /// - `RuntimeGoarch` — host architecture in Go-compatible naming (e.g. "amd64", "arm64")
872    /// - `Runtime_Goos` / `Runtime_Goarch` — GoReleaser-compatible nested aliases
873    pub fn populate_runtime_vars(&mut self) {
874        let goos = map_os_to_goos(std::env::consts::OS);
875        let goarch = map_arch_to_goarch(std::env::consts::ARCH);
876        self.template_vars.set("RuntimeGoos", goos);
877        self.template_vars.set("RuntimeGoarch", goarch);
878        // GoReleaser uses Runtime.Goos / Runtime.Goarch — after preprocessing
879        // the dot becomes an underscore-separated flat key. We expose both forms.
880        self.template_vars.set("Runtime_Goos", goos);
881        self.template_vars.set("Runtime_Goarch", goarch);
882    }
883
884    /// Populate the `ReleaseNotes` template variable from stored changelogs.
885    ///
886    /// Should be called after the changelog stage has run and populated
887    /// `self.stage_outputs.changelogs`. Uses the first crate (by config
888    /// order) whose changelog is present, or an empty string if no
889    /// changelogs exist. Config order is deterministic, unlike HashMap
890    /// iteration order.
891    pub fn populate_release_notes_var(&mut self) {
892        // Look up changelogs in config-defined crate order for determinism.
893        let notes = self
894            .config
895            .crates
896            .iter()
897            .find_map(|c| self.stage_outputs.changelogs.get(&c.name))
898            .cloned()
899            .unwrap_or_default();
900        self.template_vars.set("ReleaseNotes", &notes);
901    }
902
903    /// Refresh the `Artifacts` structured template variable from the current
904    /// artifact registry. Should be called before rendering release body and
905    /// announce templates so they can iterate over all artifacts.
906    ///
907    /// Each artifact is serialized as a map with keys: `name`, `path`, `target`,
908    /// `kind`, `crate_name`, and `metadata`.
909    ///
910    /// **Known metadata keys** (populated by individual stages):
911    /// - `format` — archive format (e.g. `"tar.gz"`, `"zip"`), set by archive stage
912    /// - `extra_file` — `"true"` when artifact is an extra file, set by checksum stage
913    /// - `extra_name_template` — name template override for extra files, set by checksum stage
914    /// - `digest` — docker image digest (e.g. `sha256:abc123...`), set by docker stage
915    /// - `id` — artifact ID from config, set by docker and build stages
916    /// - `binary` — binary name, set by build stage
917    pub fn refresh_artifacts_var(&mut self) {
918        // CSV metadata keys we expose as JSON arrays for template iteration.
919        // Storage remains HashMap<String,String> (flat); only the
920        // template-exposed view is expanded. Matches GoReleaser's
921        // ExtraBinaries / ExtraFiles list semantics.
922        const CSV_LIST_KEYS: &[&str] = &["extra_binaries", "extra_files"];
923
924        let artifacts_value: Vec<serde_json::Value> = self
925            .artifacts
926            .all()
927            .iter()
928            .map(|a| {
929                // Rebuild metadata map converting known CSV keys into arrays.
930                let mut metadata_map = serde_json::Map::with_capacity(a.metadata.len());
931                for (k, v) in &a.metadata {
932                    if CSV_LIST_KEYS.contains(&k.as_str()) {
933                        let items: Vec<serde_json::Value> = if v.is_empty() {
934                            Vec::new()
935                        } else {
936                            v.split(',')
937                                .map(|s| serde_json::Value::String(s.to_string()))
938                                .collect()
939                        };
940                        metadata_map.insert(k.clone(), serde_json::Value::Array(items));
941                    } else {
942                        metadata_map.insert(k.clone(), serde_json::Value::String(v.clone()));
943                    }
944                }
945                serde_json::json!({
946                    "name": a.name,
947                    "path": a.path.to_string_lossy(),
948                    "target": a.target.as_deref().unwrap_or(""),
949                    "kind": a.kind.as_str(),
950                    "crate_name": a.crate_name,
951                    "metadata": serde_json::Value::Object(metadata_map),
952                })
953            })
954            .collect();
955        // serde_json::Value and tera::Value are the same type under the hood,
956        // so no conversion is needed — pass values directly.
957        let tera_value = tera::Value::Array(artifacts_value);
958        self.template_vars.set_structured("Artifacts", tera_value);
959    }
960
961    /// Populate the `Metadata` structured template variable from config.metadata.
962    ///
963    /// Exposes the project metadata block as a nested map with PascalCase keys
964    /// matching GoReleaser's `.Metadata.*` namespace:
965    /// `Description`, `Homepage`, `License`, `Maintainers`, `ModTimestamp`,
966    /// `FullDescription` (resolved), `CommitAuthor.{Name,Email}`.
967    /// Missing fields default to empty strings / empty arrays.
968    ///
969    /// `full_description` with `from_url` is NOT resolved here (avoids a
970    /// reqwest dep in core); the FromUrl case returns an error and the caller
971    /// should surface it. Inline and FromFile are resolved synchronously.
972    pub fn populate_metadata_var(&mut self) -> anyhow::Result<()> {
973        use crate::config::ContentSource;
974
975        // Clone the small scalar fields so we don't hold a borrow on self.config
976        // across the render_template calls below.
977        let (
978            description,
979            homepage,
980            license,
981            maintainers,
982            mod_timestamp,
983            full_desc_src,
984            commit_author,
985        ) = {
986            let meta = self.config.metadata.as_ref();
987            let description = meta
988                .and_then(|m| m.description.as_deref())
989                .unwrap_or("")
990                .to_string();
991            let homepage = meta
992                .and_then(|m| m.homepage.as_deref())
993                .unwrap_or("")
994                .to_string();
995            let license = meta
996                .and_then(|m| m.license.as_deref())
997                .unwrap_or("")
998                .to_string();
999            let maintainers: Vec<String> = meta
1000                .and_then(|m| m.maintainers.as_ref())
1001                .cloned()
1002                .unwrap_or_default();
1003            let mod_timestamp = meta
1004                .and_then(|m| m.mod_timestamp.as_deref())
1005                .unwrap_or("")
1006                .to_string();
1007            let full_desc_src = meta.and_then(|m| m.full_description.clone());
1008            let commit_author = meta.and_then(|m| m.commit_author.clone());
1009            (
1010                description,
1011                homepage,
1012                license,
1013                maintainers,
1014                mod_timestamp,
1015                full_desc_src,
1016                commit_author,
1017            )
1018        };
1019
1020        // Resolve full_description (Inline + FromFile in-core; FromUrl errors here).
1021        let full_description = match full_desc_src {
1022            None => String::new(),
1023            Some(ContentSource::Inline(s)) => s,
1024            Some(ContentSource::FromFile { from_file }) => {
1025                let rendered_path = self.render_template(&from_file).with_context(|| {
1026                    format!("metadata.full_description: render path '{}'", from_file)
1027                })?;
1028                std::fs::read_to_string(&rendered_path).with_context(|| {
1029                    format!(
1030                        "metadata.full_description: read from_file '{}'",
1031                        rendered_path
1032                    )
1033                })?
1034            }
1035            Some(ContentSource::FromUrl { .. }) => {
1036                anyhow::bail!(
1037                    "metadata.full_description: `from_url` is not yet supported at metadata \
1038                     population time (core has no HTTP client). Use `from_file` with a \
1039                     pre-fetched file, or inline the content. Tracked for future: move \
1040                     URL resolution into a late-pipeline stage or add reqwest to core."
1041                );
1042            }
1043        };
1044
1045        let commit_author_map = serde_json::json!({
1046            "Name": commit_author.as_ref().and_then(|c| c.name.clone()).unwrap_or_default(),
1047            "Email": commit_author.as_ref().and_then(|c| c.email.clone()).unwrap_or_default(),
1048        });
1049
1050        let meta_map = serde_json::json!({
1051            "Description": description,
1052            "Homepage": homepage,
1053            "License": license,
1054            "Maintainers": maintainers,
1055            "ModTimestamp": mod_timestamp,
1056            "FullDescription": full_description,
1057            "CommitAuthor": commit_author_map,
1058        });
1059        // serde_json::Value and tera::Value are the same type, so pass directly.
1060        self.template_vars.set_structured("Metadata", meta_map);
1061        Ok(())
1062    }
1063}
1064
1065/// Map Rust's `std::env::consts::OS` to Go-compatible GOOS naming.
1066/// GoReleaser templates expect Go runtime names (e.g. "darwin" not "macos").
1067pub fn map_os_to_goos(os: &str) -> &str {
1068    match os {
1069        "macos" => "darwin",
1070        other => other, // linux, windows, freebsd, etc. already match
1071    }
1072}
1073
1074/// Map Rust's `std::env::consts::ARCH` to Go-compatible GOARCH naming.
1075/// GoReleaser templates expect Go runtime names (e.g. "amd64" not "x86_64").
1076pub fn map_arch_to_goarch(arch: &str) -> &str {
1077    match arch {
1078        "x86_64" => "amd64",
1079        "x86" => "386",
1080        "aarch64" => "arm64",
1081        "powerpc64" => "ppc64",
1082        "s390x" => "s390x",
1083        "mips" => "mips",
1084        "mips64" => "mips64",
1085        "riscv64" => "riscv64",
1086        other => other,
1087    }
1088}
1089
1090#[cfg(test)]
1091#[allow(clippy::field_reassign_with_default)]
1092mod tests {
1093    use super::*;
1094    use crate::config::Config;
1095    use crate::git::{GitInfo, SemVer};
1096
1097    fn make_git_info(dirty: bool, prerelease: Option<&str>) -> GitInfo {
1098        let tag = match prerelease {
1099            Some(pre) => format!("v1.2.3-{pre}"),
1100            None => "v1.2.3".to_string(),
1101        };
1102        GitInfo {
1103            tag,
1104            commit: "abc123def456abc123def456abc123def456abc1".to_string(),
1105            short_commit: "abc123d".to_string(),
1106            branch: "main".to_string(),
1107            dirty,
1108            semver: SemVer {
1109                major: 1,
1110                minor: 2,
1111                patch: 3,
1112                prerelease: prerelease.map(|s| s.to_string()),
1113                build_metadata: None,
1114            },
1115            commit_date: "2026-03-25T10:30:00+00:00".to_string(),
1116            commit_timestamp: "1774463400".to_string(),
1117            previous_tag: Some("v1.2.2".to_string()),
1118            remote_url: "https://github.com/test/repo.git".to_string(),
1119            summary: "v1.2.3-0-gabc123d".to_string(),
1120            tag_subject: "Release v1.2.3".to_string(),
1121            tag_contents: "Release v1.2.3\n\nFull release notes here.".to_string(),
1122            tag_body: "Full release notes here.".to_string(),
1123            first_commit: None,
1124        }
1125    }
1126
1127    #[test]
1128    fn test_context_template_vars() {
1129        let mut config = Config::default();
1130        config.project_name = "test-project".to_string();
1131        let ctx = Context::new(config, ContextOptions::default());
1132        assert_eq!(
1133            ctx.template_vars().get("ProjectName"),
1134            Some(&"test-project".to_string())
1135        );
1136    }
1137
1138    #[test]
1139    fn test_context_should_skip() {
1140        let config = Config::default();
1141        let opts = ContextOptions {
1142            skip_stages: vec!["publish".to_string(), "announce".to_string()],
1143            ..Default::default()
1144        };
1145        let ctx = Context::new(config, opts);
1146        assert!(ctx.should_skip("publish"));
1147        assert!(ctx.should_skip("announce"));
1148        assert!(!ctx.should_skip("build"));
1149    }
1150
1151    #[test]
1152    fn test_context_render_template() {
1153        let mut config = Config::default();
1154        config.project_name = "myapp".to_string();
1155        let ctx = Context::new(config, ContextOptions::default());
1156        let result = ctx.render_template("{{ .ProjectName }}-release").unwrap();
1157        assert_eq!(result, "myapp-release");
1158    }
1159
1160    #[test]
1161    fn test_populate_git_vars_sets_all_expected_vars() {
1162        let config = Config::default();
1163        let mut ctx = Context::new(config, ContextOptions::default());
1164        ctx.git_info = Some(make_git_info(false, None));
1165        ctx.populate_git_vars();
1166
1167        let v = ctx.template_vars();
1168        assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
1169        assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
1170        assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
1171        assert_eq!(v.get("Major"), Some(&"1".to_string()));
1172        assert_eq!(v.get("Minor"), Some(&"2".to_string()));
1173        assert_eq!(v.get("Patch"), Some(&"3".to_string()));
1174        assert_eq!(v.get("Prerelease"), Some(&"".to_string()));
1175        assert_eq!(
1176            v.get("FullCommit"),
1177            Some(&"abc123def456abc123def456abc123def456abc1".to_string())
1178        );
1179        assert_eq!(v.get("ShortCommit"), Some(&"abc123d".to_string()));
1180        assert_eq!(v.get("Branch"), Some(&"main".to_string()));
1181        assert_eq!(
1182            v.get("CommitDate"),
1183            Some(&"2026-03-25T10:30:00+00:00".to_string())
1184        );
1185        assert_eq!(v.get("CommitTimestamp"), Some(&"1774463400".to_string()));
1186        assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
1187    }
1188
1189    #[test]
1190    fn test_commit_is_alias_for_full_commit() {
1191        let config = Config::default();
1192        let mut ctx = Context::new(config, ContextOptions::default());
1193        ctx.git_info = Some(make_git_info(false, None));
1194        ctx.populate_git_vars();
1195
1196        let v = ctx.template_vars();
1197        assert_eq!(v.get("Commit"), v.get("FullCommit"));
1198    }
1199
1200    #[test]
1201    fn test_populate_git_vars_prerelease() {
1202        let config = Config::default();
1203        let mut ctx = Context::new(config, ContextOptions::default());
1204        ctx.git_info = Some(make_git_info(false, Some("rc.1")));
1205        ctx.populate_git_vars();
1206
1207        let v = ctx.template_vars();
1208        assert_eq!(v.get("Version"), Some(&"1.2.3-rc.1".to_string()));
1209        assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
1210        assert_eq!(v.get("Prerelease"), Some(&"rc.1".to_string()));
1211    }
1212
1213    #[test]
1214    fn test_build_metadata_template_var() {
1215        let config = Config::default();
1216        let mut ctx = Context::new(config, ContextOptions::default());
1217        let mut info = make_git_info(false, None);
1218        info.tag = "v1.2.3+build.42".to_string();
1219        info.semver.build_metadata = Some("build.42".to_string());
1220        ctx.git_info = Some(info);
1221        ctx.populate_git_vars();
1222
1223        let v = ctx.template_vars();
1224        assert_eq!(v.get("BuildMetadata"), Some(&"build.42".to_string()));
1225        // Version should include build metadata (strip v prefix only)
1226        assert_eq!(v.get("Version"), Some(&"1.2.3+build.42".to_string()));
1227    }
1228
1229    #[test]
1230    fn test_build_metadata_empty_when_none() {
1231        let config = Config::default();
1232        let mut ctx = Context::new(config, ContextOptions::default());
1233        ctx.git_info = Some(make_git_info(false, None));
1234        ctx.populate_git_vars();
1235
1236        assert_eq!(
1237            ctx.template_vars().get("BuildMetadata"),
1238            Some(&"".to_string())
1239        );
1240    }
1241
1242    #[test]
1243    fn test_populate_git_vars_monorepo_prefixed_tag() {
1244        // Workspace tags like "core-v0.3.2" should produce Version="0.3.2",
1245        // not "core-v0.3.2" (which breaks RPM Version fields and templates).
1246        let config = Config::default();
1247        let mut ctx = Context::new(config, ContextOptions::default());
1248        let mut info = make_git_info(false, None);
1249        info.tag = "core-v0.3.2".to_string();
1250        info.semver = SemVer {
1251            major: 0,
1252            minor: 3,
1253            patch: 2,
1254            prerelease: None,
1255            build_metadata: None,
1256        };
1257        ctx.git_info = Some(info);
1258        ctx.populate_git_vars();
1259
1260        let v = ctx.template_vars();
1261        assert_eq!(v.get("Tag"), Some(&"core-v0.3.2".to_string()));
1262        assert_eq!(v.get("Version"), Some(&"0.3.2".to_string()));
1263        assert_eq!(v.get("RawVersion"), Some(&"0.3.2".to_string()));
1264        assert_eq!(v.get("Major"), Some(&"0".to_string()));
1265        assert_eq!(v.get("Minor"), Some(&"3".to_string()));
1266        assert_eq!(v.get("Patch"), Some(&"2".to_string()));
1267    }
1268
1269    #[test]
1270    fn test_populate_git_vars_monorepo_prefixed_tag_with_prerelease() {
1271        let config = Config::default();
1272        let mut ctx = Context::new(config, ContextOptions::default());
1273        let mut info = make_git_info(false, None);
1274        info.tag = "operator-v1.0.0-rc.1".to_string();
1275        info.semver = SemVer {
1276            major: 1,
1277            minor: 0,
1278            patch: 0,
1279            prerelease: Some("rc.1".to_string()),
1280            build_metadata: None,
1281        };
1282        ctx.git_info = Some(info);
1283        ctx.populate_git_vars();
1284
1285        let v = ctx.template_vars();
1286        assert_eq!(v.get("Tag"), Some(&"operator-v1.0.0-rc.1".to_string()));
1287        assert_eq!(v.get("Version"), Some(&"1.0.0-rc.1".to_string()));
1288        assert_eq!(v.get("RawVersion"), Some(&"1.0.0".to_string()));
1289    }
1290
1291    #[test]
1292    fn test_git_tree_state_clean() {
1293        let config = Config::default();
1294        let mut ctx = Context::new(config, ContextOptions::default());
1295        ctx.git_info = Some(make_git_info(false, None));
1296        ctx.populate_git_vars();
1297
1298        let v = ctx.template_vars();
1299        assert_eq!(v.get("IsGitDirty"), Some(&"false".to_string()));
1300        assert_eq!(v.get("GitTreeState"), Some(&"clean".to_string()));
1301    }
1302
1303    #[test]
1304    fn test_git_tree_state_dirty() {
1305        let config = Config::default();
1306        let mut ctx = Context::new(config, ContextOptions::default());
1307        ctx.git_info = Some(make_git_info(true, None));
1308        ctx.populate_git_vars();
1309
1310        let v = ctx.template_vars();
1311        assert_eq!(v.get("IsGitDirty"), Some(&"true".to_string()));
1312        assert_eq!(v.get("GitTreeState"), Some(&"dirty".to_string()));
1313    }
1314
1315    #[test]
1316    fn test_is_snapshot_reflects_context_options() {
1317        let config = Config::default();
1318        let opts = ContextOptions {
1319            snapshot: true,
1320            ..Default::default()
1321        };
1322        let mut ctx = Context::new(config, opts);
1323        ctx.git_info = Some(make_git_info(false, None));
1324        ctx.populate_git_vars();
1325
1326        assert_eq!(
1327            ctx.template_vars().get("IsSnapshot"),
1328            Some(&"true".to_string())
1329        );
1330
1331        // Non-snapshot
1332        let config2 = Config::default();
1333        let opts2 = ContextOptions {
1334            snapshot: false,
1335            ..Default::default()
1336        };
1337        let mut ctx2 = Context::new(config2, opts2);
1338        ctx2.git_info = Some(make_git_info(false, None));
1339        ctx2.populate_git_vars();
1340
1341        assert_eq!(
1342            ctx2.template_vars().get("IsSnapshot"),
1343            Some(&"false".to_string())
1344        );
1345    }
1346
1347    #[test]
1348    fn test_is_draft_defaults_to_false() {
1349        let config = Config::default();
1350        let mut ctx = Context::new(config, ContextOptions::default());
1351        ctx.git_info = Some(make_git_info(false, None));
1352        ctx.populate_git_vars();
1353
1354        assert_eq!(
1355            ctx.template_vars().get("IsDraft"),
1356            Some(&"false".to_string())
1357        );
1358    }
1359
1360    #[test]
1361    fn test_previous_tag_empty_when_none() {
1362        let config = Config::default();
1363        let mut ctx = Context::new(config, ContextOptions::default());
1364        let mut info = make_git_info(false, None);
1365        info.previous_tag = None;
1366        ctx.git_info = Some(info);
1367        ctx.populate_git_vars();
1368
1369        assert_eq!(
1370            ctx.template_vars().get("PreviousTag"),
1371            Some(&"".to_string())
1372        );
1373    }
1374
1375    /// Regression: `populate_time_vars` MUST derive `Date` / `Timestamp` /
1376    /// `Now` (and the calendar fields) from `SOURCE_DATE_EPOCH` when the
1377    /// env var is set — the standard reproducible-build contract the
1378    /// determinism harness depends on. Two from-clean runs of the same
1379    /// commit otherwise emit `dist/metadata.json` files that differ in
1380    /// the embedded `date` field, drifting `metadata.json` AND its
1381    /// `.sha256` sidecar across runs. CI run 25975073213 surfaced this
1382    /// drift on every platform shard before the fix landed.
1383    #[test]
1384    #[serial_test::serial(env)]
1385    fn populate_time_vars_uses_source_date_epoch_when_set() {
1386        let key = "SOURCE_DATE_EPOCH";
1387        let prev = std::env::var(key).ok();
1388        // 1_715_000_000 = 2024-05-06T12:53:20+00:00 — picked to be safely
1389        // earlier than wall-clock so a wall-clock-derived assertion would
1390        // visibly fail.
1391        // SAFETY: serialized on the `env` lock group.
1392        unsafe { std::env::set_var(key, "1715000000") };
1393
1394        let config = Config::default();
1395        let mut ctx = Context::new(config, ContextOptions::default());
1396        ctx.populate_time_vars();
1397
1398        let v = ctx.template_vars();
1399        assert_eq!(
1400            v.get("Timestamp"),
1401            Some(&"1715000000".to_string()),
1402            "Timestamp must equal SOURCE_DATE_EPOCH seconds"
1403        );
1404        assert_eq!(
1405            v.get("Date"),
1406            Some(&"2024-05-06T12:53:20+00:00".to_string()),
1407            "Date must be RFC 3339 derived from SDE"
1408        );
1409        assert_eq!(v.get("Year"), Some(&"2024".to_string()));
1410        assert_eq!(v.get("Month"), Some(&"05".to_string()));
1411        assert_eq!(v.get("Day"), Some(&"06".to_string()));
1412
1413        // SAFETY: serialized.
1414        unsafe {
1415            match prev {
1416                Some(v) => std::env::set_var(key, v),
1417                None => std::env::remove_var(key),
1418            }
1419        }
1420    }
1421
1422    #[test]
1423    #[serial_test::serial(env)]
1424    fn test_populate_time_vars() {
1425        // Wall-clock fallback path: clear SDE so we exercise the
1426        // chrono::Utc::now() branch.
1427        let key = "SOURCE_DATE_EPOCH";
1428        let prev = std::env::var(key).ok();
1429        // SAFETY: serialized.
1430        unsafe { std::env::remove_var(key) };
1431
1432        let config = Config::default();
1433        let mut ctx = Context::new(config, ContextOptions::default());
1434        ctx.populate_time_vars();
1435
1436        let v = ctx.template_vars();
1437
1438        // Date should be RFC 3339 format (e.g. 2026-03-30T12:00:00+00:00)
1439        let date = v
1440            .get("Date")
1441            .unwrap_or_else(|| panic!("Date should be set"));
1442        assert!(
1443            date.contains('T') && date.len() > 10,
1444            "Date should be RFC 3339, got: {date}"
1445        );
1446
1447        // Timestamp should be numeric
1448        let ts = v
1449            .get("Timestamp")
1450            .unwrap_or_else(|| panic!("Timestamp should be set"));
1451        assert!(
1452            ts.parse::<i64>().is_ok(),
1453            "Timestamp should be a numeric string, got: {ts}"
1454        );
1455
1456        // Now should be ISO 8601
1457        let now = v.get("Now").unwrap_or_else(|| panic!("Now should be set"));
1458        assert!(now.contains('T'), "Now should be ISO 8601, got: {now}");
1459
1460        // SAFETY: serialized.
1461        unsafe {
1462            if let Some(v) = prev {
1463                std::env::set_var(key, v);
1464            }
1465        }
1466    }
1467
1468    #[test]
1469    fn test_env_vars_accessible_in_templates() {
1470        let mut config = Config::default();
1471        config.project_name = "myapp".to_string();
1472        let mut ctx = Context::new(config, ContextOptions::default());
1473        ctx.template_vars_mut().set_env("MY_VAR", "hello-world");
1474        ctx.template_vars_mut().set_env("DEPLOY_ENV", "staging");
1475
1476        let result = ctx
1477            .render_template("{{ .Env.MY_VAR }}-{{ .Env.DEPLOY_ENV }}")
1478            .unwrap();
1479        assert_eq!(result, "hello-world-staging");
1480    }
1481
1482    #[test]
1483    fn test_populate_git_vars_without_git_info_still_sets_snapshot() {
1484        let config = Config::default();
1485        let opts = ContextOptions {
1486            snapshot: true,
1487            ..Default::default()
1488        };
1489        let mut ctx = Context::new(config, opts);
1490        // Don't set git_info — populate_git_vars should still set IsSnapshot/IsDraft
1491        ctx.populate_git_vars();
1492
1493        assert_eq!(
1494            ctx.template_vars().get("IsSnapshot"),
1495            Some(&"true".to_string())
1496        );
1497        assert_eq!(
1498            ctx.template_vars().get("IsDraft"),
1499            Some(&"false".to_string())
1500        );
1501        // Git-specific vars should NOT be set
1502        assert_eq!(ctx.template_vars().get("Tag"), None);
1503    }
1504
1505    #[test]
1506    fn test_is_nightly_set_when_nightly_mode_active() {
1507        let config = Config::default();
1508        let opts = ContextOptions {
1509            nightly: true,
1510            ..Default::default()
1511        };
1512        let mut ctx = Context::new(config, opts);
1513        ctx.git_info = Some(make_git_info(false, None));
1514        ctx.populate_git_vars();
1515
1516        assert_eq!(
1517            ctx.template_vars().get("IsNightly"),
1518            Some(&"true".to_string()),
1519            "IsNightly should be 'true' when nightly mode is active"
1520        );
1521        assert!(ctx.is_nightly(), "is_nightly() should return true");
1522    }
1523
1524    #[test]
1525    fn test_is_nightly_false_by_default() {
1526        let config = Config::default();
1527        let mut ctx = Context::new(config, ContextOptions::default());
1528        ctx.git_info = Some(make_git_info(false, None));
1529        ctx.populate_git_vars();
1530
1531        assert_eq!(
1532            ctx.template_vars().get("IsNightly"),
1533            Some(&"false".to_string()),
1534            "IsNightly should default to 'false'"
1535        );
1536        assert!(
1537            !ctx.is_nightly(),
1538            "is_nightly() should return false by default"
1539        );
1540    }
1541
1542    #[test]
1543    fn test_version_returns_populated_value() {
1544        let config = Config::default();
1545        let mut ctx = Context::new(config, ContextOptions::default());
1546        ctx.git_info = Some(make_git_info(false, None));
1547        ctx.populate_git_vars();
1548
1549        assert_eq!(ctx.version(), "1.2.3");
1550    }
1551
1552    #[test]
1553    fn test_version_returns_empty_when_not_set() {
1554        let config = Config::default();
1555        let ctx = Context::new(config, ContextOptions::default());
1556        assert_eq!(ctx.version(), "");
1557    }
1558
1559    #[test]
1560    fn test_is_nightly_without_git_info() {
1561        let config = Config::default();
1562        let opts = ContextOptions {
1563            nightly: true,
1564            ..Default::default()
1565        };
1566        let mut ctx = Context::new(config, opts);
1567        // No git_info set — populate_git_vars still sets IsNightly
1568        ctx.populate_git_vars();
1569
1570        assert_eq!(
1571            ctx.template_vars().get("IsNightly"),
1572            Some(&"true".to_string()),
1573            "IsNightly should be set even without git info"
1574        );
1575    }
1576
1577    #[test]
1578    fn test_is_git_clean_when_not_dirty() {
1579        let config = Config::default();
1580        let mut ctx = Context::new(config, ContextOptions::default());
1581        ctx.git_info = Some(make_git_info(false, None));
1582        ctx.populate_git_vars();
1583
1584        assert_eq!(
1585            ctx.template_vars().get("IsGitClean"),
1586            Some(&"true".to_string())
1587        );
1588    }
1589
1590    #[test]
1591    fn test_is_git_clean_when_dirty() {
1592        let config = Config::default();
1593        let mut ctx = Context::new(config, ContextOptions::default());
1594        ctx.git_info = Some(make_git_info(true, None));
1595        ctx.populate_git_vars();
1596
1597        assert_eq!(
1598            ctx.template_vars().get("IsGitClean"),
1599            Some(&"false".to_string())
1600        );
1601    }
1602
1603    #[test]
1604    fn test_git_url_set_from_git_info() {
1605        let config = Config::default();
1606        let mut ctx = Context::new(config, ContextOptions::default());
1607        ctx.git_info = Some(make_git_info(false, None));
1608        ctx.populate_git_vars();
1609
1610        assert_eq!(
1611            ctx.template_vars().get("GitURL"),
1612            Some(&"https://github.com/test/repo.git".to_string())
1613        );
1614    }
1615
1616    #[test]
1617    fn test_summary_set_from_git_info() {
1618        let config = Config::default();
1619        let mut ctx = Context::new(config, ContextOptions::default());
1620        ctx.git_info = Some(make_git_info(false, None));
1621        ctx.populate_git_vars();
1622
1623        assert_eq!(
1624            ctx.template_vars().get("Summary"),
1625            Some(&"v1.2.3-0-gabc123d".to_string())
1626        );
1627    }
1628
1629    #[test]
1630    fn test_tag_subject_set_from_git_info() {
1631        let config = Config::default();
1632        let mut ctx = Context::new(config, ContextOptions::default());
1633        ctx.git_info = Some(make_git_info(false, None));
1634        ctx.populate_git_vars();
1635
1636        assert_eq!(
1637            ctx.template_vars().get("TagSubject"),
1638            Some(&"Release v1.2.3".to_string())
1639        );
1640    }
1641
1642    #[test]
1643    fn test_tag_contents_set_from_git_info() {
1644        let config = Config::default();
1645        let mut ctx = Context::new(config, ContextOptions::default());
1646        ctx.git_info = Some(make_git_info(false, None));
1647        ctx.populate_git_vars();
1648
1649        assert_eq!(
1650            ctx.template_vars().get("TagContents"),
1651            Some(&"Release v1.2.3\n\nFull release notes here.".to_string())
1652        );
1653    }
1654
1655    #[test]
1656    fn test_tag_body_set_from_git_info() {
1657        let config = Config::default();
1658        let mut ctx = Context::new(config, ContextOptions::default());
1659        ctx.git_info = Some(make_git_info(false, None));
1660        ctx.populate_git_vars();
1661
1662        assert_eq!(
1663            ctx.template_vars().get("TagBody"),
1664            Some(&"Full release notes here.".to_string())
1665        );
1666    }
1667
1668    #[test]
1669    fn test_is_single_target_false_by_default() {
1670        let config = Config::default();
1671        let mut ctx = Context::new(config, ContextOptions::default());
1672        ctx.git_info = Some(make_git_info(false, None));
1673        ctx.populate_git_vars();
1674
1675        assert_eq!(
1676            ctx.template_vars().get("IsSingleTarget"),
1677            Some(&"false".to_string())
1678        );
1679    }
1680
1681    #[test]
1682    fn test_is_single_target_true_when_set() {
1683        let config = Config::default();
1684        let opts = ContextOptions {
1685            single_target: Some("x86_64-unknown-linux-gnu".to_string()),
1686            ..Default::default()
1687        };
1688        let mut ctx = Context::new(config, opts);
1689        ctx.git_info = Some(make_git_info(false, None));
1690        ctx.populate_git_vars();
1691
1692        assert_eq!(
1693            ctx.template_vars().get("IsSingleTarget"),
1694            Some(&"true".to_string())
1695        );
1696    }
1697
1698    #[test]
1699    fn test_populate_runtime_vars() {
1700        let config = Config::default();
1701        let mut ctx = Context::new(config, ContextOptions::default());
1702        ctx.populate_runtime_vars();
1703
1704        let v = ctx.template_vars();
1705
1706        let goos = v
1707            .get("RuntimeGoos")
1708            .unwrap_or_else(|| panic!("RuntimeGoos should be set"));
1709        assert!(
1710            !goos.is_empty(),
1711            "RuntimeGoos should not be empty, got: {goos}"
1712        );
1713        // RuntimeGoos uses Go naming (e.g. "darwin" not "macos")
1714        assert_eq!(goos, map_os_to_goos(std::env::consts::OS));
1715
1716        let goarch = v
1717            .get("RuntimeGoarch")
1718            .unwrap_or_else(|| panic!("RuntimeGoarch should be set"));
1719        assert!(
1720            !goarch.is_empty(),
1721            "RuntimeGoarch should not be empty, got: {goarch}"
1722        );
1723        // RuntimeGoarch uses Go naming (e.g. "amd64" not "x86_64")
1724        assert_eq!(goarch, map_arch_to_goarch(std::env::consts::ARCH));
1725    }
1726
1727    #[test]
1728    fn test_populate_release_notes_var_with_changelogs() {
1729        let mut config = Config::default();
1730        config.crates.push(crate::config::CrateConfig {
1731            name: "my-crate".to_string(),
1732            ..Default::default()
1733        });
1734        let mut ctx = Context::new(config, ContextOptions::default());
1735        ctx.stage_outputs
1736            .changelogs
1737            .insert("my-crate".to_string(), "## Changes\n- fix bug".to_string());
1738        ctx.populate_release_notes_var();
1739
1740        assert_eq!(
1741            ctx.template_vars().get("ReleaseNotes"),
1742            Some(&"## Changes\n- fix bug".to_string())
1743        );
1744    }
1745
1746    #[test]
1747    fn test_populate_release_notes_var_empty_when_no_changelogs() {
1748        let config = Config::default();
1749        let mut ctx = Context::new(config, ContextOptions::default());
1750        ctx.populate_release_notes_var();
1751
1752        assert_eq!(
1753            ctx.template_vars().get("ReleaseNotes"),
1754            Some(&"".to_string())
1755        );
1756    }
1757
1758    #[test]
1759    fn test_populate_release_notes_var_deterministic_with_multiple_crates() {
1760        let mut config = Config::default();
1761        config.crates.push(crate::config::CrateConfig {
1762            name: "crate-a".to_string(),
1763            ..Default::default()
1764        });
1765        config.crates.push(crate::config::CrateConfig {
1766            name: "crate-b".to_string(),
1767            ..Default::default()
1768        });
1769        let mut ctx = Context::new(config, ContextOptions::default());
1770        ctx.stage_outputs
1771            .changelogs
1772            .insert("crate-a".to_string(), "notes-a".to_string());
1773        ctx.stage_outputs
1774            .changelogs
1775            .insert("crate-b".to_string(), "notes-b".to_string());
1776        ctx.populate_release_notes_var();
1777
1778        // Should always pick the first crate in config order, not arbitrary HashMap order
1779        assert_eq!(
1780            ctx.template_vars().get("ReleaseNotes"),
1781            Some(&"notes-a".to_string())
1782        );
1783    }
1784
1785    #[test]
1786    fn test_outputs_accessible_in_templates() {
1787        let mut config = Config::default();
1788        config.project_name = "myapp".to_string();
1789        let mut ctx = Context::new(config, ContextOptions::default());
1790        ctx.template_vars_mut().set_output("build_id", "abc123");
1791        ctx.template_vars_mut()
1792            .set_output("deploy_url", "https://example.com");
1793
1794        let result = ctx
1795            .render_template("{{ .Outputs.build_id }}-{{ .Outputs.deploy_url }}")
1796            .unwrap();
1797        assert_eq!(result, "abc123-https://example.com");
1798    }
1799
1800    #[test]
1801    fn test_artifact_ext_and_target_template_vars() {
1802        let mut config = Config::default();
1803        config.project_name = "myapp".to_string();
1804        let mut ctx = Context::new(config, ContextOptions::default());
1805        ctx.template_vars_mut().set("ArtifactName", "myapp.tar.gz");
1806        ctx.template_vars_mut().set("ArtifactExt", ".tar.gz");
1807        ctx.template_vars_mut()
1808            .set("Target", "x86_64-unknown-linux-gnu");
1809
1810        let result = ctx
1811            .render_template("{{ .ArtifactExt }}_{{ .Target }}")
1812            .unwrap();
1813        assert_eq!(result, ".tar.gz_x86_64-unknown-linux-gnu");
1814    }
1815
1816    #[test]
1817    fn test_checksums_template_var() {
1818        let mut config = Config::default();
1819        config.project_name = "myapp".to_string();
1820        let mut ctx = Context::new(config, ContextOptions::default());
1821        let checksum_text = "abc123  myapp.tar.gz\ndef456  myapp.zip\n";
1822        ctx.template_vars_mut().set("Checksums", checksum_text);
1823
1824        let result = ctx.render_template("{{ .Checksums }}").unwrap();
1825        assert_eq!(result, checksum_text);
1826    }
1827
1828    // --- Pro template variable tests ---
1829
1830    #[test]
1831    fn test_prefixed_tag_with_tag_prefix() {
1832        let mut config = Config::default();
1833        config.tag = Some(crate::config::TagConfig {
1834            tag_prefix: Some("api/".to_string()),
1835            ..Default::default()
1836        });
1837        let mut ctx = Context::new(config, ContextOptions::default());
1838        ctx.git_info = Some(make_git_info(false, None));
1839        ctx.populate_git_vars();
1840
1841        assert_eq!(
1842            ctx.template_vars().get("PrefixedTag"),
1843            Some(&"api/v1.2.3".to_string())
1844        );
1845    }
1846
1847    #[test]
1848    fn test_prefixed_tag_without_tag_prefix() {
1849        let config = Config::default();
1850        let mut ctx = Context::new(config, ContextOptions::default());
1851        ctx.git_info = Some(make_git_info(false, None));
1852        ctx.populate_git_vars();
1853
1854        // No tag_prefix configured — PrefixedTag should equal Tag
1855        assert_eq!(
1856            ctx.template_vars().get("PrefixedTag"),
1857            Some(&"v1.2.3".to_string())
1858        );
1859    }
1860
1861    #[test]
1862    fn test_prefixed_previous_tag_with_tag_prefix() {
1863        let mut config = Config::default();
1864        config.tag = Some(crate::config::TagConfig {
1865            tag_prefix: Some("api/".to_string()),
1866            ..Default::default()
1867        });
1868        let mut ctx = Context::new(config, ContextOptions::default());
1869        ctx.git_info = Some(make_git_info(false, None));
1870        ctx.populate_git_vars();
1871
1872        assert_eq!(
1873            ctx.template_vars().get("PrefixedPreviousTag"),
1874            Some(&"api/v1.2.2".to_string())
1875        );
1876    }
1877
1878    #[test]
1879    fn test_prefixed_previous_tag_empty_when_no_previous() {
1880        let mut config = Config::default();
1881        config.tag = Some(crate::config::TagConfig {
1882            tag_prefix: Some("api/".to_string()),
1883            ..Default::default()
1884        });
1885        let mut ctx = Context::new(config, ContextOptions::default());
1886        let mut info = make_git_info(false, None);
1887        info.previous_tag = None;
1888        ctx.git_info = Some(info);
1889        ctx.populate_git_vars();
1890
1891        // When there is no previous tag, PrefixedPreviousTag should be empty
1892        // (not just the prefix), matching GoReleaser behavior.
1893        assert_eq!(
1894            ctx.template_vars().get("PrefixedPreviousTag"),
1895            Some(&"".to_string())
1896        );
1897    }
1898
1899    #[test]
1900    fn test_prefixed_summary_with_tag_prefix() {
1901        let mut config = Config::default();
1902        config.tag = Some(crate::config::TagConfig {
1903            tag_prefix: Some("api/".to_string()),
1904            ..Default::default()
1905        });
1906        let mut ctx = Context::new(config, ContextOptions::default());
1907        ctx.git_info = Some(make_git_info(false, None));
1908        ctx.populate_git_vars();
1909
1910        assert_eq!(
1911            ctx.template_vars().get("PrefixedSummary"),
1912            Some(&"api/v1.2.3-0-gabc123d".to_string())
1913        );
1914    }
1915
1916    #[test]
1917    fn test_is_release_true_for_normal_release() {
1918        let config = Config::default();
1919        let opts = ContextOptions {
1920            snapshot: false,
1921            nightly: false,
1922            ..Default::default()
1923        };
1924        let mut ctx = Context::new(config, opts);
1925        ctx.git_info = Some(make_git_info(false, None));
1926        ctx.populate_git_vars();
1927
1928        assert_eq!(
1929            ctx.template_vars().get("IsRelease"),
1930            Some(&"true".to_string())
1931        );
1932    }
1933
1934    #[test]
1935    fn test_is_release_false_for_snapshot() {
1936        let config = Config::default();
1937        let opts = ContextOptions {
1938            snapshot: true,
1939            ..Default::default()
1940        };
1941        let mut ctx = Context::new(config, opts);
1942        ctx.git_info = Some(make_git_info(false, None));
1943        ctx.populate_git_vars();
1944
1945        assert_eq!(
1946            ctx.template_vars().get("IsRelease"),
1947            Some(&"false".to_string())
1948        );
1949    }
1950
1951    #[test]
1952    fn test_is_release_false_for_nightly() {
1953        let config = Config::default();
1954        let opts = ContextOptions {
1955            nightly: true,
1956            ..Default::default()
1957        };
1958        let mut ctx = Context::new(config, opts);
1959        ctx.git_info = Some(make_git_info(false, None));
1960        ctx.populate_git_vars();
1961
1962        assert_eq!(
1963            ctx.template_vars().get("IsRelease"),
1964            Some(&"false".to_string())
1965        );
1966    }
1967
1968    #[test]
1969    fn test_is_merging_true_when_merge_flag_set() {
1970        let config = Config::default();
1971        let opts = ContextOptions {
1972            merge: true,
1973            ..Default::default()
1974        };
1975        let mut ctx = Context::new(config, opts);
1976        ctx.git_info = Some(make_git_info(false, None));
1977        ctx.populate_git_vars();
1978
1979        assert_eq!(
1980            ctx.template_vars().get("IsMerging"),
1981            Some(&"true".to_string())
1982        );
1983    }
1984
1985    #[test]
1986    fn test_is_merging_false_by_default() {
1987        let config = Config::default();
1988        let mut ctx = Context::new(config, ContextOptions::default());
1989        ctx.git_info = Some(make_git_info(false, None));
1990        ctx.populate_git_vars();
1991
1992        assert_eq!(
1993            ctx.template_vars().get("IsMerging"),
1994            Some(&"false".to_string())
1995        );
1996    }
1997
1998    #[test]
1999    fn test_refresh_artifacts_var_empty() {
2000        let config = Config::default();
2001        let mut ctx = Context::new(config, ContextOptions::default());
2002        ctx.refresh_artifacts_var();
2003
2004        // Should render as an empty array
2005        let result = ctx
2006            .render_template("{% for a in Artifacts %}{{ a.name }}{% endfor %}")
2007            .unwrap();
2008        assert_eq!(result, "");
2009    }
2010
2011    #[test]
2012    fn test_refresh_artifacts_var_with_artifacts() {
2013        use crate::artifact::{Artifact, ArtifactKind};
2014        use std::collections::HashMap;
2015        use std::path::PathBuf;
2016
2017        let config = Config::default();
2018        let mut ctx = Context::new(config, ContextOptions::default());
2019        // Artifacts are created with empty `name` — ArtifactRegistry::add()
2020        // auto-derives the name from the path's filename component when name
2021        // is empty (see artifact.rs add() implementation).
2022        ctx.artifacts.add(Artifact {
2023            kind: ArtifactKind::Archive,
2024            name: String::new(),
2025            path: PathBuf::from("dist/myapp-1.0.0-linux-amd64.tar.gz"),
2026            target: Some("x86_64-unknown-linux-gnu".to_string()),
2027            crate_name: "myapp".to_string(),
2028            metadata: HashMap::from([("format".to_string(), "tar.gz".to_string())]),
2029            size: None,
2030        });
2031        ctx.artifacts.add(Artifact {
2032            kind: ArtifactKind::Binary,
2033            name: String::new(),
2034            path: PathBuf::from("dist/myapp"),
2035            target: Some("x86_64-unknown-linux-gnu".to_string()),
2036            crate_name: "myapp".to_string(),
2037            metadata: HashMap::new(),
2038            size: None,
2039        });
2040        ctx.refresh_artifacts_var();
2041
2042        // Iterate over artifacts and collect names
2043        let result = ctx
2044            .render_template("{% for a in Artifacts %}{{ a.name }},{% endfor %}")
2045            .unwrap();
2046        assert!(result.contains("myapp-1.0.0-linux-amd64.tar.gz"));
2047        assert!(result.contains("myapp"));
2048
2049        // Check kind field
2050        let result_kinds = ctx
2051            .render_template("{% for a in Artifacts %}{{ a.kind }},{% endfor %}")
2052            .unwrap();
2053        assert!(result_kinds.contains("archive"));
2054        assert!(result_kinds.contains("binary"));
2055    }
2056
2057    #[test]
2058    fn test_populate_metadata_var_with_mod_timestamp() {
2059        let mut config = Config::default();
2060        config.metadata = Some(crate::config::MetadataConfig {
2061            mod_timestamp: Some("{{ .CommitTimestamp }}".to_string()),
2062            ..Default::default()
2063        });
2064        let mut ctx = Context::new(config, ContextOptions::default());
2065        ctx.populate_metadata_var().unwrap();
2066
2067        // Metadata should be accessible as a nested map with PascalCase keys
2068        let result = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
2069        assert_eq!(result, "{{ .CommitTimestamp }}");
2070    }
2071
2072    #[test]
2073    fn test_populate_metadata_var_empty_when_no_config() {
2074        let config = Config::default();
2075        let mut ctx = Context::new(config, ContextOptions::default());
2076        ctx.populate_metadata_var().unwrap();
2077
2078        // Should render empty strings for missing fields (PascalCase keys)
2079        let result = ctx.render_template("{{ Metadata.Description }}").unwrap();
2080        assert_eq!(result, "");
2081    }
2082
2083    #[test]
2084    fn test_populate_metadata_var_reads_from_config() {
2085        let mut config = Config::default();
2086        config.metadata = Some(crate::config::MetadataConfig {
2087            description: Some("A test project".to_string()),
2088            homepage: Some("https://example.com".to_string()),
2089            license: Some("MIT".to_string()),
2090            maintainers: Some(vec!["Alice".to_string(), "Bob".to_string()]),
2091            mod_timestamp: Some("1234567890".to_string()),
2092            ..Default::default()
2093        });
2094        let mut ctx = Context::new(config, ContextOptions::default());
2095        ctx.populate_metadata_var().unwrap();
2096
2097        let desc = ctx.render_template("{{ Metadata.Description }}").unwrap();
2098        assert_eq!(desc, "A test project");
2099
2100        let home = ctx.render_template("{{ Metadata.Homepage }}").unwrap();
2101        assert_eq!(home, "https://example.com");
2102
2103        let lic = ctx.render_template("{{ Metadata.License }}").unwrap();
2104        assert_eq!(lic, "MIT");
2105
2106        let ts = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
2107        assert_eq!(ts, "1234567890");
2108    }
2109
2110    #[test]
2111    fn test_populate_metadata_var_full_description_inline() {
2112        use crate::config::ContentSource;
2113        let mut config = Config::default();
2114        config.metadata = Some(crate::config::MetadataConfig {
2115            full_description: Some(ContentSource::Inline(
2116                "A long-form description of the project.".to_string(),
2117            )),
2118            ..Default::default()
2119        });
2120        let mut ctx = Context::new(config, ContextOptions::default());
2121        ctx.populate_metadata_var().unwrap();
2122        let rendered = ctx
2123            .render_template("{{ Metadata.FullDescription }}")
2124            .unwrap();
2125        assert_eq!(rendered, "A long-form description of the project.");
2126    }
2127
2128    #[test]
2129    fn test_populate_metadata_var_full_description_from_file() {
2130        use crate::config::ContentSource;
2131        let tmp = tempfile::tempdir().unwrap();
2132        let desc_path = tmp.path().join("DESCRIPTION.md");
2133        std::fs::write(&desc_path, "read from disk").unwrap();
2134        let mut config = Config::default();
2135        config.metadata = Some(crate::config::MetadataConfig {
2136            full_description: Some(ContentSource::FromFile {
2137                from_file: desc_path.to_string_lossy().into_owned(),
2138            }),
2139            ..Default::default()
2140        });
2141        let mut ctx = Context::new(config, ContextOptions::default());
2142        ctx.populate_metadata_var().unwrap();
2143        let rendered = ctx
2144            .render_template("{{ Metadata.FullDescription }}")
2145            .unwrap();
2146        assert_eq!(rendered, "read from disk");
2147    }
2148
2149    #[test]
2150    fn test_populate_metadata_var_full_description_from_url_errors() {
2151        // Avoids silent-skip footgun (see W1 in pro-features-audit.md). If the user
2152        // configures from_url for metadata.full_description, emit a clear, actionable
2153        // error at context-populate time rather than quietly shipping an empty string.
2154        use crate::config::ContentSource;
2155        let mut config = Config::default();
2156        config.metadata = Some(crate::config::MetadataConfig {
2157            full_description: Some(ContentSource::FromUrl {
2158                from_url: "https://example.com/description.md".to_string(),
2159                headers: None,
2160            }),
2161            ..Default::default()
2162        });
2163        let mut ctx = Context::new(config, ContextOptions::default());
2164        let err = ctx
2165            .populate_metadata_var()
2166            .expect_err("from_url must error");
2167        let msg = format!("{:#}", err);
2168        assert!(
2169            msg.contains("metadata.full_description") && msg.contains("from_url"),
2170            "error should mention the feature + limitation, got: {msg}"
2171        );
2172    }
2173
2174    #[test]
2175    fn test_populate_metadata_var_commit_author() {
2176        use crate::config::CommitAuthorConfig;
2177        let mut config = Config::default();
2178        config.metadata = Some(crate::config::MetadataConfig {
2179            commit_author: Some(CommitAuthorConfig {
2180                name: Some("Alice Developer".to_string()),
2181                email: Some("alice@example.com".to_string()),
2182                signing: None,
2183                use_github_app_token: false,
2184            }),
2185            ..Default::default()
2186        });
2187        let mut ctx = Context::new(config, ContextOptions::default());
2188        ctx.populate_metadata_var().unwrap();
2189        let name = ctx
2190            .render_template("{{ Metadata.CommitAuthor.Name }}")
2191            .unwrap();
2192        assert_eq!(name, "Alice Developer");
2193        let email = ctx
2194            .render_template("{{ Metadata.CommitAuthor.Email }}")
2195            .unwrap();
2196        assert_eq!(email, "alice@example.com");
2197    }
2198
2199    #[test]
2200    fn test_artifact_id_template_var() {
2201        let mut config = Config::default();
2202        config.project_name = "myapp".to_string();
2203        let mut ctx = Context::new(config, ContextOptions::default());
2204        ctx.template_vars_mut().set("ArtifactID", "default");
2205
2206        let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
2207        assert_eq!(result, "default");
2208    }
2209
2210    #[test]
2211    fn test_artifact_id_empty_when_not_set() {
2212        let mut config = Config::default();
2213        config.project_name = "myapp".to_string();
2214        let mut ctx = Context::new(config, ContextOptions::default());
2215        ctx.template_vars_mut().set("ArtifactID", "");
2216
2217        let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
2218        assert_eq!(result, "");
2219    }
2220
2221    #[test]
2222    fn test_pro_vars_rendered_in_templates() {
2223        // Test that all Pro vars can be used in templates together
2224        let mut config = Config::default();
2225        config.tag = Some(crate::config::TagConfig {
2226            tag_prefix: Some("api/".to_string()),
2227            ..Default::default()
2228        });
2229        let opts = ContextOptions {
2230            snapshot: false,
2231            nightly: false,
2232            merge: true,
2233            ..Default::default()
2234        };
2235        let mut ctx = Context::new(config, opts);
2236        ctx.git_info = Some(make_git_info(false, None));
2237        ctx.populate_git_vars();
2238
2239        let result = ctx
2240            .render_template(
2241                "{% if IsRelease %}release{% endif %}-{% if IsMerging %}merge{% endif %}-{{ .PrefixedTag }}",
2242            )
2243            .unwrap();
2244        assert_eq!(result, "release-merge-api/v1.2.3");
2245    }
2246
2247    #[test]
2248    fn test_is_release_without_git_info() {
2249        // IsRelease should still be set even without git info
2250        let config = Config::default();
2251        let opts = ContextOptions {
2252            snapshot: false,
2253            nightly: false,
2254            ..Default::default()
2255        };
2256        let mut ctx = Context::new(config, opts);
2257        ctx.populate_git_vars();
2258
2259        assert_eq!(
2260            ctx.template_vars().get("IsRelease"),
2261            Some(&"true".to_string())
2262        );
2263    }
2264
2265    #[test]
2266    fn test_is_merging_without_git_info() {
2267        // IsMerging should still be set even without git info
2268        let config = Config::default();
2269        let opts = ContextOptions {
2270            merge: true,
2271            ..Default::default()
2272        };
2273        let mut ctx = Context::new(config, opts);
2274        ctx.populate_git_vars();
2275
2276        assert_eq!(
2277            ctx.template_vars().get("IsMerging"),
2278            Some(&"true".to_string())
2279        );
2280    }
2281
2282    // -----------------------------------------------------------------------
2283    // Monorepo template variable tests
2284    // -----------------------------------------------------------------------
2285
2286    #[test]
2287    fn test_monorepo_tag_prefix_strips_tag_for_template_var() {
2288        let mut config = Config::default();
2289        config.monorepo = Some(crate::config::MonorepoConfig {
2290            tag_prefix: Some("subproject1/".to_string()),
2291            dir: None,
2292        });
2293        let mut ctx = Context::new(config, ContextOptions::default());
2294
2295        // Simulate a monorepo tag: the full prefixed tag is stored in git_info.
2296        let mut info = make_git_info(false, None);
2297        info.tag = "subproject1/v1.2.3".to_string();
2298        info.previous_tag = Some("subproject1/v1.2.2".to_string());
2299        info.summary = "subproject1/v1.2.3-0-gabc123d".to_string();
2300        ctx.git_info = Some(info);
2301        ctx.populate_git_vars();
2302
2303        let v = ctx.template_vars();
2304        // Tag should have the prefix stripped.
2305        assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2306        // Version should derive from stripped tag.
2307        assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
2308        // PrefixedTag should retain the full tag.
2309        assert_eq!(
2310            v.get("PrefixedTag"),
2311            Some(&"subproject1/v1.2.3".to_string())
2312        );
2313        // PreviousTag should be stripped (consistent with Tag).
2314        assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
2315        // PrefixedPreviousTag should retain the full tag.
2316        assert_eq!(
2317            v.get("PrefixedPreviousTag"),
2318            Some(&"subproject1/v1.2.2".to_string())
2319        );
2320        // Summary should be stripped.
2321        assert_eq!(v.get("Summary"), Some(&"v1.2.3-0-gabc123d".to_string()));
2322        // PrefixedSummary should retain the full summary.
2323        assert_eq!(
2324            v.get("PrefixedSummary"),
2325            Some(&"subproject1/v1.2.3-0-gabc123d".to_string())
2326        );
2327    }
2328
2329    #[test]
2330    fn test_monorepo_prefixed_previous_tag() {
2331        let mut config = Config::default();
2332        config.monorepo = Some(crate::config::MonorepoConfig {
2333            tag_prefix: Some("svc/".to_string()),
2334            dir: None,
2335        });
2336        let mut ctx = Context::new(config, ContextOptions::default());
2337
2338        let mut info = make_git_info(false, None);
2339        info.tag = "svc/v2.0.0".to_string();
2340        info.previous_tag = Some("svc/v1.9.0".to_string());
2341        ctx.git_info = Some(info);
2342        ctx.populate_git_vars();
2343
2344        let v = ctx.template_vars();
2345        // PrefixedPreviousTag should be the full previous tag.
2346        assert_eq!(
2347            v.get("PrefixedPreviousTag"),
2348            Some(&"svc/v1.9.0".to_string())
2349        );
2350        // PreviousTag should be stripped (prefix removed), consistent with Tag.
2351        assert_eq!(v.get("PreviousTag"), Some(&"v1.9.0".to_string()));
2352    }
2353
2354    #[test]
2355    fn test_no_monorepo_falls_back_to_tag_prefix() {
2356        // When monorepo is not set, PrefixedTag should use tag.tag_prefix.
2357        let mut config = Config::default();
2358        config.tag = Some(crate::config::TagConfig {
2359            tag_prefix: Some("release/".to_string()),
2360            ..Default::default()
2361        });
2362        let mut ctx = Context::new(config, ContextOptions::default());
2363        ctx.git_info = Some(make_git_info(false, None));
2364        ctx.populate_git_vars();
2365
2366        let v = ctx.template_vars();
2367        // Tag is plain "v1.2.3" (not stripped because no monorepo).
2368        assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2369        // PrefixedTag should prepend tag_prefix.
2370        assert_eq!(v.get("PrefixedTag"), Some(&"release/v1.2.3".to_string()));
2371        assert_eq!(
2372            v.get("PrefixedPreviousTag"),
2373            Some(&"release/v1.2.2".to_string())
2374        );
2375    }
2376
2377    #[test]
2378    fn test_monorepo_overrides_tag_prefix_for_prefixed_vars() {
2379        // When both monorepo.tag_prefix and tag.tag_prefix are set,
2380        // monorepo should take precedence for PrefixedTag.
2381        let mut config = Config::default();
2382        config.tag = Some(crate::config::TagConfig {
2383            tag_prefix: Some("release/".to_string()),
2384            ..Default::default()
2385        });
2386        config.monorepo = Some(crate::config::MonorepoConfig {
2387            tag_prefix: Some("svc/".to_string()),
2388            dir: None,
2389        });
2390        let mut ctx = Context::new(config, ContextOptions::default());
2391
2392        let mut info = make_git_info(false, None);
2393        info.tag = "svc/v1.2.3".to_string();
2394        info.previous_tag = Some("svc/v1.2.2".to_string());
2395        ctx.git_info = Some(info);
2396        ctx.populate_git_vars();
2397
2398        let v = ctx.template_vars();
2399        // Monorepo takes precedence: Tag is stripped.
2400        assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
2401        // PrefixedTag is the full monorepo tag, NOT tag_prefix-prepended.
2402        assert_eq!(v.get("PrefixedTag"), Some(&"svc/v1.2.3".to_string()));
2403    }
2404
2405    #[test]
2406    fn test_monorepo_prefixed_summary() {
2407        let mut config = Config::default();
2408        config.monorepo = Some(crate::config::MonorepoConfig {
2409            tag_prefix: Some("pkg/".to_string()),
2410            dir: None,
2411        });
2412        let mut ctx = Context::new(config, ContextOptions::default());
2413
2414        let mut info = make_git_info(false, None);
2415        info.tag = "pkg/v1.2.3".to_string();
2416        // In a real monorepo, `git describe` already includes the prefix in the summary.
2417        info.summary = "pkg/v1.2.3-0-gabc123d".to_string();
2418        ctx.git_info = Some(info);
2419        ctx.populate_git_vars();
2420
2421        // PrefixedSummary is info.summary as-is (already contains prefix).
2422        assert_eq!(
2423            ctx.template_vars().get("PrefixedSummary"),
2424            Some(&"pkg/v1.2.3-0-gabc123d".to_string())
2425        );
2426        // Summary should have the prefix stripped.
2427        assert_eq!(
2428            ctx.template_vars().get("Summary"),
2429            Some(&"v1.2.3-0-gabc123d".to_string())
2430        );
2431    }
2432
2433    #[test]
2434    fn test_monorepo_no_previous_tag() {
2435        let mut config = Config::default();
2436        config.monorepo = Some(crate::config::MonorepoConfig {
2437            tag_prefix: Some("svc/".to_string()),
2438            dir: None,
2439        });
2440        let mut ctx = Context::new(config, ContextOptions::default());
2441
2442        let mut info = make_git_info(false, None);
2443        info.tag = "svc/v1.0.0".to_string();
2444        info.previous_tag = None;
2445        ctx.git_info = Some(info);
2446        ctx.populate_git_vars();
2447
2448        let v = ctx.template_vars();
2449        assert_eq!(v.get("PrefixedPreviousTag"), Some(&"".to_string()));
2450        // PreviousTag should also be empty when no previous tag exists.
2451        assert_eq!(v.get("PreviousTag"), Some(&"".to_string()));
2452    }
2453
2454    // -----------------------------------------------------------------------
2455    // Integration test: full monorepo flow
2456    // -----------------------------------------------------------------------
2457
2458    #[test]
2459    fn test_monorepo_full_flow_all_vars() {
2460        // End-to-end test: config with monorepo.tag_prefix + dir
2461        // → context creation → populate_git_vars → verify ALL template vars.
2462        let mut config = Config::default();
2463        config.project_name = "mymonorepo".to_string();
2464        config.monorepo = Some(crate::config::MonorepoConfig {
2465            tag_prefix: Some("services/api/".to_string()),
2466            dir: Some("services/api".to_string()),
2467        });
2468
2469        // Verify Config helper methods work
2470        assert_eq!(config.monorepo_tag_prefix(), Some("services/api/"));
2471        assert_eq!(config.monorepo_dir(), Some("services/api"));
2472
2473        let mut ctx = Context::new(config, ContextOptions::default());
2474
2475        // Simulate git info as it would appear in a monorepo:
2476        // tag and summary already contain the prefix from git.
2477        let mut info = make_git_info(false, None);
2478        info.tag = "services/api/v2.1.0".to_string();
2479        info.previous_tag = Some("services/api/v2.0.5".to_string());
2480        info.summary = "services/api/v2.1.0-0-gabc123d".to_string();
2481        info.semver = crate::git::SemVer {
2482            major: 2,
2483            minor: 1,
2484            patch: 0,
2485            prerelease: None,
2486            build_metadata: None,
2487        };
2488        ctx.git_info = Some(info);
2489        ctx.populate_git_vars();
2490
2491        let v = ctx.template_vars();
2492
2493        // Base vars should have the prefix STRIPPED.
2494        assert_eq!(v.get("Tag"), Some(&"v2.1.0".to_string()));
2495        assert_eq!(v.get("Version"), Some(&"2.1.0".to_string()));
2496        assert_eq!(v.get("RawVersion"), Some(&"2.1.0".to_string()));
2497        assert_eq!(v.get("Major"), Some(&"2".to_string()));
2498        assert_eq!(v.get("Minor"), Some(&"1".to_string()));
2499        assert_eq!(v.get("Patch"), Some(&"0".to_string()));
2500        assert_eq!(v.get("PreviousTag"), Some(&"v2.0.5".to_string()));
2501        assert_eq!(v.get("Summary"), Some(&"v2.1.0-0-gabc123d".to_string()));
2502
2503        // Prefixed vars should retain the FULL prefix.
2504        assert_eq!(
2505            v.get("PrefixedTag"),
2506            Some(&"services/api/v2.1.0".to_string())
2507        );
2508        assert_eq!(
2509            v.get("PrefixedPreviousTag"),
2510            Some(&"services/api/v2.0.5".to_string())
2511        );
2512        assert_eq!(
2513            v.get("PrefixedSummary"),
2514            Some(&"services/api/v2.1.0-0-gabc123d".to_string())
2515        );
2516
2517        // Project name should be available.
2518        assert_eq!(v.get("ProjectName"), Some(&"mymonorepo".to_string()));
2519    }
2520}