Skip to main content

anodizer_core/
context.rs

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