Skip to main content

anodizer_core/
context.rs

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