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