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
727/// JSON Schema for the [`Config`] document as a canonical `serde_json::Value`,
728/// in the JSON Schema draft-07 dialect.
729///
730/// The published `schema.json`, the `anodizer jsonschema` command, and the
731/// config-reference doc generator all read the schema from this one function so
732/// the dialect (`definitions` + `#/definitions/` refs) and the byte-form are
733/// fixed in a single place. draft-07 is the dialect editors (VS Code, the JSON
734/// Schema Store) resolve for `.anodizer.yaml`, so the published schema and the
735/// editor integration agree.
736///
737/// Returns a plain `Value` rather than [`schemars::Schema`] deliberately:
738/// serializing a `Schema` re-imposes schemars 1.x's keyword ordering (via its
739/// internal `OrderedKeywordWrapper`), which would undo [`canonicalize_schema`].
740/// Serializing the `Value` directly preserves the canonical order.
741#[must_use]
742pub fn config_schema() -> serde_json::Value {
743    let schema = schemars::generate::SchemaSettings::draft07()
744        .into_generator()
745        .into_root_schema_for::<Config>();
746    let mut value = schema.to_value();
747    canonicalize_schema(&mut value);
748    value
749}
750
751/// JSON Schema keyword serialization order matching schemars 0.8's `SchemaObject`
752/// field declaration order (its flattened `Metadata` / `SubschemaValidation` /
753/// number / string / array / object validation structs concatenated in struct
754/// order). The published `schema.json` is byte-pinned to this order so it stays
755/// stable across schemars upgrades (1.x emits a different keyword order, and the
756/// workspace builds `serde_json` with `preserve_order` — via `stage-publish` —
757/// so insertion order leaks into the file unless re-imposed here). An unlisted
758/// keyword sorts after all listed ones, then lexicographically.
759const SCHEMA_KEYWORD_ORDER: &[&str] = &[
760    "$id",
761    "$schema",
762    "title",
763    "description",
764    "default",
765    "deprecated",
766    "readOnly",
767    "writeOnly",
768    "type",
769    "format",
770    "enum",
771    "const",
772    "allOf",
773    "anyOf",
774    "oneOf",
775    "not",
776    "if",
777    "then",
778    "else",
779    "multipleOf",
780    "maximum",
781    "exclusiveMaximum",
782    "minimum",
783    "exclusiveMinimum",
784    "maxLength",
785    "minLength",
786    "pattern",
787    "items",
788    "additionalItems",
789    "maxItems",
790    "minItems",
791    "uniqueItems",
792    "contains",
793    "maxProperties",
794    "minProperties",
795    "required",
796    "properties",
797    "patternProperties",
798    "additionalProperties",
799    "propertyNames",
800    "$ref",
801    "definitions",
802];
803
804/// Schema object keys whose VALUE is a map of name → subschema (not a subschema
805/// itself). Their entries are sorted by NAME (schemars 0.8 backed these with a
806/// `BTreeMap`); every other keyword's value is a schema whose own keys are
807/// ordered by [`SCHEMA_KEYWORD_ORDER`].
808const SCHEMA_DEFINITION_MAPS: &[&str] = &["properties", "patternProperties", "definitions"];
809
810/// Re-impose schemars 0.8's deterministic serialization on a draft-07 schema
811/// `Value` so the published artifact is byte-stable across schemars versions:
812/// recursively (1) order each schema object's keys by [`SCHEMA_KEYWORD_ORDER`],
813/// (2) sort definition-map entries (`properties`/`definitions`/…) by name,
814/// (3) sort `required` (a set), and (4) normalize every `description` to single
815/// spaces within a paragraph while preserving blank-line paragraph breaks.
816fn canonicalize_schema(value: &mut serde_json::Value) {
817    use serde_json::Value;
818    match value {
819        Value::Object(map) => {
820            if let Some(Value::String(d)) = map.get_mut("description") {
821                *d = collapse_description(d);
822            }
823            if let Some(Value::Array(required)) = map.get_mut("required") {
824                required.sort_by(|a, b| match (a.as_str(), b.as_str()) {
825                    (Some(x), Some(y)) => x.cmp(y),
826                    _ => std::cmp::Ordering::Equal,
827                });
828            }
829            // Recurse, treating each value by its role:
830            // - a definition-map value (`properties`/`definitions`/…) is a
831            //   name→schema map: sort its entries by name, recurse each schema;
832            // - `default`/`enum`/`const`/`examples` hold literal instance DATA,
833            //   not schemas — never reorder their keys (they preserve the config
834            //   struct's serialization order);
835            // - every other value is itself a schema (or array of schemas).
836            for (key, child) in map.iter_mut() {
837                match key.as_str() {
838                    k if SCHEMA_DEFINITION_MAPS.contains(&k) => {
839                        if let Value::Object(entries) = child {
840                            sort_object_by_key(entries);
841                            for sub in entries.values_mut() {
842                                canonicalize_schema(sub);
843                            }
844                        }
845                    }
846                    "default" | "enum" | "const" | "examples" => {}
847                    _ => canonicalize_schema(child),
848                }
849            }
850            reorder_object(map, SCHEMA_KEYWORD_ORDER);
851        }
852        Value::Array(items) => {
853            for item in items {
854                canonicalize_schema(item);
855            }
856        }
857        _ => {}
858    }
859}
860
861/// Reorder `map`'s entries so listed keys come first in `order`, then any
862/// remaining keys lexicographically. `serde_json`'s `preserve_order` feature is
863/// active workspace-wide, so a `Map` serializes in insertion order — rebuilding
864/// it in the target order fixes the serialized key order.
865fn reorder_object(map: &mut serde_json::Map<String, serde_json::Value>, order: &[&str]) {
866    let mut keys: Vec<String> = map.keys().cloned().collect();
867    keys.sort_by(|a, b| {
868        let rank = |k: &str| order.iter().position(|o| *o == k).unwrap_or(order.len());
869        rank(a).cmp(&rank(b)).then_with(|| a.cmp(b))
870    });
871    let mut rebuilt = serde_json::Map::with_capacity(map.len());
872    for k in keys {
873        if let Some(v) = map.remove(&k) {
874            rebuilt.insert(k, v);
875        }
876    }
877    *map = rebuilt;
878}
879
880/// Sort an object map's entries by key (rebuilt because `preserve_order` keeps
881/// insertion order). Used for definition maps where 0.8 emitted `BTreeMap`-sorted
882/// names.
883fn sort_object_by_key(map: &mut serde_json::Map<String, serde_json::Value>) {
884    let mut keys: Vec<String> = map.keys().cloned().collect();
885    keys.sort();
886    let mut rebuilt = serde_json::Map::with_capacity(map.len());
887    for k in keys {
888        if let Some(v) = map.remove(&k) {
889            rebuilt.insert(k, v);
890        }
891    }
892    *map = rebuilt;
893}
894
895/// Normalize a schema `description`: collapse each paragraph's internal
896/// whitespace (including the rustdoc doc-comment's hard line wraps, which
897/// schemars 1.x preserves verbatim) to single spaces, while preserving
898/// blank-line paragraph breaks (`\n\n`). Reproduces the single-spaced,
899/// paragraph-separated form earlier schemars releases emitted, so the published
900/// schema's tooltips render as clean prose in editors.
901fn collapse_description(s: &str) -> String {
902    s.split("\n\n")
903        .map(|para| {
904            para.split('\n')
905                .map(str::trim)
906                .collect::<Vec<_>>()
907                .join(" ")
908        })
909        .collect::<Vec<_>>()
910        .join("\n\n")
911}
912
913/// Run a deserialization closure on a worker thread sized large enough that
914/// the `Config` derive (60+ `Option<NestedStruct>` fields) cannot exhaust
915/// the host's main-thread stack.
916///
917/// Background: debug builds of `serde_yaml_ng::from_value::<Config>` and
918/// `toml::from_str::<Config>` consume several MiB of stack because each
919/// generated visitor branch for the giant struct lives in a single
920/// monomorphised frame and debug builds neither inline nor tail-call. The
921/// Windows main-thread default reservation is 1 MiB, so any debug-built
922/// integration test that triggers full-config deserialization overflows
923/// before reaching the visitor's body.
924///
925/// Routing every full-`Config` deserialization through this helper keeps
926/// every entry-point platform-agnostic without resorting to per-platform
927/// linker flags or `RUST_MIN_STACK`.
928pub fn deserialize_on_worker<F, T>(f: F) -> anyhow::Result<T>
929where
930    F: FnOnce() -> anyhow::Result<T> + Send + 'static,
931    T: Send + 'static,
932{
933    use anyhow::Context as _;
934
935    // 8 MiB matches the Linux/macOS process default and comfortably exceeds
936    // the ~2 MiB peak observed for debug `Config` deserialization.
937    const WORKER_STACK_SIZE: usize = 8 * 1024 * 1024;
938
939    let handle = std::thread::Builder::new()
940        .stack_size(WORKER_STACK_SIZE)
941        .name("anodizer-config-deserialize".to_string())
942        .spawn(f)
943        .context("failed to spawn config deserialization worker thread")?;
944    match handle.join() {
945        Ok(result) => result,
946        Err(payload) => std::panic::resume_unwind(payload),
947    }
948}
949
950/// Validate the config schema version. Accepts version 1 (default) and 2.
951/// Returns an error for unknown versions.
952pub fn validate_version(config: &Config) -> Result<(), String> {
953    match config.version {
954        None | Some(1) | Some(2) => Ok(()),
955        Some(v) => Err(format!(
956            "unsupported config version: {}. Supported versions are 1 and 2.",
957            v
958        )),
959    }
960}
961
962/// Validate `git.tag_sort` if present. Accepted values:
963/// - `"-version:refname"` (default, lexicographic version sort)
964/// - `"-version:creatordate"` (sort by tag creation date, newest first)
965/// - `"semver"` (Rust-side strict SemVer 2.0.0 ordering, prereleases sort
966///   below their release per spec section 11)
967/// - `"smartsemver"` (same ordering as `semver`, but when the current version
968///   is non-prerelease, prerelease tags are skipped when picking the previous
969///   tag — avoids selecting `v0.2.0-beta.3` as the predecessor of `v0.2.0`)
970///
971/// Returns an error for unrecognized values.
972pub fn validate_tag_sort(config: &Config) -> Result<(), String> {
973    if let Some(ref git) = config.git
974        && let Some(ref sort) = git.tag_sort
975    {
976        match sort.as_str() {
977            "-version:refname" | "-version:creatordate" | "semver" | "smartsemver" => {}
978            other => {
979                return Err(format!(
980                    "unsupported git.tag_sort value: \"{}\". \
981                     Accepted values: \"-version:refname\", \"-version:creatordate\", \
982                     \"semver\", \"smartsemver\".",
983                    other
984                ));
985            }
986        }
987    }
988    Ok(())
989}
990
991/// Validate `partial.by` up front so a stale value is rejected at config-load
992/// time regardless of which target-resolution path runs.
993///
994/// `partial.by` is read in two unrelated places: the host-detection branch of
995/// [`crate::partial::resolve_partial_target`] (which already rejects unknown
996/// values) and the split-matrix generator (which treats anything that is not
997/// `"os"` as `"target"`). Those two readers disagree on an out-of-set value
998/// like the pre-rename `"goos"`: one errors, the other silently mis-groups the
999/// matrix. Centralising the check means a typo fails loudly once, before
1000/// either reader can diverge.
1001pub fn validate_partial(config: &Config) -> Result<(), String> {
1002    if let Some(ref partial) = config.partial
1003        && let Some(ref by) = partial.by
1004    {
1005        match by.as_str() {
1006            "os" | "target" => {}
1007            other => {
1008                return Err(format!(
1009                    "unsupported partial.by value: \"{}\". \
1010                     Accepted values: \"os\", \"target\".",
1011                    other
1012                ));
1013            }
1014        }
1015    }
1016    Ok(())
1017}
1018
1019/// Known OS values accepted by `archives[].format_overrides[].os`.
1020/// The Go runtime's `runtime.GOOS` values the archive pipe
1021/// recognises; anything outside this set is almost always a typo
1022/// (e.g. a Rust target triple slice like `pc-windows-msvc`).
1023const KNOWN_OS: &[&str] = &[
1024    "aix",
1025    "android",
1026    "darwin",
1027    "dragonfly",
1028    "freebsd",
1029    "illumos",
1030    "ios",
1031    "js",
1032    "linux",
1033    "netbsd",
1034    "openbsd",
1035    "plan9",
1036    "solaris",
1037    "wasip1",
1038    "windows",
1039];
1040
1041/// Validate that each crate's `release:` block configures at most one SCM
1042/// backend. A multiple-releases error, which
1043/// errors at `Default()` time. Anodizer dispatches on `ctx.token_type` at
1044/// runtime so a silently-ignored extra backend is easy to miss.
1045pub fn validate_release_backends(config: &Config) -> Result<(), String> {
1046    let check = |crate_name: &str, release: &ReleaseConfig| -> Result<(), String> {
1047        let mut set = Vec::new();
1048        if release.github.is_some() {
1049            set.push("github");
1050        }
1051        if release.gitlab.is_some() {
1052            set.push("gitlab");
1053        }
1054        if release.gitea.is_some() {
1055            set.push("gitea");
1056        }
1057        if set.len() > 1 {
1058            return Err(format!(
1059                "crate {}: release config sets multiple mutually-exclusive SCM \
1060                 backends ({}). Pick one.",
1061                crate_name,
1062                set.join(" + ")
1063            ));
1064        }
1065        Ok(())
1066    };
1067    for krate in &config.crates {
1068        if let Some(ref release) = krate.release {
1069            check(&krate.name, release)?;
1070        }
1071    }
1072    if let Some(ws_list) = config.workspaces.as_ref() {
1073        for ws in ws_list {
1074            for krate in &ws.crates {
1075                if let Some(ref release) = krate.release {
1076                    check(&krate.name, release)?;
1077                }
1078            }
1079        }
1080    }
1081    Ok(())
1082}
1083
1084/// Validate that `release.on_failure` is set only at the root.
1085///
1086/// The failure policy is one process-wide decision per run, resolved
1087/// from the top-level `release:` block alone. Crate-level `release:`
1088/// blocks share the `ReleaseConfig` struct, so the field parses there
1089/// — but it would never be read; rejecting the misplacement at config
1090/// load keeps a policy choice from being silently ignored.
1091pub fn validate_on_failure_root_only(config: &Config) -> Result<(), String> {
1092    // Deliberately raw (not `crate_universe()`): validation must flag every
1093    // entry as written, including a workspace entry the dedup would shadow —
1094    // a policy violation on a shadowed crate is still a config mistake.
1095    let mut offenders: Vec<&str> = config
1096        .crates
1097        .iter()
1098        .chain(
1099            config
1100                .workspaces
1101                .iter()
1102                .flatten()
1103                .flat_map(|ws| ws.crates.iter()),
1104        )
1105        .filter(|c| c.release.as_ref().is_some_and(|r| r.on_failure.is_some()))
1106        .map(|c| c.name.as_str())
1107        .collect();
1108    offenders.sort_unstable();
1109    offenders.dedup();
1110    if offenders.is_empty() {
1111        return Ok(());
1112    }
1113    Err(format!(
1114        "release.on_failure is a root-level policy and cannot be set per crate \
1115         (set on: {}). Move it to the top-level `release:` block.",
1116        offenders.join(", ")
1117    ))
1118}
1119
1120/// Marker prefix for the axis-mismatch validation error class. Existing
1121/// validators in this module return `Result<(), String>` rather than a
1122/// typed enum, so we expose this constant (instead of a `ConfigError`
1123/// variant) for callers that want to recognise the error class
1124/// programmatically.
1125///
1126/// The prefix is emitted at the start of every error returned by
1127/// [`validate_defaults_axis`] (formatted as `"DefaultsAxisMismatch: …"`),
1128/// so callers can match with `err.starts_with(ERR_DEFAULTS_AXIS_MISMATCH)`
1129/// or `err.contains(ERR_DEFAULTS_AXIS_MISMATCH)` without depending on the
1130/// exact human-readable wording.
1131///
1132/// ```ignore
1133/// match validate_defaults_axis(&config) {
1134///     Err(e) if e.starts_with(ERR_DEFAULTS_AXIS_MISMATCH) => {
1135///         // handle the axis-mismatch error class
1136///     }
1137///     other => other?,
1138/// }
1139/// ```
1140///
1141/// Future error-type unification can rename to
1142/// `ConfigError::DefaultsAxisMismatch` without changing call-sites that
1143/// match on this prefix.
1144pub const ERR_DEFAULTS_AXIS_MISMATCH: &str = "DefaultsAxisMismatch";
1145
1146/// Validate that `defaults.crates:` and `defaults.workspaces:` match the
1147/// top-level axis.
1148///
1149/// Rules:
1150/// - `defaults.crates:` is set → top-level `crates:` MUST be present.
1151/// - `defaults.workspaces:` is set → top-level `workspaces:` MUST be present.
1152/// - Both `defaults.crates` and `defaults.workspaces` set simultaneously → error
1153///   (mutually exclusive).
1154/// - Wrong-axis (e.g. `defaults.crates:` while top-level uses `workspaces:`) → error.
1155pub fn validate_defaults_axis(config: &Config) -> Result<(), String> {
1156    let Some(ref defaults) = config.defaults else {
1157        return Ok(());
1158    };
1159    let has_crate_block = defaults.crates.is_some();
1160    let has_workspace_block = defaults.workspaces.is_some();
1161
1162    if has_crate_block && has_workspace_block {
1163        return Err(format!(
1164            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates and defaults.workspaces are \
1165             mutually exclusive — pick the axis that matches the top-level config \
1166             (`crates:` or `workspaces:`)",
1167        ));
1168    }
1169
1170    let top_uses_workspaces = config.workspaces.as_ref().is_some_and(|w| !w.is_empty());
1171    let top_uses_crates = !config.crates.is_empty();
1172
1173    if has_crate_block && !top_uses_crates {
1174        return Err(format!(
1175            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates is set but top-level `crates:` \
1176             is {}; move defaults under `defaults.workspaces:` or remove the block",
1177            if top_uses_workspaces {
1178                "absent (top-level uses `workspaces:`)"
1179            } else {
1180                "absent"
1181            },
1182        ));
1183    }
1184    if has_workspace_block && !top_uses_workspaces {
1185        return Err(format!(
1186            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.workspaces is set but top-level \
1187             `workspaces:` is {}; move defaults under `defaults.crates:` or remove the block",
1188            if top_uses_crates {
1189                "absent (top-level uses `crates:`)"
1190            } else {
1191                "absent"
1192            },
1193        ));
1194    }
1195
1196    Ok(())
1197}
1198
1199/// Validate `archives[].format_overrides[].os` values reject unknown OSes.
1200/// Silently no-op-ing unknown overrides has burned users typing
1201/// Rust triples like `apple` or `pc-windows-msvc`.
1202///
1203/// Walks every `archives[]` location in the config:
1204/// - `crates[].archives:`
1205/// - `workspaces[].crates[].archives:`
1206/// - `defaults.archives:` (an unknown `os` here would otherwise pass silently
1207///   and propagate to every inheriting crate at merge time).
1208pub fn validate_format_overrides(config: &Config) -> Result<(), String> {
1209    let check = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
1210        for (idx, archive) in archives.iter().enumerate() {
1211            let Some(ref overrides) = archive.format_overrides else {
1212                continue;
1213            };
1214            for over in overrides {
1215                if !KNOWN_OS.contains(&over.os.as_str()) {
1216                    let archive_id = archive.id.as_deref().unwrap_or("default");
1217                    return Err(format!(
1218                        "{}: archives[{}] (id={}): format_overrides.os=\"{}\" is not a recognised OS. \
1219                         Accepted values: {}.",
1220                        location,
1221                        idx,
1222                        archive_id,
1223                        over.os,
1224                        KNOWN_OS.join(", ")
1225                    ));
1226                }
1227            }
1228        }
1229        Ok(())
1230    };
1231    for krate in &config.crates {
1232        if let ArchivesConfig::Configs(ref list) = krate.archives {
1233            check(&format!("crate {}", krate.name), list)?;
1234        }
1235    }
1236    if let Some(ws_list) = config.workspaces.as_ref() {
1237        for ws in ws_list {
1238            for krate in &ws.crates {
1239                if let ArchivesConfig::Configs(ref list) = krate.archives {
1240                    check(&format!("crate {}", krate.name), list)?;
1241                }
1242            }
1243        }
1244    }
1245    if let Some(ref defaults) = config.defaults
1246        && let Some(ref archive) = defaults.archives
1247    {
1248        // defaults.archives is a single ArchiveConfig (not a list); wrap it
1249        // into a one-element slice so the same checker walks it.
1250        check("defaults.archives", std::slice::from_ref(archive))?;
1251    }
1252    Ok(())
1253}
1254
1255/// Validate that no [`HomebrewCaskConfig`] sets both `url_template` AND
1256/// `url.template` simultaneously — they are mutually exclusive shorthands
1257/// for the same URL field and combining them is ambiguous.
1258///
1259/// Inspects every occurrence of `HomebrewCaskConfig` in the config:
1260/// - `homebrew_casks:` (top-level array)
1261/// - `crates[].publish.homebrew_cask:`
1262/// - `workspaces[].crates[].publish.homebrew_cask:`
1263/// - `defaults.publish.homebrew_cask:`
1264pub fn validate_homebrew_cask_url_template(config: &Config) -> Result<(), String> {
1265    let check = |location: &str, cask: &HomebrewCaskConfig| -> Result<(), String> {
1266        let has_url_template = cask.url_template.is_some();
1267        let has_url_dot_template = cask.url.as_ref().is_some_and(|u| u.template.is_some());
1268        if has_url_template && has_url_dot_template {
1269            return Err(format!(
1270                "{location}: homebrew_cask sets both `url_template` and `url.template`. \
1271                 These are mutually exclusive — use one or the other."
1272            ));
1273        }
1274        Ok(())
1275    };
1276
1277    // Top-level homebrew_casks list (not nested under publish:) — not a
1278    // publish axis, so it is scanned separately from the visitor.
1279    if let Some(ref casks) = config.homebrew_casks {
1280        for (i, cask) in casks.iter().enumerate() {
1281            check(&format!("homebrew_casks[{i}]"), cask)?;
1282        }
1283    }
1284
1285    try_for_each_crate_publish(config, |axis, publish| {
1286        if let Some(cask) = publish.homebrew_cask() {
1287            check(&axis.homebrew_cask_location(), cask)?;
1288        }
1289        Ok(())
1290    })
1291}
1292
1293/// Allowed `winget.upgrade_behavior` values, mirroring the winget installer
1294/// manifest schema (1.12.0) `UpgradeBehavior` enum. A value outside this set
1295/// renders an installer manifest the winget validator rejects at PR time —
1296/// catch it at config-validate instead.
1297pub const WINGET_UPGRADE_BEHAVIORS: [&str; 3] = ["install", "uninstallPrevious", "deny"];
1298
1299/// Validate that every configured `winget.upgrade_behavior` is one of the
1300/// winget-recognized values ([`WINGET_UPGRADE_BEHAVIORS`]). Walks the per-crate,
1301/// per-workspace, and `defaults.publish` axes.
1302pub fn validate_winget_upgrade_behavior(config: &Config) -> Result<(), String> {
1303    let check = |location: &str, winget: &WingetConfig| -> Result<(), String> {
1304        if let Some(ref behavior) = winget.upgrade_behavior
1305            && !WINGET_UPGRADE_BEHAVIORS.contains(&behavior.as_str())
1306        {
1307            return Err(format!(
1308                "{location}: upgrade_behavior `{behavior}` is not a valid winget value. \
1309                 Use one of: {}.",
1310                WINGET_UPGRADE_BEHAVIORS.join(", ")
1311            ));
1312        }
1313        Ok(())
1314    };
1315
1316    try_for_each_crate_publish(config, |axis, publish| {
1317        if let Some(winget) = publish.winget() {
1318            check(&axis.winget_location(), winget)?;
1319        }
1320        Ok(())
1321    })
1322}
1323
1324/// Validate that every `winget.dependencies[].architectures` entry names a
1325/// recognized WinGet architecture ([`WINGET_ARCHITECTURES`]). Walks the
1326/// per-crate, per-workspace, and `defaults.publish` axes.
1327///
1328/// The per-installer dependency emitter matches a scope value against each
1329/// installer's WinGet architecture by exact, case-sensitive equality. A value
1330/// outside the canonical set ([`WINGET_ARCHITECTURES`]: `x64`, `arm64`, `x86`)
1331/// therefore matches
1332/// no installer, so the dependency would silently disappear from the generated
1333/// manifest. Reject it at config-validate instead of shipping a manifest that
1334/// quietly omits a declared dependency. An empty list (or absent
1335/// `architectures`) means "all installers" and is valid.
1336pub fn validate_winget_dependency_architectures(config: &Config) -> Result<(), String> {
1337    let check = |location: &str, winget: &WingetConfig| -> Result<(), String> {
1338        let Some(ref deps) = winget.dependencies else {
1339            return Ok(());
1340        };
1341        for (i, dep) in deps.iter().enumerate() {
1342            let Some(ref scopes) = dep.architectures else {
1343                continue;
1344            };
1345            for scope in scopes {
1346                if !WINGET_ARCHITECTURES.contains(&scope.as_str()) {
1347                    return Err(format!(
1348                        "{location}: dependencies[{i}].architectures contains `{scope}`, \
1349                         which is not a valid winget architecture. Use one of: {} \
1350                         (or leave architectures empty/unset to apply the dependency \
1351                         to every installer).",
1352                        WINGET_ARCHITECTURES.join(", ")
1353                    ));
1354                }
1355            }
1356        }
1357        Ok(())
1358    };
1359
1360    try_for_each_crate_publish(config, |axis, publish| {
1361        if let Some(winget) = publish.winget() {
1362            check(&axis.winget_location(), winget)?;
1363        }
1364        Ok(())
1365    })
1366}
1367
1368/// Validate that `archives[].id` and `universal_binaries[].id` are unique
1369/// within their respective lists.
1370///
1371/// The id-uniqueness validation for archives and universal binaries.
1372/// Two archive
1373/// configs with the same `id` silently both set the same `id` metadata key
1374/// on artifacts, breaking publishers that filter `ids: [<id>]`. Anodizer's
1375/// build/sign stages already enforce id uniqueness; archive and
1376/// universal_binary were missed.
1377///
1378/// Walks every occurrence of `archives[]` and `universal_binaries[]`:
1379/// - `crates[].archives:` / `crates[].universal_binaries:`
1380/// - `workspaces[].crates[].archives:` / `.universal_binaries:`
1381/// - `defaults.archives:` is a single `ArchiveConfig`, so uniqueness within
1382///   itself is vacuously true; not walked here.
1383///
1384pub fn validate_id_uniqueness(config: &Config) -> Result<(), String> {
1385    fn check_unique(
1386        location: &str,
1387        kind: &str,
1388        ids: impl IntoIterator<Item = (usize, Option<String>)>,
1389    ) -> Result<(), String> {
1390        let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1391        for (idx, maybe_id) in ids {
1392            // Empty is stored as "default" for archives via Default-time
1393            // assignment. Anodizer applies `default_archive_id` at deserialize
1394            // time, so the option is normally `Some("default")`. A truly empty
1395            // / None id here means the user explicitly cleared it; we still
1396            // dedupe across `None` so two None-id'd entries collide just like
1397            // two "default"-id'd entries would.
1398            let key = maybe_id.unwrap_or_else(|| "<unset>".to_string());
1399            if let Some(prev_idx) = seen.insert(key.clone(), idx) {
1400                return Err(format!(
1401                    "{location}: {kind} id \"{key}\" is used by both entry {prev_idx} and entry {idx} — \
1402                     ids must be unique within a {kind} list."
1403                ));
1404            }
1405        }
1406        Ok(())
1407    }
1408
1409    let check_archives = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
1410        check_unique(
1411            location,
1412            "archives",
1413            archives.iter().enumerate().map(|(i, a)| (i, a.id.clone())),
1414        )
1415    };
1416    let check_unibins = |location: &str, ubs: &[UniversalBinaryConfig]| -> Result<(), String> {
1417        check_unique(
1418            location,
1419            "universal_binaries",
1420            ubs.iter().enumerate().map(|(i, u)| (i, u.id.clone())),
1421        )
1422    };
1423
1424    for krate in &config.crates {
1425        if let ArchivesConfig::Configs(ref list) = krate.archives {
1426            check_archives(&format!("crates[{}].archives", krate.name), list)?;
1427        }
1428        if let Some(ref ubs) = krate.universal_binaries {
1429            check_unibins(&format!("crates[{}].universal_binaries", krate.name), ubs)?;
1430        }
1431    }
1432    if let Some(ws_list) = config.workspaces.as_ref() {
1433        for ws in ws_list {
1434            for krate in &ws.crates {
1435                if let ArchivesConfig::Configs(ref list) = krate.archives {
1436                    check_archives(
1437                        &format!("workspaces[{}].crates[{}].archives", ws.name, krate.name),
1438                        list,
1439                    )?;
1440                }
1441                if let Some(ref ubs) = krate.universal_binaries {
1442                    check_unibins(
1443                        &format!(
1444                            "workspaces[{}].crates[{}].universal_binaries",
1445                            ws.name, krate.name
1446                        ),
1447                        ubs,
1448                    )?;
1449                }
1450            }
1451        }
1452    }
1453    Ok(())
1454}
1455
1456/// Validate `builds[]` entries that opt into `builder: prebuilt`.
1457///
1458/// `builder: prebuilt` skips `cargo build` and imports a binary the
1459/// operator staged elsewhere. The validation rules below follow the
1460/// `prebuilt` builder contract (`/customization/builds/builders/prebuilt.md`):
1461///
1462/// 1. `prebuilt:` block MUST be set and `prebuilt.path` MUST be non-empty.
1463/// 2. `targets:` MUST be explicit on the build entry — no `defaults.targets`
1464///    fallback. Without this rule the build matrix has no rows.
1465/// 3. Cargo-only knobs are rejected as mutually exclusive: `cross_tool`,
1466///    `features`, `no_default_features`, `command`. The crate-level
1467///    `cross:` strategy is also rejected when any build on the crate is
1468///    prebuilt (the strategy has no meaning when nothing is being
1469///    compiled).
1470/// 4. `builder: cargo` (the default) with a `prebuilt:` block set warns —
1471///    the block has no effect and likely indicates a forgotten
1472///    `builder: prebuilt`.
1473pub fn validate_builds(config: &Config) -> Result<(), String> {
1474    let check_crate = |location: &str, krate: &CrateConfig| -> Result<(), String> {
1475        let Some(ref builds) = krate.builds else {
1476            return Ok(());
1477        };
1478        let crate_is_prebuilt = builds
1479            .iter()
1480            .any(|b| matches!(b.builder, Some(BuilderKind::Prebuilt)));
1481        if crate_is_prebuilt && krate.cross.is_some() {
1482            return Err(format!(
1483                "{location}: crate-level `cross:` strategy is set but at least one \
1484                 build uses `builder: prebuilt`; remove `cross:` (prebuilt imports a \
1485                 binary instead of compiling) or change the build's builder to `cargo`."
1486            ));
1487        }
1488        for (idx, build) in builds.iter().enumerate() {
1489            match build.builder {
1490                Some(BuilderKind::Prebuilt) => {
1491                    let path = build.prebuilt.as_ref().map(|p| p.path.trim()).unwrap_or("");
1492                    if path.is_empty() {
1493                        return Err(format!(
1494                            "{location}.builds[{idx}]: `builder: prebuilt` requires a non-empty \
1495                             `prebuilt.path` template. Example: \
1496                             `prebuilt: {{ path: \"output/mybin_{{{{ .Target }}}}\" }}`"
1497                        ));
1498                    }
1499                    let targets_explicit = build.targets.as_ref().is_some_and(|t| !t.is_empty());
1500                    if !targets_explicit {
1501                        return Err(format!(
1502                            "{location}.builds[{idx}] has `builder: prebuilt` but no explicit \
1503                             `targets:` — the prebuilt builder requires per-build target triples \
1504                             (no `defaults.targets:` fallback). Add `targets: [<triple>, ...]`."
1505                        ));
1506                    }
1507                    if build.cross_tool.as_ref().is_some_and(|s| !s.is_empty()) {
1508                        return Err(format!(
1509                            "{location}.builds[{idx}]: `cross_tool` is set with \
1510                             `builder: prebuilt` — the two are mutually exclusive. \
1511                             `cross_tool` controls how cargo cross-compiles; `prebuilt` \
1512                             imports an already-built binary. Drop `cross_tool` or use \
1513                             `builder: cargo`."
1514                        ));
1515                    }
1516                    if build.command.as_ref().is_some_and(|s| !s.is_empty()) {
1517                        return Err(format!(
1518                            "{location}.builds[{idx}]: `command:` override is set with \
1519                             `builder: prebuilt` — the override selects the cargo \
1520                             subcommand, which is not invoked under the prebuilt \
1521                             builder. Drop `command:` or use `builder: cargo`."
1522                        ));
1523                    }
1524                    if build.features.as_ref().is_some_and(|f| !f.is_empty()) {
1525                        return Err(format!(
1526                            "{location}.builds[{idx}]: `features:` is set with \
1527                             `builder: prebuilt` — Cargo features are evaluated at \
1528                             compile time, which the prebuilt builder skips. \
1529                             Drop `features:` or use `builder: cargo`."
1530                        ));
1531                    }
1532                    if build.no_default_features.is_some() {
1533                        return Err(format!(
1534                            "{location}.builds[{idx}]: `no_default_features:` is set with \
1535                             `builder: prebuilt` — Cargo feature flags are evaluated at \
1536                             compile time, which the prebuilt builder skips. \
1537                             Drop the flag or use `builder: cargo`."
1538                        ));
1539                    }
1540                }
1541                Some(BuilderKind::Cargo) | None => {
1542                    if build.prebuilt.is_some() {
1543                        tracing::warn!(
1544                            "{location}: build[{idx}] has a `prebuilt:` block but `builder:` \
1545                             is not `prebuilt`; the block is ignored. Set `builder: prebuilt` \
1546                             or remove the block."
1547                        );
1548                    }
1549                }
1550            }
1551        }
1552        Ok(())
1553    };
1554
1555    for krate in &config.crates {
1556        check_crate(&format!("crates[{}]", krate.name), krate)?;
1557    }
1558    if let Some(ws_list) = config.workspaces.as_ref() {
1559        for ws in ws_list {
1560            for krate in &ws.crates {
1561                check_crate(
1562                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
1563                    krate,
1564                )?;
1565            }
1566        }
1567    }
1568    Ok(())
1569}
1570
1571/// Returns `true` if every build entry on every crate has
1572/// `builder: prebuilt`. Used by the determinism harness to short-circuit:
1573/// when no target compiles, there is nothing for the harness to rebuild
1574/// and compare across runs.
1575pub fn all_builds_prebuilt(config: &Config) -> bool {
1576    let crate_all_prebuilt = |krate: &CrateConfig| -> Option<bool> {
1577        let builds = krate.builds.as_ref()?;
1578        if builds.is_empty() {
1579            return None;
1580        }
1581        Some(
1582            builds
1583                .iter()
1584                .all(|b| matches!(b.builder, Some(BuilderKind::Prebuilt))),
1585        )
1586    };
1587
1588    let mut saw_any = false;
1589    for krate in config.crate_universe() {
1590        match crate_all_prebuilt(krate) {
1591            Some(true) => saw_any = true,
1592            Some(false) => return false,
1593            None => {}
1594        }
1595    }
1596    saw_any
1597}
1598
1599/// Validate the depth of `changelog.groups[].groups`.
1600///
1601/// Subgroups are capped at ONE level
1602/// (`/customization/publish/changelog.md`: "There can only be one level of
1603/// subgroups"). Anodizer's renderer can technically handle deeper nesting
1604/// (capped at 6 to match Markdown's heading limit), but accepting deeper
1605/// configs silently is a footgun: a config that works in anodizer but is
1606/// rejected here breaks parity for users migrating in.
1607///
1608/// Rejects any `changelog.groups[i].groups[j].groups[..]` configuration
1609/// with a clear error pointing at the offending parent group title.
1610pub fn validate_changelog_groups_depth(config: &Config) -> Result<(), String> {
1611    let check = |location: &str, cfg: &ChangelogConfig| -> Result<(), String> {
1612        let Some(ref groups) = cfg.groups else {
1613            return Ok(());
1614        };
1615        for g in groups {
1616            if let Some(ref subs) = g.groups {
1617                for sub in subs {
1618                    if sub.groups.as_ref().is_some_and(|s| !s.is_empty()) {
1619                        return Err(format!(
1620                            "{location}: changelog group '{}' > '{}' nests further \
1621                             subgroups; GoReleaser permits only one level of subgroups \
1622                             (see https://goreleaser.com/customization/changelog/). \
1623                             Flatten the inner groups into the parent or split into \
1624                             sibling top-level groups.",
1625                            g.title, sub.title
1626                        ));
1627                    }
1628                }
1629            }
1630        }
1631        Ok(())
1632    };
1633    if let Some(ref cfg) = config.changelog {
1634        check("changelog", cfg)?;
1635    }
1636    if let Some(ref ws_list) = config.workspaces {
1637        for ws in ws_list {
1638            if let Some(ref cfg) = ws.changelog {
1639                check(&format!("workspaces[{}].changelog", ws.name), cfg)?;
1640            }
1641        }
1642    }
1643    Ok(())
1644}
1645
1646/// Validate `changelog.paths[]` syntax.
1647///
1648/// Path patterns are passed straight to `git log -- <path>` (or the
1649/// per-SCM equivalent). Two patterns are always wrong:
1650/// - Leading `/` — git pathspec treats this as anchored-to-CWD which is
1651///   almost never what the user wrote and produces empty changelogs.
1652/// - Empty string — silently matches everything; rejected so a typo
1653///   doesn't disable filtering.
1654///
1655/// Globs containing `**` are accepted (git accepts them) but the docs
1656/// note their semantics differ from gitignore; that's a docs concern,
1657/// not a hard error.
1658pub fn validate_changelog_paths(config: &Config) -> Result<(), String> {
1659    let check = |location: &str, cfg: &ChangelogConfig| -> Result<(), String> {
1660        let Some(ref paths) = cfg.paths else {
1661            return Ok(());
1662        };
1663        for (idx, p) in paths.iter().enumerate() {
1664            if p.is_empty() {
1665                return Err(format!(
1666                    "{location}: changelog.paths[{idx}] is empty; remove the entry \
1667                     or set a real path (empty string matches everything and \
1668                     disables filtering)"
1669                ));
1670            }
1671            if p.starts_with('/') {
1672                return Err(format!(
1673                    "{location}: changelog.paths[{idx}] = {:?} starts with '/'; \
1674                     git pathspec is repo-root-relative — write {:?} instead",
1675                    p,
1676                    p.trim_start_matches('/')
1677                ));
1678            }
1679        }
1680        Ok(())
1681    };
1682    if let Some(ref cfg) = config.changelog {
1683        check("changelog", cfg)?;
1684    }
1685    if let Some(ref ws_list) = config.workspaces {
1686        for ws in ws_list {
1687            if let Some(ref cfg) = ws.changelog {
1688                check(&format!("workspaces[{}].changelog", ws.name), cfg)?;
1689            }
1690        }
1691    }
1692    Ok(())
1693}
1694
1695/// Validate every upload-destination `exclude:` glob across all config axes.
1696///
1697/// `exclude:` drops artifacts whose file name matches a glob (see
1698/// [`crate::artifact::passes_exclude_filter`]). An unparseable glob is treated
1699/// as non-matching at runtime so it never crashes a release — but a typo'd
1700/// glob that silently keeps an asset (or, worse, drops every asset) is a
1701/// foot-gun. Reject malformed globs here, at config-load, with a clear message
1702/// before they can take effect.
1703///
1704/// Covers every config position where `exclude:` is settable: per-crate
1705/// `release:` and `blobs:` (top-level crates AND `workspaces[].crates[]`), the
1706/// top-level `artifactories:`, `cloudsmiths:`, `gemfury:`, and `uploads:`
1707/// lists, and the top-level shared `release:` block.
1708pub fn validate_exclude_globs(config: &Config) -> Result<(), String> {
1709    fn check(location: &str, exclude: Option<&[String]>) -> Result<(), String> {
1710        let Some(globs) = exclude else {
1711            return Ok(());
1712        };
1713        for (idx, g) in globs.iter().enumerate() {
1714            if g.is_empty() {
1715                return Err(format!(
1716                    "{location}: exclude[{idx}] is empty; remove the entry or set a \
1717                     real glob (an empty pattern matches nothing and is a no-op)"
1718                ));
1719            }
1720            if let Err(e) = glob::Pattern::new(g) {
1721                return Err(format!(
1722                    "{location}: exclude[{idx}] = {g:?} is not a valid glob: {e}"
1723                ));
1724            }
1725        }
1726        Ok(())
1727    }
1728
1729    let check_crate = |location: &str, krate: &CrateConfig| -> Result<(), String> {
1730        if let Some(ref release) = krate.release {
1731            check(&format!("{location}.release"), release.exclude.as_deref())?;
1732        }
1733        if let Some(ref blobs) = krate.blobs {
1734            for (i, b) in blobs.iter().enumerate() {
1735                check(&format!("{location}.blobs[{i}]"), b.exclude.as_deref())?;
1736            }
1737        }
1738        Ok(())
1739    };
1740
1741    for krate in &config.crates {
1742        check_crate(&format!("crates[{}]", krate.name), krate)?;
1743    }
1744    if let Some(ref ws_list) = config.workspaces {
1745        for ws in ws_list {
1746            for krate in &ws.crates {
1747                check_crate(
1748                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
1749                    krate,
1750                )?;
1751            }
1752        }
1753    }
1754    if let Some(ref list) = config.artifactories {
1755        for (i, a) in list.iter().enumerate() {
1756            check(&format!("artifactories[{i}]"), a.exclude.as_deref())?;
1757        }
1758    }
1759    if let Some(ref list) = config.cloudsmiths {
1760        for (i, c) in list.iter().enumerate() {
1761            check(&format!("cloudsmiths[{i}]"), c.exclude.as_deref())?;
1762        }
1763    }
1764    if let Some(ref list) = config.gemfury {
1765        for (i, g) in list.iter().enumerate() {
1766            check(&format!("gemfury[{i}]"), g.exclude.as_deref())?;
1767        }
1768    }
1769    if let Some(ref list) = config.uploads {
1770        for (i, u) in list.iter().enumerate() {
1771            check(&format!("uploads[{i}]"), u.exclude.as_deref())?;
1772        }
1773    }
1774    if let Some(ref release) = config.release {
1775        check("release", release.exclude.as_deref())?;
1776    }
1777    Ok(())
1778}
1779
1780// ---------------------------------------------------------------------------
1781// Per-crate publish visitor
1782// ---------------------------------------------------------------------------
1783
1784/// Identifies which of the three publish-config axes a visited block came from.
1785///
1786/// The config-validation walkers each format their own location string from
1787/// this identity, so different walkers can keep their distinct location wording
1788/// (`crate '{name}'` vs `crates[{name}].publish.homebrew_cask`) while sharing a
1789/// single iteration order: crates, then workspaces, then defaults.
1790pub(crate) enum PublishAxis<'a> {
1791    /// A top-level `crates[].publish` block, carrying the crate name.
1792    Crate { name: &'a str },
1793    /// A `workspaces[].crates[].publish` block, carrying the workspace and
1794    /// crate names.
1795    Workspace {
1796        workspace: &'a str,
1797        crate_name: &'a str,
1798    },
1799    /// The `defaults.publish` block.
1800    Defaults,
1801}
1802
1803impl PublishAxis<'_> {
1804    /// Location string in the bare publish-block wording shared by the
1805    /// submitter-required and legacy-Homebrew-Formula warnings:
1806    /// `crate '{name}'`, `workspaces[{ws}].crates[{krate}]`, or
1807    /// `defaults.publish`.
1808    pub(crate) fn location(&self) -> String {
1809        match self {
1810            PublishAxis::Crate { name } => format!("crate '{name}'"),
1811            PublishAxis::Workspace {
1812                workspace,
1813                crate_name,
1814            } => format!("workspaces[{workspace}].crates[{crate_name}]"),
1815            PublishAxis::Defaults => "defaults.publish".to_string(),
1816        }
1817    }
1818
1819    /// Location string in the cask-block wording used by the legacy
1820    /// Homebrew-Cask singular fold: `crates[{name}].publish.homebrew_cask`,
1821    /// `workspaces[{ws}].crates[{krate}].publish.homebrew_cask`, or
1822    /// `defaults.publish.homebrew_cask`.
1823    pub(crate) fn homebrew_cask_location(&self) -> String {
1824        match self {
1825            PublishAxis::Crate { name } => {
1826                format!("crates[{name}].publish.homebrew_cask")
1827            }
1828            PublishAxis::Workspace {
1829                workspace,
1830                crate_name,
1831            } => format!("workspaces[{workspace}].crates[{crate_name}].publish.homebrew_cask"),
1832            PublishAxis::Defaults => "defaults.publish.homebrew_cask".to_string(),
1833        }
1834    }
1835
1836    /// Location string in the winget-block wording:
1837    /// `crates[{name}].publish.winget`,
1838    /// `workspaces[{ws}].crates[{krate}].publish.winget`, or
1839    /// `defaults.publish.winget`.
1840    pub(crate) fn winget_location(&self) -> String {
1841        match self {
1842            PublishAxis::Crate { name } => format!("crates[{name}].publish.winget"),
1843            PublishAxis::Workspace {
1844                workspace,
1845                crate_name,
1846            } => format!("workspaces[{workspace}].crates[{crate_name}].publish.winget"),
1847            PublishAxis::Defaults => "defaults.publish.winget".to_string(),
1848        }
1849    }
1850}
1851
1852/// Shared, immutable view over the publisher sub-configs that appear on both
1853/// [`PublishConfig`] (the `crates[].publish` axis) and [`PublishDefaults`] (the
1854/// `defaults.publish` axis). The two underlying structs are distinct types, so
1855/// this enum erases the difference for read-only walkers.
1856pub(crate) enum PublishRef<'a> {
1857    /// A per-crate `publish:` block.
1858    Crate(&'a PublishConfig),
1859    /// The `defaults.publish:` block.
1860    Defaults(&'a PublishDefaults),
1861}
1862
1863impl PublishRef<'_> {
1864    pub(crate) fn homebrew(&self) -> Option<&HomebrewConfig> {
1865        match self {
1866            PublishRef::Crate(p) => p.homebrew.as_ref(),
1867            PublishRef::Defaults(p) => p.homebrew.as_ref(),
1868        }
1869    }
1870
1871    pub(crate) fn chocolatey(&self) -> Option<&ChocolateyConfig> {
1872        match self {
1873            PublishRef::Crate(p) => p.chocolatey.as_ref(),
1874            PublishRef::Defaults(p) => p.chocolatey.as_ref(),
1875        }
1876    }
1877
1878    pub(crate) fn winget(&self) -> Option<&WingetConfig> {
1879        match self {
1880            PublishRef::Crate(p) => p.winget.as_ref(),
1881            PublishRef::Defaults(p) => p.winget.as_ref(),
1882        }
1883    }
1884
1885    pub(crate) fn aur_source(&self) -> Option<&AurSourceConfig> {
1886        match self {
1887            PublishRef::Crate(p) => p.aur_source.as_ref(),
1888            PublishRef::Defaults(p) => p.aur_source.as_ref(),
1889        }
1890    }
1891
1892    pub(crate) fn homebrew_cask(&self) -> Option<&HomebrewCaskConfig> {
1893        match self {
1894            PublishRef::Crate(p) => p.homebrew_cask.as_ref(),
1895            PublishRef::Defaults(p) => p.homebrew_cask.as_ref(),
1896        }
1897    }
1898}
1899
1900/// Shared, mutable view over the publisher sub-configs that appear on both
1901/// [`PublishConfig`] and [`PublishDefaults`]. The `_mut` companion to
1902/// [`PublishRef`], for walkers that fold or rewrite a publisher block in place.
1903pub(crate) enum PublishMut<'a> {
1904    /// A per-crate `publish:` block.
1905    Crate(&'a mut PublishConfig),
1906    /// The `defaults.publish:` block.
1907    Defaults(&'a mut PublishDefaults),
1908}
1909
1910impl PublishMut<'_> {
1911    pub(crate) fn homebrew_cask_mut(&mut self) -> Option<&mut HomebrewCaskConfig> {
1912        match self {
1913            PublishMut::Crate(p) => p.homebrew_cask.as_mut(),
1914            PublishMut::Defaults(p) => p.homebrew_cask.as_mut(),
1915        }
1916    }
1917}
1918
1919/// Visit every `publish:` block across all three config axes — `crates[]`,
1920/// `workspaces[].crates[]`, then `defaults` — in that fixed order, passing each
1921/// block's [`PublishAxis`] identity and a read-only [`PublishRef`] view to
1922/// `visit`. Axes with no `publish:` block are skipped.
1923pub(crate) fn for_each_crate_publish<F>(config: &Config, mut visit: F)
1924where
1925    F: FnMut(PublishAxis<'_>, PublishRef<'_>),
1926{
1927    for krate in &config.crates {
1928        if let Some(ref publish) = krate.publish {
1929            visit(
1930                PublishAxis::Crate { name: &krate.name },
1931                PublishRef::Crate(publish),
1932            );
1933        }
1934    }
1935
1936    if let Some(ref workspaces) = config.workspaces {
1937        for ws in workspaces {
1938            for krate in &ws.crates {
1939                if let Some(ref publish) = krate.publish {
1940                    visit(
1941                        PublishAxis::Workspace {
1942                            workspace: &ws.name,
1943                            crate_name: &krate.name,
1944                        },
1945                        PublishRef::Crate(publish),
1946                    );
1947                }
1948            }
1949        }
1950    }
1951
1952    if let Some(ref defaults) = config.defaults
1953        && let Some(ref publish) = defaults.publish
1954    {
1955        visit(PublishAxis::Defaults, PublishRef::Defaults(publish));
1956    }
1957}
1958
1959/// Fallible companion to [`for_each_crate_publish`]: visits the same three axes
1960/// in the same fixed order, but short-circuits on the first `Err` the callback
1961/// returns, propagating it to the caller. For validators that early-exit on the
1962/// first offending block.
1963pub(crate) fn try_for_each_crate_publish<F, E>(config: &Config, mut visit: F) -> Result<(), E>
1964where
1965    F: FnMut(PublishAxis<'_>, PublishRef<'_>) -> Result<(), E>,
1966{
1967    for krate in &config.crates {
1968        if let Some(ref publish) = krate.publish {
1969            visit(
1970                PublishAxis::Crate { name: &krate.name },
1971                PublishRef::Crate(publish),
1972            )?;
1973        }
1974    }
1975
1976    if let Some(ref workspaces) = config.workspaces {
1977        for ws in workspaces {
1978            for krate in &ws.crates {
1979                if let Some(ref publish) = krate.publish {
1980                    visit(
1981                        PublishAxis::Workspace {
1982                            workspace: &ws.name,
1983                            crate_name: &krate.name,
1984                        },
1985                        PublishRef::Crate(publish),
1986                    )?;
1987                }
1988            }
1989        }
1990    }
1991
1992    if let Some(ref defaults) = config.defaults
1993        && let Some(ref publish) = defaults.publish
1994    {
1995        visit(PublishAxis::Defaults, PublishRef::Defaults(publish))?;
1996    }
1997
1998    Ok(())
1999}
2000
2001/// Mutable companion to [`for_each_crate_publish`]: visits the same three axes
2002/// in the same fixed order, passing a [`PublishMut`] view so the callback can
2003/// rewrite the publisher block in place.
2004pub(crate) fn for_each_crate_publish_mut<F>(config: &mut Config, mut visit: F)
2005where
2006    F: FnMut(PublishAxis<'_>, PublishMut<'_>),
2007{
2008    for krate in &mut config.crates {
2009        if let Some(ref mut publish) = krate.publish {
2010            visit(
2011                PublishAxis::Crate { name: &krate.name },
2012                PublishMut::Crate(publish),
2013            );
2014        }
2015    }
2016
2017    if let Some(ref mut workspaces) = config.workspaces {
2018        for ws in workspaces {
2019            for krate in &mut ws.crates {
2020                if let Some(ref mut publish) = krate.publish {
2021                    visit(
2022                        PublishAxis::Workspace {
2023                            workspace: &ws.name,
2024                            crate_name: &krate.name,
2025                        },
2026                        PublishMut::Crate(publish),
2027                    );
2028                }
2029            }
2030        }
2031    }
2032
2033    if let Some(ref mut defaults) = config.defaults
2034        && let Some(ref mut publish) = defaults.publish
2035    {
2036        visit(PublishAxis::Defaults, PublishMut::Defaults(publish));
2037    }
2038}
2039
2040/// A submitter moderation-queue advisory paired with the dispatch publisher
2041/// identity that produced it. The CLI filters by [`SubmitterAdvisory::publisher`]
2042/// so an advisory for a publisher deselected by `--skip` / `--publishers`
2043/// (e.g. `chocolatey` under a `--publishers npm` run) is suppressed instead of
2044/// emitted as noise.
2045#[derive(Debug, Clone, PartialEq, Eq)]
2046pub struct SubmitterAdvisory {
2047    /// Dispatch publisher name, matching the string
2048    /// [`crate::context::Context::publisher_deselected`] tests: `chocolatey`,
2049    /// `winget`, or `upstream-aur` (the AUR-source publisher's dispatch name).
2050    /// The CLI keys its deselection predicate on this value.
2051    pub publisher: String,
2052    /// The verbose advisory line surfaced to the operator.
2053    pub message: String,
2054}
2055
2056/// One advisory per publisher configured with `required: true` whose group is
2057/// Submitter (chocolatey, winget, aur_source), each tagged with its dispatch
2058/// publisher identity so the CLI can suppress advisories for deselected
2059/// publishers.
2060///
2061/// `required: true` on a submitter still fails the release when the submission
2062/// itself fails (it feeds `required_failures()`), but the external moderation
2063/// outcome resolves after the release run and cannot be gated on. The advisory
2064/// is non-fatal and clarifies which half of the semantics applies. Cargo is
2065/// excluded: its default is already `required: true` and the message would be
2066/// noise.
2067///
2068/// Covers all three publish axes — `crates[].publish`,
2069/// `workspaces[].crates[].publish`, and `defaults.publish` (via
2070/// [`for_each_crate_publish`]) — plus the top-level `aur_sources:` list.
2071///
2072/// Pure: this returns the advisories without emitting them. The CLI surfaces
2073/// them through `StageLogger::verbose` (the `--verbose`-gated register), so
2074/// they stay hidden at the default log level — see
2075/// `pipeline::load_config_logged`.
2076pub fn submitter_required_warnings(config: &Config) -> Vec<SubmitterAdvisory> {
2077    fn advisory(location: &str, name: &str, publisher: &str) -> SubmitterAdvisory {
2078        SubmitterAdvisory {
2079            publisher: publisher.to_string(),
2080            message: format!(
2081                "{location}: publisher '{name}' submits to an external moderation queue; \
2082                 `required: true` fails the release when the submission itself fails, \
2083                 but the eventual moderation outcome happens outside the release run \
2084                 and cannot be gated."
2085            ),
2086        }
2087    }
2088
2089    let mut warnings = Vec::new();
2090
2091    for_each_crate_publish(config, |axis, publish| {
2092        let loc = axis.location();
2093        if publish.chocolatey().and_then(|c| c.required) == Some(true) {
2094            warnings.push(advisory(&loc, "chocolatey", "chocolatey"));
2095        }
2096        if publish.winget().and_then(|w| w.required) == Some(true) {
2097            warnings.push(advisory(&loc, "winget", "winget"));
2098        }
2099        if publish.aur_source().and_then(|a| a.required) == Some(true) {
2100            // The AUR-source publisher dispatches under the name `upstream-aur`
2101            // (`AurSourcePublisher::PUBLISHER_NAME`); key the advisory on that so
2102            // the CLI's `publisher_deselected("upstream-aur")` filter matches.
2103            warnings.push(advisory(&loc, "aur_source", "upstream-aur"));
2104        }
2105    });
2106
2107    // Top-level aur_sources list (not nested under publish:) — no crate axis,
2108    // distinguish via the index in the list so two top-level entries collide cleanly.
2109    if let Some(ref sources) = config.aur_sources {
2110        for (idx, src) in sources.iter().enumerate() {
2111            if src.required == Some(true) {
2112                let loc = format!("top-level aur_sources[{idx}]");
2113                warnings.push(advisory(&loc, "aur_source", "upstream-aur"));
2114            }
2115        }
2116    }
2117
2118    warnings
2119}
2120
2121/// No-op preserved for API stability; the legacy `format:` and `builds:`
2122/// folds happen inline in `<ArchiveConfig as Deserialize>::deserialize` and
2123/// `<FormatOverride as Deserialize>::deserialize`. Emits no warning of its
2124/// own — every alias hit was already announced at deserialize time.
2125///
2126pub fn apply_archive_legacy_aliases(_config: &mut Config) {
2127    // Intentionally empty — see Deserialize impls.
2128}
2129
2130/// Reject the legacy V1 `dockers:` block at config-load time with a
2131/// clear migration error.
2132///
2133/// anodizer is V2-only by design: it implements `dockers_v2:` and the
2134/// associated multi-arch buildx flow, but does not ship the V1
2135/// `dockers: -> dockerfile + image_templates` pipe. Without this check the
2136/// top-level `Config` struct's `deny_unknown_fields` would emit a generic
2137/// "unknown field `dockers`" message that doesn't tell the user how to
2138/// migrate. This explicit error names the field, points at `dockers_v2:`,
2139/// and references the rationale.
2140///
2141pub fn validate_no_docker_v1(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
2142    if raw_yaml.get("dockers").is_some() {
2143        return Err(
2144            "config: legacy GoReleaser `dockers:` block is not supported — anodizer ships \
2145             dockers_v2: only (multi-arch buildx flow). Port the config to `dockers_v2:` per \
2146             https://anodize.dev/docs/migration/docker.html."
2147                .to_string(),
2148        );
2149    }
2150    Ok(())
2151}
2152
2153/// Emit a `tracing::warn!` for each `publish.homebrew:` (Homebrew Formula)
2154/// occurrence in the loaded config. The upstream deprecated the
2155/// Formula publisher in favour of `homebrew_casks:`; anodizer mirrors the
2156/// upstream deprecation so users following the change-log see the
2157/// same migration prompt.
2158///
2159/// Covers three placement axes (matching how `publish.homebrew` may appear):
2160///   * `crates[].publish.homebrew`
2161///   * `workspaces[].crates[].publish.homebrew`
2162///   * `defaults.publish.homebrew`
2163///
2164/// There is no top-level `homebrew:` or `brews:` field on anodizer's
2165/// `Config` — only `homebrew_casks:` lives at the top level — so this
2166/// function does not need a top-level scan.
2167pub fn warn_on_legacy_homebrew_formula(config: &Config) {
2168    for msg in legacy_homebrew_formula_warnings(config) {
2169        tracing::warn!("{}", msg);
2170    }
2171}
2172
2173/// Pure helper: returns the warning strings without emitting them.
2174/// Exposed for tests; production callers use
2175/// [`warn_on_legacy_homebrew_formula`].
2176pub(crate) fn legacy_homebrew_formula_warnings(config: &Config) -> Vec<String> {
2177    fn formula_warning(location: &str) -> String {
2178        format!(
2179            "DEPRECATION: {location}: publish.homebrew (Homebrew Formula) is deprecated upstream \
2180             in GoReleaser v2.16; migrate to homebrew_casks. Cask is now the canonical Homebrew \
2181             distribution channel for pre-compiled binaries. See \
2182             https://anodize.dev/docs/publish/homebrew-casks/ for migration."
2183        )
2184    }
2185
2186    let mut warnings = Vec::new();
2187
2188    for_each_crate_publish(config, |axis, publish| {
2189        if publish.homebrew().is_some() {
2190            warnings.push(formula_warning(&axis.location()));
2191        }
2192    });
2193
2194    warnings
2195}
2196
2197/// Fold the deprecated `snapshot.name_template` alias into `version_template`.
2198/// Serde already accepts both spellings via `#[serde(alias = "name_template")]`,
2199/// so this function only needs to emit the deprecation warning when the
2200/// raw YAML key was the legacy one.
2201///
2202/// Because serde collapses the two spellings to a single field on parse, we
2203/// lose the information about which key the user wrote. This function
2204/// therefore consults the raw YAML pre-parse value (when supplied) to decide.
2205pub fn warn_on_legacy_snapshot_name_template(raw_yaml: &serde_yaml_ng::Value) {
2206    if let Some(snap) = raw_yaml.get("snapshot")
2207        && snap.get("name_template").is_some()
2208    {
2209        tracing::warn!(
2210            "DEPRECATION: snapshot.name_template is deprecated; use \
2211             snapshot.version_template instead. Both spellings are accepted \
2212             but the legacy key will be removed in a future release."
2213        );
2214    }
2215}
2216
2217/// Emit a one-time deprecation warning when a config uses the legacy
2218/// `furies:` top-level key. Serde transparently folds `furies:` into
2219/// `gemfury:` via `#[serde(alias)]`, so this function consults the raw YAML
2220/// pre-parse value to detect the legacy spelling.
2221///
2222/// The `furies → gemfury` rename messaging.
2223pub fn warn_on_legacy_furies_alias(raw_yaml: &serde_yaml_ng::Value) {
2224    if raw_yaml.get("furies").is_some() {
2225        tracing::warn!(
2226            "DEPRECATION: the top-level `furies:` config key is deprecated since GoReleaser \
2227             Pro v2.14; rename it to `gemfury:`. Both spellings are accepted but the legacy \
2228             key will be removed in a future release."
2229        );
2230    }
2231}
2232
2233/// Emit a one-time deprecation warning for each nfpm config object that uses
2234/// the legacy `builds:` key. Serde transparently folds `builds:` into `ids:`
2235/// via `#[serde(alias = "builds")]` on [`NfpmConfig::ids`], so this function
2236/// consults the raw YAML pre-parse value to detect the legacy spelling that the
2237/// typed parse would otherwise erase.
2238///
2239/// The deprecated `NFPM.Builds` field (use `ids` instead).
2240///
2241/// nfpm config objects appear under the key `nfpm` or `nfpms` (a single map or
2242/// a sequence of maps) at multiple nesting depths — top-level, under
2243/// `defaults:`, under each `crates[]` entry, and under each
2244/// `workspaces[].crates[]` entry. Rather than enumerate every path, this walks
2245/// the tree recursively and inspects a node as an nfpm config only when it is
2246/// the value of an `nfpm:`/`nfpms:` key, so an unrelated `builds:` key
2247/// elsewhere (e.g. archives) is not double-counted.
2248pub fn warn_on_legacy_nfpm_builds(raw_yaml: &serde_yaml_ng::Value) {
2249    fn warn_for_nfpm_value(value: &serde_yaml_ng::Value) {
2250        match value {
2251            serde_yaml_ng::Value::Mapping(_) => {
2252                if value.get("builds").is_some() {
2253                    tracing::warn!(
2254                        "DEPRECATION: nfpm `builds:` is deprecated; use `ids:` instead. \
2255                         Both spellings are accepted but the legacy key will be removed in \
2256                         a future release."
2257                    );
2258                }
2259            }
2260            serde_yaml_ng::Value::Sequence(items) => {
2261                for item in items {
2262                    warn_for_nfpm_value(item);
2263                }
2264            }
2265            _ => {}
2266        }
2267    }
2268
2269    fn descend(value: &serde_yaml_ng::Value) {
2270        match value {
2271            serde_yaml_ng::Value::Mapping(map) => {
2272                for (key, child) in map {
2273                    if matches!(key.as_str(), Some("nfpm") | Some("nfpms")) {
2274                        warn_for_nfpm_value(child);
2275                    }
2276                    descend(child);
2277                }
2278            }
2279            serde_yaml_ng::Value::Sequence(items) => {
2280                for item in items {
2281                    descend(item);
2282                }
2283            }
2284            _ => {}
2285        }
2286    }
2287
2288    descend(raw_yaml);
2289}
2290
2291/// Emit a one-time deprecation warning for each block that carries the legacy
2292/// `disable:` spelling of the canonical `skip:` field. Many config blocks
2293/// (`release`, `changelog`, `snapcraft`, the docker / installer / packager
2294/// blocks, …) accept `disable:` via `#[serde(alias = "disable")]` for
2295/// back-compat with imported configs; serde folds the alias into
2296/// `skip` on parse, erasing which spelling the user wrote. This helper
2297/// consults the raw YAML pre-parse value so porting users get a migration
2298/// prompt pointing at the canonical `skip:`.
2299///
2300/// Detection is allow-listed by enclosing block key, NOT a blind tree walk,
2301/// because free-form string-keyed maps would otherwise produce false
2302/// positives:
2303///   * Free-form string-keyed maps (`variables`, `derived_metadata`,
2304///     `build_args`, `labels`, `annotations`, `env`, header maps, …) let a
2305///     user legitimately name a key `disable`. Matching only when the key's
2306///     immediate enclosing block is allow-listed skips those — the nearest
2307///     named ancestor of such a key is the map's own key (e.g. `build_args`),
2308///     never an allow-listed block.
2309///
2310/// Axis-agnostic: the enclosing block key is identical whether the block sits
2311/// at the top level, under `defaults.<block>`, under `crates[].<block>`, or
2312/// under `workspaces[].crates[].<block>`, so a single nearest-named-ancestor
2313/// rule covers every placement.
2314pub fn warn_on_legacy_disable_alias(raw_yaml: &serde_yaml_ng::Value) {
2315    for msg in legacy_disable_alias_warnings(raw_yaml) {
2316        tracing::warn!("{}", msg);
2317    }
2318}
2319
2320/// Pure helper: returns one warning string per offending `disable:` key,
2321/// each naming the YAML path to the key. Exposed for tests; production callers
2322/// use [`warn_on_legacy_disable_alias`].
2323pub(crate) fn legacy_disable_alias_warnings(raw_yaml: &serde_yaml_ng::Value) -> Vec<String> {
2324    // Block key names whose struct exposes `skip` with `#[serde(alias =
2325    // "disable")]`. Resolved from the field's serde key on its parent (see the
2326    // `alias = "disable"` sites in core). `makeselfs` (top-level) and
2327    // `makeselves` (defaults.) both map to MakeselfConfig, so both are listed;
2328    // `gemfury` and its legacy `furies` alias both map to GemFuryConfig.
2329    const ALLOWLIST: &[&str] = &[
2330        "mcp",
2331        "makeselfs",
2332        "makeselves",
2333        "appimages",
2334        "msis",
2335        "pkgs",
2336        "nsis",
2337        "dockerhub",
2338        "release",
2339        "dockers_v2",
2340        "docker_v2",
2341        "changelog",
2342        "snapcrafts",
2343        "npms",
2344        "gemfury",
2345        "furies",
2346        "publishers",
2347        "sboms",
2348        "aur",
2349        "aur_source",
2350        "aur_sources",
2351        "blobs",
2352        "docker_digest",
2353        "checksum",
2354        "flatpaks",
2355    ];
2356
2357    fn disable_warning(path: &str) -> String {
2358        format!(
2359            "DEPRECATION: {path}: legacy `disable:` is deprecated; rename it to `skip:`. \
2360             Both spellings are accepted but the legacy key will be removed in a future release."
2361        )
2362    }
2363
2364    // `enclosing_block`: the nearest named (non-list-index) ancestor key — the
2365    // block the `disable:` key belongs to. Only warn when it is allow-listed.
2366    fn descend(
2367        value: &serde_yaml_ng::Value,
2368        path: &str,
2369        enclosing_block: Option<&str>,
2370        warnings: &mut Vec<String>,
2371    ) {
2372        match value {
2373            serde_yaml_ng::Value::Mapping(map) => {
2374                for (key, child) in map {
2375                    let Some(key) = key.as_str() else { continue };
2376                    let child_path = if path.is_empty() {
2377                        key.to_string()
2378                    } else {
2379                        format!("{path}.{key}")
2380                    };
2381                    if key == "disable"
2382                        && enclosing_block.is_some_and(|block| ALLOWLIST.contains(&block))
2383                    {
2384                        warnings.push(disable_warning(&child_path));
2385                    }
2386                    descend(child, &child_path, Some(key), warnings);
2387                }
2388            }
2389            serde_yaml_ng::Value::Sequence(items) => {
2390                for (idx, item) in items.iter().enumerate() {
2391                    let item_path = format!("{path}[{idx}]");
2392                    // A list index is not a named ancestor: keep the enclosing
2393                    // block (the list's own key) so e.g. `snapcrafts[0].disable`
2394                    // still resolves to the `snapcrafts` block.
2395                    descend(item, &item_path, enclosing_block, warnings);
2396                }
2397            }
2398            _ => {}
2399        }
2400    }
2401
2402    let mut warnings = Vec::new();
2403    descend(raw_yaml, "", None, &mut warnings);
2404    warnings
2405}
2406
2407/// Reject the legacy nested `mcp.github:` block with a
2408/// clear migration error.
2409///
2410/// The registry metadata that used to live under
2411/// `mcp.github:` (repository owner/name/url) to the top-level `mcp:` block
2412/// (canonical surface: `mcp.repository:`, `mcp.name:`, etc.). Anodizer
2413/// never carried the nested shim — its `McpConfig` has `deny_unknown_fields`
2414/// so the key would otherwise produce a generic "unknown field" message.
2415/// This pre-parse check intercepts the legacy spelling so the user sees a
2416/// migration pointer rather than a schema-shape error.
2417pub fn validate_no_mcp_github(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
2418    if raw_yaml.get("mcp").and_then(|m| m.get("github")).is_some() {
2419        return Err(
2420            "config: nested `mcp.github:` block is not supported — anodizer mirrors GoReleaser \
2421             v2.13.1+ where registry metadata moved to top-level `mcp:` fields (`mcp.name`, \
2422             `mcp.repository.url`, `mcp.repository.source`). Port the nested keys to the \
2423             canonical surface."
2424                .to_string(),
2425        );
2426    }
2427    Ok(())
2428}
2429
2430/// Emit a one-time deprecation warning for each `dockers_v2[].retry:` or
2431/// `docker_manifests[].retry:` block at config-load time. The per-pipe
2432/// `retry:` field is the legacy shape (retry handling moved to
2433/// the top-level `retry:` block); the per-pipe value is still honored at
2434/// resolve-time (see `stage-docker::resolve_retry_params`) but a top-level
2435/// `retry:` is the canonical surface for retry policy. Warning fires once
2436/// per occurrence so users porting from older configs see a clear
2437/// pointer at load time without waiting for the docker pipe to execute.
2438pub fn warn_on_legacy_docker_retry(config: &Config) {
2439    for msg in legacy_docker_retry_warnings(config) {
2440        tracing::warn!("{}", msg);
2441    }
2442}
2443
2444/// Pure helper: returns the warning strings without emitting them. Exposed
2445/// for tests; production callers use [`warn_on_legacy_docker_retry`].
2446pub(crate) fn legacy_docker_retry_warnings(config: &Config) -> Vec<String> {
2447    fn pipe_warning(location: &str, kind: &str) -> String {
2448        format!(
2449            "DEPRECATION: {location}: nested `{kind}.retry:` is deprecated since GoReleaser \
2450             v2.15.3; move retry settings to the top-level `retry:` block. The per-pipe \
2451             value still wins at resolve time for back-compat, but the legacy spelling will \
2452             be removed in a future release."
2453        )
2454    }
2455
2456    let mut warnings = Vec::new();
2457
2458    let scan_crate = |krate: &CrateConfig, prefix: &str, warnings: &mut Vec<String>| {
2459        if let Some(ref v2) = krate.dockers_v2 {
2460            for (i, cfg) in v2.iter().enumerate() {
2461                if cfg.retry.is_some() {
2462                    warnings.push(pipe_warning(
2463                        &format!("{prefix}.dockers_v2[{i}]"),
2464                        "dockers_v2",
2465                    ));
2466                }
2467            }
2468        }
2469        if let Some(ref manifests) = krate.docker_manifests {
2470            for (i, cfg) in manifests.iter().enumerate() {
2471                if cfg.retry.is_some() {
2472                    warnings.push(pipe_warning(
2473                        &format!("{prefix}.docker_manifests[{i}]"),
2474                        "docker_manifests",
2475                    ));
2476                }
2477            }
2478        }
2479    };
2480
2481    for krate in &config.crates {
2482        scan_crate(krate, &format!("crates[{}]", krate.name), &mut warnings);
2483    }
2484
2485    if let Some(ref workspaces) = config.workspaces {
2486        for ws in workspaces {
2487            for krate in &ws.crates {
2488                scan_crate(
2489                    krate,
2490                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
2491                    &mut warnings,
2492                );
2493            }
2494        }
2495    }
2496
2497    if let Some(ref defaults) = config.defaults
2498        && let Some(ref v2) = defaults.dockers_v2
2499        && v2.retry.is_some()
2500    {
2501        warnings.push(pipe_warning("defaults.dockers_v2", "dockers_v2"));
2502    }
2503
2504    warnings
2505}
2506
2507/// Fold the deprecated singular Homebrew Cask fields into their canonical
2508/// plural lists and emit a one-time deprecation warning per folded field:
2509///
2510/// - `binary: <name>` → [`HomebrewCaskConfig::binaries`] (the upstream
2511///   renamed `binary:` to `binaries:`).
2512/// - `manpage: <page>` → [`HomebrewCaskConfig::manpages`].
2513///
2514/// anodizer accepts both spellings so imported configs keep parsing.
2515/// The captured values are moved out of [`HomebrewCaskConfig::legacy_binary`]
2516/// and [`HomebrewCaskConfig::legacy_manpage`] so downstream code only ever
2517/// reads the canonical plural fields.
2518///
2519/// The two folds use different insertion order: a legacy
2520/// `binary` is **prepended** to `binaries` so any explicit `binaries:` ordering
2521/// is preserved at the tail, whereas a legacy `manpage` is **appended** to
2522/// `manpages` (the cask renderer does
2523/// `brew.Manpages = append(brew.Manpages, brew.Manpage)`).
2524///
2525/// The fold runs across every config mode — top-level `homebrew_casks`,
2526/// per-crate `publish.homebrew_cask`, `workspaces[].crates[].publish`, and
2527/// `defaults.publish`.
2528pub fn apply_homebrew_cask_legacy_singulars(config: &mut Config) {
2529    /// Fold both deprecated singular fields (`binary:` → `binaries`,
2530    /// `manpage:` → `manpages`) on one cask, returning a warning per folded
2531    /// field. The singular `binary` is prepended to `binaries` so an explicit
2532    /// `binaries[0]` ordering is preserved at the tail; the singular `manpage`
2533    /// is appended to `manpages`.
2534    fn fold_one(location: &str, cask: &mut HomebrewCaskConfig) -> Vec<String> {
2535        let mut warnings = Vec::new();
2536        if let Some(legacy) = cask.legacy_binary.take() {
2537            let entry = HomebrewCaskBinary::Name(legacy.clone());
2538            match cask.binaries {
2539                Some(ref mut list) => list.insert(0, entry),
2540                None => cask.binaries = Some(vec![entry]),
2541            }
2542            warnings.push(format!(
2543                "DEPRECATION: {location}: singular `binary: {legacy}` is deprecated since \
2544                 GoReleaser v2.12.6; use the plural `binaries: [{legacy}]` form. The legacy \
2545                 value has been folded into binaries[0]."
2546            ));
2547        }
2548        if let Some(legacy) = cask.legacy_manpage.take() {
2549            match cask.manpages {
2550                Some(ref mut list) => list.push(legacy.clone()),
2551                None => cask.manpages = Some(vec![legacy.clone()]),
2552            }
2553            warnings.push(format!(
2554                "DEPRECATION: {location}: singular `manpage: {legacy}` is deprecated; \
2555                 use the plural `manpages: [{legacy}]` form. The legacy value has been \
2556                 folded into manpages."
2557            ));
2558        }
2559        warnings
2560    }
2561
2562    let mut warnings = Vec::new();
2563
2564    // Top-level homebrew_casks list (not nested under publish:) — not a
2565    // publish axis, so it is scanned separately from the visitor.
2566    if let Some(ref mut casks) = config.homebrew_casks {
2567        for (i, cask) in casks.iter_mut().enumerate() {
2568            warnings.extend(fold_one(&format!("homebrew_casks[{i}]"), cask));
2569        }
2570    }
2571
2572    for_each_crate_publish_mut(config, |axis, mut publish| {
2573        if let Some(cask) = publish.homebrew_cask_mut() {
2574            warnings.extend(fold_one(&axis.homebrew_cask_location(), cask));
2575        }
2576    });
2577
2578    for msg in warnings {
2579        tracing::warn!("{}", msg);
2580    }
2581}
2582
2583// ---------------------------------------------------------------------------
2584// EnvFilesConfig — accepts list of .env paths OR structured token file paths
2585// ---------------------------------------------------------------------------
2586
2587mod env_files;
2588pub use env_files::*;
2589
2590// ---------------------------------------------------------------------------
2591// Defaults
2592// ---------------------------------------------------------------------------
2593
2594mod defaults;
2595pub use defaults::*;
2596
2597// ---------------------------------------------------------------------------
2598// BuildIgnore — exclude specific os/arch combos from builds
2599// ---------------------------------------------------------------------------
2600
2601mod build;
2602pub use build::*;
2603
2604// ---------------------------------------------------------------------------
2605// ArchivesConfig — untagged enum: false => Disabled, array => Configs
2606// ---------------------------------------------------------------------------
2607
2608mod archives;
2609pub use archives::*;
2610
2611mod completions;
2612pub use completions::*;
2613
2614// ---------------------------------------------------------------------------
2615// ReleaseConfig
2616// ---------------------------------------------------------------------------
2617
2618mod release;
2619pub use release::*;
2620
2621// ---------------------------------------------------------------------------
2622// Shared publisher config types: RepositoryConfig, CommitAuthorConfig
2623// ---------------------------------------------------------------------------
2624
2625mod publishers;
2626pub use publishers::*;
2627
2628// ---------------------------------------------------------------------------
2629// DockerV2Config
2630// ---------------------------------------------------------------------------
2631
2632mod docker;
2633pub use docker::*;
2634
2635// ---------------------------------------------------------------------------
2636// NfpmConfig
2637// ---------------------------------------------------------------------------
2638
2639mod nfpm;
2640pub use nfpm::*;
2641
2642// ---------------------------------------------------------------------------
2643// SnapcraftConfig
2644// ---------------------------------------------------------------------------
2645
2646mod snapcraft;
2647pub use snapcraft::*;
2648// ---------------------------------------------------------------------------
2649// DmgConfig / MsiConfig / PkgConfig / NsisConfig / AppBundleConfig / FlatpakConfig
2650// ---------------------------------------------------------------------------
2651
2652mod installers;
2653pub use installers::*;
2654
2655// ---------------------------------------------------------------------------
2656// BlobConfig (S3/GCS/Azure cloud storage)
2657// ---------------------------------------------------------------------------
2658
2659mod blob;
2660pub use blob::*;
2661
2662// ---------------------------------------------------------------------------
2663// PartialConfig (split/merge CI fan-out)
2664// ---------------------------------------------------------------------------
2665
2666mod partial;
2667pub use partial::*;
2668
2669// ---------------------------------------------------------------------------
2670// BinstallConfig
2671// ---------------------------------------------------------------------------
2672
2673mod binstall;
2674pub use binstall::*;
2675
2676// ---------------------------------------------------------------------------
2677// NotarizeConfig (macOS code signing and notarization)
2678// ---------------------------------------------------------------------------
2679
2680mod notarize;
2681pub use notarize::*;
2682// ---------------------------------------------------------------------------
2683// SourceConfig
2684// ---------------------------------------------------------------------------
2685
2686mod source;
2687pub use source::*;
2688
2689// ---------------------------------------------------------------------------
2690// SbomConfig
2691// ---------------------------------------------------------------------------
2692
2693mod sbom;
2694pub use sbom::*;
2695
2696// ---------------------------------------------------------------------------
2697// AttestationConfig
2698// ---------------------------------------------------------------------------
2699
2700mod attestation;
2701pub use attestation::*;
2702
2703// ---------------------------------------------------------------------------
2704// VersionSyncConfig
2705// ---------------------------------------------------------------------------
2706
2707mod version_sync;
2708pub use version_sync::*;
2709
2710// ---------------------------------------------------------------------------
2711// ChangelogConfig
2712// ---------------------------------------------------------------------------
2713
2714mod changelog;
2715pub use changelog::*;
2716// ---------------------------------------------------------------------------
2717// SignConfig / DockerSignConfig — lifted to `crate::signing`
2718// ---------------------------------------------------------------------------
2719//
2720// see `crate::signing` for the type definitions. The
2721// re-exports below preserve the historical
2722// `anodizer_core::config::{SignConfig, DockerSignConfig}` import paths
2723// used by every stage that consumes a sign config.
2724
2725pub use crate::signing::{AuthenticodeConfig, DockerSignConfig, SignConfig, SignVerifyConfig};
2726
2727// ---------------------------------------------------------------------------
2728// UpxConfig
2729// ---------------------------------------------------------------------------
2730
2731mod upx;
2732pub use upx::*;
2733
2734// ---------------------------------------------------------------------------
2735// SnapshotConfig
2736// ---------------------------------------------------------------------------
2737
2738mod snapshot_nightly;
2739pub use snapshot_nightly::*;
2740
2741mod cargo_metadata;
2742pub use cargo_metadata::derive_metadata_from_cargo_toml;
2743
2744/// Extract the name portion of a `"Name <email>"` maintainer/author string,
2745/// dropping any `<…>` email suffix. Returns `None` when the result is empty
2746/// (e.g. a bare-email `<ada@example.com>`), so a derived Vendor / OCI `vendor`
2747/// value is never emitted blank.
2748pub fn maintainer_name_only(maintainer: &str) -> Option<String> {
2749    let name = maintainer.split('<').next().unwrap_or(maintainer).trim();
2750    (!name.is_empty()).then(|| name.to_string())
2751}
2752
2753// ---------------------------------------------------------------------------
2754// TemplateFileConfig
2755// ---------------------------------------------------------------------------
2756
2757mod templatefiles;
2758pub use templatefiles::*;
2759
2760// ---------------------------------------------------------------------------
2761// AnnounceConfig
2762// ---------------------------------------------------------------------------
2763mod announce;
2764pub use announce::*;
2765// ---------------------------------------------------------------------------
2766// DockerHub description sync
2767// ---------------------------------------------------------------------------
2768
2769mod dockerhub;
2770pub use dockerhub::*;
2771
2772// ---------------------------------------------------------------------------
2773// Artifactory publisher
2774// ---------------------------------------------------------------------------
2775
2776mod artifactory;
2777pub use artifactory::*;
2778
2779// ---------------------------------------------------------------------------
2780// CloudSmith publisher
2781// ---------------------------------------------------------------------------
2782
2783mod cloudsmith;
2784pub use cloudsmith::*;
2785
2786// ---------------------------------------------------------------------------
2787// PublisherConfig
2788// ---------------------------------------------------------------------------
2789
2790mod publisher;
2791pub use publisher::*;
2792
2793// ---------------------------------------------------------------------------
2794// HooksConfig
2795// ---------------------------------------------------------------------------
2796
2797mod hooks;
2798pub use hooks::*;
2799
2800// ---------------------------------------------------------------------------
2801// GitConfig
2802// ---------------------------------------------------------------------------
2803
2804mod git_config;
2805pub use git_config::*;
2806
2807// ---------------------------------------------------------------------------
2808// MonorepoConfig
2809// ---------------------------------------------------------------------------
2810
2811mod monorepo;
2812pub use monorepo::*;
2813
2814// ---------------------------------------------------------------------------
2815// TagConfig
2816// ---------------------------------------------------------------------------
2817
2818mod tag;
2819pub use tag::*;
2820
2821// ---------------------------------------------------------------------------
2822// WorkspaceConfig
2823// ---------------------------------------------------------------------------
2824
2825mod workspace;
2826pub use workspace::*;
2827
2828// ---------------------------------------------------------------------------
2829// RetryConfig (top-level `retry:` block — bridges to crate::retry::RetryPolicy)
2830// ---------------------------------------------------------------------------
2831
2832mod retry;
2833pub use retry::*;
2834
2835// ---------------------------------------------------------------------------
2836// PostPublishPollConfig (per-publisher post-publish polling)
2837// ---------------------------------------------------------------------------
2838
2839mod post_publish_poll;
2840pub use post_publish_poll::*;
2841
2842// ---------------------------------------------------------------------------
2843// VerifyReleaseConfig (top-level `verify_release:` post-publish gate)
2844// ---------------------------------------------------------------------------
2845
2846mod verify_release;
2847pub use verify_release::*;
2848
2849// ---------------------------------------------------------------------------
2850// PreflightConfig (top-level `preflight:` pre-publish probe tuning)
2851// ---------------------------------------------------------------------------
2852
2853mod preflight;
2854pub use preflight::*;
2855
2856// ---------------------------------------------------------------------------
2857// StringOrBool — accepts bool or template string in YAML
2858// ---------------------------------------------------------------------------
2859
2860mod string_or_bool;
2861pub use string_or_bool::*;
2862
2863// ---------------------------------------------------------------------------
2864// MakeselfConfig + SrpmConfig — lifted to `crate::packagers`
2865// ---------------------------------------------------------------------------
2866//
2867// All packaging config types live in their own modules under
2868// `crate::packagers`. The re-exports below preserve the historical
2869// `anodizer_core::config::{MakeselfConfig, MakeselfFile, SrpmConfig}`
2870// import paths used by stages and tests.
2871
2872pub use crate::packagers::{
2873    AppImageConfig, AppImageExtra, MakeselfConfig, MakeselfFile, RuntimeHarvest, SrpmConfig,
2874};
2875pub(crate) use crate::packagers::{
2876    appimages_schema, deserialize_appimages, deserialize_makeselfs, makeselfs_schema,
2877};
2878
2879// ---------------------------------------------------------------------------
2880// MilestoneConfig
2881// ---------------------------------------------------------------------------
2882
2883mod milestone;
2884pub use milestone::*;
2885
2886// ---------------------------------------------------------------------------
2887// UploadConfig (generic HTTP upload)
2888// ---------------------------------------------------------------------------
2889
2890mod upload;
2891pub use upload::*;
2892
2893// ---------------------------------------------------------------------------
2894// AurSourceConfig
2895// ---------------------------------------------------------------------------
2896
2897mod aur_source;
2898pub use aur_source::*;
2899
2900// ---------------------------------------------------------------------------
2901// McpConfig (MCP registry publisher)
2902// ---------------------------------------------------------------------------
2903
2904mod mcp;
2905pub use mcp::*;
2906
2907// ---------------------------------------------------------------------------
2908// NpmConfig (NPM package registry publisher)
2909// ---------------------------------------------------------------------------
2910
2911mod npm;
2912pub use npm::*;
2913
2914// ---------------------------------------------------------------------------
2915// GemFuryConfig (Gemfury / fury.io publisher)
2916// ---------------------------------------------------------------------------
2917
2918mod gemfury;
2919pub use gemfury::*;
2920
2921// ---------------------------------------------------------------------------
2922// Well-known config file discovery
2923// ---------------------------------------------------------------------------
2924
2925mod discovery;
2926pub use discovery::*;
2927
2928// ---------------------------------------------------------------------------
2929// Tests
2930// ---------------------------------------------------------------------------
2931
2932#[cfg(test)]
2933mod tests;