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