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