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