Skip to main content

anodizer_core/config/
mod.rs

1use std::collections::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/goreleaser.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)]
37pub struct IncludeFilePath {
38    /// Path to the include file (relative to the config file).
39    pub path: String,
40}
41
42/// URL configuration for a structured include.
43#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
44pub struct IncludeUrlConfig {
45    /// URL to fetch. If it does not start with `http://` or `https://`,
46    /// `https://raw.githubusercontent.com/` is prepended (GitHub shorthand).
47    pub url: String,
48    /// Optional HTTP headers. Values support `${VAR_NAME}` environment variable expansion.
49    pub headers: Option<HashMap<String, String>>,
50}
51
52// ---------------------------------------------------------------------------
53// Top-level config
54// ---------------------------------------------------------------------------
55
56/// `deny_unknown_fields` rejects typos and unknown config
57/// fields at parse time, matching GoReleaser's `yaml.UnmarshalStrict`.
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59#[serde(default, deny_unknown_fields)]
60pub struct Config {
61    /// Schema version. Currently supports 1 (implicit default) and 2.
62    pub version: Option<u32>,
63    /// Human-readable project name used in templates and release titles.
64    pub project_name: String,
65    /// Output directory for build artifacts (default: ./dist).
66    #[serde(default = "default_dist")]
67    pub dist: PathBuf,
68    /// Additional config files to merge into this config.
69    /// Supports plain string paths, `from_file:` for structured file paths,
70    /// and `from_url:` for fetching configs from URLs with optional headers.
71    pub includes: Option<Vec<IncludeSpec>>,
72    /// Environment file configuration. Accepts either:
73    /// - A list of `.env` file paths: `[".env", ".release.env"]`
74    /// - A struct with token file paths: `{ github_token: "~/.config/goreleaser/github_token" }`
75    pub env_files: Option<EnvFilesConfig>,
76    /// Default values applied to all crates unless overridden.
77    pub defaults: Option<Defaults>,
78    /// Hooks run before the release pipeline starts.
79    pub before: Option<HooksConfig>,
80    /// Hooks run after the release pipeline completes.
81    pub after: Option<HooksConfig>,
82    /// List of crates in this project.
83    pub crates: Vec<CrateConfig>,
84    /// Changelog generation configuration.
85    pub changelog: Option<ChangelogConfig>,
86    /// Signing configurations for binaries, archives, and checksums.
87    #[serde(default, deserialize_with = "deserialize_signs")]
88    #[schemars(schema_with = "signs_schema")]
89    pub signs: Vec<SignConfig>,
90    /// Binary-specific signing configs (same shape as `signs` but only for
91    /// binary artifacts). The `artifacts` field on each entry is constrained
92    /// at parse time to `binary` / `none` (or omitted) — a broader filter on
93    /// `binary_signs` would silently match nothing because the loop only
94    /// iterates Binary artifacts. Constraint lives in `deserialize_binary_signs`.
95    #[serde(default, deserialize_with = "deserialize_binary_signs")]
96    #[schemars(schema_with = "signs_schema")]
97    pub binary_signs: Vec<SignConfig>,
98    /// Docker image signing configurations.
99    pub docker_signs: Option<Vec<DockerSignConfig>>,
100    // No `alias` attribute needed: unlike `signs`/`sign`, "upx" is already
101    // both singular and plural, so a separate alias adds no value.
102    /// UPX binary compression configurations.
103    #[serde(default, deserialize_with = "deserialize_upx")]
104    #[schemars(schema_with = "upx_schema")]
105    pub upx: Vec<UpxConfig>,
106    /// Snapshot release configuration (local/non-tag builds).
107    pub snapshot: Option<SnapshotConfig>,
108    /// Nightly release configuration.
109    pub nightly: Option<NightlyConfig>,
110    /// Announcement configuration (Slack, Discord, email, etc.).
111    pub announce: Option<AnnounceConfig>,
112    /// When true, log artifact file sizes after building.
113    pub report_sizes: Option<bool>,
114    /// Environment variables available to all template expressions.
115    ///
116    /// List of `KEY=VALUE` strings (matches GoReleaser):
117    /// `env: ["MY_VAR=hello", "DEPLOY_ENV=staging"]`. Order is preserved so
118    /// chained env applications (sign + sbom + notarize) see entries in
119    /// declared order. Values are rendered through the template engine before
120    /// being set, so expressions like `{{ .Tag }}` or `{{ .Date }}` are
121    /// expanded.
122    #[serde(default)]
123    pub env: Option<Vec<String>>,
124    /// Custom template variables accessible as {{ .Var.key }} in templates.
125    /// Provides a way to define reusable values, especially useful with config includes.
126    pub variables: Option<HashMap<String, String>>,
127    /// Generic artifact publisher configurations.
128    pub publishers: Option<Vec<PublisherConfig>>,
129    /// DockerHub description sync configurations.
130    pub dockerhub: Option<Vec<DockerHubConfig>>,
131    /// Artifactory upload configurations.
132    pub artifactories: Option<Vec<ArtifactoryConfig>>,
133    /// CloudSmith publisher configurations.
134    pub cloudsmiths: Option<Vec<CloudSmithConfig>>,
135    /// Top-level Homebrew Cask configurations.
136    /// `homebrew_casks` is a top-level array with its own
137    /// repository, commit_author, directory, skip_upload, hooks, dependencies,
138    /// conflicts, completions, manpages, structured uninstall/zap, etc.
139    pub homebrew_casks: Option<Vec<HomebrewCaskConfig>>,
140    /// Automatic semantic version tagging configuration.
141    pub tag: Option<TagConfig>,
142    /// Git-level tag discovery and sorting settings.
143    pub git: Option<GitConfig>,
144    /// Partial/split build configuration for fan-out CI pipelines.
145    pub partial: Option<PartialConfig>,
146    /// Independent workspace roots in a monorepo.
147    pub workspaces: Option<Vec<WorkspaceConfig>>,
148    /// Source archive configuration.
149    pub source: Option<SourceConfig>,
150    /// Software bill of materials (SBOM) generation configurations.
151    #[serde(default, deserialize_with = "deserialize_sboms")]
152    #[schemars(schema_with = "sboms_schema")]
153    pub sboms: Vec<SbomConfig>,
154    /// GitHub release configuration shared by all crates.
155    pub release: Option<ReleaseConfig>,
156    /// Custom GitHub API/upload/download URLs for GitHub Enterprise installations.
157    pub github_urls: Option<GitHubUrlsConfig>,
158    /// Custom GitLab API/download URLs for self-hosted GitLab installations.
159    pub gitlab_urls: Option<GitLabUrlsConfig>,
160    /// Custom Gitea API/download URLs for self-hosted Gitea installations.
161    pub gitea_urls: Option<GiteaUrlsConfig>,
162    /// Force a specific token type for authentication.
163    /// When set, overrides automatic token detection from environment variables.
164    pub force_token: Option<ForceTokenKind>,
165    /// macOS code signing and notarization configuration.
166    pub notarize: Option<NotarizeConfig>,
167    /// Project metadata configuration (applied to metadata.json output files).
168    pub metadata: Option<MetadataConfig>,
169    /// Template files to render and include as release artifacts.
170    /// File contents are processed through the template engine.
171    pub template_files: Option<Vec<TemplateFileConfig>>,
172    /// GoReleaser Pro monorepo configuration.
173    /// When configured, tag discovery filters by tag_prefix and the working
174    /// directory is scoped to dir.
175    pub monorepo: Option<MonorepoConfig>,
176    /// Makeself self-extracting archive configurations.
177    #[serde(default, deserialize_with = "deserialize_makeselfs")]
178    #[schemars(schema_with = "makeselfs_schema")]
179    pub makeselfs: Vec<MakeselfConfig>,
180    /// Source RPM configuration. Renamed from `srpm:` (singular) for spelling
181    /// parity with `Defaults.srpms` and the rest of the plural-name packaging
182    /// fields. The `srpm:` spelling is still accepted via serde alias for
183    /// back-compat.
184    #[serde(alias = "srpm")]
185    pub srpms: Option<SrpmConfig>,
186    /// Milestone closing configurations.
187    pub milestones: Option<Vec<MilestoneConfig>>,
188    /// Generic HTTP upload configurations.
189    pub uploads: Option<Vec<UploadConfig>>,
190    /// AUR source package publishing configurations (source-only PKGBUILD, not -bin).
191    pub aur_sources: Option<Vec<AurSourceConfig>>,
192    /// Top-level retry configuration applied to network-bound operations
193    /// (announcers, git providers, HTTP uploads, docker pipes). When omitted,
194    /// `RetryConfig::default()` is used (10 attempts, 10s base, 5m cap —
195    /// matching GoReleaser `Project.Retry`).
196    pub retry: Option<RetryConfig>,
197    /// MCP (Model Context Protocol) server registry publishing
198    /// configuration. When `name` is empty (the default), the publisher is
199    /// skipped. Mirrors GoReleaser's `mcp:` block.
200    #[serde(default)]
201    pub mcp: McpConfig,
202}
203
204/// Helper schema function for the signs field (accepts object or array).
205fn signs_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
206    let mut schema = generator.subschema_for::<Vec<SignConfig>>();
207    if let schemars::schema::Schema::Object(ref mut obj) = schema {
208        obj.metadata().description = Some("Artifact signing configurations (cosign, GPG, etc.). Accepts a single object or array.".to_owned());
209    }
210    schema
211}
212
213/// Helper schema function for the upx field (accepts object or array).
214fn upx_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
215    let mut schema = generator.subschema_for::<Vec<UpxConfig>>();
216    if let schemars::schema::Schema::Object(ref mut obj) = schema {
217        obj.metadata().description = Some(
218            "UPX binary compression configurations. Accepts a single object or array.".to_owned(),
219        );
220    }
221    schema
222}
223
224/// Helper schema function for the sboms field (accepts object or array).
225fn sboms_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
226    let mut schema = generator.subschema_for::<Vec<SbomConfig>>();
227    if let schemars::schema::Schema::Object(ref mut obj) = schema {
228        obj.metadata().description =
229            Some("SBOM generation configurations. Accepts a single object or array.".to_owned());
230    }
231    schema
232}
233
234fn default_dist() -> PathBuf {
235    PathBuf::from("./dist")
236}
237
238impl Default for Config {
239    fn default() -> Self {
240        Config {
241            version: None,
242            project_name: String::new(),
243            dist: default_dist(),
244            includes: None,
245            env_files: None,
246            defaults: None,
247            before: None,
248            after: None,
249            crates: Vec::new(),
250            changelog: None,
251            signs: Vec::new(),
252            binary_signs: Vec::new(),
253            docker_signs: None,
254            upx: Vec::new(),
255            snapshot: None,
256            nightly: None,
257            announce: None,
258            report_sizes: None,
259            env: None,
260            variables: None,
261            publishers: None,
262            dockerhub: None,
263            artifactories: None,
264            cloudsmiths: None,
265            homebrew_casks: None,
266            tag: None,
267            git: None,
268            partial: None,
269            workspaces: None,
270            source: None,
271            sboms: Vec::new(),
272            release: None,
273            github_urls: None,
274            gitlab_urls: None,
275            gitea_urls: None,
276            force_token: None,
277            notarize: None,
278            metadata: None,
279            template_files: None,
280            monorepo: None,
281            makeselfs: Vec::new(),
282            srpms: None,
283            milestones: None,
284            uploads: None,
285            aur_sources: None,
286            retry: None,
287            mcp: McpConfig::default(),
288        }
289    }
290}
291
292impl Config {
293    /// Return the monorepo tag prefix, if configured.
294    ///
295    /// Shorthand for `config.monorepo.as_ref().and_then(|m| m.tag_prefix.as_deref())`.
296    pub fn monorepo_tag_prefix(&self) -> Option<&str> {
297        self.monorepo.as_ref().and_then(|m| m.tag_prefix.as_deref())
298    }
299
300    /// Return the monorepo working directory, if configured.
301    ///
302    /// Shorthand for `config.monorepo.as_ref().and_then(|m| m.dir.as_deref())`.
303    pub fn monorepo_dir(&self) -> Option<&str> {
304        self.monorepo.as_ref().and_then(|m| m.dir.as_deref())
305    }
306
307    // --- Project metadata defaulting helpers (GoReleaser Pro parity) ---
308    //
309    // Publishers that expose homepage/license/description/maintainer fields
310    // should fall back to these when their own field is unset, so a project
311    // only needs to declare metadata once. Pattern:
312    //
313    //   let homepage = nfpm_cfg.homepage
314    //       .as_deref()
315    //       .or_else(|| cfg.meta_homepage());
316    //
317    // Returns None if the `metadata` section is missing or the field is unset.
318
319    /// Project homepage from `metadata.homepage` (Pro default source for publishers).
320    pub fn meta_homepage(&self) -> Option<&str> {
321        self.metadata.as_ref().and_then(|m| m.homepage.as_deref())
322    }
323
324    /// Project license from `metadata.license`.
325    pub fn meta_license(&self) -> Option<&str> {
326        self.metadata.as_ref().and_then(|m| m.license.as_deref())
327    }
328
329    /// Project description from `metadata.description`.
330    pub fn meta_description(&self) -> Option<&str> {
331        self.metadata
332            .as_ref()
333            .and_then(|m| m.description.as_deref())
334    }
335
336    /// Project maintainers from `metadata.maintainers`.
337    pub fn meta_maintainers(&self) -> &[String] {
338        self.metadata
339            .as_ref()
340            .and_then(|m| m.maintainers.as_deref())
341            .unwrap_or(&[])
342    }
343
344    /// First maintainer as "Name <email>" or just "Name" (publisher convention).
345    /// Returns None when no maintainers are configured.
346    pub fn meta_first_maintainer(&self) -> Option<&str> {
347        self.meta_maintainers().first().map(|s| s.as_str())
348    }
349}
350
351/// Run a deserialization closure on a worker thread sized large enough that
352/// the `Config` derive (60+ `Option<NestedStruct>` fields) cannot exhaust
353/// the host's main-thread stack.
354///
355/// Background: debug builds of `serde_yaml_ng::from_value::<Config>` and
356/// `toml::from_str::<Config>` consume several MiB of stack because each
357/// generated visitor branch for the giant struct lives in a single
358/// monomorphised frame and debug builds neither inline nor tail-call. The
359/// Windows main-thread default reservation is 1 MiB, so any debug-built
360/// integration test that triggers full-config deserialization overflows
361/// before reaching the visitor's body.
362///
363/// Routing every full-`Config` deserialization through this helper keeps
364/// every entry-point platform-agnostic without resorting to per-platform
365/// linker flags or `RUST_MIN_STACK`.
366pub fn deserialize_on_worker<F, T>(f: F) -> anyhow::Result<T>
367where
368    F: FnOnce() -> anyhow::Result<T> + Send + 'static,
369    T: Send + 'static,
370{
371    use anyhow::Context as _;
372
373    // 8 MiB matches the Linux/macOS process default and comfortably exceeds
374    // the ~2 MiB peak observed for debug `Config` deserialization.
375    const WORKER_STACK_SIZE: usize = 8 * 1024 * 1024;
376
377    let handle = std::thread::Builder::new()
378        .stack_size(WORKER_STACK_SIZE)
379        .name("anodizer-config-deserialize".to_string())
380        .spawn(f)
381        .context("failed to spawn config deserialization worker thread")?;
382    match handle.join() {
383        Ok(result) => result,
384        Err(payload) => std::panic::resume_unwind(payload),
385    }
386}
387
388/// Validate the config schema version. Accepts version 1 (default) and 2.
389/// Returns an error for unknown versions.
390pub fn validate_version(config: &Config) -> Result<(), String> {
391    match config.version {
392        None | Some(1) | Some(2) => Ok(()),
393        Some(v) => Err(format!(
394            "unsupported config version: {}. Supported versions are 1 and 2.",
395            v
396        )),
397    }
398}
399
400/// Validate `git.tag_sort` if present. Accepted values:
401/// - `"-version:refname"` (default, lexicographic version sort)
402/// - `"-version:creatordate"` (sort by tag creation date, newest first)
403///
404/// Returns an error for unrecognized values.
405pub fn validate_tag_sort(config: &Config) -> Result<(), String> {
406    if let Some(ref git) = config.git
407        && let Some(ref sort) = git.tag_sort
408    {
409        match sort.as_str() {
410            "-version:refname" | "-version:creatordate" => {}
411            other => {
412                return Err(format!(
413                    "unsupported git.tag_sort value: \"{}\". \
414                     Accepted values: \"-version:refname\", \"-version:creatordate\".",
415                    other
416                ));
417            }
418        }
419    }
420    Ok(())
421}
422
423/// Known GOOS values accepted by `archives[].format_overrides[].goos`.
424/// Mirrors the Go runtime's `runtime.GOOS` values GoReleaser's archive pipe
425/// recognises; anything outside this set is almost always a typo
426/// (e.g. a Rust target triple slice like `pc-windows-msvc`).
427const KNOWN_GOOS: &[&str] = &[
428    "aix",
429    "android",
430    "darwin",
431    "dragonfly",
432    "freebsd",
433    "illumos",
434    "ios",
435    "js",
436    "linux",
437    "netbsd",
438    "openbsd",
439    "plan9",
440    "solaris",
441    "wasip1",
442    "windows",
443];
444
445/// Validate that each crate's `release:` block configures at most one SCM
446/// backend. Matches GoReleaser release.go:41-53 `ErrMultipleReleases`, which
447/// errors at `Default()` time. Anodizer dispatches on `ctx.token_type` at
448/// runtime so a silently-ignored extra backend is easy to miss.
449pub fn validate_release_backends(config: &Config) -> Result<(), String> {
450    let check = |crate_name: &str, release: &ReleaseConfig| -> Result<(), String> {
451        let mut set = Vec::new();
452        if release.github.is_some() {
453            set.push("github");
454        }
455        if release.gitlab.is_some() {
456            set.push("gitlab");
457        }
458        if release.gitea.is_some() {
459            set.push("gitea");
460        }
461        if set.len() > 1 {
462            return Err(format!(
463                "crate {}: release config sets multiple mutually-exclusive SCM \
464                 backends ({}). Pick one.",
465                crate_name,
466                set.join(" + ")
467            ));
468        }
469        Ok(())
470    };
471    for krate in &config.crates {
472        if let Some(ref release) = krate.release {
473            check(&krate.name, release)?;
474        }
475    }
476    if let Some(ws_list) = config.workspaces.as_ref() {
477        for ws in ws_list {
478            for krate in &ws.crates {
479                if let Some(ref release) = krate.release {
480                    check(&krate.name, release)?;
481                }
482            }
483        }
484    }
485    Ok(())
486}
487
488/// Marker prefix for the axis-mismatch validation error class. Existing
489/// validators in this module return `Result<(), String>` rather than a
490/// typed enum, so we expose this constant (instead of a `ConfigError`
491/// variant) for callers that want to recognise the error class
492/// programmatically.
493///
494/// The prefix is emitted at the start of every error returned by
495/// [`validate_defaults_axis`] (formatted as `"DefaultsAxisMismatch: …"`),
496/// so callers can match with `err.starts_with(ERR_DEFAULTS_AXIS_MISMATCH)`
497/// or `err.contains(ERR_DEFAULTS_AXIS_MISMATCH)` without depending on the
498/// exact human-readable wording.
499///
500/// ```ignore
501/// match validate_defaults_axis(&config) {
502///     Err(e) if e.starts_with(ERR_DEFAULTS_AXIS_MISMATCH) => {
503///         // handle the axis-mismatch error class
504///     }
505///     other => other?,
506/// }
507/// ```
508///
509/// Future error-type unification can rename to
510/// `ConfigError::DefaultsAxisMismatch` without changing call-sites that
511/// match on this prefix.
512pub const ERR_DEFAULTS_AXIS_MISMATCH: &str = "DefaultsAxisMismatch";
513
514/// Validate that `defaults.crates:` and `defaults.workspaces:` match the
515/// top-level axis (DEC-4).
516///
517/// Rules:
518/// - `defaults.crates:` is set → top-level `crates:` MUST be present.
519/// - `defaults.workspaces:` is set → top-level `workspaces:` MUST be present.
520/// - Both `defaults.crates` and `defaults.workspaces` set simultaneously → error
521///   (mutually exclusive).
522/// - Wrong-axis (e.g. `defaults.crates:` while top-level uses `workspaces:`) → error.
523pub fn validate_defaults_axis(config: &Config) -> Result<(), String> {
524    let Some(ref defaults) = config.defaults else {
525        return Ok(());
526    };
527    let has_crate_block = defaults.crates.is_some();
528    let has_workspace_block = defaults.workspaces.is_some();
529
530    if has_crate_block && has_workspace_block {
531        return Err(format!(
532            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates and defaults.workspaces are \
533             mutually exclusive — pick the axis that matches the top-level config \
534             (`crates:` or `workspaces:`)",
535        ));
536    }
537
538    let top_uses_workspaces = config.workspaces.as_ref().is_some_and(|w| !w.is_empty());
539    let top_uses_crates = !config.crates.is_empty();
540
541    if has_crate_block && !top_uses_crates {
542        return Err(format!(
543            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates is set but top-level `crates:` \
544             is {}; move defaults under `defaults.workspaces:` or remove the block",
545            if top_uses_workspaces {
546                "absent (top-level uses `workspaces:`)"
547            } else {
548                "absent"
549            },
550        ));
551    }
552    if has_workspace_block && !top_uses_workspaces {
553        return Err(format!(
554            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.workspaces is set but top-level \
555             `workspaces:` is {}; move defaults under `defaults.crates:` or remove the block",
556            if top_uses_crates {
557                "absent (top-level uses `crates:`)"
558            } else {
559                "absent"
560            },
561        ));
562    }
563
564    Ok(())
565}
566
567/// Validate `archives[].format_overrides[].goos` values reject unknown OSes.
568/// GoReleaser silently no-ops unknown overrides, which has burned users typing
569/// Rust triples like `apple` or `pc-windows-msvc`.
570///
571/// Walks every `archives[]` location in the config:
572/// - `crates[].archives:`
573/// - `workspaces[].crates[].archives:`
574/// - `defaults.archives:` (an unknown `os` here would otherwise pass silently
575///   and propagate to every inheriting crate at merge time).
576pub fn validate_format_overrides(config: &Config) -> Result<(), String> {
577    let check = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
578        for (idx, archive) in archives.iter().enumerate() {
579            let Some(ref overrides) = archive.format_overrides else {
580                continue;
581            };
582            for over in overrides {
583                if !KNOWN_GOOS.contains(&over.os.as_str()) {
584                    let archive_id = archive.id.as_deref().unwrap_or("default");
585                    return Err(format!(
586                        "{}: archives[{}] (id={}): format_overrides.goos=\"{}\" is not a recognised OS. \
587                         Accepted values: {}.",
588                        location,
589                        idx,
590                        archive_id,
591                        over.os,
592                        KNOWN_GOOS.join(", ")
593                    ));
594                }
595            }
596        }
597        Ok(())
598    };
599    for krate in &config.crates {
600        if let ArchivesConfig::Configs(ref list) = krate.archives {
601            check(&format!("crate {}", krate.name), list)?;
602        }
603    }
604    if let Some(ws_list) = config.workspaces.as_ref() {
605        for ws in ws_list {
606            for krate in &ws.crates {
607                if let ArchivesConfig::Configs(ref list) = krate.archives {
608                    check(&format!("crate {}", krate.name), list)?;
609                }
610            }
611        }
612    }
613    if let Some(ref defaults) = config.defaults
614        && let Some(ref archive) = defaults.archives
615    {
616        // defaults.archives is a single ArchiveConfig (not a list); wrap it
617        // into a one-element slice so the same checker walks it.
618        check("defaults.archives", std::slice::from_ref(archive))?;
619    }
620    Ok(())
621}
622
623/// Validate that no [`HomebrewCaskConfig`] sets both `url_template` AND
624/// `url.template` simultaneously — they are mutually exclusive shorthands
625/// for the same URL field and combining them is ambiguous.
626///
627/// Inspects every occurrence of `HomebrewCaskConfig` in the config:
628/// - `homebrew_casks:` (top-level array)
629/// - `crates[].publish.homebrew_cask:`
630/// - `workspaces[].crates[].publish.homebrew_cask:`
631/// - `defaults.publish.homebrew_cask:`
632pub fn validate_homebrew_cask_url_template(config: &Config) -> Result<(), String> {
633    let check = |location: &str, cask: &HomebrewCaskConfig| -> Result<(), String> {
634        let has_url_template = cask.url_template.is_some();
635        let has_url_dot_template = cask.url.as_ref().is_some_and(|u| u.template.is_some());
636        if has_url_template && has_url_dot_template {
637            return Err(format!(
638                "{location}: homebrew_cask sets both `url_template` and `url.template`. \
639                 These are mutually exclusive — use one or the other."
640            ));
641        }
642        Ok(())
643    };
644
645    // Top-level homebrew_casks array
646    if let Some(ref casks) = config.homebrew_casks {
647        for (i, cask) in casks.iter().enumerate() {
648            check(&format!("homebrew_casks[{i}]"), cask)?;
649        }
650    }
651
652    // Per-crate publish.homebrew_cask
653    for krate in &config.crates {
654        if let Some(ref publish) = krate.publish
655            && let Some(ref cask) = publish.homebrew_cask
656        {
657            check(
658                &format!("crates[{}].publish.homebrew_cask", krate.name),
659                cask,
660            )?;
661        }
662    }
663
664    // Workspace crates
665    if let Some(ref workspaces) = config.workspaces {
666        for ws in workspaces {
667            for krate in &ws.crates {
668                if let Some(ref publish) = krate.publish
669                    && let Some(ref cask) = publish.homebrew_cask
670                {
671                    check(
672                        &format!(
673                            "workspaces[{}].crates[{}].publish.homebrew_cask",
674                            ws.name, krate.name
675                        ),
676                        cask,
677                    )?;
678                }
679            }
680        }
681    }
682
683    // defaults.publish.homebrew_cask
684    if let Some(ref defaults) = config.defaults
685        && let Some(ref publish) = defaults.publish
686        && let Some(ref cask) = publish.homebrew_cask
687    {
688        check("defaults.publish.homebrew_cask", cask)?;
689    }
690
691    Ok(())
692}
693
694/// Validate that `archives[].id` and `universal_binaries[].id` are unique
695/// within their respective lists.
696///
697/// Mirrors GoReleaser's `ids.New("archives").Inc(...).Validate()` pattern in
698/// `internal/pipe/archive/archive.go:56-102` and the equivalent
699/// `internal/pipe/universalbinary/universalbinary.go:36-50`. Two archive
700/// configs with the same `id` silently both set the same `id` metadata key
701/// on artifacts, breaking publishers that filter `ids: [<id>]`. Anodizer's
702/// build/sign stages already enforce id uniqueness; archive and
703/// universal_binary were missed.
704///
705/// Walks every occurrence of `archives[]` and `universal_binaries[]`:
706/// - `crates[].archives:` / `crates[].universal_binaries:`
707/// - `workspaces[].crates[].archives:` / `.universal_binaries:`
708/// - `defaults.archives:` is a single `ArchiveConfig`, so uniqueness within
709///   itself is vacuously true; not walked here.
710///
711/// Q-arch2 from `.claude/audits/2026-05-08-second-opinion/build-archive.md`
712/// sections 1.1 + 1.2.
713pub fn validate_id_uniqueness(config: &Config) -> Result<(), String> {
714    fn check_unique<F>(
715        location: &str,
716        kind: &str,
717        ids: impl IntoIterator<Item = (usize, Option<String>)>,
718        empty_ok: F,
719    ) -> Result<(), String>
720    where
721        F: Fn() -> bool,
722    {
723        let _ = empty_ok;
724        let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
725        for (idx, maybe_id) in ids {
726            // GoReleaser stores empty as "default" for archives via Default-time
727            // assignment. Anodizer applies `default_archive_id` at deserialize
728            // time, so the option is normally `Some("default")`. A truly empty
729            // / None id here means the user explicitly cleared it; we still
730            // dedupe across `None` so two None-id'd entries collide just like
731            // two "default"-id'd entries would.
732            let key = maybe_id.unwrap_or_else(|| "<unset>".to_string());
733            if let Some(prev_idx) = seen.insert(key.clone(), idx) {
734                return Err(format!(
735                    "{location}: {kind} id \"{key}\" is used by both entry {prev_idx} and entry {idx} — \
736                     ids must be unique within a {kind} list."
737                ));
738            }
739        }
740        Ok(())
741    }
742
743    let check_archives = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
744        check_unique(
745            location,
746            "archives",
747            archives.iter().enumerate().map(|(i, a)| (i, a.id.clone())),
748            || true,
749        )
750    };
751    let check_unibins = |location: &str, ubs: &[UniversalBinaryConfig]| -> Result<(), String> {
752        check_unique(
753            location,
754            "universal_binaries",
755            ubs.iter().enumerate().map(|(i, u)| (i, u.id.clone())),
756            || true,
757        )
758    };
759
760    for krate in &config.crates {
761        if let ArchivesConfig::Configs(ref list) = krate.archives {
762            check_archives(&format!("crates[{}].archives", krate.name), list)?;
763        }
764        if let Some(ref ubs) = krate.universal_binaries {
765            check_unibins(&format!("crates[{}].universal_binaries", krate.name), ubs)?;
766        }
767    }
768    if let Some(ws_list) = config.workspaces.as_ref() {
769        for ws in ws_list {
770            for krate in &ws.crates {
771                if let ArchivesConfig::Configs(ref list) = krate.archives {
772                    check_archives(
773                        &format!("workspaces[{}].crates[{}].archives", ws.name, krate.name),
774                        list,
775                    )?;
776                }
777                if let Some(ref ubs) = krate.universal_binaries {
778                    check_unibins(
779                        &format!(
780                            "workspaces[{}].crates[{}].universal_binaries",
781                            ws.name, krate.name
782                        ),
783                        ubs,
784                    )?;
785                }
786            }
787        }
788    }
789    Ok(())
790}
791
792/// No-op preserved for API stability; the legacy `format:` and `builds:`
793/// folds happen inline in `<ArchiveConfig as Deserialize>::deserialize` and
794/// `<FormatOverride as Deserialize>::deserialize`. Emits no warning of its
795/// own — every alias hit was already announced at deserialize time.
796///
797/// F3 from `.claude/audits/2026-05-08-second-opinion/build-archive.md`
798/// sections 1.9 + 1.10.
799pub fn apply_archive_legacy_aliases(_config: &mut Config) {
800    // Intentionally empty — see Deserialize impls.
801}
802
803/// Reject the GoReleaser V1 `dockers:` block at config-load time with a
804/// clear migration error.
805///
806/// anodizer is V2-only by design (DEC-7): it implements `docker_v2:` and the
807/// associated multi-arch buildx flow, but does not ship the V1
808/// `dockers: -> dockerfile + image_templates` pipe. Without this check the
809/// top-level `Config` struct's `deny_unknown_fields` would emit a generic
810/// "unknown field `dockers`" message that doesn't tell the user how to
811/// migrate. This explicit error names the field, points at `docker_v2:`,
812/// and references the rationale.
813///
814/// M3 from `.claude/audits/2026-05-08-second-opinion/docker-pro.md`
815/// section 2.1.
816pub fn validate_no_docker_v1(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
817    if raw_yaml.get("dockers").is_some() {
818        return Err(
819            "config: legacy GoReleaser `dockers:` block is not supported — anodizer ships \
820             docker_v2: only (multi-arch buildx flow). Port the config to `docker_v2:` per \
821             https://anodize.dev/docs/migration/docker.html."
822                .to_string(),
823        );
824    }
825    Ok(())
826}
827
828/// Fold the deprecated `snapshot.name_template` alias into `version_template`.
829/// Serde already accepts both spellings via `#[serde(alias = "name_template")]`,
830/// so this function only needs to emit the deprecation warning when the
831/// raw YAML key was the legacy one.
832///
833/// GR ref: `internal/pipe/snapshot/snapshot.go:25-28`. Because serde collapses
834/// the two spellings to a single field on parse, we lose the information
835/// about which key the user wrote. This function therefore consults the
836/// raw YAML pre-parse value (when supplied) to decide.
837///
838/// F3 — section 1.8 of the build-archive.md audit report.
839pub fn warn_on_legacy_snapshot_name_template(raw_yaml: &serde_yaml_ng::Value) {
840    if let Some(snap) = raw_yaml.get("snapshot")
841        && snap.get("name_template").is_some()
842    {
843        tracing::warn!(
844            "DEPRECATION: snapshot.name_template is deprecated; use \
845             snapshot.version_template instead. Both spellings are accepted \
846             but the legacy key will be removed in a future release."
847        );
848    }
849}
850
851/// Emit a deprecation warning for any `builds[].gobinary` field. The field
852/// is captured by [`BuildConfig::legacy_gobinary`] purely for back-compat
853/// YAML import; anodizer's tool is always `cargo` so the value is unused.
854///
855/// GR ref: `internal/pipe/build/build.go:93-95`.
856pub fn apply_build_legacy_aliases(config: &mut Config) {
857    let warn_one = |location: &str, legacy: &mut Option<String>| {
858        if let Some(go_bin) = legacy.take() {
859            tracing::warn!(
860                "DEPRECATION: {location}: 'gobinary: {go_bin}' is a Go-only field; anodizer \
861                 builds with cargo unconditionally. The value has been ignored."
862            );
863        }
864    };
865    for krate in &mut config.crates {
866        if let Some(ref mut builds) = krate.builds {
867            for (i, b) in builds.iter_mut().enumerate() {
868                warn_one(
869                    &format!("crates[{}].builds[{i}]", krate.name),
870                    &mut b.legacy_gobinary,
871                );
872            }
873        }
874    }
875    if let Some(ref mut workspaces) = config.workspaces {
876        for ws in workspaces {
877            for krate in &mut ws.crates {
878                if let Some(ref mut builds) = krate.builds {
879                    for (i, b) in builds.iter_mut().enumerate() {
880                        warn_one(
881                            &format!("workspaces[{}].crates[{}].builds[{i}]", ws.name, krate.name),
882                            &mut b.legacy_gobinary,
883                        );
884                    }
885                }
886            }
887        }
888    }
889    if let Some(ref mut defaults) = config.defaults
890        && let Some(ref mut b) = defaults.builds
891    {
892        warn_one("defaults.builds", &mut b.legacy_gobinary);
893    }
894}
895
896// ---------------------------------------------------------------------------
897// EnvFilesConfig — accepts list of .env paths OR structured token file paths
898// ---------------------------------------------------------------------------
899
900mod env_files;
901pub use env_files::*;
902
903// ---------------------------------------------------------------------------
904// Defaults
905// ---------------------------------------------------------------------------
906
907mod defaults;
908pub use defaults::*;
909
910// ---------------------------------------------------------------------------
911// BuildIgnore — exclude specific os/arch combos from builds
912// ---------------------------------------------------------------------------
913
914mod build;
915pub use build::*;
916
917// ---------------------------------------------------------------------------
918// ArchivesConfig — untagged enum: false => Disabled, array => Configs
919// ---------------------------------------------------------------------------
920
921mod archives;
922pub use archives::*;
923
924// ---------------------------------------------------------------------------
925// ReleaseConfig
926// ---------------------------------------------------------------------------
927
928mod release;
929pub use release::*;
930
931// ---------------------------------------------------------------------------
932// Shared publisher config types: RepositoryConfig, CommitAuthorConfig
933// ---------------------------------------------------------------------------
934
935mod publishers;
936pub use publishers::*;
937
938// ---------------------------------------------------------------------------
939// DockerV2Config
940// ---------------------------------------------------------------------------
941
942mod docker;
943pub use docker::*;
944
945// ---------------------------------------------------------------------------
946// NfpmConfig
947// ---------------------------------------------------------------------------
948
949mod nfpm;
950pub use nfpm::*;
951
952// ---------------------------------------------------------------------------
953// SnapcraftConfig
954// ---------------------------------------------------------------------------
955
956mod snapcraft;
957pub use snapcraft::*;
958// ---------------------------------------------------------------------------
959// DmgConfig / MsiConfig / PkgConfig / NsisConfig / AppBundleConfig / FlatpakConfig
960// ---------------------------------------------------------------------------
961
962mod installers;
963pub use installers::*;
964
965// ---------------------------------------------------------------------------
966// BlobConfig (S3/GCS/Azure cloud storage)
967// ---------------------------------------------------------------------------
968
969mod blob;
970pub use blob::*;
971
972// ---------------------------------------------------------------------------
973// PartialConfig (split/merge CI fan-out)
974// ---------------------------------------------------------------------------
975
976mod partial;
977pub use partial::*;
978
979// ---------------------------------------------------------------------------
980// BinstallConfig
981// ---------------------------------------------------------------------------
982
983mod binstall;
984pub use binstall::*;
985
986// ---------------------------------------------------------------------------
987// NotarizeConfig (macOS code signing and notarization)
988// ---------------------------------------------------------------------------
989
990mod notarize;
991pub use notarize::*;
992// ---------------------------------------------------------------------------
993// SourceConfig
994// ---------------------------------------------------------------------------
995
996mod source;
997pub use source::*;
998
999// ---------------------------------------------------------------------------
1000// SbomConfig
1001// ---------------------------------------------------------------------------
1002
1003mod sbom;
1004pub use sbom::*;
1005
1006// ---------------------------------------------------------------------------
1007// VersionSyncConfig
1008// ---------------------------------------------------------------------------
1009
1010mod version_sync;
1011pub use version_sync::*;
1012
1013// ---------------------------------------------------------------------------
1014// ChangelogConfig
1015// ---------------------------------------------------------------------------
1016
1017mod changelog;
1018pub use changelog::*;
1019// ---------------------------------------------------------------------------
1020// SignConfig / DockerSignConfig — lifted to `crate::signing`
1021// ---------------------------------------------------------------------------
1022//
1023// WAVE 5 split: see `crate::signing` for the type definitions. The
1024// re-exports below preserve the historical
1025// `anodizer_core::config::{SignConfig, DockerSignConfig}` import paths
1026// used by every stage that consumes a sign config.
1027
1028pub use crate::signing::{DockerSignConfig, SignConfig};
1029
1030// ---------------------------------------------------------------------------
1031// UpxConfig
1032// ---------------------------------------------------------------------------
1033
1034mod upx;
1035pub use upx::*;
1036
1037// ---------------------------------------------------------------------------
1038// SnapshotConfig
1039// ---------------------------------------------------------------------------
1040
1041mod snapshot_nightly;
1042pub use snapshot_nightly::*;
1043
1044// ---------------------------------------------------------------------------
1045// TemplateFileConfig
1046// ---------------------------------------------------------------------------
1047
1048mod templatefiles;
1049pub use templatefiles::*;
1050
1051// ---------------------------------------------------------------------------
1052// AnnounceConfig
1053// ---------------------------------------------------------------------------
1054mod announce;
1055pub use announce::*;
1056// ---------------------------------------------------------------------------
1057// DockerHub description sync
1058// ---------------------------------------------------------------------------
1059
1060mod dockerhub;
1061pub use dockerhub::*;
1062
1063// ---------------------------------------------------------------------------
1064// Artifactory publisher
1065// ---------------------------------------------------------------------------
1066
1067mod artifactory;
1068pub use artifactory::*;
1069
1070// ---------------------------------------------------------------------------
1071// CloudSmith publisher
1072// ---------------------------------------------------------------------------
1073
1074mod cloudsmith;
1075pub use cloudsmith::*;
1076
1077// ---------------------------------------------------------------------------
1078// PublisherConfig
1079// ---------------------------------------------------------------------------
1080
1081mod publisher;
1082pub use publisher::*;
1083
1084// ---------------------------------------------------------------------------
1085// HooksConfig
1086// ---------------------------------------------------------------------------
1087
1088mod hooks;
1089pub use hooks::*;
1090
1091// ---------------------------------------------------------------------------
1092// GitConfig
1093// ---------------------------------------------------------------------------
1094
1095mod git_config;
1096pub use git_config::*;
1097
1098// ---------------------------------------------------------------------------
1099// MonorepoConfig
1100// ---------------------------------------------------------------------------
1101
1102mod monorepo;
1103pub use monorepo::*;
1104
1105// ---------------------------------------------------------------------------
1106// TagConfig
1107// ---------------------------------------------------------------------------
1108
1109mod tag;
1110pub use tag::*;
1111
1112// ---------------------------------------------------------------------------
1113// WorkspaceConfig
1114// ---------------------------------------------------------------------------
1115
1116mod workspace;
1117pub use workspace::*;
1118
1119// ---------------------------------------------------------------------------
1120// RetryConfig (top-level `retry:` block — bridges to crate::retry::RetryPolicy)
1121// ---------------------------------------------------------------------------
1122
1123mod retry;
1124pub use retry::*;
1125
1126// ---------------------------------------------------------------------------
1127// StringOrBool — accepts bool or template string in YAML
1128// ---------------------------------------------------------------------------
1129
1130mod string_or_bool;
1131pub use string_or_bool::*;
1132
1133// ---------------------------------------------------------------------------
1134// MakeselfConfig + SrpmConfig — lifted to `crate::packagers`
1135// ---------------------------------------------------------------------------
1136//
1137// Wave B carve completed. All packaging config types were lifted to
1138// `crate::packagers` during the Wave 5 split. The re-exports below
1139// preserve the historical
1140// `anodizer_core::config::{MakeselfConfig, MakeselfFile, SrpmConfig}`
1141// import paths used by stages and tests.
1142
1143pub use crate::packagers::{MakeselfConfig, MakeselfFile, SrpmConfig};
1144pub(crate) use crate::packagers::{deserialize_makeselfs, makeselfs_schema};
1145
1146// ---------------------------------------------------------------------------
1147// MilestoneConfig
1148// ---------------------------------------------------------------------------
1149
1150mod milestone;
1151pub use milestone::*;
1152
1153// ---------------------------------------------------------------------------
1154// UploadConfig (generic HTTP upload)
1155// ---------------------------------------------------------------------------
1156
1157mod upload;
1158pub use upload::*;
1159
1160// ---------------------------------------------------------------------------
1161// AurSourceConfig
1162// ---------------------------------------------------------------------------
1163
1164mod aur_source;
1165pub use aur_source::*;
1166
1167// ---------------------------------------------------------------------------
1168// McpConfig (MCP registry publisher)
1169// ---------------------------------------------------------------------------
1170
1171mod mcp;
1172pub use mcp::*;
1173
1174// ---------------------------------------------------------------------------
1175// Tests
1176// ---------------------------------------------------------------------------
1177
1178#[cfg(test)]
1179mod tests;