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