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