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