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