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