Skip to main content

anodizer_core/
context.rs

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