Skip to main content

anodizer_core/config/
mod.rs

1use std::collections::{BTreeMap, HashMap};
2use std::path::PathBuf;
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7// ---------------------------------------------------------------------------
8// Include specification types
9// ---------------------------------------------------------------------------
10
11/// An include specification: either a plain path string or a structured from_file/from_url.
12///
13/// YAML examples:
14/// ```yaml
15/// includes:
16///   - ./defaults.yaml                           # plain string (backward compat)
17///   - from_file:
18///       path: ./config/release.yaml              # structured file path
19///   - from_url:
20///       url: https://example.com/config.yaml     # URL fetch
21///       headers:
22///         x-api-token: "${MYCOMPANY_TOKEN}"       # env var expansion in headers
23/// ```
24#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
25#[serde(untagged)]
26pub enum IncludeSpec {
27    /// Plain string path (backward compatible): "path/to/file.yaml"
28    Path(String),
29    /// Structured file include with `from_file.path`.
30    FromFile { from_file: IncludeFilePath },
31    /// Structured URL include with `from_url.url` and optional headers.
32    FromUrl { from_url: IncludeUrlConfig },
33}
34
35/// File path for a structured include.
36#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
37#[serde(deny_unknown_fields)]
38pub struct IncludeFilePath {
39    /// Path to the include file (relative to the config file).
40    pub path: String,
41}
42
43/// URL configuration for a structured include.
44#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
45#[serde(deny_unknown_fields)]
46pub struct IncludeUrlConfig {
47    /// URL to fetch. If it does not start with `http://` or `https://`,
48    /// `https://raw.githubusercontent.com/` is prepended (GitHub shorthand).
49    pub url: String,
50    /// Optional HTTP headers. Values support `${VAR_NAME}` environment variable expansion.
51    pub headers: Option<HashMap<String, String>>,
52}
53
54// ---------------------------------------------------------------------------
55// Top-level config
56// ---------------------------------------------------------------------------
57
58/// `deny_unknown_fields` rejects typos and unknown config
59/// fields at parse time (strict YAML unmarshalling).
60#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
61#[serde(default, deny_unknown_fields)]
62pub struct Config {
63    /// Schema version. Currently supports 1 (implicit default) and 2.
64    pub version: Option<u32>,
65    /// Human-readable project name used in templates and release titles.
66    pub project_name: String,
67    /// Output directory for build artifacts (default: ./dist).
68    #[serde(default = "default_dist")]
69    pub dist: PathBuf,
70    /// Additional config files to merge into this config.
71    /// Supports plain string paths, `from_file:` for structured file paths,
72    /// and `from_url:` for fetching configs from URLs with optional headers.
73    pub includes: Option<Vec<IncludeSpec>>,
74    /// Environment file configuration. Accepts either:
75    /// - A list of `.env` file paths: `[".env", ".release.env"]`
76    /// - A struct with token file paths: `{ github_token: "~/.config/goreleaser/github_token" }`
77    pub env_files: Option<EnvFilesConfig>,
78    /// Default values applied to all crates unless overridden.
79    pub defaults: Option<Defaults>,
80    /// Hooks run before the release pipeline starts.
81    pub before: Option<HooksConfig>,
82    /// Hooks run after the release pipeline completes.
83    pub after: Option<HooksConfig>,
84    /// Hooks run when the release pipeline fails at ANY stage (build,
85    /// sign, publish, ...), after the failure policy (rollback / hold)
86    /// has executed, so `{{ .RolledBack }}` reflects the taken path.
87    ///
88    /// Notification / cleanup hooks: a hook's own failure is logged as a
89    /// warning and never masks the pipeline error. The failure context is
90    /// exposed both as template vars (`{{ .Error }}`, `{{ .RolledBack }}`)
91    /// and as `ANODIZER_*` env vars (`ANODIZER_ERROR`,
92    /// `ANODIZER_ROLLED_BACK`, `ANODIZER_VERSION`, `ANODIZER_TAG`) so
93    /// hooks can consume the error text without shell interpolation.
94    ///
95    /// ```yaml
96    /// on_error:
97    ///   hooks:
98    ///     - cmd: ./notify-release-failed.sh
99    /// ```
100    pub on_error: Option<HooksConfig>,
101    /// Hooks run after build/archive/sign/sbom/checksum complete but
102    /// immediately before the publish phase dispatches any publisher.
103    ///
104    /// Use cases: smoke-test artifacts against the staged dist tree,
105    /// run external validators (antivirus, vulnerability scanners),
106    /// stage external state, or abort the release before any
107    /// publisher writes to a registry.
108    ///
109    /// A non-zero exit code from any hook aborts the release before
110    /// publish runs. Hooks fire in declared order. Use `--skip=before-publish`
111    /// to bypass.
112    pub before_publish: Option<HooksConfig>,
113    /// List of crates in this project.
114    pub crates: Vec<CrateConfig>,
115    /// Changelog generation configuration.
116    pub changelog: Option<ChangelogConfig>,
117    /// Signing configurations for binaries, archives, and checksums.
118    #[serde(default, deserialize_with = "deserialize_signs")]
119    #[schemars(schema_with = "signs_schema")]
120    pub signs: Vec<SignConfig>,
121    /// Binary-specific signing configs (same shape as `signs` but only for
122    /// binary artifacts). The `artifacts` field on each entry is constrained
123    /// at parse time to `binary` / `none` (or omitted) — a broader filter on
124    /// `binary_signs` would silently match nothing because the loop only
125    /// iterates Binary artifacts. Constraint lives in `deserialize_binary_signs`.
126    #[serde(default, deserialize_with = "deserialize_binary_signs")]
127    #[schemars(schema_with = "signs_schema")]
128    pub binary_signs: Vec<SignConfig>,
129    /// Docker image signing configurations.
130    pub docker_signs: Option<Vec<DockerSignConfig>>,
131    // No `alias` attribute needed: unlike `signs`/`sign`, "upx" is already
132    // both singular and plural, so a separate alias adds no value.
133    /// UPX binary compression configurations.
134    #[serde(default, deserialize_with = "deserialize_upx")]
135    #[schemars(schema_with = "upx_schema")]
136    pub upx: Vec<UpxConfig>,
137    /// Snapshot release configuration (local/non-tag builds).
138    pub snapshot: Option<SnapshotConfig>,
139    /// Nightly release configuration.
140    pub nightly: Option<NightlyConfig>,
141    /// Announcement configuration (Slack, Discord, email, etc.).
142    pub announce: Option<AnnounceConfig>,
143    /// When true, log artifact file sizes after building.
144    pub report_sizes: Option<bool>,
145    /// Environment variables available to all template expressions.
146    ///
147    /// List of `KEY=VALUE` strings:
148    /// `env: ["MY_VAR=hello", "DEPLOY_ENV=staging"]`. Order is preserved so
149    /// chained env applications (sign + sbom + notarize) see entries in
150    /// declared order. Values are rendered through the template engine before
151    /// being set, so expressions like `{{ Tag }}` or `{{ Date }}` are
152    /// expanded.
153    #[serde(default)]
154    pub env: Option<Vec<String>>,
155    /// Custom template variables accessible as `{{ Var.<key> }}` in templates.
156    /// Provides a way to define reusable values, especially useful with config includes.
157    ///
158    /// Stored as a `BTreeMap` so rendering iterates in deterministic
159    /// (sorted) key order — without this guarantee, a value that references
160    /// another variable (`b: "{{ Var.a }}_v2"`) could render before its
161    /// dependency on a different process / host. The current resolver is
162    /// single-pass (one render per value), so cross-variable references
163    /// only resolve when the referenced key sorts earlier.
164    pub variables: Option<BTreeMap<String, String>>,
165    /// Generic artifact publisher configurations.
166    pub publishers: Option<Vec<PublisherConfig>>,
167    /// DockerHub description sync configurations.
168    pub dockerhub: Option<Vec<DockerHubConfig>>,
169    /// Artifactory upload configurations.
170    pub artifactories: Option<Vec<ArtifactoryConfig>>,
171    /// CloudSmith publisher configurations.
172    pub cloudsmiths: Option<Vec<CloudSmithConfig>>,
173    /// Top-level Homebrew Cask configurations.
174    /// `homebrew_casks` is a top-level array with its own
175    /// repository, commit_author, directory, skip_upload, hooks, dependencies,
176    /// conflicts, completions, manpages, structured uninstall/zap, etc.
177    pub homebrew_casks: Option<Vec<HomebrewCaskConfig>>,
178    /// Repo-committed files that embed the release version outside
179    /// `Cargo.toml` (e.g. a Helm `Chart.yaml`, an install doc, a README
180    /// badge), given as repo-root-relative path strings. At `tag` time each
181    /// listed file has its occurrences of the old version rewritten to the new
182    /// version — both the bare (`0.1.0`) and `v`-prefixed (`v0.1.0`) forms,
183    /// word-boundary anchored — and is staged into the same bump commit as
184    /// `Cargo.toml` / `Cargo.lock`, so these files never drift from the tag.
185    ///
186    /// ```yaml
187    /// version_files:
188    ///   - charts/cfgd/Chart.yaml
189    ///   - docs/installation.md
190    /// ```
191    pub version_files: Option<Vec<String>>,
192    /// Automatic semantic version tagging configuration.
193    pub tag: Option<TagConfig>,
194    /// Git-level tag discovery and sorting settings.
195    pub git: Option<GitConfig>,
196    /// Partial/split build configuration for fan-out CI pipelines.
197    pub partial: Option<PartialConfig>,
198    /// Independent workspace roots in a monorepo.
199    pub workspaces: Option<Vec<WorkspaceConfig>>,
200    /// Source archive configuration.
201    pub source: Option<SourceConfig>,
202    /// Software bill of materials (SBOM) generation configurations.
203    #[serde(default, deserialize_with = "deserialize_sboms")]
204    #[schemars(schema_with = "sboms_schema")]
205    pub sboms: Vec<SbomConfig>,
206    /// SLSA build-provenance / attestation configuration for binaries and
207    /// archives. In the default `subjects` mode, anodizer writes a subjects
208    /// manifest for `actions/attest-build-provenance`; in `emit` mode it
209    /// generates and signs a self-contained in-toto SLSA provenance statement.
210    /// When omitted (or `enabled: false`), the attestation stage is a no-op.
211    pub attestations: Option<AttestationConfig>,
212    /// GitHub release configuration shared by all crates.
213    pub release: Option<ReleaseConfig>,
214    /// Custom GitHub API/upload/download URLs for GitHub Enterprise installations.
215    pub github_urls: Option<GitHubUrlsConfig>,
216    /// Custom GitLab API/download URLs for self-hosted GitLab installations.
217    pub gitlab_urls: Option<GitLabUrlsConfig>,
218    /// Custom Gitea API/download URLs for self-hosted Gitea installations.
219    pub gitea_urls: Option<GiteaUrlsConfig>,
220    /// Force a specific token type for authentication.
221    /// When set, overrides automatic token detection from environment variables.
222    pub force_token: Option<ForceTokenKind>,
223    /// macOS code signing and notarization configuration.
224    pub notarize: Option<NotarizeConfig>,
225    /// Project metadata configuration (applied to metadata.json output files).
226    pub metadata: Option<MetadataConfig>,
227    /// Template files to render and include as release artifacts.
228    /// File contents are processed through the template engine.
229    pub template_files: Option<Vec<TemplateFileConfig>>,
230    /// Monorepo configuration.
231    /// When configured, tag discovery filters by tag_prefix and the working
232    /// directory is scoped to dir.
233    pub monorepo: Option<MonorepoConfig>,
234    /// Makeself self-extracting archive configurations.
235    #[serde(default, deserialize_with = "deserialize_makeselfs")]
236    #[schemars(schema_with = "makeselfs_schema")]
237    pub makeselfs: Vec<MakeselfConfig>,
238    /// AppImage configurations. Each entry bundles a built Linux binary plus
239    /// its desktop integration into a single self-contained `.AppImage` via
240    /// linuxdeploy.
241    #[serde(default, deserialize_with = "deserialize_appimages")]
242    #[schemars(schema_with = "appimages_schema")]
243    pub appimages: Vec<AppImageConfig>,
244    /// Opt-in post-release verification gate. Runs LAST (after the release is
245    /// created and every publisher has run) and REPORTS post-publish defects —
246    /// missing assets, failed install smoke-tests, glibc-ceiling violations.
247    /// Because it runs after the irreversible publish, a failure exits
248    /// non-zero to flag CI but never undoes the release. Off unless
249    /// `verify_release.enabled: true`.
250    #[serde(default)]
251    pub verify_release: VerifyReleaseConfig,
252    /// Pre-publish preflight tuning. `preflight.strict: true` promotes
253    /// indeterminate probe outcomes (5xx / rate-limit / network failure /
254    /// undeterminable permissions) from warnings to hard blockers. The
255    /// probes themselves always run read-only before any publisher mutates
256    /// a registry; the default (lenient) behavior needs no config.
257    #[serde(default)]
258    pub preflight: PreflightConfig,
259    /// Source RPM configuration. Renamed from `srpm:` (singular) for spelling
260    /// parity with `Defaults.srpms` and the rest of the plural-name packaging
261    /// fields. The `srpm:` spelling is still accepted via serde alias for
262    /// back-compat.
263    #[serde(alias = "srpm")]
264    pub srpms: Option<SrpmConfig>,
265    /// Milestone closing configurations.
266    pub milestones: Option<Vec<MilestoneConfig>>,
267    /// Generic HTTP upload configurations.
268    pub uploads: Option<Vec<UploadConfig>>,
269    /// AUR source package publishing configurations (source-only PKGBUILD, not -bin).
270    pub aur_sources: Option<Vec<AurSourceConfig>>,
271    /// Top-level retry configuration applied to network-bound operations
272    /// (announcers, git providers, HTTP uploads, docker pipes). When omitted,
273    /// `RetryConfig::default()` is used (10 attempts, 10s base, 5m cap —
274    /// the project-level retry policy).
275    pub retry: Option<RetryConfig>,
276    /// MCP (Model Context Protocol) server registry publishing
277    /// configuration. When `name` is empty (the default), the publisher is
278    /// skipped. The `mcp:` publisher block.
279    #[serde(default)]
280    pub mcp: McpConfig,
281    /// SchemaStore publisher. Registers the project's JSON Schema(s) on
282    /// SchemaStore at release time. When `schemas` is empty (the default),
283    /// the publisher is skipped. The `schemastore:` publisher block.
284    #[serde(default)]
285    pub schemastore: crate::config::publishers::SchemastoreConfig,
286    /// NPM package registry publishing configurations. One entry per
287    /// published package. In the default `optional-deps` mode anodizer emits
288    /// npm's native per-platform packages (biome / git-cliff pattern); in
289    /// `postinstall` mode it emits a download shim (the `npms:`
290    /// parity).
291    pub npms: Option<Vec<NpmConfig>>,
292    /// GemFury (fury.io) deb/rpm/apk publishing configurations. Mirrors
293    /// The `gemfury:` block. The legacy spelling
294    /// `furies:` is accepted via serde alias; a one-time deprecation
295    /// warning is emitted by [`warn_on_legacy_furies_alias`].
296    #[serde(alias = "furies")]
297    pub gemfury: Option<Vec<GemFuryConfig>>,
298    /// Per-crate metadata derived from each crate's `Cargo.toml [package]`
299    /// table (description / license / homepage / authors). Populated at
300    /// config-load time by [`Config::populate_derived_metadata`], keyed by
301    /// crate name. NOT a user-facing YAML field — it backs the
302    /// crate-aware `meta_*_for` accessors so a plain Rust project gets its
303    /// publisher metadata without repeating it in a top-level `metadata:`
304    /// block. A hand-written `metadata:` field and per-publisher overrides
305    /// still win.
306    #[serde(skip)]
307    #[schemars(skip)]
308    pub derived_metadata: BTreeMap<String, MetadataConfig>,
309}
310
311/// Helper schema function for the signs field (accepts object or array).
312fn signs_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
313    let mut schema = generator.subschema_for::<Vec<SignConfig>>();
314    schema.ensure_object().insert(
315        "description".to_owned(),
316        "Artifact signing configurations (cosign, GPG, etc.). Accepts a single object or array."
317            .into(),
318    );
319    schema
320}
321
322/// Helper schema function for the upx field (accepts object or array).
323fn upx_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
324    let mut schema = generator.subschema_for::<Vec<UpxConfig>>();
325    schema.ensure_object().insert(
326        "description".to_owned(),
327        "UPX binary compression configurations. Accepts a single object or array.".into(),
328    );
329    schema
330}
331
332/// Helper schema function for the sboms field (accepts object or array).
333fn sboms_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
334    let mut schema = generator.subschema_for::<Vec<SbomConfig>>();
335    schema.ensure_object().insert(
336        "description".to_owned(),
337        "SBOM generation configurations. Accepts a single object or array.".into(),
338    );
339    schema
340}
341
342fn default_dist() -> PathBuf {
343    PathBuf::from("./dist")
344}
345
346impl Default for Config {
347    fn default() -> Self {
348        Config {
349            version: None,
350            project_name: String::new(),
351            dist: default_dist(),
352            includes: None,
353            env_files: None,
354            defaults: None,
355            before: None,
356            after: None,
357            on_error: None,
358            before_publish: None,
359            crates: Vec::new(),
360            changelog: None,
361            signs: Vec::new(),
362            binary_signs: Vec::new(),
363            docker_signs: None,
364            upx: Vec::new(),
365            snapshot: None,
366            nightly: None,
367            announce: None,
368            report_sizes: None,
369            env: None,
370            variables: None,
371            publishers: None,
372            dockerhub: None,
373            artifactories: None,
374            cloudsmiths: None,
375            homebrew_casks: None,
376            version_files: None,
377            tag: None,
378            git: None,
379            partial: None,
380            workspaces: None,
381            source: None,
382            sboms: Vec::new(),
383            attestations: None,
384            release: None,
385            github_urls: None,
386            gitlab_urls: None,
387            gitea_urls: None,
388            force_token: None,
389            notarize: None,
390            metadata: None,
391            template_files: None,
392            monorepo: None,
393            makeselfs: Vec::new(),
394            appimages: Vec::new(),
395            verify_release: VerifyReleaseConfig::default(),
396            preflight: PreflightConfig::default(),
397            srpms: None,
398            milestones: None,
399            uploads: None,
400            aur_sources: None,
401            retry: None,
402            mcp: McpConfig::default(),
403            schemastore: crate::config::publishers::SchemastoreConfig::default(),
404            npms: None,
405            gemfury: None,
406            derived_metadata: BTreeMap::new(),
407        }
408    }
409}
410
411impl Config {
412    /// The full crate universe: top-level `crates` plus every
413    /// `workspaces[].crates` entry, deduplicated by name (first-seen wins,
414    /// so a top-level entry shadows a same-named workspace entry).
415    ///
416    /// Single source of the read-only "all crates that can carry per-crate
417    /// config" walk. Publisher registration, required/retain gate
418    /// collapsing, per-crate dispatch, requirement derivation,
419    /// `--crate`/`--all` selection, tool-need detection, artifact guards,
420    /// and default-naming decisions must all resolve through this walker so
421    /// a workspace-only crate carrying a publisher block is either visible
422    /// everywhere or nowhere — a consumer iterating `config.crates`
423    /// directly silently excludes workspace crates and hides their
424    /// publishes. Only two shapes may keep a raw chained walk: mutation
425    /// passes (`&mut` access — this walker hands out shared borrows) and
426    /// validation/diagnostics that must see every entry as written,
427    /// including the shadowed duplicates this walker dedups away.
428    pub fn crate_universe(&self) -> Vec<&CrateConfig> {
429        self.crate_universe_walk().0
430    }
431
432    /// Borrow a crate by name from [`Self::crate_universe`] (top-level wins
433    /// on a name collision). The single by-name lookup every consumer must
434    /// use — a `config.crates.iter().find(...)` cannot see workspace-only
435    /// crates.
436    pub fn find_crate(&self, name: &str) -> Option<&CrateConfig> {
437        self.crate_universe().into_iter().find(|c| c.name == name)
438    }
439
440    /// Operator-facing warnings for crate-name collisions in the universe
441    /// where the colliding entries disagree on `path` — almost certainly a
442    /// config mistake (two distinct crates sharing a name). The legitimate
443    /// duplicate (the same crate referenced from both top-level and a
444    /// workspace) dedups silently. Emitted by the publish stage at entry so
445    /// the warning appears once per run rather than once per universe walk.
446    pub fn crate_universe_collision_warnings(&self) -> Vec<String> {
447        self.crate_universe_walk().1
448    }
449
450    /// The one walk both [`Self::crate_universe`] and
451    /// [`Self::crate_universe_collision_warnings`] derive from, so the
452    /// merge/dedup policy and its diagnostics cannot diverge.
453    fn crate_universe_walk(&self) -> (Vec<&CrateConfig>, Vec<String>) {
454        let mut out: Vec<&CrateConfig> = self.crates.iter().collect();
455        let mut warnings = Vec::new();
456        for ws in self.workspaces.iter().flatten() {
457            for c in &ws.crates {
458                if let Some(existing) = out.iter().find(|e| e.name == c.name) {
459                    if existing.path != c.path {
460                        warnings.push(format!(
461                            "workspace '{}' crate '{}' path '{}' shadowed by \
462                             prior entry with path '{}'; workspace entry dropped (name \
463                             collision with different paths — likely a config mistake)",
464                            ws.name, c.name, c.path, existing.path
465                        ));
466                    }
467                    continue;
468                }
469                out.push(c);
470            }
471        }
472        (out, warnings)
473    }
474
475    /// Return the monorepo tag prefix, if configured.
476    ///
477    /// Shorthand for `config.monorepo.as_ref().and_then(|m| m.tag_prefix.as_deref())`.
478    pub fn monorepo_tag_prefix(&self) -> Option<&str> {
479        self.monorepo.as_ref().and_then(|m| m.tag_prefix.as_deref())
480    }
481
482    /// Return the monorepo working directory, if configured.
483    ///
484    /// Shorthand for `config.monorepo.as_ref().and_then(|m| m.dir.as_deref())`.
485    pub fn monorepo_dir(&self) -> Option<&str> {
486        self.monorepo.as_ref().and_then(|m| m.dir.as_deref())
487    }
488
489    /// The build targets compiled when neither a per-build `targets` nor
490    /// `defaults.targets` is set: `defaults.targets` (when non-empty), else the
491    /// canonical `DEFAULT_TARGETS`. Single source of truth for the target-set
492    /// fallback — every target enumeration MUST resolve through this rather than
493    /// re-deriving the fallback, so they never diverge.
494    pub fn effective_default_targets(&self) -> Vec<String> {
495        self.defaults
496            .as_ref()
497            .and_then(|d| d.targets.clone())
498            .filter(|t| !t.is_empty())
499            .unwrap_or_else(|| {
500                crate::target::DEFAULT_TARGETS
501                    .iter()
502                    .map(|s| (*s).to_string())
503                    .collect()
504            })
505    }
506
507    /// The cross-compilation strategy applied to a crate that does not set its
508    /// own `cross:` — `defaults.cross`, else `Auto`. SSOT for the per-crate
509    /// strategy fallback.
510    pub fn default_cross_strategy(&self) -> CrossStrategy {
511        self.defaults
512            .as_ref()
513            .and_then(|d| d.cross.clone())
514            .unwrap_or(CrossStrategy::Auto)
515    }
516
517    // --- Project metadata defaulting helpers ---
518    //
519    // Publishers that expose homepage/license/description/maintainer fields
520    // fall back to these when their own field is unset, so a project only
521    // needs to declare metadata once. Resolution precedence (highest first):
522    //
523    //   1. the per-publisher override (the publisher's own config field)
524    //   2. a hand-written top-level `metadata:` YAML field
525    //   3. the value derived from the crate's `Cargo.toml [package]` table
526    //      (populated by `populate_derived_metadata`)
527    //
528    // Steps 1 is enforced by the publisher's `or_else(|| cfg.meta_*_for(..))`
529    // chain; steps 2-3 are enforced inside the `meta_*_for` accessors. A
530    // publisher that knows which crate it is publishing for should call the
531    // crate-aware `meta_*_for(crate_name)` variant so workspace/per-crate
532    // configs resolve each crate's OWN Cargo.toml metadata. The crate-agnostic
533    // `meta_*` variants resolve the top-level `metadata:` block only (no
534    // Cargo.toml fallback) and exist for truly project-level callers.
535
536    /// Per-crate derived metadata for `crate_name`, if `Cargo.toml` supplied any.
537    fn derived_for(&self, crate_name: &str) -> Option<&MetadataConfig> {
538        self.derived_metadata.get(crate_name)
539    }
540
541    /// Name of the primary crate (first declared `crates:` entry, else the
542    /// first workspace crate). Used as the metadata-derivation source and
543    /// crate-name fallback for project-level publishers (e.g. top-level
544    /// `homebrew_casks:`, `npms:`) that are not bound to a single crate.
545    pub fn primary_crate_name(&self) -> Option<&str> {
546        self.crate_universe().first().map(|c| c.name.as_str())
547    }
548
549    /// Project homepage: top-level `metadata.homepage` wins, else the primary
550    /// crate's `Cargo.toml`-derived homepage. For project-level publishers
551    /// (top-level casks) with no owning crate.
552    pub fn meta_homepage_project(&self) -> Option<&str> {
553        self.meta_homepage()
554            .or_else(|| self.meta_homepage_for(self.primary_crate_name()?))
555    }
556
557    /// Project description: top-level `metadata.description` wins, else the
558    /// primary crate's `Cargo.toml`-derived description.
559    pub fn meta_description_project(&self) -> Option<&str> {
560        self.meta_description()
561            .or_else(|| self.meta_description_for(self.primary_crate_name()?))
562    }
563
564    /// Project source-repository URL: top-level `metadata.repository` wins, else
565    /// the primary crate's `Cargo.toml`-derived repository. Backs the
566    /// `{{ Metadata.Repository }}` template var.
567    pub fn meta_repository_project(&self) -> Option<&str> {
568        self.meta_repository()
569            .or_else(|| self.meta_repository_for(self.primary_crate_name()?))
570    }
571
572    /// Project license: top-level `metadata.license` wins, else the primary
573    /// crate's `Cargo.toml`-derived license. For the `{{ Metadata.License }}`
574    /// template var and project-level publishers with no owning crate.
575    pub fn meta_license_project(&self) -> Option<&str> {
576        self.meta_license()
577            .or_else(|| self.meta_license_for(self.primary_crate_name()?))
578    }
579
580    /// Project documentation URL: top-level `metadata.documentation` wins, else
581    /// the primary crate's `Cargo.toml`-derived documentation URL.
582    pub fn meta_documentation_project(&self) -> Option<&str> {
583        self.meta_documentation()
584            .or_else(|| self.meta_documentation_for(self.primary_crate_name()?))
585    }
586
587    /// Project homepage from `metadata.homepage` (top-level YAML only).
588    pub fn meta_homepage(&self) -> Option<&str> {
589        self.metadata.as_ref().and_then(|m| m.homepage.as_deref())
590    }
591
592    /// Project license from `metadata.license` (top-level YAML only).
593    pub fn meta_license(&self) -> Option<&str> {
594        self.metadata.as_ref().and_then(|m| m.license.as_deref())
595    }
596
597    /// Project source-repository URL from `metadata.repository` (top-level YAML only).
598    pub fn meta_repository(&self) -> Option<&str> {
599        self.metadata.as_ref().and_then(|m| m.repository.as_deref())
600    }
601
602    /// Project description from `metadata.description` (top-level YAML only).
603    pub fn meta_description(&self) -> Option<&str> {
604        self.metadata
605            .as_ref()
606            .and_then(|m| m.description.as_deref())
607    }
608
609    /// Project documentation URL from `metadata.documentation` (top-level YAML only).
610    pub fn meta_documentation(&self) -> Option<&str> {
611        self.metadata
612            .as_ref()
613            .and_then(|m| m.documentation.as_deref())
614    }
615
616    /// Project maintainers from `metadata.maintainers` (top-level YAML only).
617    pub fn meta_maintainers(&self) -> &[String] {
618        self.metadata
619            .as_ref()
620            .and_then(|m| m.maintainers.as_deref())
621            .unwrap_or(&[])
622    }
623
624    /// First maintainer as "Name <email>" or just "Name" (publisher convention).
625    /// Returns None when no maintainers are configured.
626    pub fn meta_first_maintainer(&self) -> Option<&str> {
627        self.meta_maintainers().first().map(|s| s.as_str())
628    }
629
630    /// Homepage for `crate_name`: top-level `metadata.homepage` wins, else the
631    /// value derived from the crate's `Cargo.toml [package]`.
632    pub fn meta_homepage_for(&self, crate_name: &str) -> Option<&str> {
633        self.meta_homepage()
634            .or_else(|| self.derived_for(crate_name)?.homepage.as_deref())
635    }
636
637    /// License for `crate_name`: top-level `metadata.license` wins, else the
638    /// crate's `Cargo.toml [package].license` (never synthesised from
639    /// `license-file`).
640    pub fn meta_license_for(&self, crate_name: &str) -> Option<&str> {
641        self.meta_license()
642            .or_else(|| self.derived_for(crate_name)?.license.as_deref())
643    }
644
645    /// Source-repository URL for `crate_name`: top-level `metadata.repository`
646    /// wins, else the crate's `Cargo.toml [package].repository`. Feeds the npm
647    /// `package.json` `repository` field so npm provenance validation (which
648    /// matches it against the OIDC-claimed repository) passes without requiring
649    /// the operator to restate the URL in the publisher config.
650    pub fn meta_repository_for(&self, crate_name: &str) -> Option<&str> {
651        self.meta_repository()
652            .or_else(|| self.derived_for(crate_name)?.repository.as_deref())
653    }
654
655    /// Description for `crate_name`: top-level `metadata.description` wins, else
656    /// the crate's `Cargo.toml [package].description`.
657    pub fn meta_description_for(&self, crate_name: &str) -> Option<&str> {
658        self.meta_description()
659            .or_else(|| self.derived_for(crate_name)?.description.as_deref())
660    }
661
662    /// Documentation URL for `crate_name`: top-level `metadata.documentation`
663    /// wins, else the crate's `Cargo.toml [package].documentation`.
664    pub fn meta_documentation_for(&self, crate_name: &str) -> Option<&str> {
665        self.meta_documentation()
666            .or_else(|| self.derived_for(crate_name)?.documentation.as_deref())
667    }
668
669    /// Maintainers for `crate_name`: top-level `metadata.maintainers` wins
670    /// (when non-empty), else the crate's `Cargo.toml [package].authors`.
671    pub fn meta_maintainers_for(&self, crate_name: &str) -> &[String] {
672        let top = self.meta_maintainers();
673        if !top.is_empty() {
674            return top;
675        }
676        self.derived_for(crate_name)
677            .and_then(|m| m.maintainers.as_deref())
678            .unwrap_or(&[])
679    }
680
681    /// First maintainer for `crate_name` as "Name <email>" or just "Name".
682    pub fn meta_first_maintainer_for(&self, crate_name: &str) -> Option<&str> {
683        self.meta_maintainers_for(crate_name)
684            .first()
685            .map(|s| s.as_str())
686    }
687
688    /// Vendor / distributing-entity name for `crate_name`: the first
689    /// maintainer with any `<email>` suffix stripped (e.g.
690    /// `"Ada Lovelace <ada@x>"` → `"Ada Lovelace"`). `None` when no maintainer
691    /// is derivable or the result is empty, so a Vendor field is never emitted
692    /// blank. Reused by the rpm/deb Vendor and the OCI image `vendor` label.
693    pub fn meta_vendor_for(&self, crate_name: &str) -> Option<String> {
694        self.meta_first_maintainer_for(crate_name)
695            .and_then(maintainer_name_only)
696    }
697
698    /// Populate [`Config::derived_metadata`] by reading each crate's
699    /// `Cargo.toml [package]` table (description / license / homepage /
700    /// authors), so publishers resolve a plain Rust project's metadata without
701    /// requiring a top-level `metadata:` YAML block.
702    ///
703    /// Covers every crate the config knows about: top-level `crates:` plus
704    /// every `workspaces[].crates[]`, so single-crate, workspace-lockstep, and
705    /// per-crate configs all populate. Each crate is read from
706    /// `<crate.path>/Cargo.toml` relative to `base_dir` (the directory the
707    /// config was loaded from / the monorepo working directory).
708    ///
709    /// Idempotent and non-destructive: only fills entries; existing
710    /// `derived_metadata` keys are overwritten with a fresh read. Crates whose
711    /// `Cargo.toml` is missing or supplies nothing contribute an all-`None`
712    /// entry (harmless — the accessors treat it as "no value").
713    pub fn populate_derived_metadata(&mut self, base_dir: &std::path::Path) {
714        let crate_paths: Vec<(String, String)> = self
715            .crate_universe()
716            .into_iter()
717            .map(|c| (c.name.clone(), c.path.clone()))
718            .collect();
719        for (name, path) in crate_paths {
720            let crate_dir = base_dir.join(&path);
721            let derived = derive_metadata_from_cargo_toml(&crate_dir);
722            self.derived_metadata.insert(name, derived);
723        }
724    }
725
726    /// `true` when any top-level / workspace `signs:` or `binary_signs:`
727    /// entry will invoke gpg (via `SignConfig::is_gpg()`).
728    ///
729    /// Used by preflight to decide whether to probe
730    /// `gpg --faked-system-time` support. `docker_signs:` is excluded
731    /// because that driver only ever invokes cosign.
732    pub fn has_gpg_sign_configured(&self) -> bool {
733        let top_level = self
734            .signs
735            .iter()
736            .chain(self.binary_signs.iter())
737            .any(|s| s.is_gpg());
738        if top_level {
739            return true;
740        }
741        // Workspaces inherit their own signs:/binary_signs: lists.
742        self.workspaces.iter().flatten().any(|w| {
743            w.signs
744                .iter()
745                .chain(w.binary_signs.iter())
746                .any(|s| s.is_gpg())
747        })
748    }
749}
750
751/// JSON Schema for the [`Config`] document as a canonical `serde_json::Value`,
752/// in the JSON Schema draft-07 dialect.
753///
754/// The published `schema.json`, the `anodizer jsonschema` command, and the
755/// config-reference doc generator all read the schema from this one function so
756/// the dialect (`definitions` + `#/definitions/` refs) and the byte-form are
757/// fixed in a single place. draft-07 is the dialect editors (VS Code, the JSON
758/// Schema Store) resolve for `.anodizer.yaml`, so the published schema and the
759/// editor integration agree.
760///
761/// Returns a plain `Value` rather than [`schemars::Schema`] deliberately:
762/// serializing a `Schema` re-imposes schemars 1.x's keyword ordering (via its
763/// internal `OrderedKeywordWrapper`), which would undo [`canonicalize_schema`].
764/// Serializing the `Value` directly preserves the canonical order.
765#[must_use]
766pub fn config_schema() -> serde_json::Value {
767    let schema = schemars::generate::SchemaSettings::draft07()
768        .into_generator()
769        .into_root_schema_for::<Config>();
770    let mut value = schema.to_value();
771    canonicalize_schema(&mut value);
772    value
773}
774
775/// JSON Schema keyword serialization order matching schemars 0.8's `SchemaObject`
776/// field declaration order (its flattened `Metadata` / `SubschemaValidation` /
777/// number / string / array / object validation structs concatenated in struct
778/// order). The published `schema.json` is byte-pinned to this order so it stays
779/// stable across schemars upgrades (1.x emits a different keyword order, and the
780/// workspace builds `serde_json` with `preserve_order` — via `stage-publish` —
781/// so insertion order leaks into the file unless re-imposed here). An unlisted
782/// keyword sorts after all listed ones, then lexicographically.
783const SCHEMA_KEYWORD_ORDER: &[&str] = &[
784    "$id",
785    "$schema",
786    "title",
787    "description",
788    "default",
789    "deprecated",
790    "readOnly",
791    "writeOnly",
792    "type",
793    "format",
794    "enum",
795    "const",
796    "allOf",
797    "anyOf",
798    "oneOf",
799    "not",
800    "if",
801    "then",
802    "else",
803    "multipleOf",
804    "maximum",
805    "exclusiveMaximum",
806    "minimum",
807    "exclusiveMinimum",
808    "maxLength",
809    "minLength",
810    "pattern",
811    "items",
812    "additionalItems",
813    "maxItems",
814    "minItems",
815    "uniqueItems",
816    "contains",
817    "maxProperties",
818    "minProperties",
819    "required",
820    "properties",
821    "patternProperties",
822    "additionalProperties",
823    "propertyNames",
824    "$ref",
825    "definitions",
826];
827
828/// Schema object keys whose VALUE is a map of name → subschema (not a subschema
829/// itself). Their entries are sorted by NAME (schemars 0.8 backed these with a
830/// `BTreeMap`); every other keyword's value is a schema whose own keys are
831/// ordered by [`SCHEMA_KEYWORD_ORDER`].
832const SCHEMA_DEFINITION_MAPS: &[&str] = &["properties", "patternProperties", "definitions"];
833
834/// Re-impose schemars 0.8's deterministic serialization on a draft-07 schema
835/// `Value` so the published artifact is byte-stable across schemars versions:
836/// recursively (1) order each schema object's keys by [`SCHEMA_KEYWORD_ORDER`],
837/// (2) sort definition-map entries (`properties`/`definitions`/…) by name,
838/// (3) sort `required` (a set), and (4) normalize every `description` to single
839/// spaces within a paragraph while preserving blank-line paragraph breaks.
840fn canonicalize_schema(value: &mut serde_json::Value) {
841    use serde_json::Value;
842    match value {
843        Value::Object(map) => {
844            if let Some(Value::String(d)) = map.get_mut("description") {
845                *d = collapse_description(d);
846            }
847            if let Some(Value::Array(required)) = map.get_mut("required") {
848                required.sort_by(|a, b| match (a.as_str(), b.as_str()) {
849                    (Some(x), Some(y)) => x.cmp(y),
850                    _ => std::cmp::Ordering::Equal,
851                });
852            }
853            // Recurse, treating each value by its role:
854            // - a definition-map value (`properties`/`definitions`/…) is a
855            //   name→schema map: sort its entries by name, recurse each schema;
856            // - `default`/`enum`/`const`/`examples` hold literal instance DATA,
857            //   not schemas — never reorder their keys (they preserve the config
858            //   struct's serialization order);
859            // - every other value is itself a schema (or array of schemas).
860            for (key, child) in map.iter_mut() {
861                match key.as_str() {
862                    k if SCHEMA_DEFINITION_MAPS.contains(&k) => {
863                        if let Value::Object(entries) = child {
864                            sort_object_by_key(entries);
865                            for sub in entries.values_mut() {
866                                canonicalize_schema(sub);
867                            }
868                        }
869                    }
870                    "default" | "enum" | "const" | "examples" => {}
871                    _ => canonicalize_schema(child),
872                }
873            }
874            reorder_object(map, SCHEMA_KEYWORD_ORDER);
875        }
876        Value::Array(items) => {
877            for item in items {
878                canonicalize_schema(item);
879            }
880        }
881        _ => {}
882    }
883}
884
885/// Reorder `map`'s entries so listed keys come first in `order`, then any
886/// remaining keys lexicographically. `serde_json`'s `preserve_order` feature is
887/// active workspace-wide, so a `Map` serializes in insertion order — rebuilding
888/// it in the target order fixes the serialized key order.
889fn reorder_object(map: &mut serde_json::Map<String, serde_json::Value>, order: &[&str]) {
890    let mut keys: Vec<String> = map.keys().cloned().collect();
891    keys.sort_by(|a, b| {
892        let rank = |k: &str| order.iter().position(|o| *o == k).unwrap_or(order.len());
893        rank(a).cmp(&rank(b)).then_with(|| a.cmp(b))
894    });
895    let mut rebuilt = serde_json::Map::with_capacity(map.len());
896    for k in keys {
897        if let Some(v) = map.remove(&k) {
898            rebuilt.insert(k, v);
899        }
900    }
901    *map = rebuilt;
902}
903
904/// Sort an object map's entries by key (rebuilt because `preserve_order` keeps
905/// insertion order). Used for definition maps where 0.8 emitted `BTreeMap`-sorted
906/// names.
907fn sort_object_by_key(map: &mut serde_json::Map<String, serde_json::Value>) {
908    let mut keys: Vec<String> = map.keys().cloned().collect();
909    keys.sort();
910    let mut rebuilt = serde_json::Map::with_capacity(map.len());
911    for k in keys {
912        if let Some(v) = map.remove(&k) {
913            rebuilt.insert(k, v);
914        }
915    }
916    *map = rebuilt;
917}
918
919/// Normalize a schema `description`: collapse each paragraph's internal
920/// whitespace (including the rustdoc doc-comment's hard line wraps, which
921/// schemars 1.x preserves verbatim) to single spaces, while preserving
922/// blank-line paragraph breaks (`\n\n`). Reproduces the single-spaced,
923/// paragraph-separated form earlier schemars releases emitted, so the published
924/// schema's tooltips render as clean prose in editors.
925fn collapse_description(s: &str) -> String {
926    s.split("\n\n")
927        .map(|para| {
928            para.split('\n')
929                .map(str::trim)
930                .collect::<Vec<_>>()
931                .join(" ")
932        })
933        .collect::<Vec<_>>()
934        .join("\n\n")
935}
936
937/// Run a deserialization closure on a worker thread sized large enough that
938/// the `Config` derive (60+ `Option<NestedStruct>` fields) cannot exhaust
939/// the host's main-thread stack.
940///
941/// Background: debug builds of `serde_yaml_ng::from_value::<Config>` and
942/// `toml::from_str::<Config>` consume several MiB of stack because each
943/// generated visitor branch for the giant struct lives in a single
944/// monomorphised frame and debug builds neither inline nor tail-call. The
945/// Windows main-thread default reservation is 1 MiB, so any debug-built
946/// integration test that triggers full-config deserialization overflows
947/// before reaching the visitor's body.
948///
949/// Routing every full-`Config` deserialization through this helper keeps
950/// every entry-point platform-agnostic without resorting to per-platform
951/// linker flags or `RUST_MIN_STACK`.
952pub fn deserialize_on_worker<F, T>(f: F) -> anyhow::Result<T>
953where
954    F: FnOnce() -> anyhow::Result<T> + Send + 'static,
955    T: Send + 'static,
956{
957    use anyhow::Context as _;
958
959    // 8 MiB matches the Linux/macOS process default and comfortably exceeds
960    // the ~2 MiB peak observed for debug `Config` deserialization.
961    const WORKER_STACK_SIZE: usize = 8 * 1024 * 1024;
962
963    let handle = std::thread::Builder::new()
964        .stack_size(WORKER_STACK_SIZE)
965        .name("anodizer-config-deserialize".to_string())
966        .spawn(f)
967        .context("failed to spawn config deserialization worker thread")?;
968    match handle.join() {
969        Ok(result) => result,
970        Err(payload) => std::panic::resume_unwind(payload),
971    }
972}
973
974/// Validate the config schema version. Accepts version 1 (default) and 2.
975/// Returns an error for unknown versions.
976pub fn validate_version(config: &Config) -> Result<(), String> {
977    match config.version {
978        None | Some(1) | Some(2) => Ok(()),
979        Some(v) => Err(format!(
980            "unsupported config version: {}. Supported versions are 1 and 2.",
981            v
982        )),
983    }
984}
985
986/// Validate `git.tag_sort` if present. Accepted values:
987/// - `"-version:refname"` (default, lexicographic version sort)
988/// - `"-version:creatordate"` (sort by tag creation date, newest first)
989/// - `"semver"` (Rust-side strict SemVer 2.0.0 ordering, prereleases sort
990///   below their release per spec section 11)
991/// - `"smartsemver"` (same ordering as `semver`, but when the current version
992///   is non-prerelease, prerelease tags are skipped when picking the previous
993///   tag — avoids selecting `v0.2.0-beta.3` as the predecessor of `v0.2.0`)
994///
995/// Returns an error for unrecognized values.
996pub fn validate_tag_sort(config: &Config) -> Result<(), String> {
997    if let Some(ref git) = config.git
998        && let Some(ref sort) = git.tag_sort
999    {
1000        match sort.as_str() {
1001            "-version:refname" | "-version:creatordate" | "semver" | "smartsemver" => {}
1002            other => {
1003                return Err(format!(
1004                    "unsupported git.tag_sort value: \"{}\". \
1005                     Accepted values: \"-version:refname\", \"-version:creatordate\", \
1006                     \"semver\", \"smartsemver\".",
1007                    other
1008                ));
1009            }
1010        }
1011    }
1012    Ok(())
1013}
1014
1015/// Validate `partial.by` up front so a stale value is rejected at config-load
1016/// time regardless of which target-resolution path runs.
1017///
1018/// `partial.by` is read in two unrelated places: the host-detection branch of
1019/// [`crate::partial::resolve_partial_target`] (which already rejects unknown
1020/// values) and the split-matrix generator (which treats anything that is not
1021/// `"os"` as `"target"`). Those two readers disagree on an out-of-set value
1022/// like the pre-rename `"goos"`: one errors, the other silently mis-groups the
1023/// matrix. Centralising the check means a typo fails loudly once, before
1024/// either reader can diverge.
1025pub fn validate_partial(config: &Config) -> Result<(), String> {
1026    if let Some(ref partial) = config.partial
1027        && let Some(ref by) = partial.by
1028    {
1029        match by.as_str() {
1030            "os" | "target" => {}
1031            other => {
1032                return Err(format!(
1033                    "unsupported partial.by value: \"{}\". \
1034                     Accepted values: \"os\", \"target\".",
1035                    other
1036                ));
1037            }
1038        }
1039    }
1040    Ok(())
1041}
1042
1043/// Known OS values accepted by `archives[].format_overrides[].os`.
1044/// The Go runtime's `runtime.GOOS` values the archive pipe
1045/// recognises; anything outside this set is almost always a typo
1046/// (e.g. a Rust target triple slice like `pc-windows-msvc`).
1047const KNOWN_OS: &[&str] = &[
1048    "aix",
1049    "android",
1050    "darwin",
1051    "dragonfly",
1052    "freebsd",
1053    "illumos",
1054    "ios",
1055    "js",
1056    "linux",
1057    "netbsd",
1058    "openbsd",
1059    "plan9",
1060    "solaris",
1061    "wasip1",
1062    "windows",
1063];
1064
1065/// Validate that each crate's `release:` block configures at most one SCM
1066/// backend. A multiple-releases error, which
1067/// errors at `Default()` time. Anodizer dispatches on `ctx.token_type` at
1068/// runtime so a silently-ignored extra backend is easy to miss.
1069pub fn validate_release_backends(config: &Config) -> Result<(), String> {
1070    let check = |crate_name: &str, release: &ReleaseConfig| -> Result<(), String> {
1071        let mut set = Vec::new();
1072        if release.github.is_some() {
1073            set.push("github");
1074        }
1075        if release.gitlab.is_some() {
1076            set.push("gitlab");
1077        }
1078        if release.gitea.is_some() {
1079            set.push("gitea");
1080        }
1081        if set.len() > 1 {
1082            return Err(format!(
1083                "crate {}: release config sets multiple mutually-exclusive SCM \
1084                 backends ({}). Pick one.",
1085                crate_name,
1086                set.join(" + ")
1087            ));
1088        }
1089        Ok(())
1090    };
1091    for krate in &config.crates {
1092        if let Some(ref release) = krate.release {
1093            check(&krate.name, release)?;
1094        }
1095    }
1096    if let Some(ws_list) = config.workspaces.as_ref() {
1097        for ws in ws_list {
1098            for krate in &ws.crates {
1099                if let Some(ref release) = krate.release {
1100                    check(&krate.name, release)?;
1101                }
1102            }
1103        }
1104    }
1105    Ok(())
1106}
1107
1108/// Validate that `release.on_failure` is set only at the root.
1109///
1110/// The failure policy is one process-wide decision per run, resolved
1111/// from the top-level `release:` block alone. Crate-level `release:`
1112/// blocks share the `ReleaseConfig` struct, so the field parses there
1113/// — but it would never be read; rejecting the misplacement at config
1114/// load keeps a policy choice from being silently ignored.
1115pub fn validate_on_failure_root_only(config: &Config) -> Result<(), String> {
1116    // Deliberately raw (not `crate_universe()`): validation must flag every
1117    // entry as written, including a workspace entry the dedup would shadow —
1118    // a policy violation on a shadowed crate is still a config mistake.
1119    let mut offenders: Vec<&str> = config
1120        .crates
1121        .iter()
1122        .chain(
1123            config
1124                .workspaces
1125                .iter()
1126                .flatten()
1127                .flat_map(|ws| ws.crates.iter()),
1128        )
1129        .filter(|c| c.release.as_ref().is_some_and(|r| r.on_failure.is_some()))
1130        .map(|c| c.name.as_str())
1131        .collect();
1132    offenders.sort_unstable();
1133    offenders.dedup();
1134    if offenders.is_empty() {
1135        return Ok(());
1136    }
1137    Err(format!(
1138        "release.on_failure is a root-level policy and cannot be set per crate \
1139         (set on: {}). Move it to the top-level `release:` block.",
1140        offenders.join(", ")
1141    ))
1142}
1143
1144/// Marker prefix for the axis-mismatch validation error class. Existing
1145/// validators in this module return `Result<(), String>` rather than a
1146/// typed enum, so we expose this constant (instead of a `ConfigError`
1147/// variant) for callers that want to recognise the error class
1148/// programmatically.
1149///
1150/// The prefix is emitted at the start of every error returned by
1151/// [`validate_defaults_axis`] (formatted as `"DefaultsAxisMismatch: …"`),
1152/// so callers can match with `err.starts_with(ERR_DEFAULTS_AXIS_MISMATCH)`
1153/// or `err.contains(ERR_DEFAULTS_AXIS_MISMATCH)` without depending on the
1154/// exact human-readable wording.
1155///
1156/// ```ignore
1157/// match validate_defaults_axis(&config) {
1158///     Err(e) if e.starts_with(ERR_DEFAULTS_AXIS_MISMATCH) => {
1159///         // handle the axis-mismatch error class
1160///     }
1161///     other => other?,
1162/// }
1163/// ```
1164///
1165/// Future error-type unification can rename to
1166/// `ConfigError::DefaultsAxisMismatch` without changing call-sites that
1167/// match on this prefix.
1168pub const ERR_DEFAULTS_AXIS_MISMATCH: &str = "DefaultsAxisMismatch";
1169
1170/// Validate that `defaults.crates:` and `defaults.workspaces:` match the
1171/// top-level axis.
1172///
1173/// Rules:
1174/// - `defaults.crates:` is set → top-level `crates:` MUST be present.
1175/// - `defaults.workspaces:` is set → top-level `workspaces:` MUST be present.
1176/// - Both `defaults.crates` and `defaults.workspaces` set simultaneously → error
1177///   (mutually exclusive).
1178/// - Wrong-axis (e.g. `defaults.crates:` while top-level uses `workspaces:`) → error.
1179pub fn validate_defaults_axis(config: &Config) -> Result<(), String> {
1180    let Some(ref defaults) = config.defaults else {
1181        return Ok(());
1182    };
1183    let has_crate_block = defaults.crates.is_some();
1184    let has_workspace_block = defaults.workspaces.is_some();
1185
1186    if has_crate_block && has_workspace_block {
1187        return Err(format!(
1188            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates and defaults.workspaces are \
1189             mutually exclusive — pick the axis that matches the top-level config \
1190             (`crates:` or `workspaces:`)",
1191        ));
1192    }
1193
1194    let top_uses_workspaces = config.workspaces.as_ref().is_some_and(|w| !w.is_empty());
1195    let top_uses_crates = !config.crates.is_empty();
1196
1197    if has_crate_block && !top_uses_crates {
1198        return Err(format!(
1199            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates is set but top-level `crates:` \
1200             is {}; move defaults under `defaults.workspaces:` or remove the block",
1201            if top_uses_workspaces {
1202                "absent (top-level uses `workspaces:`)"
1203            } else {
1204                "absent"
1205            },
1206        ));
1207    }
1208    if has_workspace_block && !top_uses_workspaces {
1209        return Err(format!(
1210            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.workspaces is set but top-level \
1211             `workspaces:` is {}; move defaults under `defaults.crates:` or remove the block",
1212            if top_uses_crates {
1213                "absent (top-level uses `crates:`)"
1214            } else {
1215                "absent"
1216            },
1217        ));
1218    }
1219
1220    Ok(())
1221}
1222
1223/// Validate `archives[].format_overrides[].os` values reject unknown OSes.
1224/// Silently no-op-ing unknown overrides has burned users typing
1225/// Rust triples like `apple` or `pc-windows-msvc`.
1226///
1227/// Walks every `archives[]` location in the config:
1228/// - `crates[].archives:`
1229/// - `workspaces[].crates[].archives:`
1230/// - `defaults.archives:` (an unknown `os` here would otherwise pass silently
1231///   and propagate to every inheriting crate at merge time).
1232pub fn validate_format_overrides(config: &Config) -> Result<(), String> {
1233    let check = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
1234        for (idx, archive) in archives.iter().enumerate() {
1235            let Some(ref overrides) = archive.format_overrides else {
1236                continue;
1237            };
1238            for over in overrides {
1239                if !KNOWN_OS.contains(&over.os.as_str()) {
1240                    let archive_id = archive.id.as_deref().unwrap_or("default");
1241                    return Err(format!(
1242                        "{}: archives[{}] (id={}): format_overrides.os=\"{}\" is not a recognised OS. \
1243                         Accepted values: {}.",
1244                        location,
1245                        idx,
1246                        archive_id,
1247                        over.os,
1248                        KNOWN_OS.join(", ")
1249                    ));
1250                }
1251            }
1252        }
1253        Ok(())
1254    };
1255    for krate in &config.crates {
1256        if let ArchivesConfig::Configs(ref list) = krate.archives {
1257            check(&format!("crate {}", krate.name), list)?;
1258        }
1259    }
1260    if let Some(ws_list) = config.workspaces.as_ref() {
1261        for ws in ws_list {
1262            for krate in &ws.crates {
1263                if let ArchivesConfig::Configs(ref list) = krate.archives {
1264                    check(&format!("crate {}", krate.name), list)?;
1265                }
1266            }
1267        }
1268    }
1269    if let Some(ref defaults) = config.defaults
1270        && let Some(ref archive) = defaults.archives
1271    {
1272        // defaults.archives is a single ArchiveConfig (not a list); wrap it
1273        // into a one-element slice so the same checker walks it.
1274        check("defaults.archives", std::slice::from_ref(archive))?;
1275    }
1276    Ok(())
1277}
1278
1279/// Validate that no [`HomebrewCaskConfig`] sets both `url_template` AND
1280/// `url.template` simultaneously — they are mutually exclusive shorthands
1281/// for the same URL field and combining them is ambiguous.
1282///
1283/// Inspects every occurrence of `HomebrewCaskConfig` in the config:
1284/// - `homebrew_casks:` (top-level array)
1285/// - `crates[].publish.homebrew_cask:`
1286/// - `workspaces[].crates[].publish.homebrew_cask:`
1287/// - `defaults.publish.homebrew_cask:`
1288pub fn validate_homebrew_cask_url_template(config: &Config) -> Result<(), String> {
1289    let check = |location: &str, cask: &HomebrewCaskConfig| -> Result<(), String> {
1290        let has_url_template = cask.url_template.is_some();
1291        let has_url_dot_template = cask.url.as_ref().is_some_and(|u| u.template.is_some());
1292        if has_url_template && has_url_dot_template {
1293            return Err(format!(
1294                "{location}: homebrew_cask sets both `url_template` and `url.template`. \
1295                 These are mutually exclusive — use one or the other."
1296            ));
1297        }
1298        Ok(())
1299    };
1300
1301    // Top-level homebrew_casks list (not nested under publish:) — not a
1302    // publish axis, so it is scanned separately from the visitor.
1303    if let Some(ref casks) = config.homebrew_casks {
1304        for (i, cask) in casks.iter().enumerate() {
1305            check(&format!("homebrew_casks[{i}]"), cask)?;
1306        }
1307    }
1308
1309    try_for_each_crate_publish(config, |axis, publish| {
1310        if let Some(cask) = publish.homebrew_cask() {
1311            check(&axis.homebrew_cask_location(), cask)?;
1312        }
1313        Ok(())
1314    })
1315}
1316
1317/// Allowed `winget.upgrade_behavior` values, mirroring the winget installer
1318/// manifest schema (1.12.0) `UpgradeBehavior` enum. A value outside this set
1319/// renders an installer manifest the winget validator rejects at PR time —
1320/// catch it at config-validate instead.
1321pub const WINGET_UPGRADE_BEHAVIORS: [&str; 3] = ["install", "uninstallPrevious", "deny"];
1322
1323/// Validate that every configured `winget.upgrade_behavior` is one of the
1324/// winget-recognized values ([`WINGET_UPGRADE_BEHAVIORS`]). Walks the per-crate,
1325/// per-workspace, and `defaults.publish` axes.
1326pub fn validate_winget_upgrade_behavior(config: &Config) -> Result<(), String> {
1327    let check = |location: &str, winget: &WingetConfig| -> Result<(), String> {
1328        if let Some(ref behavior) = winget.upgrade_behavior
1329            && !WINGET_UPGRADE_BEHAVIORS.contains(&behavior.as_str())
1330        {
1331            return Err(format!(
1332                "{location}: upgrade_behavior `{behavior}` is not a valid winget value. \
1333                 Use one of: {}.",
1334                WINGET_UPGRADE_BEHAVIORS.join(", ")
1335            ));
1336        }
1337        Ok(())
1338    };
1339
1340    try_for_each_crate_publish(config, |axis, publish| {
1341        if let Some(winget) = publish.winget() {
1342            check(&axis.winget_location(), winget)?;
1343        }
1344        Ok(())
1345    })
1346}
1347
1348/// Validate that every `winget.dependencies[].architectures` entry names a
1349/// recognized WinGet architecture ([`WINGET_ARCHITECTURES`]). Walks the
1350/// per-crate, per-workspace, and `defaults.publish` axes.
1351///
1352/// The per-installer dependency emitter matches a scope value against each
1353/// installer's WinGet architecture by exact, case-sensitive equality. A value
1354/// outside the canonical set ([`WINGET_ARCHITECTURES`]: `x64`, `arm64`, `x86`)
1355/// therefore matches
1356/// no installer, so the dependency would silently disappear from the generated
1357/// manifest. Reject it at config-validate instead of shipping a manifest that
1358/// quietly omits a declared dependency. An empty list (or absent
1359/// `architectures`) means "all installers" and is valid.
1360pub fn validate_winget_dependency_architectures(config: &Config) -> Result<(), String> {
1361    let check = |location: &str, winget: &WingetConfig| -> Result<(), String> {
1362        let Some(ref deps) = winget.dependencies else {
1363            return Ok(());
1364        };
1365        for (i, dep) in deps.iter().enumerate() {
1366            let Some(ref scopes) = dep.architectures else {
1367                continue;
1368            };
1369            for scope in scopes {
1370                if !WINGET_ARCHITECTURES.contains(&scope.as_str()) {
1371                    return Err(format!(
1372                        "{location}: dependencies[{i}].architectures contains `{scope}`, \
1373                         which is not a valid winget architecture. Use one of: {} \
1374                         (or leave architectures empty/unset to apply the dependency \
1375                         to every installer).",
1376                        WINGET_ARCHITECTURES.join(", ")
1377                    ));
1378                }
1379            }
1380        }
1381        Ok(())
1382    };
1383
1384    try_for_each_crate_publish(config, |axis, publish| {
1385        if let Some(winget) = publish.winget() {
1386            check(&axis.winget_location(), winget)?;
1387        }
1388        Ok(())
1389    })
1390}
1391
1392/// Validate that `archives[].id` and `universal_binaries[].id` are unique
1393/// within their respective lists.
1394///
1395/// The id-uniqueness validation for archives and universal binaries.
1396/// Two archive
1397/// configs with the same `id` silently both set the same `id` metadata key
1398/// on artifacts, breaking publishers that filter `ids: [<id>]`. Anodizer's
1399/// build/sign stages already enforce id uniqueness; archive and
1400/// universal_binary were missed.
1401///
1402/// Walks every occurrence of `archives[]` and `universal_binaries[]`:
1403/// - `crates[].archives:` / `crates[].universal_binaries:`
1404/// - `workspaces[].crates[].archives:` / `.universal_binaries:`
1405/// - `defaults.archives:` is a single `ArchiveConfig`, so uniqueness within
1406///   itself is vacuously true; not walked here.
1407///
1408pub fn validate_id_uniqueness(config: &Config) -> Result<(), String> {
1409    fn check_unique(
1410        location: &str,
1411        kind: &str,
1412        ids: impl IntoIterator<Item = (usize, Option<String>)>,
1413    ) -> Result<(), String> {
1414        let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1415        for (idx, maybe_id) in ids {
1416            // Empty is stored as "default" for archives via Default-time
1417            // assignment. Anodizer applies `default_archive_id` at deserialize
1418            // time, so the option is normally `Some("default")`. A truly empty
1419            // / None id here means the user explicitly cleared it; we still
1420            // dedupe across `None` so two None-id'd entries collide just like
1421            // two "default"-id'd entries would.
1422            let key = maybe_id.unwrap_or_else(|| "<unset>".to_string());
1423            if let Some(prev_idx) = seen.insert(key.clone(), idx) {
1424                return Err(format!(
1425                    "{location}: {kind} id \"{key}\" is used by both entry {prev_idx} and entry {idx} — \
1426                     ids must be unique within a {kind} list."
1427                ));
1428            }
1429        }
1430        Ok(())
1431    }
1432
1433    let check_archives = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
1434        check_unique(
1435            location,
1436            "archives",
1437            archives.iter().enumerate().map(|(i, a)| (i, a.id.clone())),
1438        )
1439    };
1440    let check_unibins = |location: &str, ubs: &[UniversalBinaryConfig]| -> Result<(), String> {
1441        check_unique(
1442            location,
1443            "universal_binaries",
1444            ubs.iter().enumerate().map(|(i, u)| (i, u.id.clone())),
1445        )
1446    };
1447
1448    for krate in &config.crates {
1449        if let ArchivesConfig::Configs(ref list) = krate.archives {
1450            check_archives(&format!("crates[{}].archives", krate.name), list)?;
1451        }
1452        if let Some(ref ubs) = krate.universal_binaries {
1453            check_unibins(&format!("crates[{}].universal_binaries", krate.name), ubs)?;
1454        }
1455    }
1456    if let Some(ws_list) = config.workspaces.as_ref() {
1457        for ws in ws_list {
1458            for krate in &ws.crates {
1459                if let ArchivesConfig::Configs(ref list) = krate.archives {
1460                    check_archives(
1461                        &format!("workspaces[{}].crates[{}].archives", ws.name, krate.name),
1462                        list,
1463                    )?;
1464                }
1465                if let Some(ref ubs) = krate.universal_binaries {
1466                    check_unibins(
1467                        &format!(
1468                            "workspaces[{}].crates[{}].universal_binaries",
1469                            ws.name, krate.name
1470                        ),
1471                        ubs,
1472                    )?;
1473                }
1474            }
1475        }
1476    }
1477    Ok(())
1478}
1479
1480/// Validate `builds[]` entries that opt into `builder: prebuilt`.
1481///
1482/// `builder: prebuilt` skips `cargo build` and imports a binary the
1483/// operator staged elsewhere. The validation rules below follow the
1484/// `prebuilt` builder contract (`/customization/builds/builders/prebuilt.md`):
1485///
1486/// 1. `prebuilt:` block MUST be set and `prebuilt.path` MUST be non-empty.
1487/// 2. `targets:` MUST be explicit on the build entry — no `defaults.targets`
1488///    fallback. Without this rule the build matrix has no rows.
1489/// 3. Cargo-only knobs are rejected as mutually exclusive: `cross_tool`,
1490///    `features`, `no_default_features`, `command`. The crate-level
1491///    `cross:` strategy is also rejected when any build on the crate is
1492///    prebuilt (the strategy has no meaning when nothing is being
1493///    compiled).
1494/// 4. `builder: cargo` (the default) with a `prebuilt:` block set warns —
1495///    the block has no effect and likely indicates a forgotten
1496///    `builder: prebuilt`.
1497pub fn validate_builds(config: &Config) -> Result<(), String> {
1498    let check_crate = |location: &str, krate: &CrateConfig| -> Result<(), String> {
1499        let Some(ref builds) = krate.builds else {
1500            return Ok(());
1501        };
1502        let crate_is_prebuilt = builds
1503            .iter()
1504            .any(|b| matches!(b.builder, Some(BuilderKind::Prebuilt)));
1505        if crate_is_prebuilt && krate.cross.is_some() {
1506            return Err(format!(
1507                "{location}: crate-level `cross:` strategy is set but at least one \
1508                 build uses `builder: prebuilt`; remove `cross:` (prebuilt imports a \
1509                 binary instead of compiling) or change the build's builder to `cargo`."
1510            ));
1511        }
1512        for (idx, build) in builds.iter().enumerate() {
1513            match build.builder {
1514                Some(BuilderKind::Prebuilt) => {
1515                    let path = build.prebuilt.as_ref().map(|p| p.path.trim()).unwrap_or("");
1516                    if path.is_empty() {
1517                        return Err(format!(
1518                            "{location}.builds[{idx}]: `builder: prebuilt` requires a non-empty \
1519                             `prebuilt.path` template. Example: \
1520                             `prebuilt: {{ path: \"output/mybin_{{{{ .Target }}}}\" }}`"
1521                        ));
1522                    }
1523                    let targets_explicit = build.targets.as_ref().is_some_and(|t| !t.is_empty());
1524                    if !targets_explicit {
1525                        return Err(format!(
1526                            "{location}.builds[{idx}] has `builder: prebuilt` but no explicit \
1527                             `targets:` — the prebuilt builder requires per-build target triples \
1528                             (no `defaults.targets:` fallback). Add `targets: [<triple>, ...]`."
1529                        ));
1530                    }
1531                    if build.cross_tool.as_ref().is_some_and(|s| !s.is_empty()) {
1532                        return Err(format!(
1533                            "{location}.builds[{idx}]: `cross_tool` is set with \
1534                             `builder: prebuilt` — the two are mutually exclusive. \
1535                             `cross_tool` controls how cargo cross-compiles; `prebuilt` \
1536                             imports an already-built binary. Drop `cross_tool` or use \
1537                             `builder: cargo`."
1538                        ));
1539                    }
1540                    if build.command.as_ref().is_some_and(|s| !s.is_empty()) {
1541                        return Err(format!(
1542                            "{location}.builds[{idx}]: `command:` override is set with \
1543                             `builder: prebuilt` — the override selects the cargo \
1544                             subcommand, which is not invoked under the prebuilt \
1545                             builder. Drop `command:` or use `builder: cargo`."
1546                        ));
1547                    }
1548                    if build.features.as_ref().is_some_and(|f| !f.is_empty()) {
1549                        return Err(format!(
1550                            "{location}.builds[{idx}]: `features:` is set with \
1551                             `builder: prebuilt` — Cargo features are evaluated at \
1552                             compile time, which the prebuilt builder skips. \
1553                             Drop `features:` or use `builder: cargo`."
1554                        ));
1555                    }
1556                    if build.no_default_features.is_some() {
1557                        return Err(format!(
1558                            "{location}.builds[{idx}]: `no_default_features:` is set with \
1559                             `builder: prebuilt` — Cargo feature flags are evaluated at \
1560                             compile time, which the prebuilt builder skips. \
1561                             Drop the flag or use `builder: cargo`."
1562                        ));
1563                    }
1564                }
1565                Some(BuilderKind::Cargo) | None => {
1566                    if build.prebuilt.is_some() {
1567                        tracing::warn!(
1568                            "{location}: build[{idx}] has a `prebuilt:` block but `builder:` \
1569                             is not `prebuilt`; the block is ignored. Set `builder: prebuilt` \
1570                             or remove the block."
1571                        );
1572                    }
1573                }
1574            }
1575        }
1576        Ok(())
1577    };
1578
1579    for krate in &config.crates {
1580        check_crate(&format!("crates[{}]", krate.name), krate)?;
1581    }
1582    if let Some(ws_list) = config.workspaces.as_ref() {
1583        for ws in ws_list {
1584            for krate in &ws.crates {
1585                check_crate(
1586                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
1587                    krate,
1588                )?;
1589            }
1590        }
1591    }
1592    Ok(())
1593}
1594
1595/// Returns `true` if every build entry on every crate has
1596/// `builder: prebuilt`. Used by the determinism harness to short-circuit:
1597/// when no target compiles, there is nothing for the harness to rebuild
1598/// and compare across runs.
1599pub fn all_builds_prebuilt(config: &Config) -> bool {
1600    let crate_all_prebuilt = |krate: &CrateConfig| -> Option<bool> {
1601        let builds = krate.builds.as_ref()?;
1602        if builds.is_empty() {
1603            return None;
1604        }
1605        Some(
1606            builds
1607                .iter()
1608                .all(|b| matches!(b.builder, Some(BuilderKind::Prebuilt))),
1609        )
1610    };
1611
1612    let mut saw_any = false;
1613    for krate in config.crate_universe() {
1614        match crate_all_prebuilt(krate) {
1615            Some(true) => saw_any = true,
1616            Some(false) => return false,
1617            None => {}
1618        }
1619    }
1620    saw_any
1621}
1622
1623/// Validate the depth of `changelog.groups[].groups`.
1624///
1625/// Subgroups are capped at ONE level
1626/// (`/customization/publish/changelog.md`: "There can only be one level of
1627/// subgroups"). Anodizer's renderer can technically handle deeper nesting
1628/// (capped at 6 to match Markdown's heading limit), but accepting deeper
1629/// configs silently is a footgun: a config that works in anodizer but is
1630/// rejected here breaks parity for users migrating in.
1631///
1632/// Rejects any `changelog.groups[i].groups[j].groups[..]` configuration
1633/// with a clear error pointing at the offending parent group title.
1634pub fn validate_changelog_groups_depth(config: &Config) -> Result<(), String> {
1635    let check = |location: &str, cfg: &ChangelogConfig| -> Result<(), String> {
1636        let Some(ref groups) = cfg.groups else {
1637            return Ok(());
1638        };
1639        for g in groups {
1640            if let Some(ref subs) = g.groups {
1641                for sub in subs {
1642                    if sub.groups.as_ref().is_some_and(|s| !s.is_empty()) {
1643                        return Err(format!(
1644                            "{location}: changelog group '{}' > '{}' nests further \
1645                             subgroups; GoReleaser permits only one level of subgroups \
1646                             (see https://goreleaser.com/customization/changelog/). \
1647                             Flatten the inner groups into the parent or split into \
1648                             sibling top-level groups.",
1649                            g.title, sub.title
1650                        ));
1651                    }
1652                }
1653            }
1654        }
1655        Ok(())
1656    };
1657    if let Some(ref cfg) = config.changelog {
1658        check("changelog", cfg)?;
1659    }
1660    if let Some(ref ws_list) = config.workspaces {
1661        for ws in ws_list {
1662            if let Some(ref cfg) = ws.changelog {
1663                check(&format!("workspaces[{}].changelog", ws.name), cfg)?;
1664            }
1665        }
1666    }
1667    Ok(())
1668}
1669
1670/// Validate `changelog.paths[]` syntax.
1671///
1672/// Path patterns are passed straight to `git log -- <path>` (or the
1673/// per-SCM equivalent). Two patterns are always wrong:
1674/// - Leading `/` — git pathspec treats this as anchored-to-CWD which is
1675///   almost never what the user wrote and produces empty changelogs.
1676/// - Empty string — silently matches everything; rejected so a typo
1677///   doesn't disable filtering.
1678///
1679/// Globs containing `**` are accepted (git accepts them) but the docs
1680/// note their semantics differ from gitignore; that's a docs concern,
1681/// not a hard error.
1682pub fn validate_changelog_paths(config: &Config) -> Result<(), String> {
1683    let check = |location: &str, cfg: &ChangelogConfig| -> Result<(), String> {
1684        let Some(ref paths) = cfg.paths else {
1685            return Ok(());
1686        };
1687        for (idx, p) in paths.iter().enumerate() {
1688            if p.is_empty() {
1689                return Err(format!(
1690                    "{location}: changelog.paths[{idx}] is empty; remove the entry \
1691                     or set a real path (empty string matches everything and \
1692                     disables filtering)"
1693                ));
1694            }
1695            if p.starts_with('/') {
1696                return Err(format!(
1697                    "{location}: changelog.paths[{idx}] = {:?} starts with '/'; \
1698                     git pathspec is repo-root-relative — write {:?} instead",
1699                    p,
1700                    p.trim_start_matches('/')
1701                ));
1702            }
1703        }
1704        Ok(())
1705    };
1706    if let Some(ref cfg) = config.changelog {
1707        check("changelog", cfg)?;
1708    }
1709    if let Some(ref ws_list) = config.workspaces {
1710        for ws in ws_list {
1711            if let Some(ref cfg) = ws.changelog {
1712                check(&format!("workspaces[{}].changelog", ws.name), cfg)?;
1713            }
1714        }
1715    }
1716    Ok(())
1717}
1718
1719/// Validate every upload-destination `exclude:` glob across all config axes.
1720///
1721/// `exclude:` drops artifacts whose file name matches a glob (see
1722/// [`crate::artifact::passes_exclude_filter`]). An unparseable glob is treated
1723/// as non-matching at runtime so it never crashes a release — but a typo'd
1724/// glob that silently keeps an asset (or, worse, drops every asset) is a
1725/// foot-gun. Reject malformed globs here, at config-load, with a clear message
1726/// before they can take effect.
1727///
1728/// Covers every config position where `exclude:` is settable: per-crate
1729/// `release:` and `blobs:` (top-level crates AND `workspaces[].crates[]`), the
1730/// top-level `artifactories:`, `cloudsmiths:`, `gemfury:`, and `uploads:`
1731/// lists, and the top-level shared `release:` block.
1732pub fn validate_exclude_globs(config: &Config) -> Result<(), String> {
1733    fn check(location: &str, exclude: Option<&[String]>) -> Result<(), String> {
1734        let Some(globs) = exclude else {
1735            return Ok(());
1736        };
1737        for (idx, g) in globs.iter().enumerate() {
1738            if g.is_empty() {
1739                return Err(format!(
1740                    "{location}: exclude[{idx}] is empty; remove the entry or set a \
1741                     real glob (an empty pattern matches nothing and is a no-op)"
1742                ));
1743            }
1744            if let Err(e) = glob::Pattern::new(g) {
1745                return Err(format!(
1746                    "{location}: exclude[{idx}] = {g:?} is not a valid glob: {e}"
1747                ));
1748            }
1749        }
1750        Ok(())
1751    }
1752
1753    let check_crate = |location: &str, krate: &CrateConfig| -> Result<(), String> {
1754        if let Some(ref release) = krate.release {
1755            check(&format!("{location}.release"), release.exclude.as_deref())?;
1756        }
1757        if let Some(ref blobs) = krate.blobs {
1758            for (i, b) in blobs.iter().enumerate() {
1759                check(&format!("{location}.blobs[{i}]"), b.exclude.as_deref())?;
1760            }
1761        }
1762        Ok(())
1763    };
1764
1765    for krate in &config.crates {
1766        check_crate(&format!("crates[{}]", krate.name), krate)?;
1767    }
1768    if let Some(ref ws_list) = config.workspaces {
1769        for ws in ws_list {
1770            for krate in &ws.crates {
1771                check_crate(
1772                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
1773                    krate,
1774                )?;
1775            }
1776        }
1777    }
1778    if let Some(ref list) = config.artifactories {
1779        for (i, a) in list.iter().enumerate() {
1780            check(&format!("artifactories[{i}]"), a.exclude.as_deref())?;
1781        }
1782    }
1783    if let Some(ref list) = config.cloudsmiths {
1784        for (i, c) in list.iter().enumerate() {
1785            check(&format!("cloudsmiths[{i}]"), c.exclude.as_deref())?;
1786        }
1787    }
1788    if let Some(ref list) = config.gemfury {
1789        for (i, g) in list.iter().enumerate() {
1790            check(&format!("gemfury[{i}]"), g.exclude.as_deref())?;
1791        }
1792    }
1793    if let Some(ref list) = config.uploads {
1794        for (i, u) in list.iter().enumerate() {
1795            check(&format!("uploads[{i}]"), u.exclude.as_deref())?;
1796        }
1797    }
1798    if let Some(ref release) = config.release {
1799        check("release", release.exclude.as_deref())?;
1800    }
1801    Ok(())
1802}
1803
1804// ---------------------------------------------------------------------------
1805// Per-crate publish visitor
1806// ---------------------------------------------------------------------------
1807
1808/// Identifies which of the three publish-config axes a visited block came from.
1809///
1810/// The config-validation walkers each format their own location string from
1811/// this identity, so different walkers can keep their distinct location wording
1812/// (`crate '{name}'` vs `crates[{name}].publish.homebrew_cask`) while sharing a
1813/// single iteration order: crates, then workspaces, then defaults.
1814pub(crate) enum PublishAxis<'a> {
1815    /// A top-level `crates[].publish` block, carrying the crate name.
1816    Crate { name: &'a str },
1817    /// A `workspaces[].crates[].publish` block, carrying the workspace and
1818    /// crate names.
1819    Workspace {
1820        workspace: &'a str,
1821        crate_name: &'a str,
1822    },
1823    /// The `defaults.publish` block.
1824    Defaults,
1825}
1826
1827impl PublishAxis<'_> {
1828    /// Location string in the bare publish-block wording shared by the
1829    /// submitter-required and legacy-Homebrew-Formula warnings:
1830    /// `crate '{name}'`, `workspaces[{ws}].crates[{krate}]`, or
1831    /// `defaults.publish`.
1832    pub(crate) fn location(&self) -> String {
1833        match self {
1834            PublishAxis::Crate { name } => format!("crate '{name}'"),
1835            PublishAxis::Workspace {
1836                workspace,
1837                crate_name,
1838            } => format!("workspaces[{workspace}].crates[{crate_name}]"),
1839            PublishAxis::Defaults => "defaults.publish".to_string(),
1840        }
1841    }
1842
1843    /// Location string in the cask-block wording used by the legacy
1844    /// Homebrew-Cask singular fold: `crates[{name}].publish.homebrew_cask`,
1845    /// `workspaces[{ws}].crates[{krate}].publish.homebrew_cask`, or
1846    /// `defaults.publish.homebrew_cask`.
1847    pub(crate) fn homebrew_cask_location(&self) -> String {
1848        match self {
1849            PublishAxis::Crate { name } => {
1850                format!("crates[{name}].publish.homebrew_cask")
1851            }
1852            PublishAxis::Workspace {
1853                workspace,
1854                crate_name,
1855            } => format!("workspaces[{workspace}].crates[{crate_name}].publish.homebrew_cask"),
1856            PublishAxis::Defaults => "defaults.publish.homebrew_cask".to_string(),
1857        }
1858    }
1859
1860    /// Location string in the winget-block wording:
1861    /// `crates[{name}].publish.winget`,
1862    /// `workspaces[{ws}].crates[{krate}].publish.winget`, or
1863    /// `defaults.publish.winget`.
1864    pub(crate) fn winget_location(&self) -> String {
1865        match self {
1866            PublishAxis::Crate { name } => format!("crates[{name}].publish.winget"),
1867            PublishAxis::Workspace {
1868                workspace,
1869                crate_name,
1870            } => format!("workspaces[{workspace}].crates[{crate_name}].publish.winget"),
1871            PublishAxis::Defaults => "defaults.publish.winget".to_string(),
1872        }
1873    }
1874}
1875
1876/// Shared, immutable view over the publisher sub-configs that appear on both
1877/// [`PublishConfig`] (the `crates[].publish` axis) and [`PublishDefaults`] (the
1878/// `defaults.publish` axis). The two underlying structs are distinct types, so
1879/// this enum erases the difference for read-only walkers.
1880pub(crate) enum PublishRef<'a> {
1881    /// A per-crate `publish:` block.
1882    Crate(&'a PublishConfig),
1883    /// The `defaults.publish:` block.
1884    Defaults(&'a PublishDefaults),
1885}
1886
1887impl PublishRef<'_> {
1888    pub(crate) fn homebrew(&self) -> Option<&HomebrewConfig> {
1889        match self {
1890            PublishRef::Crate(p) => p.homebrew.as_ref(),
1891            PublishRef::Defaults(p) => p.homebrew.as_ref(),
1892        }
1893    }
1894
1895    pub(crate) fn chocolatey(&self) -> Option<&ChocolateyConfig> {
1896        match self {
1897            PublishRef::Crate(p) => p.chocolatey.as_ref(),
1898            PublishRef::Defaults(p) => p.chocolatey.as_ref(),
1899        }
1900    }
1901
1902    pub(crate) fn winget(&self) -> Option<&WingetConfig> {
1903        match self {
1904            PublishRef::Crate(p) => p.winget.as_ref(),
1905            PublishRef::Defaults(p) => p.winget.as_ref(),
1906        }
1907    }
1908
1909    pub(crate) fn aur_source(&self) -> Option<&AurSourceConfig> {
1910        match self {
1911            PublishRef::Crate(p) => p.aur_source.as_ref(),
1912            PublishRef::Defaults(p) => p.aur_source.as_ref(),
1913        }
1914    }
1915
1916    pub(crate) fn homebrew_cask(&self) -> Option<&HomebrewCaskConfig> {
1917        match self {
1918            PublishRef::Crate(p) => p.homebrew_cask.as_ref(),
1919            PublishRef::Defaults(p) => p.homebrew_cask.as_ref(),
1920        }
1921    }
1922}
1923
1924/// Shared, mutable view over the publisher sub-configs that appear on both
1925/// [`PublishConfig`] and [`PublishDefaults`]. The `_mut` companion to
1926/// [`PublishRef`], for walkers that fold or rewrite a publisher block in place.
1927pub(crate) enum PublishMut<'a> {
1928    /// A per-crate `publish:` block.
1929    Crate(&'a mut PublishConfig),
1930    /// The `defaults.publish:` block.
1931    Defaults(&'a mut PublishDefaults),
1932}
1933
1934impl PublishMut<'_> {
1935    pub(crate) fn homebrew_cask_mut(&mut self) -> Option<&mut HomebrewCaskConfig> {
1936        match self {
1937            PublishMut::Crate(p) => p.homebrew_cask.as_mut(),
1938            PublishMut::Defaults(p) => p.homebrew_cask.as_mut(),
1939        }
1940    }
1941}
1942
1943/// Visit every `publish:` block across all three config axes — `crates[]`,
1944/// `workspaces[].crates[]`, then `defaults` — in that fixed order, passing each
1945/// block's [`PublishAxis`] identity and a read-only [`PublishRef`] view to
1946/// `visit`. Axes with no `publish:` block are skipped.
1947pub(crate) fn for_each_crate_publish<F>(config: &Config, mut visit: F)
1948where
1949    F: FnMut(PublishAxis<'_>, PublishRef<'_>),
1950{
1951    for krate in &config.crates {
1952        if let Some(ref publish) = krate.publish {
1953            visit(
1954                PublishAxis::Crate { name: &krate.name },
1955                PublishRef::Crate(publish),
1956            );
1957        }
1958    }
1959
1960    if let Some(ref workspaces) = config.workspaces {
1961        for ws in workspaces {
1962            for krate in &ws.crates {
1963                if let Some(ref publish) = krate.publish {
1964                    visit(
1965                        PublishAxis::Workspace {
1966                            workspace: &ws.name,
1967                            crate_name: &krate.name,
1968                        },
1969                        PublishRef::Crate(publish),
1970                    );
1971                }
1972            }
1973        }
1974    }
1975
1976    if let Some(ref defaults) = config.defaults
1977        && let Some(ref publish) = defaults.publish
1978    {
1979        visit(PublishAxis::Defaults, PublishRef::Defaults(publish));
1980    }
1981}
1982
1983/// Fallible companion to [`for_each_crate_publish`]: visits the same three axes
1984/// in the same fixed order, but short-circuits on the first `Err` the callback
1985/// returns, propagating it to the caller. For validators that early-exit on the
1986/// first offending block.
1987pub(crate) fn try_for_each_crate_publish<F, E>(config: &Config, mut visit: F) -> Result<(), E>
1988where
1989    F: FnMut(PublishAxis<'_>, PublishRef<'_>) -> Result<(), E>,
1990{
1991    for krate in &config.crates {
1992        if let Some(ref publish) = krate.publish {
1993            visit(
1994                PublishAxis::Crate { name: &krate.name },
1995                PublishRef::Crate(publish),
1996            )?;
1997        }
1998    }
1999
2000    if let Some(ref workspaces) = config.workspaces {
2001        for ws in workspaces {
2002            for krate in &ws.crates {
2003                if let Some(ref publish) = krate.publish {
2004                    visit(
2005                        PublishAxis::Workspace {
2006                            workspace: &ws.name,
2007                            crate_name: &krate.name,
2008                        },
2009                        PublishRef::Crate(publish),
2010                    )?;
2011                }
2012            }
2013        }
2014    }
2015
2016    if let Some(ref defaults) = config.defaults
2017        && let Some(ref publish) = defaults.publish
2018    {
2019        visit(PublishAxis::Defaults, PublishRef::Defaults(publish))?;
2020    }
2021
2022    Ok(())
2023}
2024
2025/// Mutable companion to [`for_each_crate_publish`]: visits the same three axes
2026/// in the same fixed order, passing a [`PublishMut`] view so the callback can
2027/// rewrite the publisher block in place.
2028pub(crate) fn for_each_crate_publish_mut<F>(config: &mut Config, mut visit: F)
2029where
2030    F: FnMut(PublishAxis<'_>, PublishMut<'_>),
2031{
2032    for krate in &mut config.crates {
2033        if let Some(ref mut publish) = krate.publish {
2034            visit(
2035                PublishAxis::Crate { name: &krate.name },
2036                PublishMut::Crate(publish),
2037            );
2038        }
2039    }
2040
2041    if let Some(ref mut workspaces) = config.workspaces {
2042        for ws in workspaces {
2043            for krate in &mut ws.crates {
2044                if let Some(ref mut publish) = krate.publish {
2045                    visit(
2046                        PublishAxis::Workspace {
2047                            workspace: &ws.name,
2048                            crate_name: &krate.name,
2049                        },
2050                        PublishMut::Crate(publish),
2051                    );
2052                }
2053            }
2054        }
2055    }
2056
2057    if let Some(ref mut defaults) = config.defaults
2058        && let Some(ref mut publish) = defaults.publish
2059    {
2060        visit(PublishAxis::Defaults, PublishMut::Defaults(publish));
2061    }
2062}
2063
2064/// A submitter moderation-queue advisory paired with the dispatch publisher
2065/// identity that produced it. The CLI filters by [`SubmitterAdvisory::publisher`]
2066/// so an advisory for a publisher deselected by `--skip` / `--publishers`
2067/// (e.g. `chocolatey` under a `--publishers npm` run) is suppressed instead of
2068/// emitted as noise.
2069#[derive(Debug, Clone, PartialEq, Eq)]
2070pub struct SubmitterAdvisory {
2071    /// Dispatch publisher name, matching the string
2072    /// [`crate::context::Context::publisher_deselected`] tests: `chocolatey`,
2073    /// `winget`, or `upstream-aur` (the AUR-source publisher's dispatch name).
2074    /// The CLI keys its deselection predicate on this value.
2075    pub publisher: String,
2076    /// The verbose advisory line surfaced to the operator.
2077    pub message: String,
2078}
2079
2080/// One advisory per publisher configured with `required: true` whose group is
2081/// Submitter (chocolatey, winget, aur_source), each tagged with its dispatch
2082/// publisher identity so the CLI can suppress advisories for deselected
2083/// publishers.
2084///
2085/// `required: true` on a submitter still fails the release when the submission
2086/// itself fails (it feeds `required_failures()`), but the external moderation
2087/// outcome resolves after the release run and cannot be gated on. The advisory
2088/// is non-fatal and clarifies which half of the semantics applies. Cargo is
2089/// excluded: its default is already `required: true` and the message would be
2090/// noise.
2091///
2092/// Covers all three publish axes — `crates[].publish`,
2093/// `workspaces[].crates[].publish`, and `defaults.publish` (via
2094/// [`for_each_crate_publish`]) — plus the top-level `aur_sources:` list.
2095///
2096/// Pure: this returns the advisories without emitting them. The CLI surfaces
2097/// them through `StageLogger::verbose` (the `--verbose`-gated register), so
2098/// they stay hidden at the default log level — see
2099/// `pipeline::load_config_logged`.
2100pub fn submitter_required_warnings(config: &Config) -> Vec<SubmitterAdvisory> {
2101    fn advisory(location: &str, name: &str, publisher: &str) -> SubmitterAdvisory {
2102        SubmitterAdvisory {
2103            publisher: publisher.to_string(),
2104            message: format!(
2105                "{location}: publisher '{name}' submits to an external moderation queue; \
2106                 `required: true` fails the release when the submission itself fails, \
2107                 but the eventual moderation outcome happens outside the release run \
2108                 and cannot be gated."
2109            ),
2110        }
2111    }
2112
2113    let mut warnings = Vec::new();
2114
2115    for_each_crate_publish(config, |axis, publish| {
2116        let loc = axis.location();
2117        if publish.chocolatey().and_then(|c| c.required) == Some(true) {
2118            warnings.push(advisory(&loc, "chocolatey", "chocolatey"));
2119        }
2120        if publish.winget().and_then(|w| w.required) == Some(true) {
2121            warnings.push(advisory(&loc, "winget", "winget"));
2122        }
2123        if publish.aur_source().and_then(|a| a.required) == Some(true) {
2124            // The AUR-source publisher dispatches under the name `upstream-aur`
2125            // (`AurSourcePublisher::PUBLISHER_NAME`); key the advisory on that so
2126            // the CLI's `publisher_deselected("upstream-aur")` filter matches.
2127            warnings.push(advisory(&loc, "aur_source", "upstream-aur"));
2128        }
2129    });
2130
2131    // Top-level aur_sources list (not nested under publish:) — no crate axis,
2132    // distinguish via the index in the list so two top-level entries collide cleanly.
2133    if let Some(ref sources) = config.aur_sources {
2134        for (idx, src) in sources.iter().enumerate() {
2135            if src.required == Some(true) {
2136                let loc = format!("top-level aur_sources[{idx}]");
2137                warnings.push(advisory(&loc, "aur_source", "upstream-aur"));
2138            }
2139        }
2140    }
2141
2142    warnings
2143}
2144
2145/// No-op preserved for API stability; the legacy `format:` and `builds:`
2146/// folds happen inline in `<ArchiveConfig as Deserialize>::deserialize` and
2147/// `<FormatOverride as Deserialize>::deserialize`. Emits no warning of its
2148/// own — every alias hit was already announced at deserialize time.
2149///
2150pub fn apply_archive_legacy_aliases(_config: &mut Config) {
2151    // Intentionally empty — see Deserialize impls.
2152}
2153
2154/// Reject the legacy V1 `dockers:` block at config-load time with a
2155/// clear migration error.
2156///
2157/// anodizer is V2-only by design: it implements `dockers_v2:` and the
2158/// associated multi-arch buildx flow, but does not ship the V1
2159/// `dockers: -> dockerfile + image_templates` pipe. Without this check the
2160/// top-level `Config` struct's `deny_unknown_fields` would emit a generic
2161/// "unknown field `dockers`" message that doesn't tell the user how to
2162/// migrate. This explicit error names the field, points at `dockers_v2:`,
2163/// and references the rationale.
2164///
2165pub fn validate_no_docker_v1(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
2166    if raw_yaml.get("dockers").is_some() {
2167        return Err(
2168            "config: legacy GoReleaser `dockers:` block is not supported — anodizer ships \
2169             dockers_v2: only (multi-arch buildx flow). Port the config to `dockers_v2:` per \
2170             https://anodize.dev/docs/migration/docker.html."
2171                .to_string(),
2172        );
2173    }
2174    Ok(())
2175}
2176
2177/// Emit a `tracing::warn!` for each `publish.homebrew:` (Homebrew Formula)
2178/// occurrence in the loaded config. The upstream deprecated the
2179/// Formula publisher in favour of `homebrew_casks:`; anodizer mirrors the
2180/// upstream deprecation so users following the change-log see the
2181/// same migration prompt.
2182///
2183/// Covers three placement axes (matching how `publish.homebrew` may appear):
2184///   * `crates[].publish.homebrew`
2185///   * `workspaces[].crates[].publish.homebrew`
2186///   * `defaults.publish.homebrew`
2187///
2188/// There is no top-level `homebrew:` or `brews:` field on anodizer's
2189/// `Config` — only `homebrew_casks:` lives at the top level — so this
2190/// function does not need a top-level scan.
2191pub fn warn_on_legacy_homebrew_formula(config: &Config) {
2192    for msg in legacy_homebrew_formula_warnings(config) {
2193        tracing::warn!("{}", msg);
2194    }
2195}
2196
2197/// Pure helper: returns the warning strings without emitting them.
2198/// Exposed for tests; production callers use
2199/// [`warn_on_legacy_homebrew_formula`].
2200pub(crate) fn legacy_homebrew_formula_warnings(config: &Config) -> Vec<String> {
2201    fn formula_warning(location: &str) -> String {
2202        format!(
2203            "DEPRECATION: {location}: publish.homebrew (Homebrew Formula) is deprecated upstream \
2204             in GoReleaser v2.16; migrate to homebrew_casks. Cask is now the canonical Homebrew \
2205             distribution channel for pre-compiled binaries. See \
2206             https://anodize.dev/docs/publish/homebrew-casks/ for migration."
2207        )
2208    }
2209
2210    let mut warnings = Vec::new();
2211
2212    for_each_crate_publish(config, |axis, publish| {
2213        if publish.homebrew().is_some() {
2214            warnings.push(formula_warning(&axis.location()));
2215        }
2216    });
2217
2218    warnings
2219}
2220
2221/// Fold the deprecated `snapshot.name_template` alias into `version_template`.
2222/// Serde already accepts both spellings via `#[serde(alias = "name_template")]`,
2223/// so this function only needs to emit the deprecation warning when the
2224/// raw YAML key was the legacy one.
2225///
2226/// Because serde collapses the two spellings to a single field on parse, we
2227/// lose the information about which key the user wrote. This function
2228/// therefore consults the raw YAML pre-parse value (when supplied) to decide.
2229pub fn warn_on_legacy_snapshot_name_template(raw_yaml: &serde_yaml_ng::Value) {
2230    if let Some(snap) = raw_yaml.get("snapshot")
2231        && snap.get("name_template").is_some()
2232    {
2233        tracing::warn!(
2234            "DEPRECATION: snapshot.name_template is deprecated; use \
2235             snapshot.version_template instead. Both spellings are accepted \
2236             but the legacy key will be removed in a future release."
2237        );
2238    }
2239}
2240
2241/// Emit a one-time deprecation warning when a config uses the legacy
2242/// `furies:` top-level key. Serde transparently folds `furies:` into
2243/// `gemfury:` via `#[serde(alias)]`, so this function consults the raw YAML
2244/// pre-parse value to detect the legacy spelling.
2245///
2246/// The `furies → gemfury` rename messaging.
2247pub fn warn_on_legacy_furies_alias(raw_yaml: &serde_yaml_ng::Value) {
2248    if raw_yaml.get("furies").is_some() {
2249        tracing::warn!(
2250            "DEPRECATION: the top-level `furies:` config key is deprecated since GoReleaser \
2251             Pro v2.14; rename it to `gemfury:`. Both spellings are accepted but the legacy \
2252             key will be removed in a future release."
2253        );
2254    }
2255}
2256
2257/// Emit a one-time deprecation warning for each nfpm config object that uses
2258/// the legacy `builds:` key. Serde transparently folds `builds:` into `ids:`
2259/// via `#[serde(alias = "builds")]` on [`NfpmConfig::ids`], so this function
2260/// consults the raw YAML pre-parse value to detect the legacy spelling that the
2261/// typed parse would otherwise erase.
2262///
2263/// The deprecated `NFPM.Builds` field (use `ids` instead).
2264///
2265/// nfpm config objects appear under the key `nfpm` or `nfpms` (a single map or
2266/// a sequence of maps) at multiple nesting depths — top-level, under
2267/// `defaults:`, under each `crates[]` entry, and under each
2268/// `workspaces[].crates[]` entry. Rather than enumerate every path, this walks
2269/// the tree recursively and inspects a node as an nfpm config only when it is
2270/// the value of an `nfpm:`/`nfpms:` key, so an unrelated `builds:` key
2271/// elsewhere (e.g. archives) is not double-counted.
2272pub fn warn_on_legacy_nfpm_builds(raw_yaml: &serde_yaml_ng::Value) {
2273    fn warn_for_nfpm_value(value: &serde_yaml_ng::Value) {
2274        match value {
2275            serde_yaml_ng::Value::Mapping(_) => {
2276                if value.get("builds").is_some() {
2277                    tracing::warn!(
2278                        "DEPRECATION: nfpm `builds:` is deprecated; use `ids:` instead. \
2279                         Both spellings are accepted but the legacy key will be removed in \
2280                         a future release."
2281                    );
2282                }
2283            }
2284            serde_yaml_ng::Value::Sequence(items) => {
2285                for item in items {
2286                    warn_for_nfpm_value(item);
2287                }
2288            }
2289            _ => {}
2290        }
2291    }
2292
2293    fn descend(value: &serde_yaml_ng::Value) {
2294        match value {
2295            serde_yaml_ng::Value::Mapping(map) => {
2296                for (key, child) in map {
2297                    if matches!(key.as_str(), Some("nfpm") | Some("nfpms")) {
2298                        warn_for_nfpm_value(child);
2299                    }
2300                    descend(child);
2301                }
2302            }
2303            serde_yaml_ng::Value::Sequence(items) => {
2304                for item in items {
2305                    descend(item);
2306                }
2307            }
2308            _ => {}
2309        }
2310    }
2311
2312    descend(raw_yaml);
2313}
2314
2315/// Emit a one-time deprecation warning for each block that carries the legacy
2316/// `disable:` spelling of the canonical `skip:` field. Many config blocks
2317/// (`release`, `changelog`, `snapcraft`, the docker / installer / packager
2318/// blocks, …) accept `disable:` via `#[serde(alias = "disable")]` for
2319/// back-compat with imported configs; serde folds the alias into
2320/// `skip` on parse, erasing which spelling the user wrote. This helper
2321/// consults the raw YAML pre-parse value so porting users get a migration
2322/// prompt pointing at the canonical `skip:`.
2323///
2324/// Detection is allow-listed by enclosing block key, NOT a blind tree walk,
2325/// because free-form string-keyed maps would otherwise produce false
2326/// positives:
2327///   * Free-form string-keyed maps (`variables`, `derived_metadata`,
2328///     `build_args`, `labels`, `annotations`, `env`, header maps, …) let a
2329///     user legitimately name a key `disable`. Matching only when the key's
2330///     immediate enclosing block is allow-listed skips those — the nearest
2331///     named ancestor of such a key is the map's own key (e.g. `build_args`),
2332///     never an allow-listed block.
2333///
2334/// Axis-agnostic: the enclosing block key is identical whether the block sits
2335/// at the top level, under `defaults.<block>`, under `crates[].<block>`, or
2336/// under `workspaces[].crates[].<block>`, so a single nearest-named-ancestor
2337/// rule covers every placement.
2338pub fn warn_on_legacy_disable_alias(raw_yaml: &serde_yaml_ng::Value) {
2339    for msg in legacy_disable_alias_warnings(raw_yaml) {
2340        tracing::warn!("{}", msg);
2341    }
2342}
2343
2344/// Pure helper: returns one warning string per offending `disable:` key,
2345/// each naming the YAML path to the key. Exposed for tests; production callers
2346/// use [`warn_on_legacy_disable_alias`].
2347pub(crate) fn legacy_disable_alias_warnings(raw_yaml: &serde_yaml_ng::Value) -> Vec<String> {
2348    // Block key names whose struct exposes `skip` with `#[serde(alias =
2349    // "disable")]`. Resolved from the field's serde key on its parent (see the
2350    // `alias = "disable"` sites in core). `makeselfs` (top-level) and
2351    // `makeselves` (defaults.) both map to MakeselfConfig, so both are listed;
2352    // `gemfury` and its legacy `furies` alias both map to GemFuryConfig.
2353    const ALLOWLIST: &[&str] = &[
2354        "mcp",
2355        "makeselfs",
2356        "makeselves",
2357        "appimages",
2358        "msis",
2359        "pkgs",
2360        "nsis",
2361        "dockerhub",
2362        "release",
2363        "dockers_v2",
2364        "docker_v2",
2365        "changelog",
2366        "snapcrafts",
2367        "npms",
2368        "gemfury",
2369        "furies",
2370        "publishers",
2371        "sboms",
2372        "aur",
2373        "aur_source",
2374        "aur_sources",
2375        "blobs",
2376        "docker_digest",
2377        "checksum",
2378        "flatpaks",
2379    ];
2380
2381    fn disable_warning(path: &str) -> String {
2382        format!(
2383            "DEPRECATION: {path}: legacy `disable:` is deprecated; rename it to `skip:`. \
2384             Both spellings are accepted but the legacy key will be removed in a future release."
2385        )
2386    }
2387
2388    // `enclosing_block`: the nearest named (non-list-index) ancestor key — the
2389    // block the `disable:` key belongs to. Only warn when it is allow-listed.
2390    fn descend(
2391        value: &serde_yaml_ng::Value,
2392        path: &str,
2393        enclosing_block: Option<&str>,
2394        warnings: &mut Vec<String>,
2395    ) {
2396        match value {
2397            serde_yaml_ng::Value::Mapping(map) => {
2398                for (key, child) in map {
2399                    let Some(key) = key.as_str() else { continue };
2400                    let child_path = if path.is_empty() {
2401                        key.to_string()
2402                    } else {
2403                        format!("{path}.{key}")
2404                    };
2405                    if key == "disable"
2406                        && enclosing_block.is_some_and(|block| ALLOWLIST.contains(&block))
2407                    {
2408                        warnings.push(disable_warning(&child_path));
2409                    }
2410                    descend(child, &child_path, Some(key), warnings);
2411                }
2412            }
2413            serde_yaml_ng::Value::Sequence(items) => {
2414                for (idx, item) in items.iter().enumerate() {
2415                    let item_path = format!("{path}[{idx}]");
2416                    // A list index is not a named ancestor: keep the enclosing
2417                    // block (the list's own key) so e.g. `snapcrafts[0].disable`
2418                    // still resolves to the `snapcrafts` block.
2419                    descend(item, &item_path, enclosing_block, warnings);
2420                }
2421            }
2422            _ => {}
2423        }
2424    }
2425
2426    let mut warnings = Vec::new();
2427    descend(raw_yaml, "", None, &mut warnings);
2428    warnings
2429}
2430
2431/// Reject the legacy nested `mcp.github:` block with a
2432/// clear migration error.
2433///
2434/// The registry metadata that used to live under
2435/// `mcp.github:` (repository owner/name/url) to the top-level `mcp:` block
2436/// (canonical surface: `mcp.repository:`, `mcp.name:`, etc.). Anodizer
2437/// never carried the nested shim — its `McpConfig` has `deny_unknown_fields`
2438/// so the key would otherwise produce a generic "unknown field" message.
2439/// This pre-parse check intercepts the legacy spelling so the user sees a
2440/// migration pointer rather than a schema-shape error.
2441pub fn validate_no_mcp_github(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
2442    if raw_yaml.get("mcp").and_then(|m| m.get("github")).is_some() {
2443        return Err(
2444            "config: nested `mcp.github:` block is not supported — anodizer mirrors GoReleaser \
2445             v2.13.1+ where registry metadata moved to top-level `mcp:` fields (`mcp.name`, \
2446             `mcp.repository.url`, `mcp.repository.source`). Port the nested keys to the \
2447             canonical surface."
2448                .to_string(),
2449        );
2450    }
2451    Ok(())
2452}
2453
2454/// Emit a one-time deprecation warning for each `dockers_v2[].retry:` or
2455/// `docker_manifests[].retry:` block at config-load time. The per-pipe
2456/// `retry:` field is the legacy shape (retry handling moved to
2457/// the top-level `retry:` block); the per-pipe value is still honored at
2458/// resolve-time (see `stage-docker::resolve_retry_params`) but a top-level
2459/// `retry:` is the canonical surface for retry policy. Warning fires once
2460/// per occurrence so users porting from older configs see a clear
2461/// pointer at load time without waiting for the docker pipe to execute.
2462pub fn warn_on_legacy_docker_retry(config: &Config) {
2463    for msg in legacy_docker_retry_warnings(config) {
2464        tracing::warn!("{}", msg);
2465    }
2466}
2467
2468/// Pure helper: returns the warning strings without emitting them. Exposed
2469/// for tests; production callers use [`warn_on_legacy_docker_retry`].
2470pub(crate) fn legacy_docker_retry_warnings(config: &Config) -> Vec<String> {
2471    fn pipe_warning(location: &str, kind: &str) -> String {
2472        format!(
2473            "DEPRECATION: {location}: nested `{kind}.retry:` is deprecated since GoReleaser \
2474             v2.15.3; move retry settings to the top-level `retry:` block. The per-pipe \
2475             value still wins at resolve time for back-compat, but the legacy spelling will \
2476             be removed in a future release."
2477        )
2478    }
2479
2480    let mut warnings = Vec::new();
2481
2482    let scan_crate = |krate: &CrateConfig, prefix: &str, warnings: &mut Vec<String>| {
2483        if let Some(ref v2) = krate.dockers_v2 {
2484            for (i, cfg) in v2.iter().enumerate() {
2485                if cfg.retry.is_some() {
2486                    warnings.push(pipe_warning(
2487                        &format!("{prefix}.dockers_v2[{i}]"),
2488                        "dockers_v2",
2489                    ));
2490                }
2491            }
2492        }
2493        if let Some(ref manifests) = krate.docker_manifests {
2494            for (i, cfg) in manifests.iter().enumerate() {
2495                if cfg.retry.is_some() {
2496                    warnings.push(pipe_warning(
2497                        &format!("{prefix}.docker_manifests[{i}]"),
2498                        "docker_manifests",
2499                    ));
2500                }
2501            }
2502        }
2503    };
2504
2505    for krate in &config.crates {
2506        scan_crate(krate, &format!("crates[{}]", krate.name), &mut warnings);
2507    }
2508
2509    if let Some(ref workspaces) = config.workspaces {
2510        for ws in workspaces {
2511            for krate in &ws.crates {
2512                scan_crate(
2513                    krate,
2514                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
2515                    &mut warnings,
2516                );
2517            }
2518        }
2519    }
2520
2521    if let Some(ref defaults) = config.defaults
2522        && let Some(ref v2) = defaults.dockers_v2
2523        && v2.retry.is_some()
2524    {
2525        warnings.push(pipe_warning("defaults.dockers_v2", "dockers_v2"));
2526    }
2527
2528    warnings
2529}
2530
2531/// Fold the deprecated singular Homebrew Cask fields into their canonical
2532/// plural lists and emit a one-time deprecation warning per folded field:
2533///
2534/// - `binary: <name>` → [`HomebrewCaskConfig::binaries`] (the upstream
2535///   renamed `binary:` to `binaries:`).
2536/// - `manpage: <page>` → [`HomebrewCaskConfig::manpages`].
2537///
2538/// anodizer accepts both spellings so imported configs keep parsing.
2539/// The captured values are moved out of [`HomebrewCaskConfig::legacy_binary`]
2540/// and [`HomebrewCaskConfig::legacy_manpage`] so downstream code only ever
2541/// reads the canonical plural fields.
2542///
2543/// The two folds use different insertion order: a legacy
2544/// `binary` is **prepended** to `binaries` so any explicit `binaries:` ordering
2545/// is preserved at the tail, whereas a legacy `manpage` is **appended** to
2546/// `manpages` (the cask renderer does
2547/// `brew.Manpages = append(brew.Manpages, brew.Manpage)`).
2548///
2549/// The fold runs across every config mode — top-level `homebrew_casks`,
2550/// per-crate `publish.homebrew_cask`, `workspaces[].crates[].publish`, and
2551/// `defaults.publish`.
2552pub fn apply_homebrew_cask_legacy_singulars(config: &mut Config) {
2553    /// Fold both deprecated singular fields (`binary:` → `binaries`,
2554    /// `manpage:` → `manpages`) on one cask, returning a warning per folded
2555    /// field. The singular `binary` is prepended to `binaries` so an explicit
2556    /// `binaries[0]` ordering is preserved at the tail; the singular `manpage`
2557    /// is appended to `manpages`.
2558    fn fold_one(location: &str, cask: &mut HomebrewCaskConfig) -> Vec<String> {
2559        let mut warnings = Vec::new();
2560        if let Some(legacy) = cask.legacy_binary.take() {
2561            let entry = HomebrewCaskBinary::Name(legacy.clone());
2562            match cask.binaries {
2563                Some(ref mut list) => list.insert(0, entry),
2564                None => cask.binaries = Some(vec![entry]),
2565            }
2566            warnings.push(format!(
2567                "DEPRECATION: {location}: singular `binary: {legacy}` is deprecated since \
2568                 GoReleaser v2.12.6; use the plural `binaries: [{legacy}]` form. The legacy \
2569                 value has been folded into binaries[0]."
2570            ));
2571        }
2572        if let Some(legacy) = cask.legacy_manpage.take() {
2573            match cask.manpages {
2574                Some(ref mut list) => list.push(legacy.clone()),
2575                None => cask.manpages = Some(vec![legacy.clone()]),
2576            }
2577            warnings.push(format!(
2578                "DEPRECATION: {location}: singular `manpage: {legacy}` is deprecated; \
2579                 use the plural `manpages: [{legacy}]` form. The legacy value has been \
2580                 folded into manpages."
2581            ));
2582        }
2583        warnings
2584    }
2585
2586    let mut warnings = Vec::new();
2587
2588    // Top-level homebrew_casks list (not nested under publish:) — not a
2589    // publish axis, so it is scanned separately from the visitor.
2590    if let Some(ref mut casks) = config.homebrew_casks {
2591        for (i, cask) in casks.iter_mut().enumerate() {
2592            warnings.extend(fold_one(&format!("homebrew_casks[{i}]"), cask));
2593        }
2594    }
2595
2596    for_each_crate_publish_mut(config, |axis, mut publish| {
2597        if let Some(cask) = publish.homebrew_cask_mut() {
2598            warnings.extend(fold_one(&axis.homebrew_cask_location(), cask));
2599        }
2600    });
2601
2602    for msg in warnings {
2603        tracing::warn!("{}", msg);
2604    }
2605}
2606
2607// ---------------------------------------------------------------------------
2608// EnvFilesConfig — accepts list of .env paths OR structured token file paths
2609// ---------------------------------------------------------------------------
2610
2611mod env_files;
2612pub use env_files::*;
2613
2614// ---------------------------------------------------------------------------
2615// Defaults
2616// ---------------------------------------------------------------------------
2617
2618mod defaults;
2619pub use defaults::*;
2620
2621// ---------------------------------------------------------------------------
2622// BuildIgnore — exclude specific os/arch combos from builds
2623// ---------------------------------------------------------------------------
2624
2625mod build;
2626pub use build::*;
2627
2628// ---------------------------------------------------------------------------
2629// ArchivesConfig — untagged enum: false => Disabled, array => Configs
2630// ---------------------------------------------------------------------------
2631
2632mod archives;
2633pub use archives::*;
2634
2635mod completions;
2636pub use completions::*;
2637
2638// ---------------------------------------------------------------------------
2639// ReleaseConfig
2640// ---------------------------------------------------------------------------
2641
2642mod release;
2643pub use release::*;
2644
2645// ---------------------------------------------------------------------------
2646// Shared publisher config types: RepositoryConfig, CommitAuthorConfig
2647// ---------------------------------------------------------------------------
2648
2649mod publishers;
2650pub use publishers::*;
2651
2652// ---------------------------------------------------------------------------
2653// DockerV2Config
2654// ---------------------------------------------------------------------------
2655
2656mod docker;
2657pub use docker::*;
2658
2659// ---------------------------------------------------------------------------
2660// NfpmConfig
2661// ---------------------------------------------------------------------------
2662
2663mod nfpm;
2664pub use nfpm::*;
2665
2666// ---------------------------------------------------------------------------
2667// SnapcraftConfig
2668// ---------------------------------------------------------------------------
2669
2670mod snapcraft;
2671pub use snapcraft::*;
2672// ---------------------------------------------------------------------------
2673// DmgConfig / MsiConfig / PkgConfig / NsisConfig / AppBundleConfig / FlatpakConfig
2674// ---------------------------------------------------------------------------
2675
2676mod installers;
2677pub use installers::*;
2678
2679// ---------------------------------------------------------------------------
2680// BlobConfig (S3/GCS/Azure cloud storage)
2681// ---------------------------------------------------------------------------
2682
2683mod blob;
2684pub use blob::*;
2685
2686// ---------------------------------------------------------------------------
2687// PartialConfig (split/merge CI fan-out)
2688// ---------------------------------------------------------------------------
2689
2690mod partial;
2691pub use partial::*;
2692
2693// ---------------------------------------------------------------------------
2694// BinstallConfig
2695// ---------------------------------------------------------------------------
2696
2697mod binstall;
2698pub use binstall::*;
2699
2700// ---------------------------------------------------------------------------
2701// NotarizeConfig (macOS code signing and notarization)
2702// ---------------------------------------------------------------------------
2703
2704mod notarize;
2705pub use notarize::*;
2706// ---------------------------------------------------------------------------
2707// SourceConfig
2708// ---------------------------------------------------------------------------
2709
2710mod source;
2711pub use source::*;
2712
2713// ---------------------------------------------------------------------------
2714// SbomConfig
2715// ---------------------------------------------------------------------------
2716
2717mod sbom;
2718pub use sbom::*;
2719
2720// ---------------------------------------------------------------------------
2721// AttestationConfig
2722// ---------------------------------------------------------------------------
2723
2724mod attestation;
2725pub use attestation::*;
2726
2727// ---------------------------------------------------------------------------
2728// VersionSyncConfig
2729// ---------------------------------------------------------------------------
2730
2731mod version_sync;
2732pub use version_sync::*;
2733
2734// ---------------------------------------------------------------------------
2735// ChangelogConfig
2736// ---------------------------------------------------------------------------
2737
2738mod changelog;
2739pub use changelog::*;
2740// ---------------------------------------------------------------------------
2741// SignConfig / DockerSignConfig — lifted to `crate::signing`
2742// ---------------------------------------------------------------------------
2743//
2744// see `crate::signing` for the type definitions. The
2745// re-exports below preserve the historical
2746// `anodizer_core::config::{SignConfig, DockerSignConfig}` import paths
2747// used by every stage that consumes a sign config.
2748
2749pub use crate::signing::{AuthenticodeConfig, DockerSignConfig, SignConfig, SignVerifyConfig};
2750
2751// ---------------------------------------------------------------------------
2752// UpxConfig
2753// ---------------------------------------------------------------------------
2754
2755mod upx;
2756pub use upx::*;
2757
2758// ---------------------------------------------------------------------------
2759// SnapshotConfig
2760// ---------------------------------------------------------------------------
2761
2762mod snapshot_nightly;
2763pub use snapshot_nightly::*;
2764
2765mod cargo_metadata;
2766pub use cargo_metadata::derive_metadata_from_cargo_toml;
2767
2768/// Extract the name portion of a `"Name <email>"` maintainer/author string,
2769/// dropping any `<…>` email suffix. Returns `None` when the result is empty
2770/// (e.g. a bare-email `<ada@example.com>`), so a derived Vendor / OCI `vendor`
2771/// value is never emitted blank.
2772pub fn maintainer_name_only(maintainer: &str) -> Option<String> {
2773    let name = maintainer.split('<').next().unwrap_or(maintainer).trim();
2774    (!name.is_empty()).then(|| name.to_string())
2775}
2776
2777// ---------------------------------------------------------------------------
2778// TemplateFileConfig
2779// ---------------------------------------------------------------------------
2780
2781mod templatefiles;
2782pub use templatefiles::*;
2783
2784// ---------------------------------------------------------------------------
2785// AnnounceConfig
2786// ---------------------------------------------------------------------------
2787mod announce;
2788pub use announce::*;
2789// ---------------------------------------------------------------------------
2790// DockerHub description sync
2791// ---------------------------------------------------------------------------
2792
2793mod dockerhub;
2794pub use dockerhub::*;
2795
2796// ---------------------------------------------------------------------------
2797// Artifactory publisher
2798// ---------------------------------------------------------------------------
2799
2800mod artifactory;
2801pub use artifactory::*;
2802
2803// ---------------------------------------------------------------------------
2804// CloudSmith publisher
2805// ---------------------------------------------------------------------------
2806
2807mod cloudsmith;
2808pub use cloudsmith::*;
2809
2810// ---------------------------------------------------------------------------
2811// PublisherConfig
2812// ---------------------------------------------------------------------------
2813
2814mod publisher;
2815pub use publisher::*;
2816
2817// ---------------------------------------------------------------------------
2818// HooksConfig
2819// ---------------------------------------------------------------------------
2820
2821mod hooks;
2822pub use hooks::*;
2823
2824// ---------------------------------------------------------------------------
2825// GitConfig
2826// ---------------------------------------------------------------------------
2827
2828mod git_config;
2829pub use git_config::*;
2830
2831// ---------------------------------------------------------------------------
2832// MonorepoConfig
2833// ---------------------------------------------------------------------------
2834
2835mod monorepo;
2836pub use monorepo::*;
2837
2838// ---------------------------------------------------------------------------
2839// TagConfig
2840// ---------------------------------------------------------------------------
2841
2842mod tag;
2843pub use tag::*;
2844
2845// ---------------------------------------------------------------------------
2846// WorkspaceConfig
2847// ---------------------------------------------------------------------------
2848
2849mod workspace;
2850pub use workspace::*;
2851
2852// ---------------------------------------------------------------------------
2853// RetryConfig (top-level `retry:` block — bridges to crate::retry::RetryPolicy)
2854// ---------------------------------------------------------------------------
2855
2856mod retry;
2857pub use retry::*;
2858
2859// ---------------------------------------------------------------------------
2860// PostPublishPollConfig (per-publisher post-publish polling)
2861// ---------------------------------------------------------------------------
2862
2863mod post_publish_poll;
2864pub use post_publish_poll::*;
2865
2866// ---------------------------------------------------------------------------
2867// VerifyReleaseConfig (top-level `verify_release:` post-publish gate)
2868// ---------------------------------------------------------------------------
2869
2870mod verify_release;
2871pub use verify_release::*;
2872
2873// ---------------------------------------------------------------------------
2874// PreflightConfig (top-level `preflight:` pre-publish probe tuning)
2875// ---------------------------------------------------------------------------
2876
2877mod preflight;
2878pub use preflight::*;
2879
2880// ---------------------------------------------------------------------------
2881// StringOrBool — accepts bool or template string in YAML
2882// ---------------------------------------------------------------------------
2883
2884mod string_or_bool;
2885pub use string_or_bool::*;
2886
2887// ---------------------------------------------------------------------------
2888// MakeselfConfig + SrpmConfig — lifted to `crate::packagers`
2889// ---------------------------------------------------------------------------
2890//
2891// All packaging config types live in their own modules under
2892// `crate::packagers`. The re-exports below preserve the historical
2893// `anodizer_core::config::{MakeselfConfig, MakeselfFile, SrpmConfig}`
2894// import paths used by stages and tests.
2895
2896pub use crate::packagers::{
2897    AppImageConfig, AppImageExtra, MakeselfConfig, MakeselfFile, RuntimeHarvest, SrpmConfig,
2898};
2899pub(crate) use crate::packagers::{
2900    appimages_schema, deserialize_appimages, deserialize_makeselfs, makeselfs_schema,
2901};
2902
2903// ---------------------------------------------------------------------------
2904// MilestoneConfig
2905// ---------------------------------------------------------------------------
2906
2907mod milestone;
2908pub use milestone::*;
2909
2910// ---------------------------------------------------------------------------
2911// UploadConfig (generic HTTP upload)
2912// ---------------------------------------------------------------------------
2913
2914mod upload;
2915pub use upload::*;
2916
2917// ---------------------------------------------------------------------------
2918// AurSourceConfig
2919// ---------------------------------------------------------------------------
2920
2921mod aur_source;
2922pub use aur_source::*;
2923
2924// ---------------------------------------------------------------------------
2925// McpConfig (MCP registry publisher)
2926// ---------------------------------------------------------------------------
2927
2928mod mcp;
2929pub use mcp::*;
2930
2931// ---------------------------------------------------------------------------
2932// NpmConfig (NPM package registry publisher)
2933// ---------------------------------------------------------------------------
2934
2935mod npm;
2936pub use npm::*;
2937
2938// ---------------------------------------------------------------------------
2939// GemFuryConfig (Gemfury / fury.io publisher)
2940// ---------------------------------------------------------------------------
2941
2942mod gemfury;
2943pub use gemfury::*;
2944
2945// ---------------------------------------------------------------------------
2946// Well-known config file discovery
2947// ---------------------------------------------------------------------------
2948
2949mod discovery;
2950pub use discovery::*;
2951
2952// ---------------------------------------------------------------------------
2953// Tests
2954// ---------------------------------------------------------------------------
2955
2956#[cfg(test)]
2957mod tests;