Skip to main content

anodizer_core/config/
mod.rs

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