Skip to main content

anodizer_core/
config.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Deserializer, 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, alias = "sign", 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 binary artifacts).
91    #[serde(default, alias = "binary_sign", deserialize_with = "deserialize_signs")]
92    #[schemars(schema_with = "signs_schema")]
93    pub binary_signs: Vec<SignConfig>,
94    /// Docker image signing configurations.
95    pub docker_signs: Option<Vec<DockerSignConfig>>,
96    // No `alias` attribute needed: unlike `signs`/`sign`, "upx" is already
97    // both singular and plural, so a separate alias adds no value.
98    /// UPX binary compression configurations.
99    #[serde(default, deserialize_with = "deserialize_upx")]
100    #[schemars(schema_with = "upx_schema")]
101    pub upx: Vec<UpxConfig>,
102    /// Snapshot release configuration (local/non-tag builds).
103    pub snapshot: Option<SnapshotConfig>,
104    /// Nightly release configuration.
105    pub nightly: Option<NightlyConfig>,
106    /// Announcement configuration (Slack, Discord, email, etc.).
107    pub announce: Option<AnnounceConfig>,
108    /// When true, log artifact file sizes after building.
109    pub report_sizes: Option<bool>,
110    /// Environment variables available to all template expressions.
111    ///
112    /// Accepts two YAML forms:
113    /// - **Map form**: `env: { MY_VAR: hello, DEPLOY_ENV: staging }`
114    /// - **List form** (GoReleaser parity): `env: ["MY_VAR=hello", "DEPLOY_ENV=staging"]`
115    ///
116    /// Values are rendered through the template engine before being set, so
117    /// expressions like `{{ .Tag }}` or `{{ .Date }}` are expanded.
118    #[serde(default, deserialize_with = "deserialize_env_map")]
119    pub env: Option<HashMap<String, String>>,
120    /// Custom template variables accessible as {{ .Var.key }} in templates.
121    /// Provides a way to define reusable values, especially useful with config includes.
122    pub variables: Option<HashMap<String, String>>,
123    /// Generic artifact publisher configurations.
124    pub publishers: Option<Vec<PublisherConfig>>,
125    /// DockerHub description sync configurations.
126    pub dockerhub: Option<Vec<DockerHubConfig>>,
127    /// Artifactory upload configurations.
128    pub artifactories: Option<Vec<ArtifactoryConfig>>,
129    /// CloudSmith publisher configurations.
130    pub cloudsmiths: Option<Vec<CloudSmithConfig>>,
131    /// Top-level Homebrew Cask configurations.
132    /// `homebrew_casks` is a top-level array with its own
133    /// repository, commit_author, directory, skip_upload, hooks, dependencies,
134    /// conflicts, completions, manpages, structured uninstall/zap, etc.
135    pub homebrew_casks: Option<Vec<TopLevelHomebrewCaskConfig>>,
136    /// Automatic semantic version tagging configuration.
137    pub tag: Option<TagConfig>,
138    /// Git-level tag discovery and sorting settings.
139    pub git: Option<GitConfig>,
140    /// Partial/split build configuration for fan-out CI pipelines.
141    pub partial: Option<PartialConfig>,
142    /// Independent workspace roots in a monorepo.
143    pub workspaces: Option<Vec<WorkspaceConfig>>,
144    /// Source archive configuration.
145    pub source: Option<SourceConfig>,
146    /// Software bill of materials (SBOM) generation configurations.
147    #[serde(default, alias = "sbom", deserialize_with = "deserialize_sboms")]
148    #[schemars(schema_with = "sboms_schema")]
149    pub sboms: Vec<SbomConfig>,
150    /// GitHub release configuration shared by all crates.
151    pub release: Option<ReleaseConfig>,
152    /// Custom GitHub API/upload/download URLs for GitHub Enterprise installations.
153    pub github_urls: Option<GitHubUrlsConfig>,
154    /// Custom GitLab API/download URLs for self-hosted GitLab installations.
155    pub gitlab_urls: Option<GitLabUrlsConfig>,
156    /// Custom Gitea API/download URLs for self-hosted Gitea installations.
157    pub gitea_urls: Option<GiteaUrlsConfig>,
158    /// Force a specific token type for authentication.
159    /// When set, overrides automatic token detection from environment variables.
160    pub force_token: Option<ForceTokenKind>,
161    /// macOS code signing and notarization configuration.
162    pub notarize: Option<NotarizeConfig>,
163    /// Project metadata configuration (applied to metadata.json output files).
164    pub metadata: Option<MetadataConfig>,
165    /// Template files to render and include as release artifacts.
166    /// File contents are processed through the template engine.
167    pub template_files: Option<Vec<TemplateFileConfig>>,
168    /// GoReleaser Pro monorepo configuration.
169    /// When configured, tag discovery filters by tag_prefix and the working
170    /// directory is scoped to dir.
171    pub monorepo: Option<MonorepoConfig>,
172    /// Makeself self-extracting archive configurations.
173    #[serde(
174        default,
175        alias = "makeself",
176        deserialize_with = "deserialize_makeselfs"
177    )]
178    #[schemars(schema_with = "makeselfs_schema")]
179    pub makeselfs: Vec<MakeselfConfig>,
180    /// Source RPM configuration.
181    pub srpm: Option<SrpmConfig>,
182    /// Milestone closing configurations.
183    pub milestones: Option<Vec<MilestoneConfig>>,
184    /// Generic HTTP upload configurations.
185    pub uploads: Option<Vec<UploadConfig>>,
186    /// AUR source package publishing configurations (source-only PKGBUILD, not -bin).
187    pub aur_sources: Option<Vec<AurSourceConfig>>,
188}
189
190/// Helper schema function for the signs field (accepts object or array).
191fn signs_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
192    let mut schema = generator.subschema_for::<Vec<SignConfig>>();
193    if let schemars::schema::Schema::Object(ref mut obj) = schema {
194        obj.metadata().description = Some("Artifact signing configurations (cosign, GPG, etc.). Accepts a single object or array.".to_owned());
195    }
196    schema
197}
198
199/// Helper schema function for the upx field (accepts object or array).
200fn upx_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
201    let mut schema = generator.subschema_for::<Vec<UpxConfig>>();
202    if let schemars::schema::Schema::Object(ref mut obj) = schema {
203        obj.metadata().description = Some(
204            "UPX binary compression configurations. Accepts a single object or array.".to_owned(),
205        );
206    }
207    schema
208}
209
210/// Helper schema function for the sboms field (accepts object or array).
211fn sboms_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
212    let mut schema = generator.subschema_for::<Vec<SbomConfig>>();
213    if let schemars::schema::Schema::Object(ref mut obj) = schema {
214        obj.metadata().description =
215            Some("SBOM generation configurations. Accepts a single object or array.".to_owned());
216    }
217    schema
218}
219
220fn default_dist() -> PathBuf {
221    PathBuf::from("./dist")
222}
223
224impl Default for Config {
225    fn default() -> Self {
226        Config {
227            version: None,
228            project_name: String::new(),
229            dist: default_dist(),
230            includes: None,
231            env_files: None,
232            defaults: None,
233            before: None,
234            after: None,
235            crates: Vec::new(),
236            changelog: None,
237            signs: Vec::new(),
238            binary_signs: Vec::new(),
239            docker_signs: None,
240            upx: Vec::new(),
241            snapshot: None,
242            nightly: None,
243            announce: None,
244            report_sizes: None,
245            env: None,
246            variables: None,
247            publishers: None,
248            dockerhub: None,
249            artifactories: None,
250            cloudsmiths: None,
251            homebrew_casks: None,
252            tag: None,
253            git: None,
254            partial: None,
255            workspaces: None,
256            source: None,
257            sboms: Vec::new(),
258            release: None,
259            github_urls: None,
260            gitlab_urls: None,
261            gitea_urls: None,
262            force_token: None,
263            notarize: None,
264            metadata: None,
265            template_files: None,
266            monorepo: None,
267            makeselfs: Vec::new(),
268            srpm: None,
269            milestones: None,
270            uploads: None,
271            aur_sources: None,
272        }
273    }
274}
275
276impl Config {
277    /// Return the monorepo tag prefix, if configured.
278    ///
279    /// Shorthand for `config.monorepo.as_ref().and_then(|m| m.tag_prefix.as_deref())`.
280    pub fn monorepo_tag_prefix(&self) -> Option<&str> {
281        self.monorepo.as_ref().and_then(|m| m.tag_prefix.as_deref())
282    }
283
284    /// Return the monorepo working directory, if configured.
285    ///
286    /// Shorthand for `config.monorepo.as_ref().and_then(|m| m.dir.as_deref())`.
287    pub fn monorepo_dir(&self) -> Option<&str> {
288        self.monorepo.as_ref().and_then(|m| m.dir.as_deref())
289    }
290
291    // --- Project metadata defaulting helpers (GoReleaser Pro parity) ---
292    //
293    // Publishers that expose homepage/license/description/maintainer fields
294    // should fall back to these when their own field is unset, so a project
295    // only needs to declare metadata once. Pattern:
296    //
297    //   let homepage = nfpm_cfg.homepage
298    //       .as_deref()
299    //       .or_else(|| cfg.meta_homepage());
300    //
301    // Returns None if the `metadata` section is missing or the field is unset.
302
303    /// Project homepage from `metadata.homepage` (Pro default source for publishers).
304    pub fn meta_homepage(&self) -> Option<&str> {
305        self.metadata.as_ref().and_then(|m| m.homepage.as_deref())
306    }
307
308    /// Project license from `metadata.license`.
309    pub fn meta_license(&self) -> Option<&str> {
310        self.metadata.as_ref().and_then(|m| m.license.as_deref())
311    }
312
313    /// Project description from `metadata.description`.
314    pub fn meta_description(&self) -> Option<&str> {
315        self.metadata
316            .as_ref()
317            .and_then(|m| m.description.as_deref())
318    }
319
320    /// Project maintainers from `metadata.maintainers`.
321    pub fn meta_maintainers(&self) -> &[String] {
322        self.metadata
323            .as_ref()
324            .and_then(|m| m.maintainers.as_deref())
325            .unwrap_or(&[])
326    }
327
328    /// First maintainer as "Name <email>" or just "Name" (publisher convention).
329    /// Returns None when no maintainers are configured.
330    pub fn meta_first_maintainer(&self) -> Option<&str> {
331        self.meta_maintainers().first().map(|s| s.as_str())
332    }
333}
334
335/// Validate the config schema version. Accepts version 1 (default) and 2.
336/// Returns an error for unknown versions.
337pub fn validate_version(config: &Config) -> Result<(), String> {
338    match config.version {
339        None | Some(1) | Some(2) => Ok(()),
340        Some(v) => Err(format!(
341            "unsupported config version: {}. Supported versions are 1 and 2.",
342            v
343        )),
344    }
345}
346
347/// Validate `git.tag_sort` if present. Accepted values:
348/// - `"-version:refname"` (default, lexicographic version sort)
349/// - `"-version:creatordate"` (sort by tag creation date, newest first)
350///
351/// Returns an error for unrecognized values.
352pub fn validate_tag_sort(config: &Config) -> Result<(), String> {
353    if let Some(ref git) = config.git
354        && let Some(ref sort) = git.tag_sort
355    {
356        match sort.as_str() {
357            "-version:refname" | "-version:creatordate" => {}
358            other => {
359                return Err(format!(
360                    "unsupported git.tag_sort value: \"{}\". \
361                     Accepted values: \"-version:refname\", \"-version:creatordate\".",
362                    other
363                ));
364            }
365        }
366    }
367    Ok(())
368}
369
370/// Known GOOS values accepted by `archives[].format_overrides[].goos`.
371/// Mirrors the Go runtime's `runtime.GOOS` values GoReleaser's archive pipe
372/// recognises; anything outside this set is almost always a typo
373/// (e.g. a Rust target triple slice like `pc-windows-msvc`).
374const KNOWN_GOOS: &[&str] = &[
375    "aix",
376    "android",
377    "darwin",
378    "dragonfly",
379    "freebsd",
380    "illumos",
381    "ios",
382    "js",
383    "linux",
384    "netbsd",
385    "openbsd",
386    "plan9",
387    "solaris",
388    "wasip1",
389    "windows",
390];
391
392/// Validate that each crate's `release:` block configures at most one SCM
393/// backend. Matches GoReleaser release.go:41-53 `ErrMultipleReleases`, which
394/// errors at `Default()` time. Anodizer dispatches on `ctx.token_type` at
395/// runtime so a silently-ignored extra backend is easy to miss.
396pub fn validate_release_backends(config: &Config) -> Result<(), String> {
397    let check = |crate_name: &str, release: &ReleaseConfig| -> Result<(), String> {
398        let mut set = Vec::new();
399        if release.github.is_some() {
400            set.push("github");
401        }
402        if release.gitlab.is_some() {
403            set.push("gitlab");
404        }
405        if release.gitea.is_some() {
406            set.push("gitea");
407        }
408        if set.len() > 1 {
409            return Err(format!(
410                "crate {}: release config sets multiple mutually-exclusive SCM \
411                 backends ({}). Pick one.",
412                crate_name,
413                set.join(" + ")
414            ));
415        }
416        Ok(())
417    };
418    for krate in &config.crates {
419        if let Some(ref release) = krate.release {
420            check(&krate.name, release)?;
421        }
422    }
423    if let Some(ws_list) = config.workspaces.as_ref() {
424        for ws in ws_list {
425            for krate in &ws.crates {
426                if let Some(ref release) = krate.release {
427                    check(&krate.name, release)?;
428                }
429            }
430        }
431    }
432    Ok(())
433}
434
435/// Validate `archives[].format_overrides[].goos` values reject unknown OSes.
436/// GoReleaser silently no-ops unknown overrides, which has burned users typing
437/// Rust triples like `apple` or `pc-windows-msvc`.
438pub fn validate_format_overrides(config: &Config) -> Result<(), String> {
439    let check = |crate_name: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
440        for (idx, archive) in archives.iter().enumerate() {
441            let Some(ref overrides) = archive.format_overrides else {
442                continue;
443            };
444            for over in overrides {
445                if !KNOWN_GOOS.contains(&over.os.as_str()) {
446                    let archive_id = archive.id.as_deref().unwrap_or("default");
447                    return Err(format!(
448                        "crate {}: archives[{}] (id={}): format_overrides.goos=\"{}\" is not a recognised OS. \
449                         Accepted values: {}.",
450                        crate_name,
451                        idx,
452                        archive_id,
453                        over.os,
454                        KNOWN_GOOS.join(", ")
455                    ));
456                }
457            }
458        }
459        Ok(())
460    };
461    for krate in &config.crates {
462        if let ArchivesConfig::Configs(ref list) = krate.archives {
463            check(&krate.name, list)?;
464        }
465    }
466    if let Some(ws_list) = config.workspaces.as_ref() {
467        for ws in ws_list {
468            for krate in &ws.crates {
469                if let ArchivesConfig::Configs(ref list) = krate.archives {
470                    check(&krate.name, list)?;
471                }
472            }
473        }
474    }
475    Ok(())
476}
477
478// ---------------------------------------------------------------------------
479// EnvFilesConfig — accepts list of .env paths OR structured token file paths
480// ---------------------------------------------------------------------------
481
482/// Environment file configuration.
483///
484/// Accepts two forms:
485/// - **List form** (anodizer extension): array of `.env` file paths loaded as KEY=VALUE.
486///   ```yaml
487///   env_files:
488///     - .env
489///     - .release.env
490///   ```
491/// - **Struct form** (GoReleaser parity): paths to files containing provider tokens.
492///   ```yaml
493///   env_files:
494///     github_token: ~/.config/goreleaser/github_token
495///     gitlab_token: ~/.config/goreleaser/gitlab_token
496///     gitea_token: ~/.config/goreleaser/gitea_token
497///   ```
498#[derive(Debug, Clone, Serialize, JsonSchema)]
499#[serde(untagged)]
500pub enum EnvFilesConfig {
501    /// List of `.env` file paths to load (KEY=VALUE format).
502    List(Vec<String>),
503    /// Structured token file paths (GoReleaser parity).
504    TokenFiles(EnvFilesTokenConfig),
505}
506
507impl<'de> Deserialize<'de> for EnvFilesConfig {
508    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
509        let value = serde_yaml_ng::Value::deserialize(deserializer)?;
510        match &value {
511            serde_yaml_ng::Value::Sequence(_) => {
512                let list: Vec<String> =
513                    serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?;
514                Ok(EnvFilesConfig::List(list))
515            }
516            serde_yaml_ng::Value::Mapping(_) => {
517                let tokens: EnvFilesTokenConfig =
518                    serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?;
519                Ok(EnvFilesConfig::TokenFiles(tokens))
520            }
521            _ => Err(serde::de::Error::custom(
522                "env_files must be an array of file paths or a mapping with token file paths",
523            )),
524        }
525    }
526}
527
528impl EnvFilesConfig {
529    /// Returns the list of .env file paths if this is the List variant.
530    pub fn as_list(&self) -> Option<&[String]> {
531        match self {
532            EnvFilesConfig::List(files) => Some(files),
533            EnvFilesConfig::TokenFiles(_) => None,
534        }
535    }
536
537    /// Returns the token files config if this is the TokenFiles variant.
538    pub fn as_token_files(&self) -> Option<&EnvFilesTokenConfig> {
539        match self {
540            EnvFilesConfig::List(_) => None,
541            EnvFilesConfig::TokenFiles(tokens) => Some(tokens),
542        }
543    }
544}
545
546/// Structured token file paths for provider authentication.
547///
548/// Each field points to a file containing a single-line token. When present,
549/// the file is read and the corresponding environment variable is set
550/// (e.g., `github_token` file -> `GITHUB_TOKEN` env var).
551///
552/// Matches GoReleaser's `EnvFiles` struct.
553#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
554#[serde(default, deny_unknown_fields)]
555pub struct EnvFilesTokenConfig {
556    /// Path to file containing the GitHub token. Default: `~/.config/goreleaser/github_token`.
557    pub github_token: Option<String>,
558    /// Path to file containing the GitLab token. Default: `~/.config/goreleaser/gitlab_token`.
559    pub gitlab_token: Option<String>,
560    /// Path to file containing the Gitea token. Default: `~/.config/goreleaser/gitea_token`.
561    pub gitea_token: Option<String>,
562}
563
564/// Read a single token from a file, returning the first line trimmed.
565///
566/// Returns `Ok(None)` if the file does not exist.
567/// Returns `Err` if the file exists but cannot be read.
568pub fn read_token_file(path: &str) -> Result<Option<String>, String> {
569    // Expand ~ to home directory
570    let expanded = if let Some(suffix) = path.strip_prefix("~/") {
571        if let Ok(home) = std::env::var("HOME") {
572            format!("{}/{}", home, suffix)
573        } else {
574            path.to_string()
575        }
576    } else {
577        path.to_string()
578    };
579
580    match std::fs::read_to_string(&expanded) {
581        Ok(content) => {
582            let token = content.lines().next().unwrap_or("").trim().to_string();
583            if token.is_empty() {
584                Ok(None)
585            } else {
586                Ok(Some(token))
587            }
588        }
589        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
590        Err(e) => Err(format!("failed to read token file '{}': {}", path, e)),
591    }
592}
593
594/// Load tokens from structured `env_files` config.
595///
596/// For each configured token file path, reads the file and returns the
597/// corresponding environment variable name and token value.
598/// Falls back to GoReleaser defaults (`~/.config/goreleaser/...`) when
599/// a field is not specified.
600///
601/// Only returns entries where the corresponding process env var is NOT already
602/// set, matching GoReleaser's `loadEnv` behavior (env var takes precedence).
603pub fn load_token_files(
604    config: &EnvFilesTokenConfig,
605    log: &crate::log::StageLogger,
606) -> Result<std::collections::HashMap<String, String>, String> {
607    let mut vars = std::collections::HashMap::new();
608
609    // Per-token candidate paths. The user's explicit `github_token` / etc.
610    // config value wins if present; otherwise we try anodizer-native first,
611    // then the goreleaser-compat path for users migrating in.
612    let github_candidates: Vec<&str> = match config.github_token.as_deref() {
613        Some(p) => vec![p],
614        None => vec![
615            "~/.config/anodizer/github_token",
616            "~/.config/goreleaser/github_token",
617        ],
618    };
619    let gitlab_candidates: Vec<&str> = match config.gitlab_token.as_deref() {
620        Some(p) => vec![p],
621        None => vec![
622            "~/.config/anodizer/gitlab_token",
623            "~/.config/goreleaser/gitlab_token",
624        ],
625    };
626    let gitea_candidates: Vec<&str> = match config.gitea_token.as_deref() {
627        Some(p) => vec![p],
628        None => vec![
629            "~/.config/anodizer/gitea_token",
630            "~/.config/goreleaser/gitea_token",
631        ],
632    };
633    let mappings: [(&str, &[&str]); 3] = [
634        ("GITHUB_TOKEN", &github_candidates),
635        ("GITLAB_TOKEN", &gitlab_candidates),
636        ("GITEA_TOKEN", &gitea_candidates),
637    ];
638
639    for (env_name, candidates) in &mappings {
640        // Skip if the env var is already set in the process environment
641        if std::env::var(env_name)
642            .ok()
643            .filter(|v| !v.is_empty())
644            .is_some()
645        {
646            log.verbose(&format!("using {} from process environment", env_name));
647            continue;
648        }
649        for file_path in candidates.iter() {
650            match read_token_file(file_path) {
651                Ok(Some(token)) => {
652                    log.verbose(&format!("loaded {} from {}", env_name, file_path));
653                    vars.insert(env_name.to_string(), token);
654                    break;
655                }
656                Ok(None) => {
657                    // File doesn't exist or is empty — try next candidate
658                }
659                Err(e) => {
660                    return Err(e);
661                }
662            }
663        }
664    }
665
666    Ok(vars)
667}
668
669/// Load environment variables from .env-style files.
670/// Each file is read as KEY=VALUE lines. Lines starting with # and empty lines are skipped.
671/// Returns a HashMap of parsed key-value pairs. Does NOT mutate the process
672/// environment — callers should inject these into the template context via
673/// `set_env()` and pass them to subprocesses via `Command::envs()`.
674pub fn load_env_files(
675    files: &[String],
676    log: &crate::log::StageLogger,
677    strict: bool,
678) -> Result<std::collections::HashMap<String, String>, String> {
679    let mut vars = std::collections::HashMap::new();
680    for file_path in files {
681        let content = match std::fs::read_to_string(file_path) {
682            Ok(c) => c,
683            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
684                if strict {
685                    return Err(format!("env file '{}' not found (strict mode)", file_path));
686                }
687                log.warn(&format!("env file '{}' not found, skipping", file_path));
688                continue;
689            }
690            Err(e) => {
691                return Err(format!("failed to read env file '{}': {}", file_path, e));
692            }
693        };
694        for line in content.lines() {
695            let trimmed = line.trim();
696            if trimmed.is_empty() || trimmed.starts_with('#') {
697                continue;
698            }
699            // Strip `export ` prefix (common in .env files)
700            let trimmed = trimmed.strip_prefix("export ").unwrap_or(trimmed);
701            if let Some((key, value)) = trimmed.split_once('=') {
702                let key = key.trim();
703                if key.is_empty() {
704                    log.warn(&format!(
705                        "skipping line with empty key in '{}': {}",
706                        file_path,
707                        line.trim()
708                    ));
709                    continue;
710                }
711                let value = value.trim();
712                // Strip surrounding quotes from value if present
713                let value = if value.len() >= 2
714                    && ((value.starts_with('"') && value.ends_with('"'))
715                        || (value.starts_with('\'') && value.ends_with('\'')))
716                {
717                    &value[1..value.len() - 1]
718                } else {
719                    value
720                };
721                vars.insert(key.to_string(), value.to_string());
722            } else {
723                log.warn(&format!(
724                    "skipping line without '=' in '{}': {}",
725                    file_path, trimmed
726                ));
727            }
728        }
729    }
730    Ok(vars)
731}
732
733// ---------------------------------------------------------------------------
734// deserialize_env_map — accepts YAML mapping OR list-of-KEY=VALUE strings
735// ---------------------------------------------------------------------------
736
737/// Custom deserializer for `env` fields that accepts both forms:
738///
739/// - **Map form** (YAML mapping):
740///   ```yaml
741///   env:
742///     MY_VAR: hello
743///     DEPLOY_ENV: staging
744///   ```
745///
746/// - **List form** (GoReleaser parity — list of `KEY=VALUE` strings):
747///   ```yaml
748///   env:
749///     - MY_VAR=hello
750///     - DEPLOY_ENV=staging
751///   ```
752///
753/// Both forms are normalized to `Option<HashMap<String, String>>`.
754/// Lines without `=` in the list form are rejected with a deserialization error.
755fn deserialize_env_map<'de, D>(deserializer: D) -> Result<Option<HashMap<String, String>>, D::Error>
756where
757    D: Deserializer<'de>,
758{
759    use serde::de::{self, Visitor};
760
761    struct EnvMapVisitor;
762
763    impl<'de> Visitor<'de> for EnvMapVisitor {
764        type Value = Option<HashMap<String, String>>;
765
766        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
767            f.write_str("a mapping of env vars (KEY: VALUE) or a list of KEY=VALUE strings")
768        }
769
770        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
771            Ok(None)
772        }
773
774        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
775            Ok(None)
776        }
777
778        fn visit_map<M: de::MapAccess<'de>>(self, mut map: M) -> Result<Self::Value, M::Error> {
779            let mut result = HashMap::new();
780            while let Some((key, value)) = map.next_entry::<String, String>()? {
781                result.insert(key, value);
782            }
783            Ok(Some(result))
784        }
785
786        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
787            let mut result = HashMap::new();
788            while let Some(entry) = seq.next_element::<String>()? {
789                match entry.split_once('=') {
790                    Some((key, value)) => {
791                        let key = key.trim();
792                        if key.is_empty() {
793                            return Err(de::Error::custom(format!(
794                                "env list entry has empty key: {:?}",
795                                entry
796                            )));
797                        }
798                        result.insert(key.to_string(), value.to_string());
799                    }
800                    None => {
801                        return Err(de::Error::custom(format!(
802                            "env list entry must be KEY=VALUE, got: {:?}",
803                            entry
804                        )));
805                    }
806                }
807            }
808            Ok(Some(result))
809        }
810    }
811
812    deserializer.deserialize_any(EnvMapVisitor)
813}
814
815// ---------------------------------------------------------------------------
816// Defaults
817// ---------------------------------------------------------------------------
818
819#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
820#[serde(default)]
821pub struct Defaults {
822    /// Default build targets (e.g., ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin"]).
823    pub targets: Option<Vec<String>>,
824    /// Default cross-compilation strategy: auto, zigbuild, cross, or cargo.
825    pub cross: Option<CrossStrategy>,
826    /// Default extra flags passed to cargo build.
827    pub flags: Option<String>,
828    /// Default archive settings applied to all crates.
829    pub archives: Option<DefaultArchiveConfig>,
830    /// Default checksum settings applied to all crates.
831    pub checksum: Option<ChecksumConfig>,
832    /// Exclude specific os/arch combinations from builds.
833    pub ignore: Option<Vec<BuildIgnore>>,
834    /// Per-target overrides for env, flags, and features.
835    pub overrides: Option<Vec<BuildOverride>>,
836}
837
838#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
839#[serde(default)]
840pub struct DefaultArchiveConfig {
841    /// Default archive format for all crates: tar.gz, tar.xz, tar.zst, zip, or binary.
842    pub format: Option<String>,
843    /// Per-OS format overrides applied by default to all crates.
844    pub format_overrides: Option<Vec<FormatOverride>>,
845}
846
847// ---------------------------------------------------------------------------
848// BuildIgnore — exclude specific os/arch combos from builds
849// ---------------------------------------------------------------------------
850
851/// Exclude a specific os/arch combination from the build matrix.
852#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
853pub struct BuildIgnore {
854    /// Operating system to exclude (e.g., "linux", "darwin", "windows").
855    pub os: String,
856    /// Architecture to exclude (e.g., "amd64", "arm64", "386").
857    pub arch: String,
858}
859
860// ---------------------------------------------------------------------------
861// BuildOverride — per-target env, flags, features
862// ---------------------------------------------------------------------------
863
864/// Override env, flags, or features for targets matching glob patterns.
865#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
866#[serde(default)]
867pub struct BuildOverride {
868    /// Glob patterns to match against target triples (e.g., `["x86_64-*", "*-linux-*"]`).
869    pub targets: Vec<String>,
870    /// Extra environment variables to set for matching targets.
871    #[serde(default, deserialize_with = "deserialize_env_map")]
872    pub env: Option<HashMap<String, String>>,
873    /// Extra flags to append for matching targets.
874    pub flags: Option<String>,
875    /// Extra features to enable for matching targets.
876    pub features: Option<Vec<String>>,
877}
878
879// ---------------------------------------------------------------------------
880// CrossStrategy
881// ---------------------------------------------------------------------------
882
883#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
884#[serde(rename_all = "lowercase")]
885pub enum CrossStrategy {
886    Auto,
887    Zigbuild,
888    Cross,
889    Cargo,
890}
891
892// ---------------------------------------------------------------------------
893// CrateConfig
894// ---------------------------------------------------------------------------
895
896#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
897#[serde(default)]
898pub struct CrateConfig {
899    /// Crate name as published (must match the Cargo.toml package name).
900    pub name: String,
901    /// Relative path to the crate directory from the project root.
902    pub path: String,
903    /// Git tag template used to tag and identify releases (supports templates).
904    pub tag_template: String,
905    /// Pinned semver version. When set, `anodizer bump --strict` refuses to
906    /// edit this crate's `Cargo.toml` to anything other than this value;
907    /// without `--strict`, the bump proceeds with a warning. Lets a release
908    /// captain freeze a crate's version while still running broad
909    /// `--workspace` bumps.
910    pub version: Option<String>,
911    /// Other crates this crate depends on; ensures release ordering.
912    pub depends_on: Option<Vec<String>>,
913    /// Build configurations for this crate. One entry per binary by default.
914    pub builds: Option<Vec<BuildConfig>>,
915    /// Cross-compilation strategy for this crate: auto, zigbuild, cross, or cargo.
916    pub cross: Option<CrossStrategy>,
917    #[serde(default, deserialize_with = "deserialize_archives_config")]
918    #[schemars(schema_with = "archives_schema")]
919    pub archives: ArchivesConfig,
920    /// Checksum configuration for this crate.
921    pub checksum: Option<ChecksumConfig>,
922    /// GitHub release configuration for this crate.
923    pub release: Option<ReleaseConfig>,
924    /// Publishing targets (Homebrew, Scoop, AUR, etc.) for this crate.
925    pub publish: Option<PublishConfig>,
926    /// Docker image build configurations for this crate (legacy API).
927    pub docker: Option<Vec<DockerConfig>>,
928    /// Docker V2 image build configurations for this crate (newer API with images+tags, annotations, build_args, sbom, disable).
929    pub docker_v2: Option<Vec<DockerV2Config>>,
930    /// Docker image digest file configuration for this crate.
931    pub docker_digest: Option<DockerDigestConfig>,
932    /// Docker multi-platform manifest configurations for this crate.
933    pub docker_manifests: Option<Vec<DockerManifestConfig>>,
934    /// Linux package (deb, rpm, apk) configurations for this crate.
935    pub nfpm: Option<Vec<NfpmConfig>>,
936    /// Snapcraft package configurations for this crate.
937    pub snapcrafts: Option<Vec<SnapcraftConfig>>,
938    /// macOS DMG disk image configurations for this crate.
939    pub dmgs: Option<Vec<DmgConfig>>,
940    /// Windows MSI installer configurations for this crate.
941    pub msis: Option<Vec<MsiConfig>>,
942    /// macOS PKG installer configurations for this crate.
943    pub pkgs: Option<Vec<PkgConfig>>,
944    /// NSIS installer configurations for this crate.
945    pub nsis: Option<Vec<NsisConfig>>,
946    /// macOS app bundle configurations for this crate.
947    pub app_bundles: Option<Vec<AppBundleConfig>>,
948    /// Linux Flatpak bundle configurations for this crate.
949    pub flatpaks: Option<Vec<FlatpakConfig>>,
950    /// Cloud storage (S3/GCS/Azure) upload configurations for this crate.
951    pub blobs: Option<Vec<BlobConfig>>,
952    /// cargo-binstall metadata configuration for this crate.
953    pub binstall: Option<BinstallConfig>,
954    /// Automatic version number synchronization configuration for this crate.
955    pub version_sync: Option<VersionSyncConfig>,
956    /// macOS universal binary (fat binary) configurations for this crate.
957    pub universal_binaries: Option<Vec<UniversalBinaryConfig>>,
958    /// When true (or template evaluating to "true"), all build outputs are
959    /// placed in a flat `dist/` directory instead of `dist/{target}/`.
960    #[serde(default, deserialize_with = "deserialize_string_or_bool_opt")]
961    pub no_unique_dist_dir: Option<StringOrBool>,
962}
963
964/// Helper schema function for archives (accepts false or array).
965fn archives_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
966    let mut schema = generator.subschema_for::<Option<Vec<ArchiveConfig>>>();
967    if let schemars::schema::Schema::Object(ref mut obj) = schema {
968        obj.metadata().description = Some("Archive configurations for this crate. Set to false to disable archiving, or provide an array of archive configs.".to_owned());
969    }
970    schema
971}
972
973impl Default for CrateConfig {
974    fn default() -> Self {
975        CrateConfig {
976            name: String::new(),
977            path: String::new(),
978            tag_template: String::new(),
979            version: None,
980            depends_on: None,
981            builds: None,
982            cross: None,
983            archives: ArchivesConfig::Configs(vec![]),
984            checksum: None,
985            release: None,
986            publish: None,
987            docker: None,
988            docker_v2: None,
989            docker_digest: None,
990            docker_manifests: None,
991            nfpm: None,
992            snapcrafts: None,
993            dmgs: None,
994            msis: None,
995            pkgs: None,
996            nsis: None,
997            app_bundles: None,
998            flatpaks: None,
999            blobs: None,
1000            binstall: None,
1001            version_sync: None,
1002            universal_binaries: None,
1003            no_unique_dist_dir: None,
1004        }
1005    }
1006}
1007
1008// ---------------------------------------------------------------------------
1009// UniversalBinaryConfig
1010// ---------------------------------------------------------------------------
1011
1012#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1013#[serde(default)]
1014pub struct UniversalBinaryConfig {
1015    /// Unique identifier for this universal binary, propagated into the
1016    /// artifact's metadata as `id` (GoReleaser universalbinary.go:42-44).
1017    #[serde(default)]
1018    pub id: Option<String>,
1019    /// Output filename template for the universal binary (supports templates).
1020    pub name_template: Option<String>,
1021    /// When true, remove the individual arch binaries after creating the universal binary.
1022    pub replace: Option<bool>,
1023    /// Build IDs filter: only combine artifacts from builds whose `id` is in this list.
1024    pub ids: Option<Vec<String>>,
1025    /// Pre/post hooks around universal binary creation.
1026    pub hooks: Option<BuildHooksConfig>,
1027    /// Override the modification timestamp for reproducible universal binaries.
1028    pub mod_timestamp: Option<String>,
1029}
1030
1031// ---------------------------------------------------------------------------
1032// BuildConfig
1033// ---------------------------------------------------------------------------
1034
1035#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1036#[serde(default)]
1037pub struct BuildConfig {
1038    /// Unique identifier for this build, used to reference it from archives and other configs.
1039    pub id: Option<String>,
1040    /// Binary name to build (must match a Cargo binary target in the crate).
1041    pub binary: String,
1042    /// When true (or template evaluating to "true"), skip this build entirely.
1043    #[serde(default, deserialize_with = "deserialize_string_or_bool_opt")]
1044    pub skip: Option<StringOrBool>,
1045    /// Target triples to build for (overrides defaults.targets for this build).
1046    pub targets: Option<Vec<String>>,
1047    /// Cargo features to enable for this build.
1048    pub features: Option<Vec<String>>,
1049    /// When true, pass --no-default-features to cargo build.
1050    pub no_default_features: Option<bool>,
1051    /// Per-target environment variables keyed as {target: {KEY: VALUE}}.
1052    pub env: Option<HashMap<String, HashMap<String, String>>>,
1053    /// Copy the binary from another build ID instead of building it.
1054    pub copy_from: Option<String>,
1055    /// Extra flags passed to cargo build (e.g., "--locked").
1056    pub flags: Option<String>,
1057    /// When true, enable reproducible builds by stripping timestamps.
1058    pub reproducible: Option<bool>,
1059    /// Per-build hooks executed before and after compilation.
1060    pub hooks: Option<BuildHooksConfig>,
1061    /// Exclude specific os/arch combinations from this build's target matrix.
1062    /// Falls back to `defaults.ignore` when not set.
1063    pub ignore: Option<Vec<BuildIgnore>>,
1064    /// Per-target overrides for env, flags, and features for this build.
1065    /// Falls back to `defaults.overrides` when not set.
1066    pub overrides: Option<Vec<BuildOverride>>,
1067    /// Override the cross-compilation tool binary path (e.g., a custom `cross` wrapper).
1068    /// When set, this binary is used instead of cargo/cross/zigbuild.
1069    pub cross_tool: Option<String>,
1070    /// Override the modification timestamp of built binaries for reproducible builds.
1071    /// Template string (e.g. `"{{ .CommitTimestamp }}"`) or unix timestamp.
1072    pub mod_timestamp: Option<String>,
1073    /// Override the cargo subcommand (default: auto-detected "build" or "zigbuild").
1074    /// Enables e.g. `cargo auditable build` by setting `command: "auditable build"`.
1075    pub command: Option<String>,
1076    /// When true (or template evaluating to "true"), place binaries in flat dist/
1077    /// instead of dist/{target}/. Overrides the crate-level setting.
1078    #[serde(default, deserialize_with = "deserialize_string_or_bool_opt")]
1079    pub no_unique_dist_dir: Option<StringOrBool>,
1080}
1081
1082/// Pre/post hook configuration shared across multiple stages. Despite the
1083/// `Build` prefix in the name, this type is used by both the **build** stage
1084/// (pre/post compilation hooks) and the **archive** stage (pre/post archiving
1085/// hooks). The name is kept for backward compatibility with existing configs.
1086/// **Not** to be confused with the top-level `HooksConfig` (which carries a
1087/// flat `hooks: Vec<String>` list for `before`/`after` lifecycle hooks).
1088///
1089/// archive hooks in GoReleaser use `before`/`after`
1090/// (not `pre`/`post`). Anodizer accepts both spellings via serde aliases so
1091/// configs copied from GoReleaser docs don't silently drop their hooks.
1092#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1093#[serde(default)]
1094pub struct BuildHooksConfig {
1095    /// Commands to run before the build (or archive) step.
1096    #[serde(alias = "before")]
1097    pub pre: Option<Vec<HookEntry>>,
1098    /// Commands to run after the build (or archive) step.
1099    #[serde(alias = "after")]
1100    pub post: Option<Vec<HookEntry>>,
1101}
1102
1103/// Pre/post archive hook configuration.
1104///
1105/// Archive hooks in GoReleaser's YAML use `before`/`after` (not the
1106/// build-stage's `pre`/`post`). `ArchiveHooksConfig` serializes with those
1107/// field names so `dist/config.yaml` round-trips user-supplied spelling;
1108/// `pre`/`post` are accepted as aliases so migrated configs still parse.
1109#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1110#[serde(default)]
1111pub struct ArchiveHooksConfig {
1112    /// Commands to run before the archive step.
1113    #[serde(alias = "pre")]
1114    pub before: Option<Vec<HookEntry>>,
1115    /// Commands to run after the archive step.
1116    #[serde(alias = "post")]
1117    pub after: Option<Vec<HookEntry>>,
1118}
1119
1120// ---------------------------------------------------------------------------
1121// ArchivesConfig — untagged enum: false => Disabled, array => Configs
1122// ---------------------------------------------------------------------------
1123
1124#[derive(Debug, Clone, JsonSchema)]
1125pub enum ArchivesConfig {
1126    Disabled,
1127    Configs(Vec<ArchiveConfig>),
1128}
1129
1130impl Serialize for ArchivesConfig {
1131    fn serialize<S: serde::Serializer>(
1132        &self,
1133        serializer: S,
1134    ) -> std::result::Result<S::Ok, S::Error> {
1135        match self {
1136            ArchivesConfig::Disabled => serializer.serialize_bool(false),
1137            ArchivesConfig::Configs(configs) => configs.serialize(serializer),
1138        }
1139    }
1140}
1141
1142impl Default for ArchivesConfig {
1143    fn default() -> Self {
1144        ArchivesConfig::Configs(vec![])
1145    }
1146}
1147
1148/// Custom deserializer for ArchivesConfig.
1149/// Accepts:
1150///   - boolean `false`  → Disabled
1151///   - array            → Configs(...)
1152///   - missing/null     → Configs([])  (via serde default)
1153fn deserialize_archives_config<'de, D>(deserializer: D) -> Result<ArchivesConfig, D::Error>
1154where
1155    D: Deserializer<'de>,
1156{
1157    use serde::de::{self, Visitor};
1158
1159    struct ArchivesVisitor;
1160
1161    impl<'de> Visitor<'de> for ArchivesVisitor {
1162        type Value = ArchivesConfig;
1163
1164        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1165            f.write_str("false or a list of archive configs")
1166        }
1167
1168        fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
1169            if !v {
1170                Ok(ArchivesConfig::Disabled)
1171            } else {
1172                Err(E::custom(
1173                    "archives: true is not valid; use false or a list",
1174                ))
1175            }
1176        }
1177
1178        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
1179            let mut configs = Vec::new();
1180            while let Some(item) = seq.next_element::<ArchiveConfig>()? {
1181                configs.push(item);
1182            }
1183            Ok(ArchivesConfig::Configs(configs))
1184        }
1185
1186        // Handle YAML null / missing when serde calls the deserializer explicitly.
1187        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1188            Ok(ArchivesConfig::Configs(vec![]))
1189        }
1190
1191        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1192            Ok(ArchivesConfig::Configs(vec![]))
1193        }
1194    }
1195
1196    deserializer.deserialize_any(ArchivesVisitor)
1197}
1198
1199/// Custom deserializer for the `signs` / `sign` field.
1200/// Accepts:
1201///   - null/missing → empty vec (via serde default)
1202///   - a single object → vec of one SignConfig
1203///   - an array → vec of SignConfig
1204fn deserialize_signs<'de, D>(deserializer: D) -> Result<Vec<SignConfig>, D::Error>
1205where
1206    D: Deserializer<'de>,
1207{
1208    use serde::de::{self, Visitor};
1209
1210    struct SignsVisitor;
1211
1212    impl<'de> Visitor<'de> for SignsVisitor {
1213        type Value = Vec<SignConfig>;
1214
1215        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1216            f.write_str("a sign config object or an array of sign config objects")
1217        }
1218
1219        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
1220            let mut configs = Vec::new();
1221            while let Some(item) = seq.next_element::<SignConfig>()? {
1222                configs.push(item);
1223            }
1224            Ok(configs)
1225        }
1226
1227        fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<Self::Value, M::Error> {
1228            let config = SignConfig::deserialize(de::value::MapAccessDeserializer::new(map))?;
1229            Ok(vec![config])
1230        }
1231
1232        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1233            Ok(Vec::new())
1234        }
1235
1236        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1237            Ok(Vec::new())
1238        }
1239    }
1240
1241    deserializer.deserialize_any(SignsVisitor)
1242}
1243
1244// ---------------------------------------------------------------------------
1245// WrapInDirectory – accepts bool (true = default dir name) or string
1246// ---------------------------------------------------------------------------
1247
1248#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
1249#[serde(untagged)]
1250pub enum WrapInDirectory {
1251    Bool(bool),
1252    Name(String),
1253}
1254
1255impl<'de> serde::Deserialize<'de> for WrapInDirectory {
1256    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1257        let value = serde_yaml_ng::Value::deserialize(deserializer)?;
1258        match value {
1259            serde_yaml_ng::Value::Bool(b) => Ok(WrapInDirectory::Bool(b)),
1260            serde_yaml_ng::Value::String(s) => Ok(WrapInDirectory::Name(s)),
1261            _ => Err(serde::de::Error::custom("expected bool or string")),
1262        }
1263    }
1264}
1265
1266impl WrapInDirectory {
1267    /// Resolve the directory name to wrap archive contents in.
1268    ///
1269    /// When `true`, uses `default_name` (typically the archive stem).
1270    /// When `false` or an empty string, returns `None` (no wrapping).
1271    /// Otherwise returns the custom name.
1272    pub fn directory_name(&self, default_name: &str) -> Option<String> {
1273        match self {
1274            WrapInDirectory::Bool(true) => Some(default_name.to_string()),
1275            WrapInDirectory::Bool(false) => None,
1276            WrapInDirectory::Name(s) if s.is_empty() => None,
1277            WrapInDirectory::Name(s) => Some(s.clone()),
1278        }
1279    }
1280}
1281
1282// ---------------------------------------------------------------------------
1283// ArchiveConfig
1284// ---------------------------------------------------------------------------
1285
1286#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1287#[serde(default)]
1288pub struct ArchiveConfig {
1289    /// Unique identifier for cross-referencing this archive from other configs.
1290    pub id: Option<String>,
1291    /// Archive filename template (supports templates, e.g., "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}").
1292    pub name_template: Option<String>,
1293    /// Archive format: tar.gz, tar.xz, tar.zst, zip, or binary.
1294    pub format: Option<String>,
1295    /// Produce multiple archive formats per config (plural, in addition to singular `format`).
1296    pub formats: Option<Vec<String>>,
1297    /// Per-OS format overrides for this archive config.
1298    pub format_overrides: Option<Vec<FormatOverride>>,
1299    /// Extra files to include in the archive (glob patterns or detailed src/dst specs).
1300    pub files: Option<Vec<ArchiveFileSpec>>,
1301    /// Binary names to include (defaults to all binaries from matched builds).
1302    pub binaries: Option<Vec<String>>,
1303    /// When set, wrap archive contents in a top-level directory.
1304    /// Accepts `true` (use archive stem as directory name), `false` (no wrapping),
1305    /// or a string template for a custom directory name.
1306    pub wrap_in_directory: Option<WrapInDirectory>,
1307    /// Build IDs filter: only include artifacts from builds whose `id` is in this list.
1308    ///
1309    /// Also accepts the deprecated `builds` alias for GoReleaser pre-v1
1310    /// compatibility (consistent with nfpm + docker manifests).
1311    #[serde(alias = "builds")]
1312    pub ids: Option<Vec<String>>,
1313    /// When true, create archive with no binaries (metadata-only).
1314    pub meta: Option<bool>,
1315    /// File permissions applied to binaries in archives.
1316    pub builds_info: Option<ArchiveFileInfo>,
1317    /// Strip binary parent directory in archive (place binaries at archive root).
1318    pub strip_binary_directory: Option<bool>,
1319    /// Allow different binary counts across targets. Default false (warn on mismatch).
1320    pub allow_different_binary_count: Option<bool>,
1321    /// Pre/post archive hooks (`before`/`after`; `pre`/`post` accepted as aliases).
1322    pub hooks: Option<ArchiveHooksConfig>,
1323}
1324
1325#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1326pub struct FormatOverride {
1327    /// Operating system this override applies to (e.g., "windows", "darwin", "linux").
1328    /// GoReleaser uses `goos` as the YAML key; both `os` and `goos` are accepted.
1329    #[serde(alias = "goos")]
1330    pub os: String,
1331    /// Archive format override for this OS: tar.gz, tar.xz, tar.zst, zip, or binary.
1332    pub format: Option<String>,
1333    /// Plural format overrides (v2.6+). Takes priority over singular format.
1334    pub formats: Option<Vec<String>>,
1335}
1336
1337/// Specifies a file to include in archives. Can be a simple glob string or a
1338/// detailed object with src/dst/info fields for controlling archive placement
1339/// and file metadata.
1340///
1341/// NOTE: This is intentionally a separate type from [`ExtraFileSpec`] (used for
1342/// checksum/release extra_files). `ArchiveFileSpec` needs `src`/`dst`/`info`
1343/// fields for archive placement and file metadata (owner, group, mode, mtime),
1344/// while `ExtraFileSpec` needs `glob`/`name_template` for checksumming and
1345/// upload renaming. The fields and semantics are different enough that a unified
1346/// type would be confusing.
1347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1348#[serde(untagged)]
1349pub enum ArchiveFileSpec {
1350    Glob(String),
1351    Detailed {
1352        src: String,
1353        dst: Option<String>,
1354        info: Option<ArchiveFileInfo>,
1355        /// When true, strip the parent directory from the file path in the archive.
1356        strip_parent: Option<bool>,
1357    },
1358}
1359
1360impl PartialEq<&str> for ArchiveFileSpec {
1361    fn eq(&self, other: &&str) -> bool {
1362        match self {
1363            ArchiveFileSpec::Glob(s) => s.as_str() == *other,
1364            _ => false,
1365        }
1366    }
1367}
1368
1369/// Shared file metadata (owner, group, mode, mtime) used by both archive entries
1370/// and nFPM package contents. Previously duplicated as `ArchiveFileInfo` and
1371/// `NfpmFileInfo`; now unified.
1372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
1373#[serde(default)]
1374pub struct FileInfo {
1375    /// File owner name (e.g., "root").
1376    pub owner: Option<String>,
1377    /// File group name (e.g., "root").
1378    pub group: Option<String>,
1379    /// File permission mode in octal (e.g., "0755" or "0o755").
1380    pub mode: Option<String>,
1381    /// File modification time in RFC3339 format (e.g., "2024-01-01T00:00:00Z").
1382    pub mtime: Option<String>,
1383}
1384
1385/// Backward-compatible alias for archive code.
1386pub type ArchiveFileInfo = FileInfo;
1387
1388/// Parse an octal mode string into a `u32`, handling common YAML-friendly
1389/// representations: `"0755"`, `"0o755"`, `"0O755"`, `"755"`, and `"0"`.
1390pub fn parse_octal_mode(s: &str) -> Option<u32> {
1391    let cleaned = s
1392        .strip_prefix("0o")
1393        .or_else(|| s.strip_prefix("0O"))
1394        .unwrap_or(s);
1395    let cleaned = if cleaned.is_empty() { "0" } else { cleaned };
1396    u32::from_str_radix(cleaned, 8).ok()
1397}
1398
1399/// The set of archive format strings recognised by the archive stage.
1400/// Used for early validation so typos are caught at config load time rather
1401/// than mid-pipeline.
1402pub const VALID_ARCHIVE_FORMATS: &[&str] = &[
1403    "tar.gz", "tgz", "tar.xz", "txz", "tar.zst", "tzst", "tar", "zip", "gz", "binary", "none",
1404];
1405
1406// ---------------------------------------------------------------------------
1407// ChecksumConfig
1408// ---------------------------------------------------------------------------
1409
1410/// Specifies an extra file to include in checksums or release uploads. Can be a
1411/// simple glob string or a detailed object with glob and name_template fields.
1412///
1413/// See [`ArchiveFileSpec`] doc comment for why this is a separate type.
1414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1415#[serde(untagged)]
1416pub enum ExtraFileSpec {
1417    Glob(String),
1418    Detailed {
1419        glob: String,
1420        /// Optional override for the upload filename. Accepts `name_template` or
1421        /// `name` in YAML (aliased for ergonomics with older configs).
1422        #[serde(alias = "name", default)]
1423        name_template: Option<String>,
1424    },
1425}
1426
1427impl ExtraFileSpec {
1428    /// Return the glob pattern for this spec.
1429    pub fn glob(&self) -> &str {
1430        match self {
1431            ExtraFileSpec::Glob(s) => s,
1432            ExtraFileSpec::Detailed { glob, .. } => glob,
1433        }
1434    }
1435
1436    /// Return the optional name_template (only present in Detailed variant).
1437    pub fn name_template(&self) -> Option<&str> {
1438        match self {
1439            ExtraFileSpec::Glob(_) => None,
1440            ExtraFileSpec::Detailed { name_template, .. } => name_template.as_deref(),
1441        }
1442    }
1443}
1444
1445/// A file whose contents are rendered through the template engine before use.
1446/// Used by `templated_extra_files` across multiple stages (GoReleaser Pro feature).
1447#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq)]
1448#[serde(default)]
1449pub struct TemplatedExtraFile {
1450    /// Source template file path.
1451    pub src: String,
1452    /// Destination filename for the rendered output.
1453    /// Supports template variables (e.g. `"{{ .ProjectName }}-NOTES.txt"`).
1454    pub dst: Option<String>,
1455    /// File permissions in octal notation as a string, e.g. `"0755"`.
1456    /// Parsed at runtime via `parse_octal_mode()` to avoid YAML interpreting as decimal.
1457    pub mode: Option<String>,
1458}
1459
1460#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1461#[serde(default)]
1462pub struct ChecksumConfig {
1463    /// Checksum filename template (default: "{{ .ProjectName }}_{{ .Version }}_checksums.txt").
1464    pub name_template: Option<String>,
1465    /// Hash algorithm: sha256, sha512, sha1, md5, crc32 (default: sha256).
1466    pub algorithm: Option<String>,
1467    /// Disable checksums. Accepts bool or template string.
1468    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
1469    pub disable: Option<StringOrBool>,
1470    /// Extra files to include in the checksum file (beyond build artifacts).
1471    pub extra_files: Option<Vec<ExtraFileSpec>>,
1472    /// Extra files whose contents are rendered through the template engine before inclusion.
1473    /// Unlike `extra_files` which copy as-is, template variables like `{{ .Tag }}` are expanded.
1474    /// GoReleaser Pro feature.
1475    pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
1476    /// Build IDs filter: only checksum artifacts from builds whose `id` is in this list.
1477    pub ids: Option<Vec<String>>,
1478    /// When true, produce one checksum file per artifact instead of a combined file.
1479    pub split: Option<bool>,
1480}
1481
1482// ---------------------------------------------------------------------------
1483// ContentSource — inline string, from_file, or from_url
1484// ---------------------------------------------------------------------------
1485
1486/// A content source that can be an inline string, read from a file, or fetched
1487/// from a URL. Used for release header/footer values.
1488///
1489/// YAML examples:
1490///   header: "inline text"
1491///   header:
1492///     from_file: ./RELEASE_HEADER.md
1493///   header:
1494///     from_url: https://example.com/header.md
1495///   header:
1496///     from_url: https://example.com/header.md
1497///     headers:
1498///       X-API-Token: "{{ .Env.API_TOKEN }}"
1499///       Accept: "text/markdown"
1500///
1501/// Both `from_file` path and `from_url` URL are template-rendered before use.
1502/// Header values are template-rendered. (GoReleaser Pro parity.)
1503#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1504#[serde(untagged)]
1505pub enum ContentSource {
1506    Inline(String),
1507    FromFile {
1508        from_file: String,
1509    },
1510    FromUrl {
1511        from_url: String,
1512        /// Optional HTTP headers (value templates allowed). Enables private
1513        /// mirrors and authenticated endpoints.
1514        #[serde(default, skip_serializing_if = "Option::is_none")]
1515        headers: Option<HashMap<String, String>>,
1516    },
1517}
1518
1519impl PartialEq for ContentSource {
1520    fn eq(&self, other: &Self) -> bool {
1521        match (self, other) {
1522            (Self::Inline(a), Self::Inline(b)) => a == b,
1523            (Self::FromFile { from_file: a }, Self::FromFile { from_file: b }) => a == b,
1524            (
1525                Self::FromUrl {
1526                    from_url: a,
1527                    headers: ha,
1528                },
1529                Self::FromUrl {
1530                    from_url: b,
1531                    headers: hb,
1532                },
1533            ) => a == b && ha == hb,
1534            _ => false,
1535        }
1536    }
1537}
1538
1539// ---------------------------------------------------------------------------
1540// ReleaseConfig
1541// ---------------------------------------------------------------------------
1542
1543#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1544#[serde(default)]
1545pub struct ReleaseConfig {
1546    /// GitHub repository to release to (owner and name).
1547    pub github: Option<ScmRepoConfig>,
1548    /// GitLab repository to release to (owner and name).
1549    pub gitlab: Option<ScmRepoConfig>,
1550    /// Gitea repository to release to (owner and name).
1551    pub gitea: Option<ScmRepoConfig>,
1552    /// When true, create the release as a draft (unpublished).
1553    pub draft: Option<bool>,
1554    #[schemars(schema_with = "prerelease_schema")]
1555    /// Mark release as pre-release: true, false, or "auto" (inferred from tag).
1556    pub prerelease: Option<PrereleaseConfig>,
1557    #[schemars(schema_with = "make_latest_schema")]
1558    /// Mark release as latest: true, false, or "auto" (latest non-prerelease).
1559    pub make_latest: Option<MakeLatestConfig>,
1560    /// Release title template (supports templates).
1561    pub name_template: Option<String>,
1562    /// Text prepended to the release body (inline string, from_file, or from_url).
1563    pub header: Option<ContentSource>,
1564    /// Text appended to the release body (inline string, from_file, or from_url).
1565    pub footer: Option<ContentSource>,
1566    /// Extra files to upload to the release beyond build artifacts.
1567    pub extra_files: Option<Vec<ExtraFileSpec>>,
1568    /// Extra files whose contents are rendered through the template engine before upload.
1569    /// Unlike `extra_files` which copy as-is, template variables like `{{ .Tag }}` are expanded.
1570    /// GoReleaser Pro feature.
1571    pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
1572    /// Skip uploading artifacts: true, false, or "auto" (skip for snapshots).
1573    /// Accepts bool or template string (GoReleaser uses string type).
1574    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
1575    pub skip_upload: Option<StringOrBool>,
1576    /// When true, replace an existing draft release instead of failing.
1577    pub replace_existing_draft: Option<bool>,
1578    /// When true, replace existing release artifacts with the same name.
1579    pub replace_existing_artifacts: Option<bool>,
1580    /// Disable the release stage. Accepts bool or template string
1581    /// (e.g. `"{{ if IsSnapshot }}true{{ endif }}"` for conditional disable).
1582    /// GoReleaser supports template strings here since v1.15.0.
1583    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
1584    pub disable: Option<StringOrBool>,
1585    /// Release mode: "keep-existing", "append", "prepend", or "replace".
1586    pub mode: Option<String>,
1587    /// Artifact IDs filter for uploads.
1588    pub ids: Option<Vec<String>>,
1589    /// Target branch or SHA for the release tag.
1590    pub target_commitish: Option<String>,
1591    /// GitHub Discussion category name for the release.
1592    pub discussion_category_name: Option<String>,
1593    /// Upload metadata.json and artifacts.json as release assets.
1594    pub include_meta: Option<bool>,
1595    /// Reuse an existing draft release instead of creating a new one.
1596    pub use_existing_draft: Option<bool>,
1597    /// Override the release tag (template string). When set, this tag is used
1598    /// as the `tag_name` in the GitHub release API instead of the crate's
1599    /// `tag_template`. Useful in monorepo setups to strip a tag prefix
1600    /// (e.g. `"{{ .Tag }}"` to publish `v1.0.0` instead of `myapp/v1.0.0`).
1601    /// This is a GoReleaser Pro feature provided for free by anodizer.
1602    pub tag: Option<String>,
1603}
1604
1605/// Schema for prerelease: "auto" or boolean.
1606fn prerelease_schema(
1607    _generator: &mut schemars::r#gen::SchemaGenerator,
1608) -> schemars::schema::Schema {
1609    use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation};
1610    Schema::Object(SchemaObject {
1611        subschemas: Some(Box::new(SubschemaValidation {
1612            one_of: Some(vec![
1613                Schema::Object(SchemaObject {
1614                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
1615                    enum_values: Some(vec![serde_json::json!("auto")]),
1616                    ..Default::default()
1617                }),
1618                Schema::Object(SchemaObject {
1619                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Boolean))),
1620                    ..Default::default()
1621                }),
1622            ]),
1623            ..Default::default()
1624        })),
1625        ..Default::default()
1626    })
1627}
1628
1629/// Schema for make_latest: "auto" or boolean.
1630fn make_latest_schema(
1631    _generator: &mut schemars::r#gen::SchemaGenerator,
1632) -> schemars::schema::Schema {
1633    use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation};
1634    Schema::Object(SchemaObject {
1635        subschemas: Some(Box::new(SubschemaValidation {
1636            one_of: Some(vec![
1637                Schema::Object(SchemaObject {
1638                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
1639                    enum_values: Some(vec![serde_json::json!("auto")]),
1640                    ..Default::default()
1641                }),
1642                Schema::Object(SchemaObject {
1643                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Boolean))),
1644                    ..Default::default()
1645                }),
1646            ]),
1647            ..Default::default()
1648        })),
1649        ..Default::default()
1650    })
1651}
1652
1653/// Schema for skip_push: "auto" or boolean.
1654fn skip_push_schema(_generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
1655    use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation};
1656    Schema::Object(SchemaObject {
1657        subschemas: Some(Box::new(SubschemaValidation {
1658            one_of: Some(vec![
1659                Schema::Object(SchemaObject {
1660                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
1661                    enum_values: Some(vec![serde_json::json!("auto")]),
1662                    ..Default::default()
1663                }),
1664                Schema::Object(SchemaObject {
1665                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Boolean))),
1666                    ..Default::default()
1667                }),
1668            ]),
1669            ..Default::default()
1670        })),
1671        ..Default::default()
1672    })
1673}
1674
1675#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1676pub struct ScmRepoConfig {
1677    /// Repository owner (user or organization).
1678    pub owner: String,
1679    /// Repository name.
1680    pub name: String,
1681}
1682
1683/// Backward-compatible alias — existing code can continue to use `GitHubConfig`.
1684pub type GitHubConfig = ScmRepoConfig;
1685
1686// ---------------------------------------------------------------------------
1687// ForceTokenKind
1688// ---------------------------------------------------------------------------
1689
1690/// Which SCM token to force for authentication, overriding automatic detection.
1691#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1692#[serde(rename_all = "lowercase")]
1693pub enum ForceTokenKind {
1694    GitHub,
1695    GitLab,
1696    Gitea,
1697}
1698
1699// ---------------------------------------------------------------------------
1700// Platform URL configs (GitHub Enterprise, GitLab self-hosted, Gitea)
1701// ---------------------------------------------------------------------------
1702
1703/// Custom GitHub API/upload/download URLs for GitHub Enterprise installations.
1704/// Matches GoReleaser's `GitHubURLs` struct.
1705#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1706#[serde(default, deny_unknown_fields)]
1707pub struct GitHubUrlsConfig {
1708    /// GitHub API base URL (e.g. `https://github.example.com/api/v3/`).
1709    pub api: Option<String>,
1710    /// GitHub upload URL for release assets (e.g. `https://github.example.com/api/uploads/`).
1711    pub upload: Option<String>,
1712    /// GitHub download URL for release assets (e.g. `https://github.example.com/`).
1713    pub download: Option<String>,
1714    /// When true, skip TLS certificate verification for the custom URLs.
1715    pub skip_tls_verify: Option<bool>,
1716}
1717
1718/// Custom GitLab API/download URLs for self-hosted GitLab installations.
1719/// Matches GoReleaser's `GitLabURLs` struct.
1720#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1721#[serde(default, deny_unknown_fields)]
1722pub struct GitLabUrlsConfig {
1723    /// GitLab API base URL (e.g. `https://gitlab.example.com/api/v4/`).
1724    pub api: Option<String>,
1725    /// GitLab download URL for release assets.
1726    pub download: Option<String>,
1727    /// When true, skip TLS certificate verification for the custom URLs.
1728    pub skip_tls_verify: Option<bool>,
1729    /// When true, use the GitLab Package Registry for uploads instead of Generic Packages.
1730    pub use_package_registry: Option<bool>,
1731    /// When true, use the CI_JOB_TOKEN for authentication instead of a personal token.
1732    pub use_job_token: Option<bool>,
1733}
1734
1735/// Custom Gitea API/download URLs for self-hosted Gitea installations.
1736/// Matches GoReleaser's `GiteaURLs` struct.
1737#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1738#[serde(default, deny_unknown_fields)]
1739pub struct GiteaUrlsConfig {
1740    /// Gitea API base URL (e.g. `https://gitea.example.com/api/v1/`).
1741    pub api: Option<String>,
1742    /// Gitea download URL for release assets.
1743    pub download: Option<String>,
1744    /// When true, skip TLS certificate verification for the custom URLs.
1745    pub skip_tls_verify: Option<bool>,
1746}
1747
1748// ---------------------------------------------------------------------------
1749// "auto" | bool enum — shared serde implementation
1750// ---------------------------------------------------------------------------
1751
1752/// Generates `Serialize` and `Deserialize` impls for enums with `Auto` and
1753/// `Bool(bool)` variants that accept the string `"auto"` or a boolean in YAML.
1754macro_rules! impl_auto_or_bool_serde {
1755    ($ty:ty, $auto:path, $bool_variant:path) => {
1756        impl Serialize for $ty {
1757            fn serialize<S: serde::Serializer>(
1758                &self,
1759                serializer: S,
1760            ) -> std::result::Result<S::Ok, S::Error> {
1761                match self {
1762                    $auto => serializer.serialize_str("auto"),
1763                    $bool_variant(b) => serializer.serialize_bool(*b),
1764                }
1765            }
1766        }
1767
1768        impl<'de> Deserialize<'de> for $ty {
1769            fn deserialize<D: serde::Deserializer<'de>>(
1770                deserializer: D,
1771            ) -> std::result::Result<Self, D::Error> {
1772                struct Visitor;
1773                impl serde::de::Visitor<'_> for Visitor {
1774                    type Value = $ty;
1775                    fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1776                        write!(f, "\"auto\" or a boolean")
1777                    }
1778                    fn visit_bool<E: serde::de::Error>(
1779                        self,
1780                        v: bool,
1781                    ) -> std::result::Result<$ty, E> {
1782                        Ok($bool_variant(v))
1783                    }
1784                    fn visit_str<E: serde::de::Error>(
1785                        self,
1786                        v: &str,
1787                    ) -> std::result::Result<$ty, E> {
1788                        if v == "auto" {
1789                            Ok($auto)
1790                        } else {
1791                            Err(E::custom(format!("expected \"auto\", got \"{}\"", v)))
1792                        }
1793                    }
1794                }
1795                deserializer.deserialize_any(Visitor)
1796            }
1797        }
1798    };
1799}
1800
1801/// `prerelease` can be the string `"auto"` or a boolean.
1802#[derive(Debug, Clone, PartialEq, Eq)]
1803pub enum PrereleaseConfig {
1804    Auto,
1805    Bool(bool),
1806}
1807
1808impl_auto_or_bool_serde!(
1809    PrereleaseConfig,
1810    PrereleaseConfig::Auto,
1811    PrereleaseConfig::Bool
1812);
1813
1814/// `make_latest` can be the string `"auto"`, a boolean, or a template string.
1815/// GoReleaser renders this field through its template engine at publish time,
1816/// so we accept arbitrary strings (e.g. `"{{ if .IsSnapshot }}false{{ else }}true{{ end }}"`)
1817/// and defer resolution to the release stage.
1818#[derive(Debug, Clone, PartialEq, Eq)]
1819pub enum MakeLatestConfig {
1820    Auto,
1821    Bool(bool),
1822    /// An arbitrary template string to be rendered at publish time.
1823    String(String),
1824}
1825
1826impl Serialize for MakeLatestConfig {
1827    fn serialize<S: serde::Serializer>(
1828        &self,
1829        serializer: S,
1830    ) -> std::result::Result<S::Ok, S::Error> {
1831        match self {
1832            MakeLatestConfig::Auto => serializer.serialize_str("auto"),
1833            MakeLatestConfig::Bool(b) => serializer.serialize_bool(*b),
1834            MakeLatestConfig::String(s) => serializer.serialize_str(s),
1835        }
1836    }
1837}
1838
1839impl<'de> Deserialize<'de> for MakeLatestConfig {
1840    fn deserialize<D: serde::Deserializer<'de>>(
1841        deserializer: D,
1842    ) -> std::result::Result<Self, D::Error> {
1843        struct Visitor;
1844        impl serde::de::Visitor<'_> for Visitor {
1845            type Value = MakeLatestConfig;
1846            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1847                write!(f, "\"auto\", a boolean, or a template string")
1848            }
1849            fn visit_bool<E: serde::de::Error>(
1850                self,
1851                v: bool,
1852            ) -> std::result::Result<MakeLatestConfig, E> {
1853                Ok(MakeLatestConfig::Bool(v))
1854            }
1855            fn visit_str<E: serde::de::Error>(
1856                self,
1857                v: &str,
1858            ) -> std::result::Result<MakeLatestConfig, E> {
1859                match v {
1860                    "auto" => Ok(MakeLatestConfig::Auto),
1861                    "true" => Ok(MakeLatestConfig::Bool(true)),
1862                    "false" => Ok(MakeLatestConfig::Bool(false)),
1863                    other => Ok(MakeLatestConfig::String(other.to_string())),
1864                }
1865            }
1866        }
1867        deserializer.deserialize_any(Visitor)
1868    }
1869}
1870
1871/// `skip_push` can be `"auto"` (skip for prereleases), a boolean, or a template string.
1872/// GoReleaser accepts template expressions like `"{{ if .IsSnapshot }}true{{ end }}"`.
1873#[derive(Debug, Clone, PartialEq, Eq)]
1874pub enum SkipPushConfig {
1875    Auto,
1876    Bool(bool),
1877    /// Arbitrary template string — rendered at runtime, truthy result means skip push.
1878    Template(String),
1879}
1880
1881impl Serialize for SkipPushConfig {
1882    fn serialize<S: serde::Serializer>(
1883        &self,
1884        serializer: S,
1885    ) -> std::result::Result<S::Ok, S::Error> {
1886        match self {
1887            SkipPushConfig::Auto => serializer.serialize_str("auto"),
1888            SkipPushConfig::Bool(b) => serializer.serialize_bool(*b),
1889            SkipPushConfig::Template(s) => serializer.serialize_str(s),
1890        }
1891    }
1892}
1893
1894impl<'de> Deserialize<'de> for SkipPushConfig {
1895    fn deserialize<D: serde::Deserializer<'de>>(
1896        deserializer: D,
1897    ) -> std::result::Result<Self, D::Error> {
1898        struct Visitor;
1899        impl serde::de::Visitor<'_> for Visitor {
1900            type Value = SkipPushConfig;
1901            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1902                write!(f, "\"auto\", a boolean, or a template string")
1903            }
1904            fn visit_bool<E: serde::de::Error>(
1905                self,
1906                v: bool,
1907            ) -> std::result::Result<SkipPushConfig, E> {
1908                Ok(SkipPushConfig::Bool(v))
1909            }
1910            fn visit_str<E: serde::de::Error>(
1911                self,
1912                v: &str,
1913            ) -> std::result::Result<SkipPushConfig, E> {
1914                match v {
1915                    "auto" => Ok(SkipPushConfig::Auto),
1916                    "true" => Ok(SkipPushConfig::Bool(true)),
1917                    "false" => Ok(SkipPushConfig::Bool(false)),
1918                    other => Ok(SkipPushConfig::Template(other.to_string())),
1919                }
1920            }
1921        }
1922        deserializer.deserialize_any(Visitor)
1923    }
1924}
1925
1926// ---------------------------------------------------------------------------
1927// Shared publisher config types: RepositoryConfig, CommitAuthorConfig
1928// ---------------------------------------------------------------------------
1929
1930/// Shared repository configuration used by all git-based publishers
1931/// (Homebrew, Scoop, Winget, Krew, Nix). Equivalent to GoReleaser's `RepoRef`.
1932#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1933#[serde(default)]
1934pub struct RepositoryConfig {
1935    /// Repository owner (GitHub user or organization).
1936    pub owner: Option<String>,
1937    /// Repository name.
1938    pub name: Option<String>,
1939    /// Auth token for the repository. Falls back to env-based resolution.
1940    pub token: Option<String>,
1941    /// Token type: "github" (default), "gitlab", "gitea".
1942    pub token_type: Option<String>,
1943    /// Branch to push to (default: repo default branch).
1944    pub branch: Option<String>,
1945    /// Git-specific settings for SSH-based publishing.
1946    pub git: Option<GitRepoConfig>,
1947    /// Pull request settings for fork-based workflows.
1948    pub pull_request: Option<PullRequestConfig>,
1949}
1950
1951/// Git-specific repository settings for SSH-based publishing.
1952#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1953#[serde(default)]
1954pub struct GitRepoConfig {
1955    /// Git URL (e.g. `ssh://git@github.com/owner/repo.git`).
1956    pub url: Option<String>,
1957    /// Custom SSH command (e.g. `ssh -i /path/to/key`).
1958    pub ssh_command: Option<String>,
1959    /// Path to SSH private key file.
1960    pub private_key: Option<String>,
1961}
1962
1963/// Pull request configuration for fork-based publisher workflows.
1964#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1965#[serde(default)]
1966pub struct PullRequestConfig {
1967    /// Enable PR creation instead of direct push.
1968    pub enabled: Option<bool>,
1969    /// Create PR as draft.
1970    pub draft: Option<bool>,
1971    /// Body text for the pull request.
1972    pub body: Option<String>,
1973    /// Target base repository/branch for the PR.
1974    pub base: Option<PullRequestBaseConfig>,
1975}
1976
1977/// Target base for pull requests (upstream repo to PR against).
1978#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1979#[serde(default)]
1980pub struct PullRequestBaseConfig {
1981    /// Owner of the upstream repository to PR against.
1982    pub owner: Option<String>,
1983    /// Name of the upstream repository to PR against.
1984    pub name: Option<String>,
1985    /// Base branch of the upstream repository to target with the PR.
1986    pub branch: Option<String>,
1987}
1988
1989/// Shared commit author configuration with optional GPG/SSH signing.
1990/// Equivalent to GoReleaser's `CommitAuthor`.
1991#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
1992#[serde(default)]
1993pub struct CommitAuthorConfig {
1994    /// Git commit author display name.
1995    pub name: Option<String>,
1996    /// Git commit author email address.
1997    pub email: Option<String>,
1998    /// Commit signing configuration.
1999    pub signing: Option<CommitSigningConfig>,
2000}
2001
2002impl CommitAuthorConfig {
2003    /// Fill in the anodizer default name/email when either field is empty.
2004    /// Matches GoReleaser's `commitauthor.Default(brew.CommitAuthor)` which
2005    /// runs during the Default pass — so validation messages that reference
2006    /// commit-author identity see non-empty strings rather than blanks.
2007    pub fn normalize_defaults(&mut self) {
2008        if self.name.as_deref().is_none_or(str::is_empty) {
2009            self.name = Some("anodizer".to_string());
2010        }
2011        if self.email.as_deref().is_none_or(str::is_empty) {
2012            self.email = Some("bot@anodizer.dev".to_string());
2013        }
2014    }
2015}
2016
2017/// Commit signing configuration (GPG, x509, or SSH).
2018#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2019#[serde(default)]
2020pub struct CommitSigningConfig {
2021    /// Enable commit signing.
2022    pub enabled: Option<bool>,
2023    /// Signing key identifier.
2024    pub key: Option<String>,
2025    /// Signing program (e.g. `gpg`, `gpg2`).
2026    pub program: Option<String>,
2027    /// Signing format: "openpgp" (default), "x509", or "ssh".
2028    pub format: Option<String>,
2029}
2030
2031// ---------------------------------------------------------------------------
2032// PublishConfig
2033// ---------------------------------------------------------------------------
2034
2035#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2036#[serde(default)]
2037pub struct PublishConfig {
2038    #[schemars(schema_with = "crates_publish_schema")]
2039    /// Publish to crates.io: true/false or object with enabled and index_timeout fields.
2040    pub crates: Option<CratesPublishConfig>,
2041    /// Homebrew formula publishing configuration.
2042    pub homebrew: Option<HomebrewConfig>,
2043    /// Scoop manifest publishing configuration.
2044    pub scoop: Option<ScoopConfig>,
2045    /// Chocolatey package publishing configuration.
2046    pub chocolatey: Option<ChocolateyConfig>,
2047    /// WinGet manifest publishing configuration.
2048    pub winget: Option<WingetConfig>,
2049    /// AUR (Arch User Repository) binary package publishing configuration.
2050    pub aur: Option<AurConfig>,
2051    /// AUR source package publishing configuration (source-only PKGBUILD, not -bin).
2052    pub aur_source: Option<AurSourceConfig>,
2053    /// Krew (kubectl plugin manager) manifest publishing configuration.
2054    pub krew: Option<KrewConfig>,
2055    /// Nix derivation publishing configuration.
2056    pub nix: Option<NixConfig>,
2057}
2058
2059/// Schema for crates publish config (bool or object).
2060fn crates_publish_schema(
2061    _generator: &mut schemars::r#gen::SchemaGenerator,
2062) -> schemars::schema::Schema {
2063    schemars::schema::Schema::Bool(true)
2064}
2065
2066impl PublishConfig {
2067    pub fn crates_config(&self) -> CratesPublishSettings {
2068        match &self.crates {
2069            None => CratesPublishSettings::default(),
2070            Some(CratesPublishConfig::Bool(enabled)) => CratesPublishSettings {
2071                enabled: *enabled,
2072                index_timeout: 300,
2073            },
2074            Some(CratesPublishConfig::Object {
2075                enabled,
2076                index_timeout,
2077            }) => CratesPublishSettings {
2078                enabled: *enabled,
2079                index_timeout: *index_timeout,
2080            },
2081        }
2082    }
2083}
2084
2085/// The `crates` field inside `publish` accepts either a bool or an object.
2086#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2087#[serde(untagged)]
2088pub enum CratesPublishConfig {
2089    Bool(bool),
2090    Object {
2091        enabled: bool,
2092        #[serde(default = "default_index_timeout")]
2093        index_timeout: u64,
2094    },
2095}
2096
2097fn default_index_timeout() -> u64 {
2098    300
2099}
2100
2101/// Resolved settings after interpreting `CratesPublishConfig`.
2102#[derive(Debug, Clone)]
2103pub struct CratesPublishSettings {
2104    pub enabled: bool,
2105    pub index_timeout: u64,
2106}
2107
2108impl Default for CratesPublishSettings {
2109    fn default() -> Self {
2110        CratesPublishSettings {
2111            enabled: false,
2112            index_timeout: 300,
2113        }
2114    }
2115}
2116
2117// ---------------------------------------------------------------------------
2118// HomebrewConfig / ScoopConfig / TapConfig / BucketConfig
2119// ---------------------------------------------------------------------------
2120
2121#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2122#[serde(default)]
2123pub struct HomebrewConfig {
2124    /// Legacy tap config (owner/name). Prefer `repository` for new configs.
2125    pub tap: Option<TapConfig>,
2126    /// Unified repository config with branch, token, PR, git SSH support.
2127    pub repository: Option<RepositoryConfig>,
2128    /// Commit author with optional signing.
2129    pub commit_author: Option<CommitAuthorConfig>,
2130    /// Formula directory in the tap (e.g. "Formula"). Matches GoReleaser `directory`.
2131    pub directory: Option<String>,
2132    /// Override the formula name (default: crate name).
2133    pub name: Option<String>,
2134    /// Short description of the formula (shown in `brew info`).
2135    pub description: Option<String>,
2136    /// SPDX license identifier (e.g., "MIT", "Apache-2.0").
2137    pub license: Option<String>,
2138    /// Ruby `install` block content for the formula.
2139    pub install: Option<String>,
2140    /// Additional install commands appended after the main install block.
2141    pub extra_install: Option<String>,
2142    /// Post-install commands (separate `def post_install` block in formula).
2143    pub post_install: Option<String>,
2144    /// Ruby `test` block content for the formula (run by `brew test`).
2145    pub test: Option<String>,
2146    /// Project homepage URL. Falls back to the GitHub release URL when unset.
2147    pub homepage: Option<String>,
2148    /// Package dependencies (e.g. `openssl`, `libgit2`).
2149    pub dependencies: Option<Vec<HomebrewDependency>>,
2150    /// Conflicting formula names with optional reason.
2151    pub conflicts: Option<Vec<HomebrewConflict>>,
2152    /// Post-install user-facing notes shown by `brew info`.
2153    pub caveats: Option<String>,
2154    /// Skip publishing the formula.  `"true"` always skips; `"auto"` skips
2155    /// for prerelease versions. Accepts bool or template string.
2156    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
2157    pub skip_upload: Option<StringOrBool>,
2158    /// Custom commit message template.  Rendered via Tera with `name` and
2159    /// `version` variables.  Defaults to `"chore: update {{ name }} formula to {{ version }}"`.
2160    pub commit_msg_template: Option<String>,
2161    /// Git commit author name for tap updates (legacy; prefer `commit_author`).
2162    pub commit_author_name: Option<String>,
2163    /// Git commit author email for tap updates (legacy; prefer `commit_author`).
2164    pub commit_author_email: Option<String>,
2165    /// Build IDs filter: only include artifacts whose `id` is in this list.
2166    pub ids: Option<Vec<String>>,
2167    /// Custom URL template for download URLs (overrides release URL).
2168    pub url_template: Option<String>,
2169    /// HTTP headers to include in download requests (e.g. for private repos).
2170    pub url_headers: Option<Vec<String>>,
2171    /// Custom download strategy class name (e.g. `:using => GitHubPrivateRepositoryReleaseDownloadStrategy`).
2172    pub download_strategy: Option<String>,
2173    /// Ruby `require` statement for custom download strategies.
2174    pub custom_require: Option<String>,
2175    /// Custom Ruby code block inserted into the formula class body.
2176    pub custom_block: Option<String>,
2177    /// Launchd plist content for `brew services`.
2178    pub plist: Option<String>,
2179    /// Homebrew service block content (alternative to plist).
2180    pub service: Option<String>,
2181    /// Homebrew Cask configuration (macOS .app bundles).
2182    pub cask: Option<HomebrewCaskConfig>,
2183    /// amd64 microarchitecture variant filter (e.g. "v1", "v2", "v3", "v4").
2184    /// Only artifacts matching this variant are included. Default: "v1".
2185    #[serde(alias = "goamd64")]
2186    pub amd64_variant: Option<String>,
2187    /// ARM version filter (e.g. "6", "7"). Only artifacts matching this
2188    /// variant are included.
2189    #[serde(alias = "goarm")]
2190    pub arm_variant: Option<String>,
2191}
2192
2193#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
2194#[serde(default)]
2195pub struct HomebrewDependency {
2196    /// Homebrew formula name of the dependency.
2197    pub name: String,
2198    /// Restrict to a specific OS: `"mac"` or `"linux"`.
2199    #[serde(skip_serializing_if = "Option::is_none")]
2200    pub os: Option<String>,
2201    /// Dependency type, e.g. `"optional"`.
2202    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
2203    pub dep_type: Option<String>,
2204    /// Version constraint for the dependency (e.g. `">= 1.1"`).
2205    #[serde(skip_serializing_if = "Option::is_none")]
2206    pub version: Option<String>,
2207}
2208
2209/// A Homebrew conflict entry, supporting both a bare name string and a
2210/// structured object with an optional `because` reason.
2211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
2212#[serde(untagged)]
2213pub enum HomebrewConflict {
2214    /// Just the formula name (e.g. `"other-tool"`).
2215    Name(String),
2216    /// Name with reason (e.g. `{name: "other-tool", because: "both install a bin/foo binary"}`).
2217    WithReason {
2218        name: String,
2219        #[serde(skip_serializing_if = "Option::is_none")]
2220        because: Option<String>,
2221    },
2222}
2223
2224impl HomebrewConflict {
2225    pub fn name(&self) -> &str {
2226        match self {
2227            Self::Name(n) => n,
2228            Self::WithReason { name, .. } => name,
2229        }
2230    }
2231    pub fn because(&self) -> Option<&str> {
2232        match self {
2233            Self::Name(_) => None,
2234            Self::WithReason { because, .. } => because.as_deref(),
2235        }
2236    }
2237}
2238
2239/// Homebrew Cask configuration for macOS .app bundles.
2240#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2241#[serde(default)]
2242pub struct HomebrewCaskConfig {
2243    /// Override the cask name (default: crate name).
2244    pub name: Option<String>,
2245    /// Alternative cask names (aliases).
2246    pub alternative_names: Option<Vec<String>>,
2247    /// macOS .app bundle name (e.g. "MyApp.app").
2248    pub app: Option<String>,
2249    /// Binary stubs to create in /usr/local/bin (paths inside the .app bundle).
2250    pub binaries: Option<Vec<String>>,
2251    /// Cask description.
2252    pub description: Option<String>,
2253    /// Project homepage URL.
2254    pub homepage: Option<String>,
2255    /// URL template for the .dmg/.zip download.
2256    pub url_template: Option<String>,
2257    /// Custom caveats shown after install.
2258    pub caveats: Option<String>,
2259    /// Zap stanza for complete uninstall cleanup.
2260    pub zap: Option<Vec<String>>,
2261    /// Uninstall stanza directives.
2262    pub uninstall: Option<Vec<String>>,
2263    /// Arbitrary Ruby code inserted into the cask block.
2264    pub custom_block: Option<String>,
2265    /// Homebrew service definition.
2266    pub service: Option<String>,
2267    /// License identifier (SPDX).
2268    pub license: Option<String>,
2269    /// Manual page references to install.
2270    pub manpages: Option<Vec<String>>,
2271    /// Shell completion definitions.
2272    pub completions: Option<HomebrewCaskCompletions>,
2273    /// Cask dependencies (other casks or formulae).
2274    pub dependencies: Option<Vec<HomebrewCaskDependencyEntry>>,
2275    /// Conflicting casks or formulae.
2276    pub conflicts: Option<Vec<HomebrewCaskConflictEntry>>,
2277    /// Pre/post install/uninstall hooks.
2278    pub hooks: Option<HomebrewCaskHooks>,
2279}
2280
2281// ---------------------------------------------------------------------------
2282// Top-level Homebrew Cask config (GoReleaser `homebrew_casks` parity)
2283// ---------------------------------------------------------------------------
2284
2285/// Top-level Homebrew Cask configuration.
2286/// GoReleaser has `homebrew_casks` as a top-level config array with its own
2287/// repository, commit_author, directory, skip_upload, etc.
2288#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2289#[serde(default)]
2290pub struct TopLevelHomebrewCaskConfig {
2291    /// Cask name (default: project name).
2292    pub name: Option<String>,
2293    /// Unified repository config for the Homebrew tap.
2294    pub repository: Option<RepositoryConfig>,
2295    /// Commit author with optional signing.
2296    pub commit_author: Option<CommitAuthorConfig>,
2297    /// Custom commit message template.
2298    /// Default: "Brew cask update for {{ .ProjectName }} version {{ .Tag }}"
2299    pub commit_msg_template: Option<String>,
2300    /// Subdirectory in the tap repo for cask placement (default: "Casks").
2301    pub directory: Option<String>,
2302    /// Cask description.
2303    pub description: Option<String>,
2304    /// Project homepage URL.
2305    pub homepage: Option<String>,
2306    /// Skip publishing the cask. `"true"` always skips; `"auto"` skips
2307    /// for prerelease versions. Accepts bool or template string.
2308    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
2309    pub skip_upload: Option<StringOrBool>,
2310    /// Custom Ruby code block inserted into the cask definition.
2311    pub custom_block: Option<String>,
2312    /// Build IDs filter: only include artifacts from builds whose `id` is in this list.
2313    pub ids: Option<Vec<String>>,
2314    /// Homebrew service block content.
2315    pub service: Option<String>,
2316    /// Binary stubs to create in /usr/local/bin.
2317    pub binaries: Option<Vec<String>>,
2318    /// Manpage file paths (glob patterns supported).
2319    pub manpages: Option<Vec<String>>,
2320    /// Custom caveats shown after install.
2321    pub caveats: Option<String>,
2322    /// SPDX license identifier.
2323    pub license: Option<String>,
2324    /// Download URL configuration.
2325    pub url: Option<HomebrewCaskURL>,
2326    /// Shell completion file paths.
2327    pub completions: Option<HomebrewCaskCompletions>,
2328    /// Cask/formula dependencies.
2329    pub dependencies: Option<Vec<HomebrewCaskDependencyEntry>>,
2330    /// Conflicting casks/formulas.
2331    pub conflicts: Option<Vec<HomebrewCaskConflictEntry>>,
2332    /// Pre/post install/uninstall hooks.
2333    pub hooks: Option<HomebrewCaskHooks>,
2334    /// Uninstall stanza configuration.
2335    pub uninstall: Option<HomebrewCaskUninstall>,
2336    /// Deep uninstall (zap) stanza configuration.
2337    pub zap: Option<HomebrewCaskUninstall>,
2338    /// Auto-generate shell completions from an executable.
2339    pub generate_completions_from_executable: Option<HomebrewCaskGeneratedCompletions>,
2340    /// macOS .app bundle name (e.g. "MyApp.app").
2341    pub app: Option<String>,
2342    /// Alternative cask names (aliases).
2343    pub alternative_names: Option<Vec<String>>,
2344}
2345
2346/// Structured URL configuration for Homebrew Cask downloads.
2347#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2348#[serde(default)]
2349pub struct HomebrewCaskURL {
2350    /// URL template for the download.
2351    pub template: Option<String>,
2352    /// Verification string (domain shown to user).
2353    pub verified: Option<String>,
2354    /// Custom downloader (e.g. `:homebrew_curl`, `:post`).
2355    pub using: Option<String>,
2356    /// HTTP cookies for the download.
2357    pub cookies: Option<HashMap<String, String>>,
2358    /// Referer header for the download.
2359    pub referer: Option<String>,
2360    /// Custom HTTP headers.
2361    pub headers: Option<Vec<String>>,
2362    /// Custom user agent string.
2363    pub user_agent: Option<String>,
2364    /// POST data for form submissions.
2365    pub data: Option<HashMap<String, String>>,
2366}
2367
2368/// Structured uninstall/zap configuration for Homebrew Cask.
2369/// Used for both `uninstall` and `zap` stanzas.
2370#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2371#[serde(default)]
2372pub struct HomebrewCaskUninstall {
2373    /// Launch daemon/agent identifiers to stop.
2374    pub launchctl: Option<Vec<String>>,
2375    /// Application bundle IDs to quit.
2376    pub quit: Option<Vec<String>>,
2377    /// Login item names to remove.
2378    pub login_item: Option<Vec<String>>,
2379    /// File paths to delete.
2380    pub delete: Option<Vec<String>>,
2381    /// File paths to trash (preserves app state).
2382    pub trash: Option<Vec<String>>,
2383}
2384
2385/// Pre/post install/uninstall hooks for Homebrew Cask.
2386#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2387#[serde(default)]
2388pub struct HomebrewCaskHooks {
2389    /// Pre-install/uninstall hooks.
2390    pub pre: Option<HomebrewCaskHook>,
2391    /// Post-install/uninstall hooks.
2392    pub post: Option<HomebrewCaskHook>,
2393}
2394
2395/// Individual hook for install/uninstall phases.
2396#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2397#[serde(default)]
2398pub struct HomebrewCaskHook {
2399    /// Ruby code for preflight/postflight during install.
2400    pub install: Option<String>,
2401    /// Ruby code for uninstall_preflight/uninstall_postflight.
2402    pub uninstall: Option<String>,
2403}
2404
2405/// Shell completion file paths for Homebrew Cask.
2406#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2407#[serde(default)]
2408pub struct HomebrewCaskCompletions {
2409    /// Path to bash completion file.
2410    pub bash: Option<String>,
2411    /// Path to zsh completion file.
2412    pub zsh: Option<String>,
2413    /// Path to fish completion file.
2414    pub fish: Option<String>,
2415}
2416
2417/// Cask dependency (on another cask or formula).
2418#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2419#[serde(default)]
2420pub struct HomebrewCaskDependencyEntry {
2421    /// Dependent cask name.
2422    pub cask: Option<String>,
2423    /// Dependent formula name.
2424    pub formula: Option<String>,
2425}
2426
2427/// Cask conflict (with another cask or formula).
2428#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2429#[serde(default)]
2430pub struct HomebrewCaskConflictEntry {
2431    /// Conflicting cask name.
2432    pub cask: Option<String>,
2433    /// Conflicting formula name (deprecated by Homebrew).
2434    pub formula: Option<String>,
2435}
2436
2437/// Auto-generate shell completions from an executable.
2438#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2439#[serde(default)]
2440pub struct HomebrewCaskGeneratedCompletions {
2441    /// Binary to generate completions from.
2442    pub executable: Option<String>,
2443    /// Arguments to pass to the executable.
2444    pub args: Option<Vec<String>>,
2445    /// Base name for completion files.
2446    pub base_name: Option<String>,
2447    /// Shell completion framework type (arg, clap, click, cobra, flag, none, typer).
2448    pub shell_parameter_format: Option<String>,
2449    /// Target shells (bash, zsh, fish, pwsh).
2450    pub shells: Option<Vec<String>>,
2451}
2452
2453#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2454#[serde(default)]
2455pub struct ScoopConfig {
2456    /// Legacy bucket config (owner/name). Prefer `repository` for new configs.
2457    pub bucket: Option<BucketConfig>,
2458    /// Unified repository config with branch, token, PR, git SSH support.
2459    pub repository: Option<RepositoryConfig>,
2460    /// Commit author with optional signing.
2461    pub commit_author: Option<CommitAuthorConfig>,
2462    /// Override the manifest name (default: crate name).
2463    pub name: Option<String>,
2464    /// Subdirectory in the bucket repo for manifest placement.
2465    pub directory: Option<String>,
2466    /// Short description of the package (shown in `scoop info`).
2467    pub description: Option<String>,
2468    /// SPDX license identifier (e.g., "MIT", "Apache-2.0").
2469    pub license: Option<String>,
2470    /// Project homepage URL. Falls back to the GitHub-derived URL when unset.
2471    pub homepage: Option<String>,
2472    /// Data paths persisted between Scoop updates.
2473    pub persist: Option<Vec<String>>,
2474    /// Application dependencies (other Scoop packages).
2475    pub depends: Option<Vec<String>>,
2476    /// Commands to run before installation.
2477    pub pre_install: Option<Vec<String>>,
2478    /// Commands to run after installation.
2479    pub post_install: Option<Vec<String>>,
2480    /// Start menu shortcuts as `[executable, label]` pairs.
2481    pub shortcuts: Option<Vec<Vec<String>>>,
2482    /// Skip publishing the manifest.  `"true"` always skips; `"auto"` skips
2483    /// for prerelease versions. Accepts bool or template string.
2484    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
2485    pub skip_upload: Option<StringOrBool>,
2486    /// Custom commit message template.
2487    pub commit_msg_template: Option<String>,
2488    /// Git commit author name (legacy; prefer `commit_author`).
2489    pub commit_author_name: Option<String>,
2490    /// Git commit author email (legacy; prefer `commit_author`).
2491    pub commit_author_email: Option<String>,
2492    /// Build IDs filter: only include artifacts whose `id` is in this list.
2493    pub ids: Option<Vec<String>>,
2494    /// Custom URL template for download URLs (overrides release URL).
2495    pub url_template: Option<String>,
2496    /// Artifact selection: "archive" (default), "msi", or "nsis".
2497    #[serde(rename = "use")]
2498    pub use_artifact: Option<String>,
2499    /// amd64 microarchitecture variant filter (e.g. "v1", "v2", "v3", "v4").
2500    /// Only artifacts matching this variant are included. Default: "v1".
2501    #[serde(alias = "goamd64")]
2502    pub amd64_variant: Option<String>,
2503}
2504
2505#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2506pub struct TapConfig {
2507    /// GitHub owner of the Homebrew tap repository.
2508    pub owner: String,
2509    /// Name of the Homebrew tap repository (e.g., "homebrew-tap").
2510    pub name: String,
2511}
2512
2513#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2514pub struct BucketConfig {
2515    /// GitHub owner of the Scoop bucket repository.
2516    pub owner: String,
2517    /// Name of the Scoop bucket repository (e.g., "scoop-bucket").
2518    pub name: String,
2519}
2520
2521// ---------------------------------------------------------------------------
2522// ChocolateyConfig
2523// ---------------------------------------------------------------------------
2524
2525#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2526#[serde(default)]
2527pub struct ChocolateyConfig {
2528    /// Override the package name (default: crate name).
2529    pub name: Option<String>,
2530    /// Build IDs filter: only include artifacts whose `id` is in this list.
2531    pub ids: Option<Vec<String>>,
2532    /// GitHub project repo (owner/name). Used to derive download URLs.
2533    pub project_repo: Option<ChocolateyRepoConfig>,
2534    /// URL shown as the package source in the Chocolatey gallery.
2535    pub package_source_url: Option<String>,
2536    /// Package owners (Chocolatey gallery user).
2537    pub owners: Option<String>,
2538    /// Package title (default: project name).
2539    pub title: Option<String>,
2540    /// Package author(s) displayed in the Chocolatey gallery.
2541    pub authors: Option<String>,
2542    /// Project homepage URL.
2543    pub project_url: Option<String>,
2544    /// Custom URL template for download URLs (overrides release URL).
2545    pub url_template: Option<String>,
2546    /// URL to the package icon image shown in the Chocolatey gallery.
2547    pub icon_url: Option<String>,
2548    /// Copyright notice.
2549    pub copyright: Option<String>,
2550    /// Package description (supports markdown).
2551    pub description: Option<String>,
2552    /// SPDX license identifier (e.g., "MIT", "Apache-2.0").
2553    pub license: Option<String>,
2554    /// Optional explicit license URL. Falls back to
2555    /// `https://opensource.org/licenses/<license>` when not set.
2556    pub license_url: Option<String>,
2557    /// Require license acceptance before install.
2558    pub require_license_acceptance: Option<bool>,
2559    /// Source code project URL.
2560    pub project_source_url: Option<String>,
2561    /// Documentation URL.
2562    pub docs_url: Option<String>,
2563    /// Bug tracker URL.
2564    pub bug_tracker_url: Option<String>,
2565    /// Space-separated tags for the Chocolatey gallery.
2566    /// Accepts either a space-separated string (GoReleaser compat) or an array.
2567    #[serde(
2568        deserialize_with = "deserialize_space_separated_string_or_vec_opt",
2569        default
2570    )]
2571    pub tags: Option<Vec<String>>,
2572    /// Short summary of the package.
2573    pub summary: Option<String>,
2574    /// Release notes for this version.
2575    pub release_notes: Option<String>,
2576    /// Package dependencies with optional version constraints.
2577    pub dependencies: Option<Vec<ChocolateyDependency>>,
2578    /// Chocolatey API key for `choco push`. Falls back to `CHOCOLATEY_API_KEY` env var.
2579    pub api_key: Option<String>,
2580    /// Push source URL (default: "https://push.chocolatey.org/").
2581    pub source_repo: Option<String>,
2582    /// Skip pushing to the Chocolatey community repository.
2583    pub skip_publish: Option<bool>,
2584    /// Disable this chocolatey config. Accepts bool or template string.
2585    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
2586    pub disable: Option<StringOrBool>,
2587    /// Artifact selection: "archive" (default), "msi", or "nsis".
2588    #[serde(rename = "use")]
2589    pub use_artifact: Option<String>,
2590    /// amd64 microarchitecture variant filter (e.g. "v1", "v2", "v3", "v4").
2591    /// Only artifacts matching this variant are included. Default: "v1".
2592    #[serde(alias = "goamd64")]
2593    pub amd64_variant: Option<String>,
2594}
2595
2596/// Chocolatey package dependency with optional version constraint.
2597#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2598#[serde(default)]
2599pub struct ChocolateyDependency {
2600    /// Chocolatey package ID of the dependency.
2601    pub id: String,
2602    /// Minimum version constraint for the dependency (e.g., "[1.0.0,)").
2603    pub version: Option<String>,
2604}
2605
2606#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2607pub struct ChocolateyRepoConfig {
2608    /// GitHub owner of the project repository.
2609    pub owner: String,
2610    /// GitHub repository name of the project.
2611    pub name: String,
2612}
2613
2614// ---------------------------------------------------------------------------
2615// WingetConfig
2616// ---------------------------------------------------------------------------
2617
2618#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2619#[serde(default)]
2620pub struct WingetConfig {
2621    /// Override the package name (default: crate name).
2622    pub name: Option<String>,
2623    /// Package name as displayed (default: same as name).
2624    pub package_name: Option<String>,
2625    /// WinGet package identifier (e.g. "Publisher.AppName"). Auto-generated if empty.
2626    pub package_identifier: Option<String>,
2627    /// Publisher name (required).
2628    pub publisher: Option<String>,
2629    /// Publisher homepage URL shown in the WinGet manifest.
2630    pub publisher_url: Option<String>,
2631    /// Publisher support URL.
2632    pub publisher_support_url: Option<String>,
2633    /// Privacy policy URL.
2634    pub privacy_url: Option<String>,
2635    /// Author name.
2636    pub author: Option<String>,
2637    /// Copyright notice.
2638    pub copyright: Option<String>,
2639    /// Copyright URL.
2640    pub copyright_url: Option<String>,
2641    /// License identifier (required, e.g. "MIT").
2642    pub license: Option<String>,
2643    /// License URL.
2644    pub license_url: Option<String>,
2645    /// Short description (required, max 256 chars).
2646    pub short_description: Option<String>,
2647    /// Full package description displayed in the WinGet gallery.
2648    pub description: Option<String>,
2649    /// Project homepage URL.
2650    pub homepage: Option<String>,
2651    /// Custom URL template for download URLs (overrides release URL).
2652    pub url_template: Option<String>,
2653    /// Build IDs filter: only include artifacts whose `id` is in this list.
2654    pub ids: Option<Vec<String>>,
2655    /// Skip publishing. `"true"` always skips; `"auto"` skips for prereleases.
2656    /// Accepts bool or template string.
2657    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
2658    pub skip_upload: Option<StringOrBool>,
2659    /// Custom commit message template.
2660    pub commit_msg_template: Option<String>,
2661    /// Manifest file path (auto-generated if empty from publisher/name/version).
2662    pub path: Option<String>,
2663    /// Release notes for this version.
2664    pub release_notes: Option<String>,
2665    /// URL to full release notes.
2666    pub release_notes_url: Option<String>,
2667    /// Post-install notes shown to the user.
2668    pub installation_notes: Option<String>,
2669    /// Tags for package discovery (lowercased, spaces→hyphens).
2670    pub tags: Option<Vec<String>>,
2671    /// Package dependencies.
2672    pub dependencies: Option<Vec<WingetDependency>>,
2673    /// Legacy manifests repo config (owner/name). Prefer `repository`.
2674    pub manifests_repo: Option<WingetManifestsRepoConfig>,
2675    /// Unified repository config with branch, token, PR, git SSH support.
2676    pub repository: Option<RepositoryConfig>,
2677    /// Commit author with optional signing.
2678    pub commit_author: Option<CommitAuthorConfig>,
2679    /// Product code for the installer (used in Add/Remove Programs).
2680    pub product_code: Option<String>,
2681    /// Artifact selection: "archive" (default), "msi", or "nsis".
2682    #[serde(rename = "use")]
2683    pub use_artifact: Option<String>,
2684    /// amd64 microarchitecture variant filter (e.g. "v1", "v2", "v3", "v4").
2685    /// Only artifacts matching this variant are included. Default: "v1".
2686    #[serde(alias = "goamd64")]
2687    pub amd64_variant: Option<String>,
2688}
2689
2690/// WinGet package dependency.
2691#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2692#[serde(default)]
2693pub struct WingetDependency {
2694    /// WinGet package identifier of the dependency (e.g., "Publisher.App").
2695    pub package_identifier: String,
2696    /// Minimum required version of the dependency.
2697    pub minimum_version: Option<String>,
2698}
2699
2700#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2701pub struct WingetManifestsRepoConfig {
2702    /// GitHub owner of the WinGet community repository fork.
2703    pub owner: String,
2704    /// GitHub repository name of the WinGet community repository fork.
2705    pub name: String,
2706}
2707
2708// ---------------------------------------------------------------------------
2709// AurConfig
2710// ---------------------------------------------------------------------------
2711
2712#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2713#[serde(default)]
2714pub struct AurConfig {
2715    /// Override the package name (default: crate name + "-bin").
2716    #[serde(alias = "package_name")]
2717    pub name: Option<String>,
2718    /// Build IDs filter: only include artifacts whose `id` is in this list.
2719    pub ids: Option<Vec<String>>,
2720    /// Commit author with optional signing.
2721    pub commit_author: Option<CommitAuthorConfig>,
2722    /// Custom commit message template. Default: "Update to {{ version }}".
2723    pub commit_msg_template: Option<String>,
2724    /// Short description of the package for PKGBUILD.
2725    pub description: Option<String>,
2726    /// Project homepage URL.
2727    pub homepage: Option<String>,
2728    /// SPDX license identifier (e.g., "MIT", "Apache-2.0").
2729    pub license: Option<String>,
2730    /// Skip publishing. `"true"` always skips; `"auto"` skips for prereleases.
2731    /// Accepts bool or template string.
2732    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
2733    pub skip_upload: Option<StringOrBool>,
2734    /// Custom URL template for download URLs (overrides release URL).
2735    pub url_template: Option<String>,
2736    /// PKGBUILD maintainer entries (e.g., "Name <email@example.com>").
2737    pub maintainers: Option<Vec<String>>,
2738    /// Contributors listed in PKGBUILD comments.
2739    pub contributors: Option<Vec<String>>,
2740    /// Packages this PKGBUILD provides (virtual package names).
2741    pub provides: Option<Vec<String>>,
2742    /// Packages this PKGBUILD conflicts with.
2743    pub conflicts: Option<Vec<String>>,
2744    /// Runtime dependencies required by this package.
2745    pub depends: Option<Vec<String>>,
2746    /// Optional dependencies with descriptions (e.g., "fzf: fuzzy finder support").
2747    pub optdepends: Option<Vec<String>>,
2748    /// List of config files to preserve on upgrade (relative to `/`).
2749    pub backup: Option<Vec<String>>,
2750    /// Package release number (default: "1").
2751    pub rel: Option<String>,
2752    /// Custom PKGBUILD `package()` function body.
2753    #[serde(alias = "install_template")]
2754    pub package: Option<String>,
2755    /// AUR SSH git URL (e.g., `ssh://aur@aur.archlinux.org/<package>.git`).
2756    pub git_url: Option<String>,
2757    /// Custom SSH command for git operations.
2758    pub git_ssh_command: Option<String>,
2759    /// Path to SSH private key file.
2760    pub private_key: Option<String>,
2761    /// Subdirectory in the git repo for committed files.
2762    pub directory: Option<String>,
2763    /// Disable this AUR config. Accepts bool or template string
2764    /// (e.g. `"{{ if .IsSnapshot }}true{{ endif }}"` for conditional disable).
2765    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
2766    pub disable: Option<StringOrBool>,
2767    /// Content for a .install file (post-install/pre-remove scripts).
2768    pub install: Option<String>,
2769    /// Legacy project URL field.
2770    pub url: Option<String>,
2771    /// Packages this PKGBUILD replaces (for upgrade paths from old package names).
2772    pub replaces: Option<Vec<String>>,
2773    /// amd64 microarchitecture variant filter (e.g. "v1", "v2", "v3", "v4").
2774    /// Only artifacts matching this variant are included. Default: "v1".
2775    #[serde(alias = "goamd64")]
2776    pub amd64_variant: Option<String>,
2777}
2778
2779// ---------------------------------------------------------------------------
2780// KrewConfig
2781// ---------------------------------------------------------------------------
2782
2783#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2784#[serde(default)]
2785pub struct KrewConfig {
2786    /// Override the plugin name (default: crate name).
2787    pub name: Option<String>,
2788    /// Build IDs filter: only include artifacts whose `id` is in this list.
2789    pub ids: Option<Vec<String>>,
2790    /// Legacy krew-index fork repo (owner/name). Prefer `repository`.
2791    pub manifests_repo: Option<KrewManifestsRepoConfig>,
2792    /// Unified repository config with branch, token, PR, git SSH support.
2793    pub repository: Option<RepositoryConfig>,
2794    /// Commit author with optional signing.
2795    pub commit_author: Option<CommitAuthorConfig>,
2796    /// Custom commit message template.
2797    pub commit_msg_template: Option<String>,
2798    /// Full description of the kubectl plugin.
2799    pub description: Option<String>,
2800    /// One-line summary of the kubectl plugin (max 255 chars).
2801    pub short_description: Option<String>,
2802    /// Project homepage URL for the plugin.
2803    pub homepage: Option<String>,
2804    /// Custom URL template for download URLs (overrides release URL).
2805    pub url_template: Option<String>,
2806    /// Post-install message shown to the user.
2807    pub caveats: Option<String>,
2808    /// Skip publishing. `"true"` always skips; `"auto"` skips for prereleases.
2809    /// Accepts bool or template string.
2810    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
2811    pub skip_upload: Option<StringOrBool>,
2812    /// Disable this Krew config entirely. Accepts bool or template string
2813    /// (e.g. `"{{ if .IsSnapshot }}true{{ endif }}"` for conditional disable).
2814    /// Matches GoReleaser's `disable` field (v2.7+).
2815    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
2816    pub disable: Option<StringOrBool>,
2817    /// Legacy upstream repo for PR target. Use `repository.pull_request.base` instead.
2818    pub upstream_repo: Option<KrewManifestsRepoConfig>,
2819    /// amd64 microarchitecture variant filter (e.g. "v1", "v2", "v3", "v4").
2820    /// Only artifacts matching this variant are included. Default: "v1".
2821    #[serde(alias = "goamd64")]
2822    pub amd64_variant: Option<String>,
2823    /// ARM version filter (e.g. "6", "7"). Only artifacts matching this
2824    /// variant are included.
2825    #[serde(alias = "goarm")]
2826    pub arm_variant: Option<String>,
2827}
2828
2829#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2830pub struct KrewManifestsRepoConfig {
2831    /// GitHub owner of the krew-index fork.
2832    pub owner: String,
2833    /// GitHub repository name of the krew-index fork.
2834    pub name: String,
2835}
2836
2837// ---------------------------------------------------------------------------
2838// NixConfig
2839// ---------------------------------------------------------------------------
2840
2841#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2842#[serde(default)]
2843pub struct NixConfig {
2844    /// Override the derivation name (default: crate name).
2845    pub name: Option<String>,
2846    /// Path for the .nix file in the repository (default: `pkgs/<name>/default.nix`).
2847    pub path: Option<String>,
2848    /// Unified repository config with branch, token, PR, git SSH support.
2849    pub repository: Option<RepositoryConfig>,
2850    /// Commit author with optional signing.
2851    pub commit_author: Option<CommitAuthorConfig>,
2852    /// Custom commit message template.
2853    pub commit_msg_template: Option<String>,
2854    /// Build IDs filter: only include artifacts whose `id` is in this list.
2855    pub ids: Option<Vec<String>>,
2856    /// Custom URL template for download URLs (overrides release URL).
2857    pub url_template: Option<String>,
2858    /// Skip publishing. `"true"` always skips; `"auto"` skips for prereleases.
2859    /// Accepts bool or template string.
2860    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
2861    pub skip_upload: Option<StringOrBool>,
2862    /// Custom install commands (replaces auto-generated binary install).
2863    pub install: Option<String>,
2864    /// Additional install commands appended after the main install.
2865    pub extra_install: Option<String>,
2866    /// Post-install commands (postInstall phase).
2867    pub post_install: Option<String>,
2868    /// Short description of the Nix derivation.
2869    pub description: Option<String>,
2870    /// Project homepage URL.
2871    pub homepage: Option<String>,
2872    /// Nix license identifier (e.g. "mit", "asl20"). Validated against known licenses.
2873    pub license: Option<String>,
2874    /// Nix package dependencies with optional OS filtering.
2875    pub dependencies: Option<Vec<NixDependency>>,
2876    /// Nix formatter to run on the generated file: "alejandra" or "nixfmt".
2877    pub formatter: Option<String>,
2878    /// amd64 microarchitecture variant filter (e.g. "v1", "v2", "v3", "v4").
2879    /// Only artifacts matching this variant are included. Default: "v1".
2880    #[serde(alias = "goamd64")]
2881    pub amd64_variant: Option<String>,
2882}
2883
2884/// Nix package dependency with optional OS restriction.
2885#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2886#[serde(default)]
2887pub struct NixDependency {
2888    /// Nix attribute path for the dependency (e.g., "openssl", "pkgs.libgit2").
2889    pub name: String,
2890    /// OS restriction: "linux", "darwin", or empty for all.
2891    pub os: Option<String>,
2892}
2893
2894// ---------------------------------------------------------------------------
2895// DockerConfig
2896// ---------------------------------------------------------------------------
2897
2898#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2899#[serde(default)]
2900pub struct DockerConfig {
2901    /// Unique identifier for this Docker config.
2902    pub id: Option<String>,
2903    /// Image tags to build and push (supports templates, e.g., "ghcr.io/owner/app:{{ .Version }}").
2904    pub image_templates: Vec<String>,
2905    /// Path to the Dockerfile relative to the project root.
2906    pub dockerfile: String,
2907    /// Target platforms for multi-arch builds (e.g., ["linux/amd64", "linux/arm64"]).
2908    pub platforms: Option<Vec<String>>,
2909    /// Binary names to copy into the image (defaults to all binaries from matched builds).
2910    pub binaries: Option<Vec<String>>,
2911    /// Extra `--build-arg` and `--label` flags as templates (e.g., "--build-arg VERSION={{ .Version }}").
2912    pub build_flag_templates: Option<Vec<String>>,
2913    /// Skip push: true, false, or "auto" (skip for prereleases).
2914    #[schemars(schema_with = "skip_push_schema")]
2915    pub skip_push: Option<SkipPushConfig>,
2916    /// Extra files to copy into the Docker build context.
2917    pub extra_files: Option<Vec<String>>,
2918    /// Extra files whose contents are rendered through the template engine before copying.
2919    /// Unlike `extra_files` which copy as-is, template variables like `{{ .Tag }}` are expanded.
2920    /// GoReleaser Pro feature.
2921    pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
2922    /// Extra flags passed to `docker push`.
2923    pub push_flags: Option<Vec<String>>,
2924    /// Build IDs filter: only include binary artifacts whose metadata `id` is in this list.
2925    pub ids: Option<Vec<String>>,
2926    /// OCI labels to apply to the image via `--label key=value` flags.
2927    pub labels: Option<HashMap<String, String>>,
2928    /// Retry configuration for docker push operations.
2929    pub retry: Option<DockerRetryConfig>,
2930    /// Docker backend: "docker", "buildx" (default), or "podman".
2931    #[serde(rename = "use")]
2932    pub use_backend: Option<String>,
2933    /// When truthy, skip this docker build entirely. Supports templates.
2934    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
2935    pub disable: Option<StringOrBool>,
2936}
2937
2938#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2939#[serde(default)]
2940pub struct DockerRetryConfig {
2941    /// Number of retry attempts for failed docker push operations (default: 3).
2942    pub attempts: Option<u32>,
2943    /// Duration string, e.g. "1s", "500ms".
2944    pub delay: Option<String>,
2945    /// Maximum delay between retries, e.g. "30s".
2946    pub max_delay: Option<String>,
2947}
2948
2949// ---------------------------------------------------------------------------
2950// DockerV2Config
2951// ---------------------------------------------------------------------------
2952
2953/// Docker V2 configuration — the newer, cleaner Docker build API.
2954///
2955/// Key differences from the legacy [`DockerConfig`]:
2956/// - `images` + `tags` instead of `image_templates` (cleaner separation)
2957/// - `annotations` map (OCI annotations via `--annotation`)
2958/// - `build_args` as a map instead of `build_flag_templates`
2959/// - `disable` as [`StringOrBool`] template
2960/// - `sbom` as [`StringOrBool`] — when truthy, adds `--sbom=true` to buildx
2961/// - `flags` for arbitrary extra flags
2962/// - No `goos`/`goarch`/`goarm` fields — uses `platforms` only
2963#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2964#[serde(default)]
2965pub struct DockerV2Config {
2966    /// Unique identifier for this Docker V2 config.
2967    pub id: Option<String>,
2968    /// Build IDs filter: only include binary artifacts whose metadata `id` is in this list.
2969    pub ids: Option<Vec<String>>,
2970    /// Path to the Dockerfile relative to the project root.
2971    pub dockerfile: String,
2972    /// Base image names (e.g., ["ghcr.io/owner/app"]). Combined with `tags` to form full references.
2973    pub images: Vec<String>,
2974    /// Tag suffixes (e.g., ["latest", "{{ .Version }}"]). Each image is tagged with each tag.
2975    pub tags: Vec<String>,
2976    /// OCI labels to apply to the image via `--label key=value` flags.
2977    pub labels: Option<HashMap<String, String>>,
2978    /// OCI annotations to apply via `--annotation key=value` flags.
2979    pub annotations: Option<HashMap<String, String>>,
2980    /// Extra files to copy into the Docker build context.
2981    pub extra_files: Option<Vec<String>>,
2982    /// Target platforms for multi-arch builds (e.g., ["linux/amd64", "linux/arm64"]).
2983    pub platforms: Option<Vec<String>>,
2984    /// Build arguments passed as `--build-arg KEY=VALUE`.
2985    pub build_args: Option<HashMap<String, String>>,
2986    /// Retry configuration for docker push operations.
2987    pub retry: Option<DockerRetryConfig>,
2988    /// Arbitrary extra flags passed to the docker build command.
2989    pub flags: Option<Vec<String>>,
2990    /// When truthy, skip this docker build entirely. Supports templates.
2991    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
2992    pub disable: Option<StringOrBool>,
2993    /// When truthy, adds `--sbom=true` to buildx. Supports templates.
2994    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
2995    pub sbom: Option<StringOrBool>,
2996    /// When truthy, skip pushing images after build. Supports templates.
2997    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
2998    pub skip_push: Option<StringOrBool>,
2999}
3000
3001// ---------------------------------------------------------------------------
3002// DockerDigestConfig
3003// ---------------------------------------------------------------------------
3004
3005/// Controls docker image digest file creation.
3006///
3007/// After each docker image push, a digest file (containing the sha256 digest)
3008/// is written to the dist directory. This config controls whether that happens
3009/// and how the files are named.
3010#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3011#[serde(default)]
3012pub struct DockerDigestConfig {
3013    /// When truthy, disable docker digest artifact creation.
3014    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
3015    pub disable: Option<StringOrBool>,
3016    /// Template for the digest artifact filename.
3017    /// Default: tag-based naming (e.g., "ghcr.io_owner_app_v1.0.0.digest").
3018    pub name_template: Option<String>,
3019}
3020
3021// ---------------------------------------------------------------------------
3022// DockerManifestConfig
3023// ---------------------------------------------------------------------------
3024
3025#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3026#[serde(default)]
3027pub struct DockerManifestConfig {
3028    /// Template for the manifest name, e.g. "ghcr.io/owner/app:{{ .Version }}".
3029    pub name_template: String,
3030    /// Image references to include in the manifest.
3031    pub image_templates: Vec<String>,
3032    /// Extra flags for `docker manifest create`.
3033    pub create_flags: Option<Vec<String>>,
3034    /// Extra flags for `docker manifest push`.
3035    pub push_flags: Option<Vec<String>>,
3036    /// Skip push: true, false, or "auto" (skip for prereleases).
3037    #[schemars(schema_with = "skip_push_schema")]
3038    pub skip_push: Option<SkipPushConfig>,
3039    /// Unique identifier for this manifest config.
3040    pub id: Option<String>,
3041    /// Docker backend for manifest commands: "docker" (default) or "podman".
3042    #[serde(rename = "use")]
3043    pub use_backend: Option<String>,
3044    /// Retry configuration for manifest push (handles transient registry errors).
3045    pub retry: Option<DockerRetryConfig>,
3046    /// When truthy, skip this docker manifest entirely. Supports templates.
3047    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
3048    pub disable: Option<StringOrBool>,
3049}
3050
3051// ---------------------------------------------------------------------------
3052// NfpmConfig
3053// ---------------------------------------------------------------------------
3054
3055#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3056#[serde(default)]
3057pub struct NfpmConfig {
3058    /// Unique identifier for cross-referencing this nFPM config.
3059    pub id: Option<String>,
3060    /// Package name (defaults to crate name).
3061    pub package_name: Option<String>,
3062    /// Package formats to produce: deb, rpm, apk, archlinux (at least one required).
3063    pub formats: Vec<String>,
3064    /// Package vendor name.
3065    pub vendor: Option<String>,
3066    /// Project homepage URL.
3067    pub homepage: Option<String>,
3068    /// Package maintainer in "Name <email>" format.
3069    pub maintainer: Option<String>,
3070    /// Package description (multiline supported).
3071    pub description: Option<String>,
3072    /// SPDX license identifier (e.g., "MIT", "Apache-2.0").
3073    pub license: Option<String>,
3074    /// Installation directory for binaries (default: /usr/bin).
3075    pub bindir: Option<String>,
3076    /// Files to include in the package beyond the main binary.
3077    pub contents: Option<Vec<NfpmContent>>,
3078    /// Runtime package dependencies keyed by format (e.g., {"deb": ["libc6"], "rpm": ["glibc"]}).
3079    pub dependencies: Option<HashMap<String, Vec<String>>>,
3080    /// Per-format setting overrides (e.g., {"deb": {compression: "xz"}}).
3081    pub overrides: Option<HashMap<String, serde_json::Value>>,
3082    /// Package filename template (supports templates).
3083    pub file_name_template: Option<String>,
3084    /// Package lifecycle scripts (preinstall, postinstall, preremove, postremove).
3085    pub scripts: Option<NfpmScripts>,
3086    /// Packages recommended (soft dependency) by this package.
3087    pub recommends: Option<Vec<String>>,
3088    /// Packages suggested (weaker than recommends) by this package.
3089    pub suggests: Option<Vec<String>>,
3090    /// Packages this package conflicts with.
3091    pub conflicts: Option<Vec<String>>,
3092    /// Packages this package replaces (for upgrade paths from old package names).
3093    pub replaces: Option<Vec<String>>,
3094    /// Virtual packages provided by this package.
3095    pub provides: Option<Vec<String>>,
3096    /// Build IDs filter: only include artifacts from builds whose `id` is in this list.
3097    #[serde(alias = "builds")]
3098    pub ids: Option<Vec<String>>,
3099    /// Package epoch for versioning (integer as string).
3100    pub epoch: Option<String>,
3101    /// Package release number.
3102    pub release: Option<String>,
3103    /// Prerelease version suffix.
3104    pub prerelease: Option<String>,
3105    /// Version metadata (e.g. git commit hash).
3106    pub version_metadata: Option<String>,
3107    /// Package section (e.g. "utils", "devel").
3108    pub section: Option<String>,
3109    /// Package priority (e.g. "optional", "required").
3110    pub priority: Option<String>,
3111    /// Whether this is a meta-package (no files, only dependencies).
3112    pub meta: Option<bool>,
3113    /// File permission umask (e.g. "0o002").
3114    pub umask: Option<String>,
3115    /// Default modification time for files in the package.
3116    pub mtime: Option<String>,
3117    /// RPM-specific configuration.
3118    pub rpm: Option<NfpmRpmConfig>,
3119    /// Deb-specific configuration.
3120    pub deb: Option<NfpmDebConfig>,
3121    /// APK-specific configuration.
3122    pub apk: Option<NfpmApkConfig>,
3123    /// Archlinux-specific configuration.
3124    pub archlinux: Option<NfpmArchlinuxConfig>,
3125    /// IPK-specific configuration (OpenWrt packages).
3126    pub ipk: Option<NfpmIpkConfig>,
3127    /// CGo library installation directories (header, carchive, cshared).
3128    pub libdirs: Option<NfpmLibdirs>,
3129    /// Path to a YAML-format changelog file for deb/rpm packages.
3130    pub changelog: Option<String>,
3131    /// Template-conditional: skip this nfpm config if rendered result is "false" or empty.
3132    /// (GoReleaser Pro v2.4+.)
3133    #[serde(rename = "if")]
3134    pub if_condition: Option<String>,
3135    /// Extra file contents whose source files are Tera-rendered before packaging (GoReleaser Pro).
3136    /// Each entry mirrors `contents`; the difference is that at stage time the file at `src` is
3137    /// read, rendered through the template engine, written to a temp file, and then included
3138    /// in the package at `dst` using the temp file as the real source. Useful for shipping
3139    /// config files with templated values (version, commit, maintainer, etc.).
3140    pub templated_contents: Option<Vec<NfpmContent>>,
3141    /// Lifecycle scripts whose script-file bodies are Tera-rendered before packaging
3142    /// (GoReleaser Pro). Each path is read, rendered through the template engine, written to
3143    /// a temp file, and used as the real script. If a field is set on both `scripts` and
3144    /// `templated_scripts`, the templated version wins.
3145    pub templated_scripts: Option<NfpmScripts>,
3146}
3147
3148/// Installation directories for CGo library outputs.
3149///
3150/// Controls where header files, static archives, and shared libraries
3151/// are installed in the package.
3152#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3153#[serde(default)]
3154pub struct NfpmLibdirs {
3155    /// Installation directory for C header files.
3156    pub header: Option<String>,
3157    /// Installation directory for carchive (.a) static libraries.
3158    pub carchive: Option<String>,
3159    /// Installation directory for cshared (.so / .dylib) shared libraries.
3160    pub cshared: Option<String>,
3161}
3162
3163#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3164#[serde(default)]
3165pub struct NfpmScripts {
3166    /// Path to script run before package installation.
3167    pub preinstall: Option<String>,
3168    /// Path to script run after package installation.
3169    pub postinstall: Option<String>,
3170    /// Path to script run before package removal.
3171    pub preremove: Option<String>,
3172    /// Path to script run after package removal.
3173    pub postremove: Option<String>,
3174}
3175
3176/// Backward-compatible alias — nFPM contents share the same `FileInfo` struct.
3177pub type NfpmFileInfo = FileInfo;
3178
3179/// A single file/directory entry in an nFPM package's `contents` list.
3180///
3181/// `Default` is intentionally **not** derived because `src` and `dst` are
3182/// required fields with no meaningful defaults — forcing callers to provide
3183/// them explicitly prevents accidentally packaging empty paths.
3184#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3185pub struct NfpmContent {
3186    /// Source path on the build machine (supports glob patterns).
3187    pub src: String,
3188    /// Destination path inside the package (absolute path).
3189    pub dst: String,
3190    /// Content entry type: "config", "config|noreplace", "doc", "dir", "symlink", "ghost", or empty for regular file.
3191    #[serde(rename = "type")]
3192    pub content_type: Option<String>,
3193    /// File ownership and permission metadata.
3194    pub file_info: Option<NfpmFileInfo>,
3195    /// Per-packager filter: only include this content entry for the specified packager
3196    /// (e.g. "deb", "rpm", "apk").
3197    pub packager: Option<String>,
3198    /// When true, expand template variables in the `src` and `dst` paths.
3199    pub expand: Option<bool>,
3200}
3201
3202// ---------------------------------------------------------------------------
3203// nFPM format-specific configs
3204// ---------------------------------------------------------------------------
3205
3206#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3207#[serde(default)]
3208pub struct NfpmRpmConfig {
3209    /// One-line package summary (RPM Summary tag).
3210    pub summary: Option<String>,
3211    /// RPM compression algorithm (e.g. "lzma", "gzip", "xz", "zstd").
3212    pub compression: Option<String>,
3213    /// RPM group classification (e.g. "System/Tools").
3214    pub group: Option<String>,
3215    /// RPM packager identity (e.g. "Build Team <build@example.com>").
3216    pub packager: Option<String>,
3217    /// Relocatable RPM prefix paths (e.g. ["/usr", "/etc"]).
3218    pub prefixes: Option<Vec<String>>,
3219    /// RPM signing configuration.
3220    pub signature: Option<NfpmSignatureConfig>,
3221    /// RPM-specific lifecycle scripts (pretrans/posttrans).
3222    pub scripts: Option<NfpmRpmScripts>,
3223    /// RPM BuildHost tag value.
3224    pub build_host: Option<String>,
3225}
3226
3227/// RPM-specific transaction scripts that run outside the normal install/remove lifecycle.
3228#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3229#[serde(default)]
3230pub struct NfpmRpmScripts {
3231    /// Script to run before the RPM transaction begins.
3232    pub pretrans: Option<String>,
3233    /// Script to run after the RPM transaction completes.
3234    pub posttrans: Option<String>,
3235}
3236
3237impl NfpmRpmConfig {
3238    /// Returns `true` when every field is `None` — the YAML section would be
3239    /// empty and should be omitted.
3240    pub fn is_empty(&self) -> bool {
3241        self.summary.is_none()
3242            && self.compression.is_none()
3243            && self.group.is_none()
3244            && self.packager.is_none()
3245            && self.prefixes.is_none()
3246            && self.signature.is_none()
3247            && self.scripts.is_none()
3248            && self.build_host.is_none()
3249    }
3250}
3251
3252#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3253#[serde(default)]
3254pub struct NfpmDebConfig {
3255    /// Deb compression algorithm (e.g. "gzip", "xz", "zstd", "none").
3256    pub compression: Option<String>,
3257    /// Pre-dependency packages (stronger than Depends).
3258    pub predepends: Option<Vec<String>>,
3259    /// Deb trigger definitions.
3260    pub triggers: Option<NfpmDebTriggers>,
3261    /// Packages this package breaks (Breaks relationship).
3262    pub breaks: Option<Vec<String>>,
3263    /// Lintian overrides to embed in the package.
3264    pub lintian_overrides: Option<Vec<String>>,
3265    /// Deb signing configuration.
3266    pub signature: Option<NfpmSignatureConfig>,
3267    /// Additional control fields (e.g. Bugs, Built-Using).
3268    pub fields: Option<HashMap<String, String>>,
3269    /// Deb-specific maintainer scripts (rules, templates, config).
3270    pub scripts: Option<NfpmDebScripts>,
3271    /// amd64 microarchitecture variant propagated to nfpm's `deb.arch_variant`
3272    /// (`v1`, `v2`, `v3`, `v4`). Auto-derived from artifact metadata's
3273    /// `amd64_variant` when unset.
3274    pub arch_variant: Option<String>,
3275}
3276
3277/// Deb-specific maintainer scripts for package configuration and rules.
3278#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3279#[serde(default)]
3280pub struct NfpmDebScripts {
3281    /// Path to debian/rules file.
3282    pub rules: Option<String>,
3283    /// Path to debian/templates file (debconf templates).
3284    pub templates: Option<String>,
3285    /// Path to debian/config script (debconf configuration).
3286    pub config: Option<String>,
3287}
3288
3289impl NfpmDebConfig {
3290    /// Returns `true` when every field is `None` — the YAML section would be
3291    /// empty and should be omitted.
3292    pub fn is_empty(&self) -> bool {
3293        self.compression.is_none()
3294            && self.predepends.is_none()
3295            && self.triggers.is_none()
3296            && self.breaks.is_none()
3297            && self.lintian_overrides.is_none()
3298            && self.signature.is_none()
3299            && self.fields.is_none()
3300            && self.scripts.is_none()
3301    }
3302}
3303
3304#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3305#[serde(default)]
3306pub struct NfpmDebTriggers {
3307    /// Deb interest triggers: package waits for these triggers to complete.
3308    pub interest: Option<Vec<String>>,
3309    /// Deb interest-await triggers: package waits with synchronous trigger processing.
3310    pub interest_await: Option<Vec<String>>,
3311    /// Deb interest-noawait triggers: package registers interest without waiting.
3312    pub interest_noawait: Option<Vec<String>>,
3313    /// Deb activate triggers: package activates these triggers after install.
3314    pub activate: Option<Vec<String>>,
3315    /// Deb activate-await triggers: activate and wait for synchronous trigger processing.
3316    pub activate_await: Option<Vec<String>>,
3317    /// Deb activate-noawait triggers: activate without waiting.
3318    pub activate_noawait: Option<Vec<String>>,
3319}
3320
3321#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3322#[serde(default)]
3323pub struct NfpmApkConfig {
3324    /// APK signing configuration.
3325    pub signature: Option<NfpmSignatureConfig>,
3326    /// APK-specific lifecycle scripts (preupgrade/postupgrade).
3327    pub scripts: Option<NfpmApkScripts>,
3328}
3329
3330/// APK-specific upgrade lifecycle scripts.
3331#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3332#[serde(default)]
3333pub struct NfpmApkScripts {
3334    /// Script to run before upgrading an existing package.
3335    pub preupgrade: Option<String>,
3336    /// Script to run after upgrading an existing package.
3337    pub postupgrade: Option<String>,
3338}
3339
3340impl NfpmApkConfig {
3341    /// Returns `true` when every field is `None` — the YAML section would be
3342    /// empty and should be omitted.
3343    pub fn is_empty(&self) -> bool {
3344        self.signature.is_none() && self.scripts.is_none()
3345    }
3346}
3347
3348#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3349#[serde(default)]
3350pub struct NfpmArchlinuxConfig {
3351    /// Base package name for split packages.
3352    pub pkgbase: Option<String>,
3353    /// Packager identity (e.g. "Build Team <build@example.com>").
3354    pub packager: Option<String>,
3355    /// Archlinux-specific lifecycle scripts.
3356    pub scripts: Option<NfpmArchlinuxScripts>,
3357}
3358
3359impl NfpmArchlinuxConfig {
3360    /// Returns `true` when every field is `None` — the YAML section would be
3361    /// empty and should be omitted.
3362    pub fn is_empty(&self) -> bool {
3363        self.pkgbase.is_none() && self.packager.is_none() && self.scripts.is_none()
3364    }
3365}
3366
3367#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3368#[serde(default)]
3369pub struct NfpmArchlinuxScripts {
3370    /// Script to run before upgrading an existing package.
3371    pub preupgrade: Option<String>,
3372    /// Script to run after upgrading an existing package.
3373    pub postupgrade: Option<String>,
3374}
3375
3376/// IPK (OpenWrt) package-specific configuration.
3377#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3378#[serde(default)]
3379pub struct NfpmIpkConfig {
3380    /// ABI version string for the package.
3381    pub abi_version: Option<String>,
3382    /// Alternative file links managed by the update-alternatives system.
3383    pub alternatives: Option<Vec<NfpmIpkAlternative>>,
3384    /// Whether the package was automatically installed as a dependency.
3385    pub auto_installed: Option<bool>,
3386    /// Whether the package is essential for the system.
3387    pub essential: Option<bool>,
3388    /// Strong pre-dependencies that must be fully installed before this package.
3389    pub predepends: Option<Vec<String>>,
3390    /// Tags for categorizing the package.
3391    pub tags: Option<Vec<String>>,
3392    /// Additional control fields as key-value pairs.
3393    pub fields: Option<HashMap<String, String>>,
3394}
3395
3396impl NfpmIpkConfig {
3397    /// Returns `true` when every field is `None` — the YAML section would be
3398    /// empty and should be omitted.
3399    pub fn is_empty(&self) -> bool {
3400        self.abi_version.is_none()
3401            && self.alternatives.is_none()
3402            && self.auto_installed.is_none()
3403            && self.essential.is_none()
3404            && self.predepends.is_none()
3405            && self.tags.is_none()
3406            && self.fields.is_none()
3407    }
3408}
3409
3410/// An alternative file link for IPK's update-alternatives system.
3411#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3412#[serde(default)]
3413pub struct NfpmIpkAlternative {
3414    /// Priority for alternative selection (higher wins).
3415    pub priority: Option<i32>,
3416    /// Target file path that the alternative points to.
3417    pub target: Option<String>,
3418    /// Symlink name in the alternatives directory.
3419    pub link_name: Option<String>,
3420}
3421
3422#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3423#[serde(default)]
3424pub struct NfpmSignatureConfig {
3425    /// Path to the signing key file.
3426    pub key_file: Option<String>,
3427    /// Key ID to use for signing.
3428    pub key_id: Option<String>,
3429    /// Passphrase for the signing key.
3430    pub key_passphrase: Option<String>,
3431    /// Public key name for APK signatures (defaults to `<maintainer email>.rsa.pub`).
3432    pub key_name: Option<String>,
3433    /// Signature type for deb packages: "origin", "maint", or "archive" (default: "origin").
3434    #[serde(rename = "type")]
3435    pub type_: Option<String>,
3436}
3437
3438// ---------------------------------------------------------------------------
3439// SnapcraftConfig
3440// ---------------------------------------------------------------------------
3441
3442#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3443#[serde(default)]
3444pub struct SnapcraftConfig {
3445    /// Unique identifier for this snapcraft config.
3446    pub id: Option<String>,
3447    /// Build IDs to include. Empty means all builds.
3448    #[serde(alias = "builds")]
3449    pub ids: Option<Vec<String>>,
3450    /// Snap package name in the store.
3451    pub name: Option<String>,
3452    /// Canonical application title (user-facing in store).
3453    pub title: Option<String>,
3454    /// Single-line elevator pitch (max 79 characters).
3455    pub summary: Option<String>,
3456    /// Extended description (user-facing in store).
3457    pub description: Option<String>,
3458    /// Path to icon image file.
3459    pub icon: Option<String>,
3460    /// Runtime base snap: core, core18, core20, core22, core24, bare.
3461    pub base: Option<String>,
3462    /// Release stability level: stable, devel.
3463    pub grade: Option<String>,
3464    /// License identifier (SPDX format).
3465    pub license: Option<String>,
3466    /// Whether to publish to the snapcraft store.
3467    pub publish: Option<bool>,
3468    /// Distribution channels: edge, beta, candidate, stable.
3469    pub channel_templates: Option<Vec<String>>,
3470    /// Security confinement level: strict, devmode, classic.
3471    pub confinement: Option<String>,
3472    /// Top-level snap plug definitions (structured map).
3473    /// Keys are plug names, values are either `null` (simple plug) or an object
3474    /// with `interface` and optional attributes (e.g. `{ interface: "content", target: "$SNAP/shared" }`).
3475    /// GoReleaser uses `map[string]any` for this field.
3476    pub plugs: Option<HashMap<String, serde_json::Value>>,
3477    /// Shared code/data interface slots for other snaps.
3478    pub slots: Option<Vec<String>>,
3479    /// Required snapd features/versions.
3480    pub assumes: Option<Vec<String>>,
3481    /// Application configurations defining daemons, commands, env vars.
3482    pub apps: Option<HashMap<String, SnapcraftApp>>,
3483    /// Directory mappings for sandbox accessibility.
3484    pub layouts: Option<HashMap<String, SnapcraftLayout>>,
3485    /// Additional static files to bundle (string shorthand or structured form).
3486    pub extra_files: Option<Vec<SnapcraftExtraFileSpec>>,
3487    /// Extra files whose contents are rendered through the template engine before bundling.
3488    /// Unlike `extra_files` which copy as-is, template variables like `{{ .Tag }}` are expanded.
3489    /// GoReleaser Pro feature.
3490    pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
3491    /// Template for the output snap filename.
3492    pub name_template: Option<String>,
3493    /// Disable this snapcraft config. Accepts bool or template string
3494    /// (e.g. `"{{ if .IsSnapshot }}true{{ endif }}"` for conditional disable).
3495    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
3496    pub disable: Option<StringOrBool>,
3497    /// Remove source archives from artifacts, keeping only snap.
3498    pub replace: Option<bool>,
3499    /// Output timestamp for reproducible builds.
3500    pub mod_timestamp: Option<String>,
3501    /// Snap hooks — maps hook name to arbitrary hook config.
3502    pub hooks: Option<HashMap<String, serde_json::Value>>,
3503}
3504
3505#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3506#[serde(default)]
3507pub struct SnapcraftApp {
3508    /// Command to run (relative to snap root).
3509    pub command: Option<String>,
3510    /// Daemon type: simple, forking, oneshot, notify, dbus.
3511    pub daemon: Option<String>,
3512    /// How to stop the daemon: sigterm, sigkill, etc.
3513    #[serde(alias = "stop-mode")]
3514    pub stop_mode: Option<String>,
3515    /// Interface plugs the app needs.
3516    pub plugs: Option<Vec<String>>,
3517    /// Environment variables for the app (supports string, integer, and boolean values).
3518    pub environment: Option<HashMap<String, serde_json::Value>>,
3519    /// Additional arguments passed to the command.
3520    pub args: Option<String>,
3521    /// Restart condition: on-failure, always, on-success, on-abnormal, on-abort, on-watchdog, never.
3522    #[serde(alias = "restart-condition")]
3523    pub restart_condition: Option<String>,
3524    /// Snap adapter type: "none" or "full" (default: "full").
3525    pub adapter: Option<String>,
3526    /// Services that must start before this app.
3527    pub after: Option<Vec<String>>,
3528    /// Alternative names for the command.
3529    pub aliases: Option<Vec<String>>,
3530    /// Desktop file for autostart.
3531    pub autostart: Option<String>,
3532    /// Services that must start after this app.
3533    pub before: Option<Vec<String>>,
3534    /// D-Bus well-known bus name.
3535    #[serde(alias = "bus-name")]
3536    pub bus_name: Option<String>,
3537    /// Wrapper commands run before the main command.
3538    #[serde(alias = "command-chain")]
3539    pub command_chain: Option<Vec<String>>,
3540    /// AppStream metadata common ID.
3541    #[serde(alias = "common-id")]
3542    pub common_id: Option<String>,
3543    /// Path to bash completion script relative to snap.
3544    pub completer: Option<String>,
3545    /// Path to .desktop file relative to snap.
3546    pub desktop: Option<String>,
3547    /// Snap extensions to apply.
3548    pub extensions: Option<Vec<String>>,
3549    /// Installation mode: "enable" or "disable".
3550    #[serde(alias = "install-mode")]
3551    pub install_mode: Option<String>,
3552    /// Arbitrary YAML passed through to snap.yaml.
3553    pub passthrough: Option<HashMap<String, serde_json::Value>>,
3554    /// Command to run after daemon stops.
3555    #[serde(alias = "post-stop-command")]
3556    pub post_stop_command: Option<String>,
3557    /// Refresh behavior: "endure" or "restart".
3558    #[serde(alias = "refresh-mode")]
3559    pub refresh_mode: Option<String>,
3560    /// Command to reload daemon config.
3561    #[serde(alias = "reload-command")]
3562    pub reload_command: Option<String>,
3563    /// Delay between restarts (duration string).
3564    #[serde(alias = "restart-delay")]
3565    pub restart_delay: Option<String>,
3566    /// Interface slots this app provides.
3567    pub slots: Option<Vec<String>>,
3568    /// Socket definitions map.
3569    pub sockets: Option<HashMap<String, serde_json::Value>>,
3570    /// Start timeout duration string.
3571    #[serde(alias = "start-timeout")]
3572    pub start_timeout: Option<String>,
3573    /// Command to gracefully stop the daemon.
3574    #[serde(alias = "stop-command")]
3575    pub stop_command: Option<String>,
3576    /// Stop timeout duration string.
3577    #[serde(alias = "stop-timeout")]
3578    pub stop_timeout: Option<String>,
3579    /// Timer definition (systemd timer syntax).
3580    pub timer: Option<String>,
3581    /// Watchdog timeout duration string.
3582    #[serde(alias = "watchdog-timeout")]
3583    pub watchdog_timeout: Option<String>,
3584}
3585
3586#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3587#[serde(default)]
3588pub struct SnapcraftLayout {
3589    /// Bind-mount a directory to the snap's layout.
3590    pub bind: Option<String>,
3591    /// Bind-mount a single file to the snap's layout.
3592    pub bind_file: Option<String>,
3593    /// Symlink a path to a location in the snap.
3594    pub symlink: Option<String>,
3595    /// Layout entry type.
3596    #[serde(rename = "type")]
3597    pub type_: Option<String>,
3598}
3599
3600/// Specifies an extra file for snapcraft. Can be a simple source path string or
3601/// a structured object with source, destination, and mode fields (matching
3602/// GoReleaser's SnapcraftExtraFiles).
3603#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3604#[serde(untagged)]
3605pub enum SnapcraftExtraFileSpec {
3606    /// Simple source path string.
3607    Source(String),
3608    /// Structured form with source, destination, and mode.
3609    Detailed {
3610        source: String,
3611        #[serde(skip_serializing_if = "Option::is_none")]
3612        destination: Option<String>,
3613        #[serde(skip_serializing_if = "Option::is_none")]
3614        mode: Option<u32>,
3615    },
3616}
3617
3618impl SnapcraftExtraFileSpec {
3619    /// Return the source path for this spec.
3620    pub fn source(&self) -> &str {
3621        match self {
3622            SnapcraftExtraFileSpec::Source(s) => s,
3623            SnapcraftExtraFileSpec::Detailed { source, .. } => source,
3624        }
3625    }
3626
3627    /// Return the optional destination path.
3628    pub fn destination(&self) -> Option<&str> {
3629        match self {
3630            SnapcraftExtraFileSpec::Source(_) => None,
3631            SnapcraftExtraFileSpec::Detailed { destination, .. } => destination.as_deref(),
3632        }
3633    }
3634
3635    /// Return the optional file mode.
3636    pub fn mode(&self) -> Option<u32> {
3637        match self {
3638            SnapcraftExtraFileSpec::Source(_) => None,
3639            SnapcraftExtraFileSpec::Detailed { mode, .. } => *mode,
3640        }
3641    }
3642}
3643
3644// ---------------------------------------------------------------------------
3645// DmgConfig
3646// ---------------------------------------------------------------------------
3647
3648#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3649#[serde(default)]
3650pub struct DmgConfig {
3651    /// Unique identifier for this DMG config.
3652    pub id: Option<String>,
3653    /// Build IDs to include. Empty means all builds.
3654    pub ids: Option<Vec<String>>,
3655    /// Output DMG filename (supports templates).
3656    pub name: Option<String>,
3657    /// Additional files to include in the DMG (glob or {glob, name_template}).
3658    pub extra_files: Option<Vec<ExtraFileSpec>>,
3659    /// Extra files whose contents are rendered through the template engine before inclusion.
3660    /// Unlike `extra_files` which copy as-is, template variables like `{{ .Tag }}` are expanded.
3661    /// GoReleaser Pro feature.
3662    pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
3663    /// Remove source archives from artifacts, keeping only DMG.
3664    pub replace: Option<bool>,
3665    /// Output timestamp for reproducible builds.
3666    pub mod_timestamp: Option<String>,
3667    /// Disable this DMG config. Accepts bool or template string.
3668    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
3669    pub disable: Option<StringOrBool>,
3670    /// Which artifact type to package: "binary" (default) or "appbundle".
3671    #[serde(rename = "use")]
3672    pub use_: Option<String>,
3673    /// Template-conditional: skip this DMG config if rendered result is "false"
3674    /// or empty (GoReleaser Pro). Render failure hard-errors (not silent-skip).
3675    #[serde(rename = "if")]
3676    pub if_condition: Option<String>,
3677}
3678
3679// ---------------------------------------------------------------------------
3680// MsiConfig
3681// ---------------------------------------------------------------------------
3682
3683#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3684#[serde(default)]
3685pub struct MsiConfig {
3686    /// Unique identifier for this MSI config.
3687    pub id: Option<String>,
3688    /// Build IDs to include. Empty means all builds.
3689    pub ids: Option<Vec<String>>,
3690    /// Path to the WiX source file (.wxs). Goes through template engine. Required.
3691    pub wxs: Option<String>,
3692    /// Output MSI filename (supports templates).
3693    pub name: Option<String>,
3694    /// WiX schema version: v3 or v4 (auto-detected from .wxs if omitted).
3695    pub version: Option<String>,
3696    /// Remove source archives from artifacts, keeping only MSI.
3697    pub replace: Option<bool>,
3698    /// Output timestamp for reproducible builds.
3699    pub mod_timestamp: Option<String>,
3700    /// Disable this MSI config. Accepts bool or template string.
3701    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
3702    pub disable: Option<StringOrBool>,
3703    /// Additional files available in the WiX build context (simple filenames).
3704    pub extra_files: Option<Vec<String>>,
3705    /// WiX extensions to enable (e.g., "WixUIExtension"). Templates allowed.
3706    pub extensions: Option<Vec<String>>,
3707    /// Template-conditional: skip this MSI config if rendered result is "false"
3708    /// or empty (GoReleaser Pro). Render failure hard-errors (not silent-skip).
3709    #[serde(rename = "if")]
3710    pub if_condition: Option<String>,
3711    /// Pre/post MSI-build hooks (GoReleaser Pro v2.14+). Accepts `pre`/`post`
3712    /// or `before`/`after` via BuildHooksConfig's serde aliases. Runs before
3713    /// / after candle+light for each matched artifact.
3714    pub hooks: Option<BuildHooksConfig>,
3715}
3716
3717// ---------------------------------------------------------------------------
3718// PkgConfig (macOS .pkg installer)
3719// ---------------------------------------------------------------------------
3720
3721#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3722#[serde(default)]
3723pub struct PkgConfig {
3724    /// Unique identifier for this PKG config.
3725    pub id: Option<String>,
3726    /// Build IDs to include. Empty means all builds.
3727    pub ids: Option<Vec<String>>,
3728    /// Package identifier in reverse-domain notation (e.g. com.example.myapp). Required.
3729    pub identifier: Option<String>,
3730    /// Output PKG filename (supports templates).
3731    pub name: Option<String>,
3732    /// Installation path. Default: /usr/local/bin.
3733    pub install_location: Option<String>,
3734    /// Path to scripts directory containing preinstall/postinstall scripts.
3735    pub scripts: Option<String>,
3736    /// Additional files to include in the package (glob or {glob, name_template}).
3737    pub extra_files: Option<Vec<ExtraFileSpec>>,
3738    /// Remove source archives from artifacts, keeping only PKG.
3739    pub replace: Option<bool>,
3740    /// Output timestamp for reproducible builds.
3741    pub mod_timestamp: Option<String>,
3742    /// Which artifact type to package: "binary" (default) or "appbundle".
3743    #[serde(rename = "use")]
3744    pub use_: Option<String>,
3745    /// Minimum macOS version (e.g. "10.13"). Forwarded to `productbuild --min-os-version`.
3746    pub min_os_version: Option<String>,
3747    /// Disable this PKG config. Accepts bool or template string.
3748    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
3749    pub disable: Option<StringOrBool>,
3750    /// Template-conditional: skip this PKG config if rendered result is "false"
3751    /// or empty (GoReleaser Pro). Render failure hard-errors (not silent-skip).
3752    #[serde(rename = "if")]
3753    pub if_condition: Option<String>,
3754}
3755
3756// ---------------------------------------------------------------------------
3757// NsisConfig (Windows NSIS installer)
3758// ---------------------------------------------------------------------------
3759
3760#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3761#[serde(default)]
3762pub struct NsisConfig {
3763    /// Unique identifier for this NSIS config.
3764    pub id: Option<String>,
3765    /// Build IDs to include. Empty means all builds.
3766    pub ids: Option<Vec<String>>,
3767    /// Output installer filename (supports templates).
3768    pub name: Option<String>,
3769    /// Path to the NSIS script template (.nsi). Goes through template engine.
3770    pub script: Option<String>,
3771    /// Additional files to include alongside the installer (glob or {glob, name_template}).
3772    pub extra_files: Option<Vec<ExtraFileSpec>>,
3773    /// Extra files whose contents are rendered through the template engine before inclusion.
3774    /// Unlike `extra_files` which copy as-is, template variables like `{{ .Tag }}` are expanded.
3775    /// GoReleaser Pro feature.
3776    pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
3777    /// Disable this NSIS config. Accepts bool or template string.
3778    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
3779    pub disable: Option<StringOrBool>,
3780    /// Remove source archives from artifacts, keeping only the installer.
3781    pub replace: Option<bool>,
3782    /// Output timestamp for reproducible builds.
3783    pub mod_timestamp: Option<String>,
3784    /// Template-conditional: skip this NSIS config if rendered result is "false"
3785    /// or empty (GoReleaser Pro). Render failure hard-errors (not silent-skip).
3786    #[serde(rename = "if")]
3787    pub if_condition: Option<String>,
3788}
3789
3790// ---------------------------------------------------------------------------
3791// AppBundleConfig (macOS .app bundle)
3792// ---------------------------------------------------------------------------
3793
3794#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3795#[serde(default)]
3796pub struct AppBundleConfig {
3797    /// Unique identifier for this app bundle config.
3798    pub id: Option<String>,
3799    /// Build IDs to include. Empty means all builds.
3800    pub ids: Option<Vec<String>>,
3801    /// Output .app bundle name (supports templates).
3802    pub name: Option<String>,
3803    /// Path to .icns icon file for the app bundle (supports templates).
3804    pub icon: Option<String>,
3805    /// Bundle identifier in reverse-DNS notation (e.g. com.example.myapp). Required.
3806    pub bundle: Option<String>,
3807    /// Additional files to include in the bundle (src/dst/info objects or glob strings).
3808    pub extra_files: Option<Vec<ArchiveFileSpec>>,
3809    /// Extra files whose contents are rendered through the template engine before inclusion.
3810    /// Unlike `extra_files` which copy as-is, template variables like `{{ .Tag }}` are expanded.
3811    /// GoReleaser Pro feature.
3812    pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
3813    /// Output timestamp for reproducible builds.
3814    pub mod_timestamp: Option<String>,
3815    /// Remove source archives from artifacts, keeping only the app bundle.
3816    pub replace: Option<bool>,
3817    /// Disable this app bundle config. Accepts bool or template string.
3818    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
3819    pub disable: Option<StringOrBool>,
3820    /// Template-conditional: skip this app bundle config if rendered result is
3821    /// "false" or empty (GoReleaser Pro). Render failure hard-errors (not silent-skip).
3822    #[serde(rename = "if")]
3823    pub if_condition: Option<String>,
3824}
3825
3826// ---------------------------------------------------------------------------
3827// FlatpakConfig (Linux Flatpak bundle)
3828// ---------------------------------------------------------------------------
3829
3830#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3831#[serde(default)]
3832pub struct FlatpakConfig {
3833    /// Unique identifier for this Flatpak config.
3834    pub id: Option<String>,
3835    /// Build IDs to include. Empty means all builds.
3836    pub ids: Option<Vec<String>>,
3837    /// Output .flatpak filename (supports templates).
3838    pub name_template: Option<String>,
3839    /// Flatpak application ID in reverse-DNS notation (e.g. org.example.MyApp). Required.
3840    pub app_id: Option<String>,
3841    /// Flatpak runtime (e.g. org.freedesktop.Platform). Required.
3842    pub runtime: Option<String>,
3843    /// Flatpak runtime version (e.g. "24.08"). Required.
3844    pub runtime_version: Option<String>,
3845    /// Flatpak SDK (e.g. org.freedesktop.Sdk). Required.
3846    pub sdk: Option<String>,
3847    /// Command to run inside the Flatpak sandbox. Defaults to first binary name.
3848    pub command: Option<String>,
3849    /// Sandbox permissions (e.g. --share=network, --socket=x11).
3850    pub finish_args: Option<Vec<String>>,
3851    /// Additional files to include alongside the binary (glob or {glob, name_template}).
3852    pub extra_files: Option<Vec<ExtraFileSpec>>,
3853    /// Remove source archives from artifacts, keeping only the Flatpak bundle.
3854    pub replace: Option<bool>,
3855    /// Output timestamp for reproducible builds.
3856    pub mod_timestamp: Option<String>,
3857    /// Disable this Flatpak config. Accepts bool or template string.
3858    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
3859    pub disable: Option<StringOrBool>,
3860}
3861
3862// ---------------------------------------------------------------------------
3863// BlobConfig (S3/GCS/Azure cloud storage)
3864// ---------------------------------------------------------------------------
3865
3866#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3867#[serde(default)]
3868pub struct BlobConfig {
3869    /// Unique identifier for this blob config.
3870    pub id: Option<String>,
3871    /// Cloud storage provider: s3, gcs (or gs), or azblob (or azure).
3872    pub provider: String,
3873    /// Bucket or container name (supports templates).
3874    pub bucket: String,
3875    /// Directory/folder within the bucket (supports templates).
3876    /// Default: `{{ ProjectName }}/{{ Tag }}`.
3877    pub directory: Option<String>,
3878    /// AWS region (S3 only).
3879    pub region: Option<String>,
3880    /// Custom endpoint URL for S3-compatible storage (e.g. MinIO, R2, DO Spaces).
3881    pub endpoint: Option<String>,
3882    /// Disable SSL for the connection (S3 only, default: false).
3883    pub disable_ssl: Option<bool>,
3884    /// Enable path-style addressing for S3-compatible backends.
3885    /// Defaults to `true` when `endpoint` is set (MinIO, R2, DO Spaces need this),
3886    /// `false` otherwise (standard AWS virtual-hosted style).
3887    pub s3_force_path_style: Option<bool>,
3888    /// ACL for uploaded objects (S3, e.g. "public-read", "private").
3889    pub acl: Option<String>,
3890    /// HTTP Cache-Control header values, joined with ", " when uploading.
3891    /// Accepts a string (single value) or array of strings in YAML.
3892    #[serde(deserialize_with = "deserialize_string_or_vec_opt", default)]
3893    pub cache_control: Option<Vec<String>>,
3894    /// HTTP Content-Disposition header (supports templates).
3895    /// Default: `"attachment;filename={{Filename}}"`. Set to `"-"` to disable.
3896    pub content_disposition: Option<String>,
3897    /// AWS KMS encryption key for server-side encryption (S3 only).
3898    pub kms_key: Option<String>,
3899    /// Build IDs to include. Empty means all artifacts.
3900    pub ids: Option<Vec<String>>,
3901    /// Disable this blob config. Accepts bool or template string
3902    /// (e.g. `"{{ if IsSnapshot }}true{{ endif }}"` for conditional disable).
3903    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
3904    pub disable: Option<StringOrBool>,
3905    /// Also upload metadata.json and artifacts.json.
3906    pub include_meta: Option<bool>,
3907    /// Pre-existing files to upload (supports glob patterns).
3908    pub extra_files: Option<Vec<ExtraFileSpec>>,
3909    /// Extra files whose contents are rendered through the template engine before upload.
3910    /// Unlike `extra_files` which copy as-is, template variables like `{{ .Tag }}` are expanded.
3911    /// GoReleaser Pro feature.
3912    pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
3913    /// Upload only extra files (skip artifacts).
3914    pub extra_files_only: Option<bool>,
3915    /// Maximum number of parallel uploads for this blob config.
3916    /// Overrides the global `--parallelism` setting when set.
3917    pub parallelism: Option<usize>,
3918}
3919
3920// ---------------------------------------------------------------------------
3921// PartialConfig (split/merge CI fan-out)
3922// ---------------------------------------------------------------------------
3923
3924#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3925#[serde(default)]
3926pub struct PartialConfig {
3927    /// How to split builds: "goos" (by OS, default) or "target" (by full triple).
3928    /// "goos" groups all arch variants for the same OS into one split job.
3929    /// "target" gives each unique target triple its own split job.
3930    pub by: Option<String>,
3931}
3932
3933// ---------------------------------------------------------------------------
3934// BinstallConfig
3935// ---------------------------------------------------------------------------
3936
3937#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3938#[serde(default)]
3939pub struct BinstallConfig {
3940    /// When true, generate a .cargo/config.toml binstall section for cargo-binstall.
3941    pub enabled: Option<bool>,
3942    /// Custom download URL template for cargo-binstall (supports templates).
3943    pub pkg_url: Option<String>,
3944    /// Directory within the archive where binaries are located.
3945    pub bin_dir: Option<String>,
3946    /// Package format hint for cargo-binstall: tgz, tar.gz, tar.xz, zip, bin, etc.
3947    pub pkg_fmt: Option<String>,
3948}
3949
3950// ---------------------------------------------------------------------------
3951// NotarizeConfig (macOS code signing and notarization)
3952// ---------------------------------------------------------------------------
3953
3954/// Top-level notarization configuration supporting both cross-platform
3955/// (`rcodesign`) and native macOS (`codesign` + `xcrun notarytool`) modes.
3956#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3957#[serde(default)]
3958pub struct NotarizeConfig {
3959    /// Disable all notarization. Accepts bool or template string.
3960    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
3961    pub disable: Option<StringOrBool>,
3962    /// Cross-platform signing/notarization (rcodesign-based, works on any OS).
3963    pub macos: Option<Vec<MacOSSignNotarizeConfig>>,
3964    /// Native signing/notarization (codesign + xcrun, macOS only).
3965    pub macos_native: Option<Vec<MacOSNativeSignNotarizeConfig>>,
3966}
3967
3968/// Cross-platform macOS signing and notarization via `rcodesign`.
3969#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3970#[serde(default)]
3971pub struct MacOSSignNotarizeConfig {
3972    /// Build IDs to filter. Default: project name.
3973    pub ids: Option<Vec<String>>,
3974    /// Enable this configuration. Accepts bool or template string.
3975    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
3976    pub enabled: Option<StringOrBool>,
3977    /// Signing configuration (P12 certificate).
3978    pub sign: Option<MacOSSignConfig>,
3979    /// Notarization configuration (App Store Connect API key). Omit for sign-only.
3980    pub notarize: Option<MacOSNotarizeApiConfig>,
3981}
3982
3983/// P12-certificate signing configuration for `rcodesign sign`.
3984#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3985#[serde(default)]
3986pub struct MacOSSignConfig {
3987    /// Path to .p12 certificate file or base64-encoded contents. Templates allowed.
3988    pub certificate: Option<String>,
3989    /// Password for the .p12 certificate. Templates allowed.
3990    pub password: Option<String>,
3991    /// Path to entitlements XML file. Templates allowed.
3992    pub entitlements: Option<String>,
3993}
3994
3995/// App Store Connect API key configuration for `rcodesign notary-submit`.
3996#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
3997#[serde(default)]
3998pub struct MacOSNotarizeApiConfig {
3999    /// App Store Connect API key issuer UUID. Templates allowed.
4000    pub issuer_id: Option<String>,
4001    /// Path to .p8 key file or base64-encoded contents. Templates allowed.
4002    pub key: Option<String>,
4003    /// API key ID. Templates allowed.
4004    pub key_id: Option<String>,
4005    /// Timeout for notarization status polling. Default: "10m".
4006    pub timeout: Option<String>,
4007    /// Whether to wait for notarization to complete.
4008    pub wait: Option<bool>,
4009}
4010
4011/// Native macOS signing and notarization via `codesign` + `xcrun notarytool`.
4012#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4013#[serde(default)]
4014pub struct MacOSNativeSignNotarizeConfig {
4015    /// Build IDs to filter. Default: project name.
4016    pub ids: Option<Vec<String>>,
4017    /// Enable this configuration. Accepts bool or template string.
4018    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4019    pub enabled: Option<StringOrBool>,
4020    /// Which artifact type to sign/notarize: "dmg" (default) or "pkg".
4021    #[serde(rename = "use")]
4022    pub use_: Option<String>,
4023    /// Native signing configuration (Keychain).
4024    pub sign: Option<MacOSNativeSignConfig>,
4025    /// Native notarization configuration (xcrun notarytool).
4026    pub notarize: Option<MacOSNativeNotarizeConfig>,
4027}
4028
4029/// Keychain-based signing configuration for native `codesign`.
4030#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4031#[serde(default)]
4032pub struct MacOSNativeSignConfig {
4033    /// Keychain identity (e.g., "Developer ID Application: Name"). Templates allowed.
4034    pub identity: Option<String>,
4035    /// Path to Keychain file. Templates allowed.
4036    pub keychain: Option<String>,
4037    /// Options to pass to codesign (e.g., ["runtime"]). Only used for DMGs.
4038    pub options: Option<Vec<String>>,
4039    /// Path to entitlements XML file. Only used for DMGs. Templates allowed.
4040    pub entitlements: Option<String>,
4041}
4042
4043/// Native notarization configuration for `xcrun notarytool`.
4044#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4045#[serde(default)]
4046pub struct MacOSNativeNotarizeConfig {
4047    /// Notarytool stored credentials profile name. Templates allowed.
4048    pub profile_name: Option<String>,
4049    /// Whether to wait for notarization to complete.
4050    pub wait: Option<bool>,
4051    /// Timeout in seconds for `xcrun notarytool submit --timeout`. Templates allowed.
4052    pub timeout: Option<String>,
4053}
4054
4055// ---------------------------------------------------------------------------
4056// SourceConfig
4057// ---------------------------------------------------------------------------
4058
4059/// An individual file entry for the source archive, supporting src/dst mapping
4060/// and file metadata overrides.
4061#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4062#[serde(default)]
4063pub struct SourceFileEntry {
4064    /// Source file path or glob pattern.
4065    pub src: String,
4066    /// Destination path within the archive prefix directory.
4067    pub dst: Option<String>,
4068    /// Strip the parent directory from the source path.
4069    pub strip_parent: Option<bool>,
4070    /// File metadata overrides.
4071    pub info: Option<SourceFileInfo>,
4072}
4073
4074/// File metadata overrides for source archive entries.
4075#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4076#[serde(default)]
4077pub struct SourceFileInfo {
4078    /// File owner.
4079    pub owner: Option<String>,
4080    /// File group.
4081    pub group: Option<String>,
4082    /// File permissions mode (octal).
4083    pub mode: Option<u32>,
4084    /// Modification time in RFC3339 format (supports templates).
4085    pub mtime: Option<String>,
4086}
4087
4088#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4089#[serde(default)]
4090pub struct SourceConfig {
4091    /// When true, generate a source code archive for the release.
4092    pub enabled: Option<bool>,
4093    /// Archive format for the source tarball: tar.gz, tgz, tar, or zip (default: tar.gz).
4094    pub format: Option<String>,
4095    /// Filename template for the source archive (supports templates).
4096    pub name_template: Option<String>,
4097    /// Prefix prepended to all paths inside the archive (supports templates).
4098    /// Defaults to name_template value. Use this to set a different prefix than the archive name.
4099    pub prefix_template: Option<String>,
4100    /// Extra files to include in the source archive. Accepts strings (glob patterns) or objects with src/dst/info.
4101    #[serde(default, deserialize_with = "deserialize_source_files")]
4102    #[schemars(schema_with = "source_files_schema")]
4103    pub files: Vec<SourceFileEntry>,
4104}
4105
4106impl SourceConfig {
4107    /// Whether source archive generation is enabled (default: false).
4108    pub fn is_enabled(&self) -> bool {
4109        self.enabled.unwrap_or(false)
4110    }
4111
4112    /// Archive format to use (default: "tar.gz").
4113    pub fn archive_format(&self) -> &str {
4114        self.format.as_deref().unwrap_or("tar.gz")
4115    }
4116}
4117
4118/// Helper schema function for the source files field (accepts strings, objects, or mixed arrays).
4119fn source_files_schema(
4120    generator: &mut schemars::r#gen::SchemaGenerator,
4121) -> schemars::schema::Schema {
4122    let mut schema = generator.subschema_for::<Vec<SourceFileEntry>>();
4123    if let schemars::schema::Schema::Object(ref mut obj) = schema {
4124        obj.metadata().description = Some(
4125            "Extra files for the source archive. Accepts strings (glob patterns), objects with src/dst/info, or a mixed array.".to_owned(),
4126        );
4127    }
4128    schema
4129}
4130
4131/// Custom deserializer for the source `files` field.
4132/// Accepts:
4133///   - null/missing → empty vec (via serde default)
4134///   - a single string → vec of one SourceFileEntry with that src
4135///   - a single object → vec of one SourceFileEntry
4136///   - an array of mixed strings/objects → vec of SourceFileEntry
4137fn deserialize_source_files<'de, D>(deserializer: D) -> Result<Vec<SourceFileEntry>, D::Error>
4138where
4139    D: Deserializer<'de>,
4140{
4141    use serde::de::{self, SeqAccess, Visitor};
4142
4143    struct SourceFilesVisitor;
4144
4145    impl<'de> Visitor<'de> for SourceFilesVisitor {
4146        type Value = Vec<SourceFileEntry>;
4147
4148        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4149            f.write_str("a string, a source file entry object, or an array of strings/objects")
4150        }
4151
4152        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
4153            Ok(vec![SourceFileEntry {
4154                src: v.to_string(),
4155                ..Default::default()
4156            }])
4157        }
4158
4159        fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
4160            let mut entries = Vec::new();
4161            while let Some(value) = seq.next_element::<serde_yaml_ng::Value>()? {
4162                match value {
4163                    serde_yaml_ng::Value::String(s) => {
4164                        entries.push(SourceFileEntry {
4165                            src: s,
4166                            ..Default::default()
4167                        });
4168                    }
4169                    other => {
4170                        let entry =
4171                            SourceFileEntry::deserialize(other).map_err(de::Error::custom)?;
4172                        entries.push(entry);
4173                    }
4174                }
4175            }
4176            Ok(entries)
4177        }
4178
4179        fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<Self::Value, M::Error> {
4180            let entry = SourceFileEntry::deserialize(de::value::MapAccessDeserializer::new(map))?;
4181            Ok(vec![entry])
4182        }
4183
4184        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
4185            Ok(Vec::new())
4186        }
4187
4188        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
4189            Ok(Vec::new())
4190        }
4191    }
4192
4193    deserializer.deserialize_any(SourceFilesVisitor)
4194}
4195
4196// ---------------------------------------------------------------------------
4197// SbomConfig
4198// ---------------------------------------------------------------------------
4199
4200#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4201#[serde(default)]
4202pub struct SbomConfig {
4203    /// Unique identifier for this SBOM config (default: "default").
4204    pub id: Option<String>,
4205    /// Command to run for SBOM generation (default: "syft").
4206    pub cmd: Option<String>,
4207    /// Environment variables to pass to the command.
4208    ///
4209    /// Accepts both map form (`KEY: value`) and GoReleaser list form
4210    /// (`- KEY=value`). Values are template-rendered before being set.
4211    #[serde(default, deserialize_with = "deserialize_env_map")]
4212    pub env: Option<HashMap<String, String>>,
4213    /// Command-line arguments (supports templates and $artifact, $document vars).
4214    pub args: Option<Vec<String>>,
4215    /// Output document path templates (supports templates).
4216    pub documents: Option<Vec<String>>,
4217    /// Which artifacts to catalog: "source", "archive", "binary", "package", "diskimage", "installer", "any" (default: "archive").
4218    pub artifacts: Option<String>,
4219    /// Filter by artifact IDs (ignored if artifacts="source").
4220    pub ids: Option<Vec<String>>,
4221    /// Disable this SBOM config. Accepts bool or template string.
4222    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4223    pub disable: Option<StringOrBool>,
4224}
4225
4226/// Custom deserializer for the `sboms` / `sbom` field.
4227/// Accepts:
4228///   - null/missing → empty vec (via serde default)
4229///   - a single object → vec of one SbomConfig
4230///   - an array → vec of SbomConfig
4231fn deserialize_sboms<'de, D>(deserializer: D) -> Result<Vec<SbomConfig>, D::Error>
4232where
4233    D: Deserializer<'de>,
4234{
4235    use serde::de::{self, Visitor};
4236
4237    struct SbomsVisitor;
4238
4239    impl<'de> Visitor<'de> for SbomsVisitor {
4240        type Value = Vec<SbomConfig>;
4241
4242        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4243            f.write_str("an SBOM config object or an array of SBOM config objects")
4244        }
4245
4246        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
4247            let mut configs = Vec::new();
4248            while let Some(item) = seq.next_element::<SbomConfig>()? {
4249                configs.push(item);
4250            }
4251            Ok(configs)
4252        }
4253
4254        fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<Self::Value, M::Error> {
4255            let config = SbomConfig::deserialize(de::value::MapAccessDeserializer::new(map))?;
4256            Ok(vec![config])
4257        }
4258
4259        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
4260            Ok(Vec::new())
4261        }
4262
4263        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
4264            Ok(Vec::new())
4265        }
4266    }
4267
4268    deserializer.deserialize_any(SbomsVisitor)
4269}
4270
4271// ---------------------------------------------------------------------------
4272// VersionSyncConfig
4273// ---------------------------------------------------------------------------
4274
4275#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4276#[serde(default)]
4277pub struct VersionSyncConfig {
4278    /// When true, synchronize the crate version with the git tag during release.
4279    pub enabled: Option<bool>,
4280    /// Sync mode: "cargo" (updates Cargo.toml) or "tag" (derives version from tag).
4281    pub mode: Option<String>,
4282}
4283
4284// ---------------------------------------------------------------------------
4285// ChangelogConfig
4286// ---------------------------------------------------------------------------
4287
4288#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4289#[serde(default)]
4290pub struct ChangelogConfig {
4291    /// Sort order for changelog entries: "asc" or "desc" (default: "asc").
4292    pub sort: Option<String>,
4293    /// Commit message filters to include or exclude from the changelog.
4294    pub filters: Option<ChangelogFilters>,
4295    /// Groups for organizing changelog entries by commit message prefix.
4296    pub groups: Option<Vec<ChangelogGroup>>,
4297    /// Text prepended to the changelog (inline string or path).
4298    pub header: Option<String>,
4299    /// Text appended to the changelog (inline string or path).
4300    pub footer: Option<String>,
4301    /// Disable changelog generation. Accepts bool or template string
4302    /// (e.g. `"{{ if IsSnapshot }}true{{ endif }}"` for conditional disable).
4303    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4304    pub disable: Option<StringOrBool>,
4305    /// Changelog source: `"git"` (default), `"github"`, or `"github-native"`.
4306    /// `"github"` fetches commits via the GitHub API, enriching entries with
4307    /// author login information (available as the `Logins` template variable).
4308    /// `"github-native"` delegates entirely to GitHub's auto-generated notes.
4309    #[serde(rename = "use")]
4310    pub use_source: Option<String>,
4311    /// Hash abbreviation length. Default: 7. Set to -1 to omit the hash entirely.
4312    pub abbrev: Option<i32>,
4313    /// Template for each changelog commit line.
4314    /// Available variables: SHA (full hash), ShortSHA (abbreviated), Message (commit subject),
4315    /// AuthorName, AuthorEmail, Login (per-commit GitHub username, `github` backend only),
4316    /// Logins (comma-separated list of all GitHub usernames in the release, `github` backend only).
4317    /// Default: `"{{ ShortSHA }} {{ Message }}"`
4318    pub format: Option<String>,
4319    /// File paths to filter commits by. Only commits touching files under these
4320    /// paths are included. Works with `use: git` for precise per-commit filtering.
4321    /// With `use: github`, only the first path is used for API queries; multi-path
4322    /// filtering is coarse. Supports template rendering.
4323    pub paths: Option<Vec<String>>,
4324    /// Title heading for the changelog. Default: "Changelog". Supports templates.
4325    pub title: Option<String>,
4326    /// Divider string inserted between changelog groups (e.g. `"---"`). Supports templates.
4327    pub divider: Option<String>,
4328    /// AI-powered changelog enhancement configuration.
4329    pub ai: Option<ChangelogAiConfig>,
4330}
4331
4332/// AI-powered changelog enhancement configuration.
4333#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4334#[serde(default)]
4335pub struct ChangelogAiConfig {
4336    /// AI provider to use. Valid: "anthropic", "openai", "ollama".
4337    /// Empty disables the feature.
4338    #[serde(rename = "use")]
4339    pub provider: Option<String>,
4340    /// Model name (e.g. "gpt-4", "claude-sonnet-4-20250514"). Defaults to provider's default.
4341    pub model: Option<String>,
4342    /// Prompt template for the AI. Can be a string, or use `from_url`/`from_file`.
4343    /// Template variable `.ReleaseNotes` contains the current changelog.
4344    pub prompt: Option<ChangelogAiPrompt>,
4345}
4346
4347/// Prompt source for AI changelog: inline string, URL, or file path.
4348#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
4349#[serde(untagged)]
4350pub enum ChangelogAiPrompt {
4351    /// Inline prompt string (supports templates).
4352    Inline(String),
4353    /// Structured prompt with from_url/from_file sources.
4354    Source(ChangelogAiPromptSource),
4355}
4356
4357/// Structured prompt source: load from URL or file.
4358#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4359#[serde(default)]
4360pub struct ChangelogAiPromptSource {
4361    /// Load prompt from a URL.
4362    pub from_url: Option<ContentFromUrl>,
4363    /// Load prompt from a local file. Overrides from_url if both set.
4364    pub from_file: Option<ContentFromFile>,
4365}
4366
4367/// Resolved prompt source kind after applying priority rules.
4368#[derive(Debug, Clone, PartialEq, Eq)]
4369pub enum ResolvedPromptSource {
4370    /// Load from a local file path.
4371    File(String),
4372    /// Load from a URL (with optional headers).
4373    Url {
4374        url: String,
4375        headers: Option<std::collections::HashMap<String, String>>,
4376    },
4377    /// No source configured.
4378    None,
4379}
4380
4381impl ChangelogAiPromptSource {
4382    /// Resolve the prompt source applying priority: from_file overrides from_url.
4383    pub fn resolve(&self) -> ResolvedPromptSource {
4384        if let Some(ref file) = self.from_file
4385            && let Some(ref path) = file.path
4386        {
4387            return ResolvedPromptSource::File(path.clone());
4388        }
4389        if let Some(ref url_cfg) = self.from_url
4390            && let Some(ref url) = url_cfg.url
4391        {
4392            return ResolvedPromptSource::Url {
4393                url: url.clone(),
4394                headers: url_cfg.headers.clone(),
4395            };
4396        }
4397        ResolvedPromptSource::None
4398    }
4399}
4400
4401/// Load content from a URL with optional headers.
4402#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4403#[serde(default)]
4404pub struct ContentFromUrl {
4405    /// URL to fetch (supports templates).
4406    pub url: Option<String>,
4407    /// HTTP headers to send with the request.
4408    pub headers: Option<std::collections::HashMap<String, String>>,
4409}
4410
4411/// Load content from a local file.
4412#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4413#[serde(default)]
4414pub struct ContentFromFile {
4415    /// Path to the file (supports templates).
4416    pub path: Option<String>,
4417}
4418
4419#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4420#[serde(default)]
4421pub struct ChangelogFilters {
4422    /// Regex patterns: commits matching any of these are excluded from the changelog.
4423    pub exclude: Option<Vec<String>>,
4424    /// Regex patterns: only commits matching at least one of these are included.
4425    pub include: Option<Vec<String>>,
4426}
4427
4428#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4429#[serde(default)]
4430pub struct ChangelogGroup {
4431    /// Section heading for this group (e.g., "Features", "Bug Fixes").
4432    pub title: String,
4433    /// Regex pattern matching commit messages to include in this group.
4434    pub regexp: Option<String>,
4435    /// Sort order for this group relative to other groups (lower = first).
4436    pub order: Option<i32>,
4437    /// Nested subgroups within this group. Rendered as sub-sections (e.g. `###`).
4438    pub groups: Option<Vec<ChangelogGroup>>,
4439}
4440
4441// ---------------------------------------------------------------------------
4442// SignConfig / DockerSignConfig
4443// ---------------------------------------------------------------------------
4444
4445#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4446#[serde(default)]
4447pub struct SignConfig {
4448    /// Unique identifier for this sign config.
4449    pub id: Option<String>,
4450    /// Artifact types to sign: "all", "archive", "binary", "checksum", "package", "sbom" (default: "none").
4451    pub artifacts: Option<String>,
4452    /// Signing command to invoke (default: "cosign" or "gpg").
4453    pub cmd: Option<String>,
4454    /// Arguments passed to the signing command (supports templates with ${artifact} and ${signature}).
4455    pub args: Option<Vec<String>>,
4456    /// Signature output filename template (supports templates).
4457    pub signature: Option<String>,
4458    /// Content written to the signing command's stdin.
4459    pub stdin: Option<String>,
4460    /// Path to a file whose content is written to the signing command's stdin.
4461    pub stdin_file: Option<String>,
4462    /// Build IDs filter: only sign artifacts from builds whose `id` is in this list.
4463    pub ids: Option<Vec<String>>,
4464    /// Environment variables passed to the signing command.
4465    #[serde(default, deserialize_with = "deserialize_env_map")]
4466    pub env: Option<HashMap<String, String>>,
4467    /// Certificate file to embed in the signature (Cosign bundle signing).
4468    pub certificate: Option<String>,
4469    /// Capture and log stdout/stderr of the signing command.
4470    /// Accepts bool or template string (e.g., "{{ .IsSnapshot }}").
4471    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4472    pub output: Option<StringOrBool>,
4473    /// Template-conditional: skip this sign config if rendered result is "false" or empty.
4474    #[serde(rename = "if")]
4475    pub if_condition: Option<String>,
4476}
4477
4478#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4479#[serde(default)]
4480pub struct DockerSignConfig {
4481    /// Unique identifier for this docker sign config.
4482    pub id: Option<String>,
4483    /// Docker artifact types to sign: "all", "image", or "manifest" (default: "none").
4484    pub artifacts: Option<String>,
4485    /// Signing command to invoke (default: "cosign").
4486    pub cmd: Option<String>,
4487    /// Arguments passed to the signing command (supports templates).
4488    pub args: Option<Vec<String>>,
4489    /// Signature output filename template (supports templates).
4490    pub signature: Option<String>,
4491    /// Certificate file to embed in the signature (Cosign bundle signing).
4492    pub certificate: Option<String>,
4493    /// Docker config IDs filter: only sign images from configs whose `id` is in this list.
4494    pub ids: Option<Vec<String>>,
4495    /// Content written to the signing command's stdin.
4496    pub stdin: Option<String>,
4497    /// Path to a file whose content is written to the signing command's stdin.
4498    pub stdin_file: Option<String>,
4499    /// Environment variables passed to the signing command.
4500    #[serde(default, deserialize_with = "deserialize_env_map")]
4501    pub env: Option<HashMap<String, String>>,
4502    /// Capture and log stdout/stderr of the docker signing command.
4503    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4504    pub output: Option<StringOrBool>,
4505    /// Template-conditional: skip this docker sign config if rendered result is "false" or empty.
4506    #[serde(rename = "if")]
4507    pub if_condition: Option<String>,
4508}
4509
4510// ---------------------------------------------------------------------------
4511// UpxConfig
4512// ---------------------------------------------------------------------------
4513
4514#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
4515#[serde(default)]
4516pub struct UpxConfig {
4517    /// Unique identifier for this UPX config.
4518    pub id: Option<String>,
4519    /// Build IDs filter: only compress binaries from builds whose `id` is in this list.
4520    pub ids: Option<Vec<String>>,
4521    /// Whether to compress binaries with UPX.
4522    /// Accepts bool or template string (GoReleaser parity: `tmpl.Bool(upx.Enabled)`).
4523    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4524    pub enabled: Option<StringOrBool>,
4525    /// UPX executable path or name (default: "upx").
4526    pub binary: String,
4527    /// Extra arguments passed to UPX (e.g., ["-9", "--brute"]).
4528    pub args: Vec<String>,
4529    /// When true, fail the build if UPX is not found.
4530    pub required: bool,
4531    /// Target triples to compress binaries for (empty means all targets).
4532    pub targets: Option<Vec<String>>,
4533    /// UPX compression level string (e.g., "1"-"9", "best"). Maps to `--compress` flag.
4534    pub compress: Option<String>,
4535    /// Use LZMA compression (--lzma flag).
4536    pub lzma: Option<bool>,
4537    /// Use brute-force compression (--brute flag). Very slow but produces smallest output.
4538    pub brute: Option<bool>,
4539}
4540
4541impl Default for UpxConfig {
4542    fn default() -> Self {
4543        UpxConfig {
4544            id: None,
4545            ids: None,
4546            enabled: None,
4547            binary: "upx".to_string(),
4548            args: Vec::new(),
4549            required: false,
4550            targets: None,
4551            compress: None,
4552            lzma: None,
4553            brute: None,
4554        }
4555    }
4556}
4557
4558/// Custom deserializer for the `upx` field.
4559/// Accepts:
4560///   - null/missing → empty vec (via serde default)
4561///   - a single object → vec of one UpxConfig
4562///   - an array → vec of UpxConfig
4563fn deserialize_upx<'de, D>(deserializer: D) -> Result<Vec<UpxConfig>, D::Error>
4564where
4565    D: Deserializer<'de>,
4566{
4567    use serde::de::{self, Visitor};
4568
4569    struct UpxVisitor;
4570
4571    impl<'de> Visitor<'de> for UpxVisitor {
4572        type Value = Vec<UpxConfig>;
4573
4574        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4575            f.write_str("a UPX config object or an array of UPX config objects")
4576        }
4577
4578        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
4579            let mut configs = Vec::new();
4580            while let Some(item) = seq.next_element::<UpxConfig>()? {
4581                configs.push(item);
4582            }
4583            Ok(configs)
4584        }
4585
4586        fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<Self::Value, M::Error> {
4587            let config = UpxConfig::deserialize(de::value::MapAccessDeserializer::new(map))?;
4588            Ok(vec![config])
4589        }
4590
4591        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
4592            Ok(Vec::new())
4593        }
4594
4595        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
4596            Ok(Vec::new())
4597        }
4598    }
4599
4600    deserializer.deserialize_any(UpxVisitor)
4601}
4602
4603// ---------------------------------------------------------------------------
4604// SnapshotConfig
4605// ---------------------------------------------------------------------------
4606
4607#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
4608pub struct SnapshotConfig {
4609    /// Version string template for snapshot builds (e.g., "{{ .Commit }}-SNAPSHOT").
4610    /// Primary field is `version_template` (GoReleaser convention); `name_template` is the
4611    /// deprecated alias kept for backwards compatibility.
4612    #[serde(alias = "name_template", rename = "version_template")]
4613    pub name_template: String,
4614}
4615
4616// ---------------------------------------------------------------------------
4617// NightlyConfig
4618// ---------------------------------------------------------------------------
4619
4620#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4621#[serde(default)]
4622pub struct NightlyConfig {
4623    /// Template for the release name. Default: "{{ .ProjectName }}-nightly"
4624    pub name_template: Option<String>,
4625    /// Tag name used for the nightly release. Default: "nightly".
4626    pub tag_name: Option<String>,
4627}
4628
4629// ---------------------------------------------------------------------------
4630// MetadataConfig
4631// ---------------------------------------------------------------------------
4632
4633#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4634#[serde(default)]
4635pub struct MetadataConfig {
4636    /// Human-readable project description (exposed as `{{ .Metadata.Description }}`).
4637    pub description: Option<String>,
4638    /// Project homepage URL (exposed as `{{ .Metadata.Homepage }}`).
4639    pub homepage: Option<String>,
4640    /// Project license identifier, e.g. "MIT" or "Apache-2.0" (exposed as `{{ .Metadata.License }}`).
4641    pub license: Option<String>,
4642    /// List of project maintainers (exposed as `{{ .Metadata.Maintainers }}`).
4643    pub maintainers: Option<Vec<String>>,
4644    /// Global modification timestamp for metadata output files (metadata.json and artifacts.json).
4645    /// Template string (e.g. "{{ .CommitTimestamp }}") or unix timestamp.
4646    /// When set, rendered late in the pipeline and applied as file mtime.
4647    /// Exposed as `{{ .Metadata.ModTimestamp }}`.
4648    pub mod_timestamp: Option<String>,
4649    /// Long-form project description (GoReleaser Pro v2.1+). Supports inline
4650    /// string, `from_file`, or `from_url`. Exposed as `{{ .Metadata.FullDescription }}`.
4651    /// FromUrl is resolved lazily (requires the release stage); FromFile is resolved
4652    /// at context-populate time with template-rendered path.
4653    pub full_description: Option<ContentSource>,
4654    /// Commit author identity for Pro commit workflows (GoReleaser Pro v2.12+).
4655    /// Reuses the shared `CommitAuthorConfig` (name + email + optional signing).
4656    /// Exposed as `{{ .Metadata.CommitAuthor.Name }}` / `{{ .Metadata.CommitAuthor.Email }}`.
4657    pub commit_author: Option<CommitAuthorConfig>,
4658}
4659
4660// ---------------------------------------------------------------------------
4661// TemplateFileConfig
4662// ---------------------------------------------------------------------------
4663
4664/// Configuration for a template file that is rendered through the template
4665/// engine and placed in the dist directory as a release artifact.
4666///
4667/// GoReleaser Pro feature: all rendered template files are uploaded to the
4668/// release by default. Both `src` and `dst` paths support template rendering.
4669#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4670#[serde(default)]
4671pub struct TemplateFileConfig {
4672    /// Identifier for this template file entry (default: "default").
4673    pub id: Option<String>,
4674    /// Source template file path. The file contents are rendered through the template engine.
4675    /// Templates: allowed (in path itself).
4676    pub src: String,
4677    /// Destination filename, prefixed with the dist directory.
4678    /// Templates: allowed.
4679    pub dst: String,
4680    /// File permissions in octal notation as a string, e.g. `"0755"` (default: `"0655"`).
4681    /// Parsed at runtime via `parse_octal_mode()` to avoid YAML interpreting as decimal.
4682    pub mode: Option<String>,
4683}
4684
4685// ---------------------------------------------------------------------------
4686// AnnounceConfig
4687// ---------------------------------------------------------------------------
4688
4689#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4690#[serde(default)]
4691pub struct AnnounceConfig {
4692    /// Template-conditional skip: if rendered to "true", skip the entire announce stage.
4693    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4694    pub skip: Option<StringOrBool>,
4695    /// Discord announcement configuration.
4696    pub discord: Option<DiscordAnnounce>,
4697    /// Discourse announcement configuration.
4698    pub discourse: Option<DiscourseAnnounce>,
4699    /// Slack announcement configuration.
4700    pub slack: Option<SlackAnnounce>,
4701    /// Generic webhook announcement configuration.
4702    pub webhook: Option<WebhookConfig>,
4703    /// Telegram announcement configuration.
4704    pub telegram: Option<TelegramAnnounce>,
4705    /// Microsoft Teams announcement configuration.
4706    pub teams: Option<TeamsAnnounce>,
4707    /// Mattermost announcement configuration.
4708    pub mattermost: Option<MattermostAnnounce>,
4709    /// Email announcement configuration.
4710    pub email: Option<EmailAnnounce>,
4711    /// Reddit announcement configuration.
4712    pub reddit: Option<RedditAnnounce>,
4713    /// Twitter/X announcement configuration.
4714    pub twitter: Option<TwitterAnnounce>,
4715    /// Mastodon announcement configuration.
4716    pub mastodon: Option<MastodonAnnounce>,
4717    /// Bluesky announcement configuration.
4718    pub bluesky: Option<BlueskyAnnounce>,
4719    /// LinkedIn announcement configuration.
4720    pub linkedin: Option<LinkedInAnnounce>,
4721    /// OpenCollective announcement configuration.
4722    pub opencollective: Option<OpenCollectiveAnnounce>,
4723}
4724
4725#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4726#[serde(default)]
4727pub struct BlueskyAnnounce {
4728    /// Enable Bluesky announcements (supports template expressions).
4729    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4730    pub enabled: Option<StringOrBool>,
4731    /// Bluesky handle/username (e.g. "user.bsky.social").
4732    pub username: Option<String>,
4733    /// Message template for the post. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
4734    pub message_template: Option<String>,
4735    /// Override the Bluesky PDS (Personal Data Server) URL. Defaults to
4736    /// `https://bsky.social`. Set this to point at a self-hosted PDS or
4737    /// alternative instance (e.g. `https://pds.example.com`).
4738    pub pds_url: Option<String>,
4739}
4740
4741#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4742#[serde(default)]
4743pub struct DiscourseAnnounce {
4744    /// Enable Discourse announcements (supports template expressions).
4745    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4746    pub enabled: Option<StringOrBool>,
4747    /// Discourse forum URL (e.g. "https://forum.example.com").
4748    pub server: Option<String>,
4749    /// Category ID to post in (required, must be non-zero).
4750    pub category_id: Option<u64>,
4751    /// Username for the API request (default: "system").
4752    pub username: Option<String>,
4753    /// Title template for the forum topic. Default: "{{ .ProjectName }} {{ .Tag }} is out!"
4754    pub title_template: Option<String>,
4755    /// Message body template for the forum topic. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
4756    pub message_template: Option<String>,
4757}
4758
4759#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4760#[serde(default)]
4761pub struct LinkedInAnnounce {
4762    /// Enable LinkedIn announcements. Requires LINKEDIN_ACCESS_TOKEN env var (supports template expressions).
4763    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4764    pub enabled: Option<StringOrBool>,
4765    /// Message template for the LinkedIn share post. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
4766    pub message_template: Option<String>,
4767}
4768
4769#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4770#[serde(default)]
4771pub struct OpenCollectiveAnnounce {
4772    /// Enable OpenCollective announcements. Requires OPENCOLLECTIVE_TOKEN env var (supports template expressions).
4773    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4774    pub enabled: Option<StringOrBool>,
4775    /// Collective slug (e.g. "my-project").
4776    pub slug: Option<String>,
4777    /// Title template for the update. Default: "{{ .Tag }}"
4778    pub title_template: Option<String>,
4779    /// HTML message template for the update. Default includes <br/> and <a> tags with ReleaseURL.
4780    pub message_template: Option<String>,
4781}
4782
4783#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4784#[serde(default)]
4785pub struct TwitterAnnounce {
4786    /// Enable Twitter/X announcements. Requires TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_TOKEN_SECRET env vars (supports template expressions).
4787    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4788    pub enabled: Option<StringOrBool>,
4789    /// Tweet message template. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
4790    pub message_template: Option<String>,
4791}
4792
4793#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4794#[serde(default)]
4795pub struct MastodonAnnounce {
4796    /// Enable Mastodon announcements. Requires `MASTODON_ACCESS_TOKEN` env var (supports template expressions).
4797    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4798    pub enabled: Option<StringOrBool>,
4799    /// Mastodon instance URL (e.g. "https://mastodon.social").
4800    pub server: Option<String>,
4801    /// Toot message template. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
4802    pub message_template: Option<String>,
4803}
4804
4805#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4806#[serde(default)]
4807pub struct DiscordAnnounce {
4808    /// Enable Discord announcements (supports template expressions).
4809    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4810    pub enabled: Option<StringOrBool>,
4811    /// Discord webhook URL. Use templates like `{{ Env.DISCORD_WEBHOOK_ID }}` to reference environment variables.
4812    pub webhook_url: Option<String>,
4813    /// Message template for the Discord embed. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
4814    pub message_template: Option<String>,
4815    /// Author name displayed in the embed.
4816    pub author: Option<String>,
4817    /// Embed color as a decimal integer string (default: "3888754", GoReleaser blue).
4818    /// Parsed to u32 at runtime. Supports template expressions.
4819    pub color: Option<String>,
4820    /// Icon URL for the embed footer.
4821    pub icon_url: Option<String>,
4822}
4823
4824#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4825#[serde(default)]
4826pub struct WebhookConfig {
4827    /// Enable generic webhook announcements (supports template expressions).
4828    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4829    pub enabled: Option<StringOrBool>,
4830    /// Webhook endpoint URL (supports template variables).
4831    pub endpoint_url: Option<String>,
4832    /// Custom HTTP headers to include in the request.
4833    ///
4834    /// Precedence — **anodizer diverges from GoReleaser here**:
4835    /// - anodizer: a config-supplied `Authorization` header wins over the
4836    ///   `BASIC_AUTH_HEADER_VALUE` / `BEARER_TOKEN_HEADER_VALUE` env var.
4837    /// - GoReleaser (webhook.go:104-115): env-supplied `Authorization` is
4838    ///   appended FIRST; most servers honour the first occurrence, so the
4839    ///   env value effectively wins.
4840    ///
4841    /// Migrating configs that relied on env-overriding the config header
4842    /// must either remove the config entry or be reconfigured. Use
4843    /// templated config (`Authorization: "Bearer {{ .Env.MY_TOKEN }}"`) for
4844    /// the cleanest migration.
4845    pub headers: Option<HashMap<String, String>>,
4846    /// Content-Type header value. Default: "application/json".
4847    pub content_type: Option<String>,
4848    /// Message body template. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
4849    pub message_template: Option<String>,
4850    /// When true, skip TLS certificate verification for the webhook endpoint.
4851    pub skip_tls_verify: Option<bool>,
4852    /// HTTP status codes to accept as success (default: [200, 201, 202, 204]).
4853    #[serde(default)]
4854    pub expected_status_codes: Vec<u16>,
4855}
4856
4857#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4858#[serde(default)]
4859pub struct TelegramAnnounce {
4860    /// Enable Telegram announcements. Requires bot_token and chat_id (supports template expressions).
4861    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4862    pub enabled: Option<StringOrBool>,
4863    /// Telegram Bot API token. Get one from @BotFather.
4864    pub bot_token: Option<String>,
4865    /// Telegram chat ID to send the message to (supports template variables).
4866    pub chat_id: Option<String>,
4867    /// Message template. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
4868    pub message_template: Option<String>,
4869    /// Parse mode: "MarkdownV2" or "HTML" (defaults to "MarkdownV2").
4870    pub parse_mode: Option<String>,
4871    /// Message thread ID for sending to a specific topic in a forum group.
4872    /// Supports template expressions; parsed to i64 at runtime.
4873    pub message_thread_id: Option<String>,
4874}
4875
4876#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4877#[serde(default)]
4878pub struct TeamsAnnounce {
4879    /// Enable Microsoft Teams announcements (supports template expressions).
4880    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4881    pub enabled: Option<StringOrBool>,
4882    /// Teams incoming webhook URL.
4883    pub webhook_url: Option<String>,
4884    /// Message template for the Adaptive Card body. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
4885    pub message_template: Option<String>,
4886    /// Title template for the Adaptive Card header.
4887    pub title_template: Option<String>,
4888    /// Theme color for the card (hex string, e.g. "0076D7").
4889    pub color: Option<String>,
4890    /// Icon URL displayed in the card header.
4891    pub icon_url: Option<String>,
4892}
4893
4894#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4895#[serde(default)]
4896pub struct MattermostAnnounce {
4897    /// Enable Mattermost announcements (supports template expressions).
4898    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4899    pub enabled: Option<StringOrBool>,
4900    /// Mattermost incoming webhook URL.
4901    pub webhook_url: Option<String>,
4902    /// Channel override (e.g. "town-square").
4903    pub channel: Option<String>,
4904    /// Username override for the bot post.
4905    pub username: Option<String>,
4906    /// Icon URL for the bot post.
4907    pub icon_url: Option<String>,
4908    /// Icon emoji for the bot post (e.g. ":rocket:").
4909    pub icon_emoji: Option<String>,
4910    /// Attachment color (hex string, e.g. "#36a64f").
4911    pub color: Option<String>,
4912    /// Message template for the Mattermost post. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
4913    pub message_template: Option<String>,
4914    /// Title template for the Mattermost attachment.
4915    pub title_template: Option<String>,
4916}
4917
4918#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4919#[serde(default)]
4920pub struct EmailAnnounce {
4921    /// Enable email announcements (supports template expressions).
4922    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4923    pub enabled: Option<StringOrBool>,
4924    /// SMTP server hostname. When set, uses SMTP transport.
4925    /// When absent, falls back to sendmail/msmtp.
4926    pub host: Option<String>,
4927    /// SMTP server port (default: 587 for STARTTLS).
4928    pub port: Option<u16>,
4929    /// SMTP username (can also be set via SMTP_USERNAME env var).
4930    pub username: Option<String>,
4931    /// Sender email address.
4932    pub from: Option<String>,
4933    /// Recipient email addresses.
4934    #[serde(default)]
4935    pub to: Vec<String>,
4936    /// Email subject template. Default: "{{ .ProjectName }} {{ .Tag }} is out!"
4937    pub subject_template: Option<String>,
4938    /// Body template (called body_template in GoReleaser, message_template here for consistency).
4939    #[serde(alias = "body_template")]
4940    pub message_template: Option<String>,
4941    /// Skip TLS certificate verification (default: false).
4942    pub insecure_skip_verify: Option<bool>,
4943}
4944
4945#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4946#[serde(default)]
4947pub struct RedditAnnounce {
4948    /// Enable Reddit announcements. Requires REDDIT_SECRET and REDDIT_PASSWORD env vars (supports template expressions).
4949    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4950    pub enabled: Option<StringOrBool>,
4951    /// Reddit application (OAuth client) ID.
4952    pub application_id: Option<String>,
4953    /// Reddit username for posting.
4954    pub username: Option<String>,
4955    /// Subreddit to post to (without /r/ prefix).
4956    pub sub: Option<String>,
4957    /// Title template for the Reddit link post. Default: "{{ .ProjectName }} {{ .Tag }} is out!"
4958    pub title_template: Option<String>,
4959    /// URL template for the Reddit link post. Default: "{{ .ReleaseURL }}"
4960    pub url_template: Option<String>,
4961}
4962
4963#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4964#[serde(default)]
4965pub struct SlackAnnounce {
4966    /// Enable Slack announcements (supports template expressions).
4967    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
4968    pub enabled: Option<StringOrBool>,
4969    /// Slack incoming webhook URL. Use template `{{ Env.SLACK_WEBHOOK }}` to reference an environment variable.
4970    pub webhook_url: Option<String>,
4971    /// Message template for the Slack post. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
4972    pub message_template: Option<String>,
4973    /// Override the webhook's default channel (e.g. "#releases").
4974    pub channel: Option<String>,
4975    /// Override the webhook's default username (e.g. "release-bot").
4976    pub username: Option<String>,
4977    /// Override the webhook's default icon with an emoji (e.g. ":rocket:").
4978    pub icon_emoji: Option<String>,
4979    /// Override the webhook's default icon with an image URL.
4980    pub icon_url: Option<String>,
4981    /// Slack Block Kit blocks (typed for schema validation).
4982    pub blocks: Option<Vec<SlackBlock>>,
4983    /// Slack legacy attachments (typed for schema validation).
4984    pub attachments: Option<Vec<SlackAttachment>>,
4985}
4986
4987/// A Slack Block Kit block element.
4988/// Common fields are typed; additional block-type-specific fields are captured via flatten.
4989#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4990pub struct SlackBlock {
4991    /// Block type (e.g., "header", "section", "divider", "actions", "context", "image").
4992    #[serde(rename = "type")]
4993    pub block_type: String,
4994    /// Text object for the block (used by header, section, context types).
4995    #[serde(default, skip_serializing_if = "Option::is_none")]
4996    pub text: Option<SlackTextObject>,
4997    /// Block ID for interactive payloads.
4998    #[serde(default, skip_serializing_if = "Option::is_none")]
4999    pub block_id: Option<String>,
5000    /// Additional block-specific fields (elements, accessory, fields, etc.).
5001    #[serde(flatten)]
5002    pub extra: HashMap<String, serde_json::Value>,
5003}
5004
5005/// A Slack text composition object.
5006#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5007pub struct SlackTextObject {
5008    /// Text type: "plain_text" or "mrkdwn".
5009    #[serde(rename = "type")]
5010    pub text_type: String,
5011    /// Text content (supports template variables).
5012    pub text: String,
5013    /// Whether to render emoji shortcodes (plain_text only).
5014    #[serde(default, skip_serializing_if = "Option::is_none")]
5015    pub emoji: Option<bool>,
5016    /// Whether to render verbatim (mrkdwn only).
5017    #[serde(default, skip_serializing_if = "Option::is_none")]
5018    pub verbatim: Option<bool>,
5019}
5020
5021/// A Slack legacy attachment.
5022/// Common fields are typed; additional fields are captured via flatten.
5023#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5024pub struct SlackAttachment {
5025    /// Attachment sidebar color (hex string, e.g., "#36a64f" for green).
5026    #[serde(default, skip_serializing_if = "Option::is_none")]
5027    pub color: Option<String>,
5028    /// Main body text of the attachment (supports template variables).
5029    #[serde(default, skip_serializing_if = "Option::is_none")]
5030    pub text: Option<String>,
5031    /// Bold title text at the top of the attachment.
5032    #[serde(default, skip_serializing_if = "Option::is_none")]
5033    pub title: Option<String>,
5034    /// Plain-text summary shown in notifications that cannot render attachments.
5035    #[serde(default, skip_serializing_if = "Option::is_none")]
5036    pub fallback: Option<String>,
5037    /// Text shown above the attachment block.
5038    #[serde(default, skip_serializing_if = "Option::is_none")]
5039    pub pretext: Option<String>,
5040    /// Small text shown at the bottom of the attachment.
5041    #[serde(default, skip_serializing_if = "Option::is_none")]
5042    pub footer: Option<String>,
5043    /// Additional attachment-specific fields.
5044    #[serde(flatten)]
5045    pub extra: HashMap<String, serde_json::Value>,
5046}
5047
5048// ---------------------------------------------------------------------------
5049// DockerHub description sync
5050// ---------------------------------------------------------------------------
5051
5052/// DockerHub description sync configuration.
5053/// Pushes image descriptions and README content to DockerHub repositories.
5054#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5055#[serde(default)]
5056pub struct DockerHubConfig {
5057    /// DockerHub username for authentication.
5058    pub username: Option<String>,
5059    /// Environment variable name containing the DockerHub token.
5060    pub secret_name: Option<String>,
5061    /// DockerHub image names to update (e.g. `myorg/myapp`).
5062    pub images: Option<Vec<String>>,
5063    /// Short description for the DockerHub repository (max 100 chars).
5064    pub description: Option<String>,
5065    /// Full description (README) source for the DockerHub repository.
5066    pub full_description: Option<DockerHubFullDescription>,
5067    /// Disable this publisher. Accepts bool or template string.
5068    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
5069    pub disable: Option<StringOrBool>,
5070}
5071
5072/// Full description source for DockerHub: either from a URL or a local file.
5073#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5074#[serde(default)]
5075pub struct DockerHubFullDescription {
5076    /// Fetch full description content from a URL.
5077    pub from_url: Option<DockerHubFromUrl>,
5078    /// Read full description content from a local file.
5079    pub from_file: Option<DockerHubFromFile>,
5080}
5081
5082/// Fetch DockerHub full description content from a URL.
5083#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5084#[serde(default)]
5085pub struct DockerHubFromUrl {
5086    /// URL to fetch the full description from.
5087    pub url: String,
5088    /// Optional HTTP headers for the request.
5089    pub headers: Option<HashMap<String, String>>,
5090}
5091
5092/// Read DockerHub full description content from a local file.
5093#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5094#[serde(default)]
5095pub struct DockerHubFromFile {
5096    /// Path to the file containing the full description.
5097    pub path: String,
5098}
5099
5100// ---------------------------------------------------------------------------
5101// Artifactory publisher
5102// ---------------------------------------------------------------------------
5103
5104/// Artifactory upload configuration.
5105/// Uploads artifacts to JFrog Artifactory repositories.
5106#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5107#[serde(default)]
5108pub struct ArtifactoryConfig {
5109    /// Human-readable name for this publisher (used in logs).
5110    pub name: Option<String>,
5111    /// Target URL template for uploads (supports template variables).
5112    pub target: Option<String>,
5113    /// Upload mode: "archive" (upload archives) or "binary" (upload binaries).
5114    pub mode: Option<String>,
5115    /// Artifactory username for authentication.
5116    pub username: Option<String>,
5117    /// Artifactory password or API key (or env var reference).
5118    pub password: Option<String>,
5119    /// Build IDs filter: only upload artifacts from builds whose `id` is in this list.
5120    pub ids: Option<Vec<String>>,
5121    /// File extension filter: only upload artifacts matching these extensions.
5122    pub exts: Option<Vec<String>>,
5123    /// Path to client X.509 certificate for mTLS authentication.
5124    pub client_x509_cert: Option<String>,
5125    /// Path to client X.509 private key for mTLS authentication.
5126    pub client_x509_key: Option<String>,
5127    /// Custom HTTP headers sent with each upload request.
5128    pub custom_headers: Option<HashMap<String, String>>,
5129    /// Header name used for checksum verification (e.g. `X-Checksum-Sha256`).
5130    pub checksum_header: Option<String>,
5131    /// Extra files to upload alongside build artifacts.
5132    pub extra_files: Option<Vec<ExtraFileSpec>>,
5133    /// Include checksums in uploaded artifacts.
5134    pub checksum: Option<bool>,
5135    /// Include signatures in uploaded artifacts.
5136    pub signature: Option<bool>,
5137    /// Include metadata artifacts in uploaded artifacts.
5138    pub meta: Option<bool>,
5139    /// Use custom artifact naming instead of default.
5140    pub custom_artifact_name: Option<bool>,
5141    /// When true, upload only extra_files (skip normal artifacts).
5142    pub extra_files_only: Option<bool>,
5143    /// HTTP method to use for uploads (default: "PUT").
5144    pub method: Option<String>,
5145    /// PEM-encoded trusted CA certificates for TLS verification.
5146    /// Appended to the system certificate pool.
5147    pub trusted_certificates: Option<String>,
5148    /// Template-conditional skip: if rendered result is `"true"`, skip this publisher.
5149    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
5150    pub skip: Option<StringOrBool>,
5151    /// Disable this Artifactory entry entirely. Accepts bool or template string
5152    /// (e.g. `"{{ if .IsSnapshot }}true{{ endif }}"` for conditional disable).
5153    /// Matches GoReleaser's Upload publisher `disable` field.
5154    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
5155    pub disable: Option<StringOrBool>,
5156}
5157
5158// ---------------------------------------------------------------------------
5159// CloudSmith publisher
5160// ---------------------------------------------------------------------------
5161
5162/// CloudSmith publisher configuration.
5163/// Pushes packages to CloudSmith repositories.
5164#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5165#[serde(default)]
5166pub struct CloudSmithConfig {
5167    /// CloudSmith organization slug.
5168    pub organization: Option<String>,
5169    /// CloudSmith repository slug.
5170    pub repository: Option<String>,
5171    /// Build IDs filter: only publish artifacts from builds whose `id` is in this list.
5172    pub ids: Option<Vec<String>>,
5173    /// Package format filter: only publish artifacts matching these formats.
5174    pub formats: Option<Vec<String>>,
5175    /// Distribution mapping per format (e.g. `deb: "ubuntu/focal"`).
5176    pub distributions: Option<HashMap<String, serde_json::Value>>,
5177    /// Debian component name (e.g. "main").
5178    pub component: Option<String>,
5179    /// Environment variable name containing the CloudSmith API key.
5180    pub secret_name: Option<String>,
5181    /// Template-conditional skip: if rendered result is `"true"`, skip this publisher.
5182    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
5183    pub skip: Option<StringOrBool>,
5184    /// When true, allow republishing over existing package versions.
5185    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
5186    pub republish: Option<StringOrBool>,
5187}
5188
5189// ---------------------------------------------------------------------------
5190// PublisherConfig
5191// ---------------------------------------------------------------------------
5192
5193#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5194#[serde(default)]
5195pub struct PublisherConfig {
5196    /// Human-readable name for this publisher (used in logs).
5197    pub name: Option<String>,
5198    /// Command to invoke for publishing.
5199    pub cmd: String,
5200    /// Arguments passed to the publish command (supports templates).
5201    pub args: Option<Vec<String>>,
5202    /// Build IDs filter: only publish artifacts from builds whose `id` is in this list.
5203    pub ids: Option<Vec<String>>,
5204    /// Artifact type filter: only publish artifacts of these types (e.g., "archive", "binary").
5205    pub artifact_types: Option<Vec<String>>,
5206    /// Environment variables passed to the publish command.
5207    #[serde(default, deserialize_with = "deserialize_env_map")]
5208    pub env: Option<HashMap<String, String>>,
5209    /// Working directory for the publisher command.
5210    pub dir: Option<String>,
5211    /// Template-conditional disable: if rendered result is `"true"`, skip this publisher.
5212    /// Accepts bool or template string (e.g. `"{{ if .IsSnapshot }}true{{ endif }}"`).
5213    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
5214    pub disable: Option<StringOrBool>,
5215    /// Include checksums in published artifacts.
5216    pub checksum: Option<bool>,
5217    /// Include signatures in published artifacts.
5218    pub signature: Option<bool>,
5219    /// Include metadata artifacts in published artifacts.
5220    pub meta: Option<bool>,
5221    /// Extra files to include in publishing (glob patterns with optional name override).
5222    pub extra_files: Option<Vec<ExtraFileSpec>>,
5223    /// Extra files whose contents are rendered through the template engine before publishing.
5224    /// Unlike `extra_files` which copy as-is, template variables like `{{ .Tag }}` are expanded.
5225    /// GoReleaser Pro feature.
5226    pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
5227}
5228
5229// ---------------------------------------------------------------------------
5230// HooksConfig
5231// ---------------------------------------------------------------------------
5232
5233/// Top-level lifecycle hooks for `before` and `after` blocks.
5234/// Each block has `pre` and `post` lists of hook commands that run around the
5235/// entire pipeline (not individual stages).
5236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
5237#[serde(default)]
5238pub struct HooksConfig {
5239    /// Commands to run before the pipeline or stage starts. Matches GoReleaser
5240    /// `before.hooks` canonically.
5241    pub hooks: Option<Vec<HookEntry>>,
5242    /// Commands to run after the pipeline or stage completes. Anodizer extension
5243    /// (GoReleaser has no top-level `after:` block).
5244    pub post: Option<Vec<HookEntry>>,
5245}
5246
5247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
5248#[serde(default)]
5249pub struct StructuredHook {
5250    /// Command to run (passed through the shell).
5251    pub cmd: String,
5252    /// Working directory for the command (defaults to project root).
5253    pub dir: Option<String>,
5254    /// Environment variables for the command.
5255    #[serde(default, deserialize_with = "deserialize_env_map")]
5256    pub env: Option<HashMap<String, String>>,
5257    /// When true, capture and log stdout/stderr of the command.
5258    pub output: Option<bool>,
5259}
5260
5261#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
5262#[serde(untagged)]
5263pub enum HookEntry {
5264    Simple(String),
5265    Structured(StructuredHook),
5266}
5267
5268impl PartialEq<&str> for HookEntry {
5269    fn eq(&self, other: &&str) -> bool {
5270        match self {
5271            HookEntry::Simple(s) => s.as_str() == *other,
5272            HookEntry::Structured(h) => h.cmd.as_str() == *other,
5273        }
5274    }
5275}
5276
5277impl<'de> Deserialize<'de> for HookEntry {
5278    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5279    where
5280        D: Deserializer<'de>,
5281    {
5282        let value = serde_json::Value::deserialize(deserializer)?;
5283        match &value {
5284            serde_json::Value::String(s) => Ok(HookEntry::Simple(s.clone())),
5285            serde_json::Value::Object(_) => {
5286                let hook: StructuredHook =
5287                    serde_json::from_value(value).map_err(serde::de::Error::custom)?;
5288                Ok(HookEntry::Structured(hook))
5289            }
5290            _ => Err(serde::de::Error::custom(
5291                "hook entry must be a string or an object with cmd/dir/env/output",
5292            )),
5293        }
5294    }
5295}
5296
5297// ---------------------------------------------------------------------------
5298// GitConfig
5299// ---------------------------------------------------------------------------
5300
5301/// Git-level tag discovery and sorting settings.
5302///
5303/// Controls how anodizer discovers and orders tags when determining the current
5304/// and previous versions. This is separate from `TagConfig`, which controls
5305/// version *bumping* logic.
5306#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5307#[serde(default)]
5308pub struct GitConfig {
5309    /// How to sort git tags when determining the latest version.
5310    ///
5311    /// Accepted values:
5312    /// - `"-version:refname"` (default) — lexicographic version sort on the tag name.
5313    /// - `"-version:creatordate"` — sort by the tag's creation date (newest first).
5314    pub tag_sort: Option<String>,
5315    /// Tag patterns to ignore during version detection (supports templates).
5316    /// Tags matching any pattern in this list are excluded from version
5317    /// detection entirely.
5318    pub ignore_tags: Option<Vec<String>>,
5319    /// Tag prefixes to ignore during version detection (supports templates).
5320    /// Tags starting with any prefix in this list are excluded.
5321    /// Mirrors GoReleaser Pro's ignore_tag_prefixes feature.
5322    pub ignore_tag_prefixes: Option<Vec<String>>,
5323    /// Suffix that identifies pre-release tags for sorting purposes.
5324    /// When set, tags ending with this suffix are treated as pre-releases
5325    /// and sorted accordingly during tag discovery.
5326    pub prerelease_suffix: Option<String>,
5327}
5328
5329// ---------------------------------------------------------------------------
5330// MonorepoConfig
5331// ---------------------------------------------------------------------------
5332
5333/// GoReleaser Pro monorepo configuration.
5334///
5335/// When configured, tag discovery filters by `tag_prefix` and the working
5336/// directory is scoped to `dir`.
5337///
5338/// This is DIFFERENT from `TagConfig.tag_prefix`:
5339/// - `MonorepoConfig.tag_prefix`: tags in git already HAVE the prefix
5340///   (e.g. `subproject1/v1.2.3`). The prefix is STRIPPED for `{{ .Tag }}`
5341///   while `{{ .PrefixedTag }}` retains the full tag.
5342/// - `TagConfig.tag_prefix`: a prefix to PREPEND when constructing
5343///   `{{ .PrefixedTag }}` from a plain tag.
5344///
5345/// When `monorepo` is configured, it takes precedence over `tag.tag_prefix`
5346/// for `PrefixedTag` / `PrefixedPreviousTag` behavior.
5347#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5348#[serde(default, deny_unknown_fields)]
5349pub struct MonorepoConfig {
5350    /// Tag prefix for this subproject (e.g. `"subproject1/"`).
5351    ///
5352    /// Tags matching this prefix are selected during tag discovery, and the
5353    /// prefix is stripped from `{{ .Tag }}` while `{{ .PrefixedTag }}` retains
5354    /// the full tag.
5355    pub tag_prefix: Option<String>,
5356    /// Working directory for this subproject.
5357    ///
5358    /// Used for changelog path filtering (when no explicit `changelog.paths`
5359    /// or `crate.path` is configured) and as the default build `dir`.
5360    pub dir: Option<String>,
5361}
5362
5363// ---------------------------------------------------------------------------
5364// TagConfig
5365// ---------------------------------------------------------------------------
5366
5367#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5368#[serde(default)]
5369pub struct TagConfig {
5370    /// Default version bump type when no conventional commit token is found: "major", "minor", "patch", or "none".
5371    pub default_bump: Option<String>,
5372    /// Prefix prepended to version tags (e.g., "v" produces "v1.2.3").
5373    pub tag_prefix: Option<String>,
5374    /// Branch name patterns (supports wildcards) that trigger releases (default: ["master", "main"]).
5375    pub release_branches: Option<Vec<String>>,
5376    /// Custom version tag to use instead of auto-incrementing.
5377    pub custom_tag: Option<String>,
5378    /// Source for determining the previous tag: "repo" (default) or "branch".
5379    pub tag_context: Option<String>,
5380    /// Branch history mode for determining the previous tag: "full" or "last".
5381    pub branch_history: Option<String>,
5382    /// Version string to use when no previous tag exists (default: "0.1.0").
5383    pub initial_version: Option<String>,
5384    /// When true, apply a pre-release suffix to the generated version.
5385    pub prerelease: Option<bool>,
5386    /// Suffix appended to pre-release versions (e.g., "beta").
5387    pub prerelease_suffix: Option<String>,
5388    /// When true, create a new tag even if no commits have changed since the last tag.
5389    pub force_without_changes: Option<bool>,
5390    /// Like force_without_changes but only for pre-release versions.
5391    pub force_without_changes_pre: Option<bool>,
5392    /// Conventional commit token triggering a major bump (default: "major").
5393    pub major_string_token: Option<String>,
5394    /// Conventional commit token triggering a minor bump (default: "minor" or "feat").
5395    pub minor_string_token: Option<String>,
5396    /// Conventional commit token triggering a patch bump (default: "patch" or "fix").
5397    pub patch_string_token: Option<String>,
5398    /// Conventional commit token suppressing a version bump entirely (default: "none").
5399    pub none_string_token: Option<String>,
5400    /// When true, use the GitHub/GitLab API for tagging instead of git CLI.
5401    pub git_api_tagging: Option<bool>,
5402    /// When true, print verbose tag calculation output.
5403    pub verbose: Option<bool>,
5404    /// Commands to run before `anodizer tag` creates the tag. Useful for updating
5405    /// lockfiles or committing sibling changes that must be part of the tagged
5406    /// commit. Env: `ANODIZER_CURRENT_TAG`, `ANODIZER_PREVIOUS_TAG` are set;
5407    /// template vars `{{ .Tag }}`, `{{ .PreviousTag }}`, `{{ .Version }}`,
5408    /// `{{ .PrefixedTag }}` are available.
5409    pub tag_pre_hooks: Option<Vec<HookEntry>>,
5410    /// Commands to run after `anodizer tag` successfully creates and pushes the
5411    /// tag. Env and template vars same as `tag_pre_hooks`.
5412    pub tag_post_hooks: Option<Vec<HookEntry>>,
5413}
5414
5415// ---------------------------------------------------------------------------
5416// WorkspaceConfig
5417// ---------------------------------------------------------------------------
5418
5419/// A workspace represents an independent project root within a monorepo.
5420/// Each workspace has its own crates, changelog, and release configuration,
5421/// allowing independently-versioned components that aren't Cargo workspace members.
5422#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
5423#[serde(default, deny_unknown_fields)]
5424pub struct WorkspaceConfig {
5425    /// Workspace identifier used in logs and template variables.
5426    pub name: String,
5427    /// Crates belonging to this workspace.
5428    pub crates: Vec<CrateConfig>,
5429    /// Changelog configuration for this workspace.
5430    pub changelog: Option<ChangelogConfig>,
5431    /// Signing configurations for binaries, archives, and checksums.
5432    #[serde(default, alias = "sign", deserialize_with = "deserialize_signs")]
5433    #[schemars(schema_with = "signs_schema")]
5434    pub signs: Vec<SignConfig>,
5435    /// Binary-specific signing configs (same shape as `signs` but only for binary artifacts).
5436    #[serde(default, alias = "binary_sign", deserialize_with = "deserialize_signs")]
5437    #[schemars(schema_with = "signs_schema")]
5438    pub binary_signs: Vec<SignConfig>,
5439    /// Hooks run before this workspace's pipeline starts.
5440    pub before: Option<HooksConfig>,
5441    /// Hooks run after this workspace's pipeline completes.
5442    pub after: Option<HooksConfig>,
5443    /// Environment variables scoped to this workspace.
5444    ///
5445    /// Accepts both map form (`MY_VAR: hello`) and GoReleaser list form
5446    /// (`- MY_VAR=hello`). Values are template-rendered at pipeline startup.
5447    #[serde(default, deserialize_with = "deserialize_env_map")]
5448    pub env: Option<HashMap<String, String>>,
5449    /// Pipeline stages to skip when releasing this workspace.
5450    /// Stage names match the CLI `--skip` flag (e.g., `announce`, `publish`).
5451    #[serde(default)]
5452    pub skip: Vec<String>,
5453}
5454
5455// ---------------------------------------------------------------------------
5456// StringOrBool — accepts bool or template string in YAML
5457// ---------------------------------------------------------------------------
5458
5459/// A value that can be either a bool or a template string.
5460/// Used by `disable`, `skip_upload`, and similar fields across multiple config
5461/// structs to support both `disable: true` and template conditionals like
5462/// `disable: "{{ if .IsSnapshot }}true{{ endif }}"`.
5463#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
5464#[serde(untagged)]
5465pub enum StringOrBool {
5466    Bool(bool),
5467    String(String),
5468}
5469
5470impl StringOrBool {
5471    /// Evaluate this value to a bool. If it's a string, treat "true" / "1" as true,
5472    /// everything else as false.
5473    pub fn as_bool(&self) -> bool {
5474        match self {
5475            StringOrBool::Bool(b) => *b,
5476            StringOrBool::String(s) => matches!(s.trim(), "true" | "1"),
5477        }
5478    }
5479
5480    /// Return the raw string value for template rendering, or the bool as a string.
5481    pub fn as_str(&self) -> &str {
5482        match self {
5483            StringOrBool::Bool(true) => "true",
5484            StringOrBool::Bool(false) => "false",
5485            StringOrBool::String(s) => s,
5486        }
5487    }
5488
5489    /// Whether this value contains a template expression that needs rendering.
5490    pub fn is_template(&self) -> bool {
5491        matches!(self, StringOrBool::String(s) if s.contains('{'))
5492    }
5493
5494    /// Evaluate whether this value resolves to `true`.
5495    ///
5496    /// If the value is a template string (contains `{`), it is rendered via
5497    /// the provided closure and the result is compared to `"true"`.
5498    /// Otherwise, the plain bool / string value is evaluated directly.
5499    pub fn evaluates_to_true(&self, render: impl Fn(&str) -> anyhow::Result<String>) -> bool {
5500        if self.is_template() {
5501            render(self.as_str())
5502                .map(|r| r.trim() == "true")
5503                .unwrap_or(false)
5504        } else {
5505            self.as_bool()
5506        }
5507    }
5508
5509    /// Evaluate whether this value means "disabled".
5510    ///
5511    /// Delegates to [`evaluates_to_true`](Self::evaluates_to_true) — a
5512    /// convenience alias with domain-specific semantics.
5513    pub fn is_disabled(&self, render: impl Fn(&str) -> anyhow::Result<String>) -> bool {
5514        self.evaluates_to_true(render)
5515    }
5516}
5517
5518impl Default for StringOrBool {
5519    fn default() -> Self {
5520        StringOrBool::Bool(false)
5521    }
5522}
5523
5524/// Custom deserializer for `Option<StringOrBool>`.
5525fn deserialize_string_or_bool_opt<'de, D>(deserializer: D) -> Result<Option<StringOrBool>, D::Error>
5526where
5527    D: Deserializer<'de>,
5528{
5529    use serde::de::{self, Visitor};
5530
5531    struct StringOrBoolVisitor;
5532
5533    impl<'de> Visitor<'de> for StringOrBoolVisitor {
5534        type Value = Option<StringOrBool>;
5535
5536        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5537            f.write_str("a bool, a string, or null")
5538        }
5539
5540        fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
5541            Ok(Some(StringOrBool::Bool(v)))
5542        }
5543
5544        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
5545            Ok(Some(StringOrBool::String(v.to_owned())))
5546        }
5547
5548        fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
5549            Ok(Some(StringOrBool::String(v)))
5550        }
5551
5552        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
5553            Ok(None)
5554        }
5555
5556        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
5557            Ok(None)
5558        }
5559    }
5560
5561    deserializer.deserialize_any(StringOrBoolVisitor)
5562}
5563
5564/// Custom deserializer for `Option<Vec<String>>` that accepts either a single
5565/// string or an array of strings. Used by `BlobConfig.cache_control`.
5566fn deserialize_string_or_vec_opt<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
5567where
5568    D: Deserializer<'de>,
5569{
5570    use serde::de::{self, Visitor};
5571
5572    struct StringOrVecVisitor;
5573
5574    impl<'de> Visitor<'de> for StringOrVecVisitor {
5575        type Value = Option<Vec<String>>;
5576
5577        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5578            f.write_str("a string, a list of strings, or null")
5579        }
5580
5581        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
5582            Ok(Some(vec![v.to_owned()]))
5583        }
5584
5585        fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
5586            Ok(Some(vec![v]))
5587        }
5588
5589        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
5590            let mut items = Vec::new();
5591            while let Some(item) = seq.next_element::<String>()? {
5592                items.push(item);
5593            }
5594            Ok(Some(items))
5595        }
5596
5597        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
5598            Ok(None)
5599        }
5600
5601        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
5602            Ok(None)
5603        }
5604    }
5605
5606    deserializer.deserialize_any(StringOrVecVisitor)
5607}
5608
5609/// Custom deserializer for `Option<Vec<String>>` that accepts either a
5610/// space-separated string (split into individual tags) or an array of strings.
5611/// Used by `ChocolateyConfig.tags` for GoReleaser compatibility where tags
5612/// are a single space-delimited string.
5613fn deserialize_space_separated_string_or_vec_opt<'de, D>(
5614    deserializer: D,
5615) -> Result<Option<Vec<String>>, D::Error>
5616where
5617    D: Deserializer<'de>,
5618{
5619    use serde::de::{self, Visitor};
5620
5621    struct SpaceSepOrVecVisitor;
5622
5623    impl<'de> Visitor<'de> for SpaceSepOrVecVisitor {
5624        type Value = Option<Vec<String>>;
5625
5626        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5627            f.write_str("a space-separated string, a list of strings, or null")
5628        }
5629
5630        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
5631            let tags: Vec<String> = v.split_whitespace().map(|s| s.to_owned()).collect();
5632            if tags.is_empty() {
5633                Ok(None)
5634            } else {
5635                Ok(Some(tags))
5636            }
5637        }
5638
5639        fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
5640            self.visit_str(&v)
5641        }
5642
5643        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
5644            let mut items = Vec::new();
5645            while let Some(item) = seq.next_element::<String>()? {
5646                items.push(item);
5647            }
5648            if items.is_empty() {
5649                Ok(None)
5650            } else {
5651                Ok(Some(items))
5652            }
5653        }
5654
5655        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
5656            Ok(None)
5657        }
5658
5659        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
5660            Ok(None)
5661        }
5662    }
5663
5664    deserializer.deserialize_any(SpaceSepOrVecVisitor)
5665}
5666
5667// ---------------------------------------------------------------------------
5668// MakeselfConfig
5669// ---------------------------------------------------------------------------
5670
5671#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5672#[serde(default)]
5673pub struct MakeselfConfig {
5674    /// Unique identifier for this makeself config (default: "default").
5675    pub id: Option<String>,
5676    /// Build IDs filter: only include artifacts whose `id` is in this list.
5677    pub ids: Option<Vec<String>>,
5678    /// Output filename template (default includes project, version, os, arch).
5679    pub name_template: Option<String>,
5680    /// Display name embedded in the self-extracting archive.
5681    pub name: Option<String>,
5682    /// Startup script to run when the archive is extracted and executed.
5683    /// Required — the archive will not be created without this.
5684    pub script: Option<String>,
5685    /// Description for LSM metadata.
5686    pub description: Option<String>,
5687    /// Maintainer for LSM metadata.
5688    pub maintainer: Option<String>,
5689    /// Keywords for LSM metadata.
5690    pub keywords: Option<Vec<String>>,
5691    /// Homepage URL for LSM metadata.
5692    pub homepage: Option<String>,
5693    /// License for LSM metadata.
5694    pub license: Option<String>,
5695    /// Compression algorithm: gzip, bzip2, xz, lzo, compress, or none.
5696    pub compression: Option<String>,
5697    /// Extra arguments passed to the makeself command.
5698    pub extra_args: Option<Vec<String>>,
5699    /// Additional files to include in the archive.
5700    pub files: Option<Vec<MakeselfFile>>,
5701    /// Target OS filter (default: ["linux", "darwin"]).
5702    pub goos: Option<Vec<String>>,
5703    /// Target architecture filter.
5704    pub goarch: Option<Vec<String>>,
5705    /// Disable this config. Accepts bool or template string.
5706    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
5707    pub disable: Option<StringOrBool>,
5708}
5709
5710#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5711#[serde(default)]
5712pub struct MakeselfFile {
5713    /// Source file path (relative to project root).
5714    #[serde(alias = "src")]
5715    pub source: String,
5716    /// Destination path inside the archive.
5717    #[serde(alias = "dst")]
5718    pub destination: Option<String>,
5719    /// Strip the parent directory from the source path.
5720    pub strip_parent: Option<bool>,
5721}
5722
5723/// Deserialize makeselfs: single object → vec of one, array → vec of many.
5724fn deserialize_makeselfs<'de, D>(deserializer: D) -> Result<Vec<MakeselfConfig>, D::Error>
5725where
5726    D: Deserializer<'de>,
5727{
5728    use serde::de::{self, Visitor};
5729
5730    struct MakeselfVisitor;
5731
5732    impl<'de> Visitor<'de> for MakeselfVisitor {
5733        type Value = Vec<MakeselfConfig>;
5734
5735        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5736            f.write_str("a makeself config object or an array of makeself config objects")
5737        }
5738
5739        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
5740            let mut configs = Vec::new();
5741            while let Some(item) = seq.next_element::<MakeselfConfig>()? {
5742                configs.push(item);
5743            }
5744            Ok(configs)
5745        }
5746
5747        fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<Self::Value, M::Error> {
5748            let config = MakeselfConfig::deserialize(de::value::MapAccessDeserializer::new(map))?;
5749            Ok(vec![config])
5750        }
5751
5752        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
5753            Ok(Vec::new())
5754        }
5755
5756        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
5757            Ok(Vec::new())
5758        }
5759    }
5760
5761    deserializer.deserialize_any(MakeselfVisitor)
5762}
5763
5764fn makeselfs_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
5765    let mut schema = generator.subschema_for::<Vec<MakeselfConfig>>();
5766    if let schemars::schema::Schema::Object(ref mut obj) = schema {
5767        obj.metadata().description = Some(
5768            "Makeself self-extracting archive configurations. Accepts a single object or array."
5769                .to_owned(),
5770        );
5771    }
5772    schema
5773}
5774
5775// ---------------------------------------------------------------------------
5776// SrpmConfig
5777// ---------------------------------------------------------------------------
5778
5779#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5780#[serde(default)]
5781pub struct SrpmConfig {
5782    /// Enable source RPM generation. Default: false.
5783    pub enabled: Option<bool>,
5784    /// Package name (default: project_name).
5785    pub package_name: Option<String>,
5786    /// Output filename template.
5787    pub file_name_template: Option<String>,
5788    /// Path to the RPM spec file template.
5789    pub spec_file: Option<String>,
5790    /// RPM epoch.
5791    pub epoch: Option<String>,
5792    /// RPM section.
5793    pub section: Option<String>,
5794    /// Package maintainer.
5795    pub maintainer: Option<String>,
5796    /// Package vendor.
5797    pub vendor: Option<String>,
5798    /// Summary line.
5799    pub summary: Option<String>,
5800    /// RPM group.
5801    pub group: Option<String>,
5802    /// Package description.
5803    pub description: Option<String>,
5804    /// License identifier.
5805    pub license: Option<String>,
5806    /// License file name to include.
5807    pub license_file_name: Option<String>,
5808    /// Homepage URL.
5809    pub url: Option<String>,
5810    /// RPM packager field.
5811    pub packager: Option<String>,
5812    /// Compression algorithm (gzip, xz, zstd, none).
5813    pub compression: Option<String>,
5814    /// Documentation files to include.
5815    pub docs: Option<Vec<String>>,
5816    /// Additional contents to include in the source RPM.
5817    pub contents: Option<Vec<NfpmContentConfig>>,
5818    /// RPM signature configuration.
5819    pub signature: Option<SrpmSignatureConfig>,
5820    /// Disable this config. Accepts bool or template string.
5821    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
5822    pub disable: Option<StringOrBool>,
5823}
5824
5825#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5826#[serde(default)]
5827pub struct SrpmSignatureConfig {
5828    /// Path to the GPG key file for signing.
5829    pub key_file: Option<String>,
5830    /// Passphrase for the GPG key. Falls back to `SRPM_PASSPHRASE` env var.
5831    pub passphrase: Option<String>,
5832}
5833
5834/// NfpmContentConfig is reused for SRPM contents (shared shape with nFPM).
5835#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5836#[serde(default)]
5837pub struct NfpmContentConfig {
5838    /// Source file path.
5839    #[serde(alias = "src")]
5840    pub source: Option<String>,
5841    /// Destination path in the package.
5842    #[serde(alias = "dst")]
5843    pub destination: String,
5844    /// Content type: symlink, ghost, config, dir, tree.
5845    #[serde(rename = "type")]
5846    pub type_: Option<String>,
5847    /// Target packager (e.g., "rpm", "deb").
5848    pub packager: Option<String>,
5849}
5850
5851// ---------------------------------------------------------------------------
5852// MilestoneConfig
5853// ---------------------------------------------------------------------------
5854
5855#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5856#[serde(default)]
5857pub struct MilestoneConfig {
5858    /// Repository owner/name. Auto-detected from git remote if not set.
5859    pub repo: Option<ScmRepoConfig>,
5860    /// Close the milestone on release. Default: false.
5861    pub close: Option<bool>,
5862    /// Fail the pipeline if milestone close fails. Default: false.
5863    pub fail_on_error: Option<bool>,
5864    /// Milestone name template (default: "{{ .Tag }}").
5865    pub name_template: Option<String>,
5866}
5867
5868// ---------------------------------------------------------------------------
5869// UploadConfig (generic HTTP upload)
5870// ---------------------------------------------------------------------------
5871
5872#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5873#[serde(default)]
5874pub struct UploadConfig {
5875    /// Human-readable name for this upload config.
5876    pub name: Option<String>,
5877    /// Build IDs filter: only upload artifacts whose `id` is in this list.
5878    pub ids: Option<Vec<String>>,
5879    /// File extension filter: only upload artifacts with these extensions.
5880    pub exts: Option<Vec<String>>,
5881    /// Target URL template (supports template variables like {{ .ProjectName }}, {{ .Version }}).
5882    pub target: String,
5883    /// Username for HTTP basic auth (or env var template).
5884    pub username: Option<String>,
5885    /// Password for HTTP basic auth (env var template recommended).
5886    pub password: Option<String>,
5887    /// HTTP method: PUT or POST (default: PUT).
5888    pub method: Option<String>,
5889    /// Upload mode: "archive" (default) or "binary".
5890    pub mode: Option<String>,
5891    /// Header name for the SHA256 checksum of the artifact.
5892    pub checksum_header: Option<String>,
5893    /// Path to PEM-encoded trusted CA certificates.
5894    pub trusted_certificates: Option<String>,
5895    /// Path to PEM-encoded client X.509 certificate for mTLS.
5896    pub client_x509_cert: Option<String>,
5897    /// Path to PEM-encoded client X.509 key for mTLS.
5898    pub client_x509_key: Option<String>,
5899    /// Include checksums in uploaded artifacts.
5900    pub checksum: Option<bool>,
5901    /// Include signatures in uploaded artifacts.
5902    pub signature: Option<bool>,
5903    /// Include metadata artifacts in uploaded artifacts.
5904    pub meta: Option<bool>,
5905    /// Custom HTTP headers (each value is template-expanded).
5906    pub custom_headers: Option<HashMap<String, String>>,
5907    /// When true, use the artifact name as-is (don't append to target URL).
5908    pub custom_artifact_name: Option<bool>,
5909    /// Extra files to include in uploading.
5910    pub extra_files: Option<Vec<ExtraFileSpec>>,
5911    /// Upload only extra files, skip normal artifacts.
5912    pub extra_files_only: Option<bool>,
5913    /// Skip condition template (if rendered to "true", skip this upload).
5914    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
5915    pub disable: Option<StringOrBool>,
5916}
5917
5918// ---------------------------------------------------------------------------
5919// AurSourceConfig
5920// ---------------------------------------------------------------------------
5921
5922#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5923#[serde(default)]
5924pub struct AurSourceConfig {
5925    /// Override the package name (default: crate name, no -bin suffix).
5926    #[serde(alias = "package_name")]
5927    pub name: Option<String>,
5928    /// Build IDs filter.
5929    pub ids: Option<Vec<String>>,
5930    /// Commit author with optional signing.
5931    pub commit_author: Option<CommitAuthorConfig>,
5932    /// Custom commit message template.
5933    pub commit_msg_template: Option<String>,
5934    /// Short description of the package.
5935    pub description: Option<String>,
5936    /// Project homepage URL.
5937    pub homepage: Option<String>,
5938    /// SPDX license identifier.
5939    pub license: Option<String>,
5940    /// Skip publishing. `"true"` always skips; `"auto"` skips for prereleases.
5941    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
5942    pub skip_upload: Option<StringOrBool>,
5943    /// Custom URL template for download URLs.
5944    pub url_template: Option<String>,
5945    /// PKGBUILD maintainer entries.
5946    pub maintainers: Option<Vec<String>>,
5947    /// Contributors listed in PKGBUILD comments.
5948    pub contributors: Option<Vec<String>>,
5949    /// Packages this PKGBUILD provides.
5950    pub provides: Option<Vec<String>>,
5951    /// Packages this PKGBUILD conflicts with.
5952    pub conflicts: Option<Vec<String>>,
5953    /// Runtime dependencies.
5954    pub depends: Option<Vec<String>>,
5955    /// Optional dependencies.
5956    pub optdepends: Option<Vec<String>>,
5957    /// Build-time dependencies (source packages need these).
5958    pub makedepends: Option<Vec<String>>,
5959    /// Backup files to preserve on upgrade.
5960    pub backup: Option<Vec<String>>,
5961    /// Package release number (default: "1").
5962    pub rel: Option<String>,
5963    /// Custom `prepare()` function body for PKGBUILD.
5964    pub prepare: Option<String>,
5965    /// Custom `build()` function body for PKGBUILD.
5966    pub build: Option<String>,
5967    /// Custom `package()` function body for PKGBUILD.
5968    pub package: Option<String>,
5969    /// AUR SSH git URL.
5970    pub git_url: Option<String>,
5971    /// Custom SSH command for git operations.
5972    pub git_ssh_command: Option<String>,
5973    /// Path to SSH private key file.
5974    pub private_key: Option<String>,
5975    /// Subdirectory in the git repo for committed files.
5976    pub directory: Option<String>,
5977    /// Disable this config.
5978    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
5979    pub disable: Option<StringOrBool>,
5980    /// Explicit architecture list (default: auto-detect from artifacts).
5981    pub arches: Option<Vec<String>>,
5982}
5983
5984// ---------------------------------------------------------------------------
5985// Tests
5986// ---------------------------------------------------------------------------
5987
5988#[cfg(test)]
5989#[allow(clippy::field_reassign_with_default)]
5990mod tests {
5991    use super::*;
5992
5993    #[test]
5994    fn test_minimal_yaml_config() {
5995        let yaml = r#"
5996project_name: myproject
5997crates:
5998  - name: myproject
5999    path: "."
6000    tag_template: "v{{ .Version }}"
6001"#;
6002        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6003        assert_eq!(config.project_name, "myproject");
6004        assert_eq!(config.crates.len(), 1);
6005        assert_eq!(config.dist, std::path::PathBuf::from("./dist"));
6006    }
6007
6008    #[test]
6009    fn test_minimal_toml_config() {
6010        let toml_str = r#"
6011project_name = "myproject"
6012
6013[[crates]]
6014name = "myproject"
6015path = "."
6016tag_template = "v{{ .Version }}"
6017"#;
6018        let config: Config = toml::from_str(toml_str).unwrap();
6019        assert_eq!(config.project_name, "myproject");
6020    }
6021
6022    #[test]
6023    fn test_full_config_with_defaults() {
6024        let yaml = r#"
6025project_name: cfgd
6026dist: ./dist
6027defaults:
6028  targets:
6029    - x86_64-unknown-linux-gnu
6030    - aarch64-apple-darwin
6031  cross: auto
6032  flags: --release
6033  archives:
6034    format: tar.gz
6035    format_overrides:
6036      - os: windows
6037        format: zip
6038  checksum:
6039    algorithm: sha256
6040crates:
6041  - name: cfgd
6042    path: crates/cfgd
6043    tag_template: "v{{ .Version }}"
6044    builds:
6045      - binary: cfgd
6046        features: []
6047        no_default_features: false
6048    archives:
6049      - name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
6050        files:
6051          - LICENSE
6052    release:
6053      github:
6054        owner: tj-smith47
6055        name: cfgd
6056      draft: false
6057      prerelease: auto
6058      name_template: "{{ .Tag }}"
6059"#;
6060        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6061        let defaults = config.defaults.unwrap();
6062        assert_eq!(defaults.targets.unwrap().len(), 2);
6063        assert_eq!(defaults.cross, Some(CrossStrategy::Auto));
6064        let release = config.crates[0].release.as_ref().unwrap();
6065        assert_eq!(release.name_template, Some("{{ .Tag }}".to_string()));
6066    }
6067
6068    #[test]
6069    fn test_snapshot_config() {
6070        let yaml = r#"
6071project_name: test
6072snapshot:
6073  name_template: "{{ .Version }}-SNAPSHOT-{{ .ShortCommit }}"
6074crates:
6075  - name: test
6076    path: "."
6077    tag_template: "v{{ .Version }}"
6078"#;
6079        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6080        assert_eq!(
6081            config.snapshot.unwrap().name_template,
6082            "{{ .Version }}-SNAPSHOT-{{ .ShortCommit }}"
6083        );
6084    }
6085
6086    #[test]
6087    fn test_archives_false() {
6088        let yaml = r#"
6089project_name: test
6090crates:
6091  - name: operator
6092    path: crates/operator
6093    tag_template: "v{{ .Version }}"
6094    archives: false
6095"#;
6096        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6097        assert!(matches!(
6098            config.crates[0].archives,
6099            ArchivesConfig::Disabled
6100        ));
6101    }
6102
6103    #[test]
6104    fn test_publish_crates_bool_and_object() {
6105        let yaml_bool = r#"
6106project_name: test
6107crates:
6108  - name: a
6109    path: "."
6110    tag_template: "v{{ .Version }}"
6111    publish:
6112      crates: true
6113"#;
6114        let config: Config = serde_yaml_ng::from_str(yaml_bool).unwrap();
6115        assert!(
6116            config.crates[0]
6117                .publish
6118                .as_ref()
6119                .unwrap()
6120                .crates_config()
6121                .enabled
6122        );
6123
6124        let yaml_obj = r#"
6125project_name: test
6126crates:
6127  - name: a
6128    path: "."
6129    tag_template: "v{{ .Version }}"
6130    publish:
6131      crates:
6132        enabled: true
6133        index_timeout: 120
6134"#;
6135        let config: Config = serde_yaml_ng::from_str(yaml_obj).unwrap();
6136        let crates_cfg = config.crates[0].publish.as_ref().unwrap().crates_config();
6137        assert!(crates_cfg.enabled);
6138        assert_eq!(crates_cfg.index_timeout, 120);
6139    }
6140
6141    // ---- MakeLatestConfig tests ----
6142
6143    #[test]
6144    fn test_make_latest_auto() {
6145        let yaml = r#"
6146project_name: test
6147crates:
6148  - name: a
6149    path: "."
6150    tag_template: "v{{ .Version }}"
6151    release:
6152      make_latest: auto
6153"#;
6154        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6155        let release = config.crates[0].release.as_ref().unwrap();
6156        assert_eq!(release.make_latest, Some(MakeLatestConfig::Auto));
6157    }
6158
6159    #[test]
6160    fn test_make_latest_true() {
6161        let yaml = r#"
6162project_name: test
6163crates:
6164  - name: a
6165    path: "."
6166    tag_template: "v{{ .Version }}"
6167    release:
6168      make_latest: true
6169"#;
6170        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6171        let release = config.crates[0].release.as_ref().unwrap();
6172        assert_eq!(release.make_latest, Some(MakeLatestConfig::Bool(true)));
6173    }
6174
6175    #[test]
6176    fn test_make_latest_false() {
6177        let yaml = r#"
6178project_name: test
6179crates:
6180  - name: a
6181    path: "."
6182    tag_template: "v{{ .Version }}"
6183    release:
6184      make_latest: false
6185"#;
6186        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6187        let release = config.crates[0].release.as_ref().unwrap();
6188        assert_eq!(release.make_latest, Some(MakeLatestConfig::Bool(false)));
6189    }
6190
6191    #[test]
6192    fn test_make_latest_omitted() {
6193        let yaml = r#"
6194project_name: test
6195crates:
6196  - name: a
6197    path: "."
6198    tag_template: "v{{ .Version }}"
6199    release:
6200      draft: false
6201"#;
6202        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6203        let release = config.crates[0].release.as_ref().unwrap();
6204        assert_eq!(release.make_latest, None);
6205    }
6206
6207    #[test]
6208    fn test_make_latest_template_string() {
6209        let yaml = r#"
6210project_name: test
6211crates:
6212  - name: a
6213    path: "."
6214    tag_template: "v{{ .Version }}"
6215    release:
6216      make_latest: "{{ if .IsSnapshot }}false{{ else }}true{{ end }}"
6217"#;
6218        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6219        let release = config.crates[0].release.as_ref().unwrap();
6220        assert_eq!(
6221            release.make_latest,
6222            Some(MakeLatestConfig::String(
6223                "{{ if .IsSnapshot }}false{{ else }}true{{ end }}".to_string()
6224            ))
6225        );
6226    }
6227
6228    #[test]
6229    fn test_make_latest_string_true() {
6230        // The string "true" should deserialize to Bool(true) for consistency.
6231        let yaml = r#"
6232project_name: test
6233crates:
6234  - name: a
6235    path: "."
6236    tag_template: "v{{ .Version }}"
6237    release:
6238      make_latest: "true"
6239"#;
6240        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6241        let release = config.crates[0].release.as_ref().unwrap();
6242        assert_eq!(release.make_latest, Some(MakeLatestConfig::Bool(true)));
6243    }
6244
6245    #[test]
6246    fn test_make_latest_string_false() {
6247        // The string "false" should deserialize to Bool(false) for consistency.
6248        let yaml = r#"
6249project_name: test
6250crates:
6251  - name: a
6252    path: "."
6253    tag_template: "v{{ .Version }}"
6254    release:
6255      make_latest: "false"
6256"#;
6257        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6258        let release = config.crates[0].release.as_ref().unwrap();
6259        assert_eq!(release.make_latest, Some(MakeLatestConfig::Bool(false)));
6260    }
6261
6262    // ---- ChangelogConfig header/footer/disable tests ----
6263
6264    #[test]
6265    fn test_changelog_header_footer() {
6266        let yaml = r##"
6267project_name: test
6268changelog:
6269  header: "# My Release Notes"
6270  footer: "---\nGenerated by anodizer"
6271crates:
6272  - name: a
6273    path: "."
6274    tag_template: "v{{ .Version }}"
6275"##;
6276        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6277        let cl = config.changelog.as_ref().unwrap();
6278        assert_eq!(cl.header, Some("# My Release Notes".to_string()));
6279        assert_eq!(cl.footer, Some("---\nGenerated by anodizer".to_string()));
6280    }
6281
6282    #[test]
6283    fn test_changelog_disable() {
6284        let yaml = r#"
6285project_name: test
6286changelog:
6287  disable: true
6288crates:
6289  - name: a
6290    path: "."
6291    tag_template: "v{{ .Version }}"
6292"#;
6293        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6294        let cl = config.changelog.as_ref().unwrap();
6295        assert_eq!(cl.disable, Some(StringOrBool::Bool(true)));
6296    }
6297
6298    #[test]
6299    fn test_changelog_disable_false() {
6300        let yaml = r#"
6301project_name: test
6302changelog:
6303  disable: false
6304  sort: desc
6305crates:
6306  - name: a
6307    path: "."
6308    tag_template: "v{{ .Version }}"
6309"#;
6310        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6311        let cl = config.changelog.as_ref().unwrap();
6312        assert_eq!(cl.disable, Some(StringOrBool::Bool(false)));
6313        assert_eq!(cl.sort, Some("desc".to_string()));
6314    }
6315
6316    // ---- ChecksumConfig disable tests ----
6317
6318    #[test]
6319    fn test_checksum_disable() {
6320        let yaml = r#"
6321project_name: test
6322defaults:
6323  checksum:
6324    disable: true
6325crates:
6326  - name: a
6327    path: "."
6328    tag_template: "v{{ .Version }}"
6329"#;
6330        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6331        let checksum = config.defaults.as_ref().unwrap().checksum.as_ref().unwrap();
6332        assert_eq!(checksum.disable, Some(StringOrBool::Bool(true)));
6333    }
6334
6335    #[test]
6336    fn test_checksum_disable_per_crate() {
6337        let yaml = r#"
6338project_name: test
6339crates:
6340  - name: a
6341    path: "."
6342    tag_template: "v{{ .Version }}"
6343    checksum:
6344      disable: true
6345      algorithm: sha512
6346"#;
6347        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6348        let checksum = config.crates[0].checksum.as_ref().unwrap();
6349        assert_eq!(checksum.disable, Some(StringOrBool::Bool(true)));
6350        assert_eq!(checksum.algorithm, Some("sha512".to_string()));
6351    }
6352
6353    #[test]
6354    fn test_checksum_disable_template_string() {
6355        let yaml = r#"
6356project_name: test
6357defaults:
6358  checksum:
6359    disable: "{{ if .IsSnapshot }}true{{ end }}"
6360crates:
6361  - name: a
6362    path: "."
6363    tag_template: "v{{ .Version }}"
6364"#;
6365        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6366        let checksum = config.defaults.as_ref().unwrap().checksum.as_ref().unwrap();
6367        match &checksum.disable {
6368            Some(StringOrBool::String(s)) => {
6369                assert!(s.contains("IsSnapshot"));
6370            }
6371            other => panic!("expected StringOrBool::String, got {:?}", other),
6372        }
6373    }
6374
6375    #[test]
6376    fn test_checksum_extra_files_object_form() {
6377        let yaml = r#"
6378project_name: test
6379crates:
6380  - name: a
6381    path: "."
6382    tag_template: "v{{ .Version }}"
6383    checksum:
6384      extra_files:
6385        - "dist/*.bin"
6386        - glob: "release/*.deb"
6387          name_template: "{{ .ArtifactName }}.checksum"
6388"#;
6389        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6390        let checksum = config.crates[0].checksum.as_ref().unwrap();
6391        let extra = checksum.extra_files.as_ref().unwrap();
6392        assert_eq!(extra.len(), 2);
6393        assert_eq!(extra[0], ExtraFileSpec::Glob("dist/*.bin".to_string()));
6394        match &extra[1] {
6395            ExtraFileSpec::Detailed {
6396                glob,
6397                name_template,
6398            } => {
6399                assert_eq!(glob, "release/*.deb");
6400                assert_eq!(
6401                    name_template.as_deref(),
6402                    Some("{{ .ArtifactName }}.checksum")
6403                );
6404            }
6405            other => panic!("expected ExtraFileSpec::Detailed, got {:?}", other),
6406        }
6407    }
6408
6409    // ---- MakeLatestConfig serialization roundtrip ----
6410
6411    #[test]
6412    fn test_make_latest_serialize_roundtrip() {
6413        let auto = MakeLatestConfig::Auto;
6414        let json = serde_json::to_string(&auto).unwrap();
6415        assert_eq!(json, "\"auto\"");
6416
6417        let bool_true = MakeLatestConfig::Bool(true);
6418        let json = serde_json::to_string(&bool_true).unwrap();
6419        assert_eq!(json, "true");
6420
6421        let bool_false = MakeLatestConfig::Bool(false);
6422        let json = serde_json::to_string(&bool_false).unwrap();
6423        assert_eq!(json, "false");
6424
6425        let tmpl = MakeLatestConfig::String(
6426            "{{ if .IsSnapshot }}false{{ else }}true{{ end }}".to_string(),
6427        );
6428        let json = serde_json::to_string(&tmpl).unwrap();
6429        assert_eq!(json, "\"{{ if .IsSnapshot }}false{{ else }}true{{ end }}\"");
6430    }
6431
6432    // ---- ReleaseConfig header/footer tests ----
6433
6434    #[test]
6435    fn test_release_header_footer_inline() {
6436        let yaml = r###"
6437project_name: test
6438crates:
6439  - name: a
6440    path: "."
6441    tag_template: "v{{ .Version }}"
6442    release:
6443      header: "## Custom Header"
6444      footer: "---\nPowered by anodizer"
6445"###;
6446        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6447        let release = config.crates[0].release.as_ref().unwrap();
6448        assert_eq!(
6449            release.header,
6450            Some(ContentSource::Inline("## Custom Header".to_string()))
6451        );
6452        assert_eq!(
6453            release.footer,
6454            Some(ContentSource::Inline(
6455                "---\nPowered by anodizer".to_string()
6456            ))
6457        );
6458    }
6459
6460    #[test]
6461    fn test_release_header_footer_from_file() {
6462        let yaml = r#"
6463project_name: test
6464crates:
6465  - name: a
6466    path: "."
6467    tag_template: "v{{ .Version }}"
6468    release:
6469      header:
6470        from_file: ./RELEASE_HEADER.md
6471      footer:
6472        from_file: ./RELEASE_FOOTER.md
6473"#;
6474        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6475        let release = config.crates[0].release.as_ref().unwrap();
6476        assert_eq!(
6477            release.header,
6478            Some(ContentSource::FromFile {
6479                from_file: "./RELEASE_HEADER.md".to_string()
6480            })
6481        );
6482        assert_eq!(
6483            release.footer,
6484            Some(ContentSource::FromFile {
6485                from_file: "./RELEASE_FOOTER.md".to_string()
6486            })
6487        );
6488    }
6489
6490    #[test]
6491    fn test_release_header_footer_from_url() {
6492        let yaml = r#"
6493project_name: test
6494crates:
6495  - name: a
6496    path: "."
6497    tag_template: "v{{ .Version }}"
6498    release:
6499      header:
6500        from_url: https://example.com/header.md
6501      footer:
6502        from_url: https://example.com/footer.md
6503"#;
6504        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6505        let release = config.crates[0].release.as_ref().unwrap();
6506        assert_eq!(
6507            release.header,
6508            Some(ContentSource::FromUrl {
6509                from_url: "https://example.com/header.md".to_string(),
6510                headers: None,
6511            })
6512        );
6513        assert_eq!(
6514            release.footer,
6515            Some(ContentSource::FromUrl {
6516                from_url: "https://example.com/footer.md".to_string(),
6517                headers: None,
6518            })
6519        );
6520    }
6521
6522    #[test]
6523    fn test_release_header_footer_omitted() {
6524        let yaml = r#"
6525project_name: test
6526crates:
6527  - name: a
6528    path: "."
6529    tag_template: "v{{ .Version }}"
6530    release:
6531      draft: false
6532"#;
6533        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6534        let release = config.crates[0].release.as_ref().unwrap();
6535        assert_eq!(release.header, None);
6536        assert_eq!(release.footer, None);
6537    }
6538
6539    // ---- ReleaseConfig extra_files tests ----
6540
6541    #[test]
6542    fn test_release_extra_files_glob_strings() {
6543        let yaml = r#"
6544project_name: test
6545crates:
6546  - name: a
6547    path: "."
6548    tag_template: "v{{ .Version }}"
6549    release:
6550      extra_files:
6551        - "dist/*.sig"
6552        - "CHANGELOG.md"
6553"#;
6554        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6555        let release = config.crates[0].release.as_ref().unwrap();
6556        let files = release.extra_files.as_ref().unwrap();
6557        assert_eq!(files.len(), 2);
6558        assert_eq!(files[0], ExtraFileSpec::Glob("dist/*.sig".to_string()));
6559        assert_eq!(files[1], ExtraFileSpec::Glob("CHANGELOG.md".to_string()));
6560    }
6561
6562    #[test]
6563    fn test_release_extra_files_detailed_objects() {
6564        let yaml = r#"
6565project_name: test
6566crates:
6567  - name: a
6568    path: "."
6569    tag_template: "v{{ .Version }}"
6570    release:
6571      extra_files:
6572        - glob: "dist/*.sig"
6573          name_template: "{{ .ArtifactName }}.sig"
6574        - glob: "docs/*.pdf"
6575"#;
6576        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6577        let release = config.crates[0].release.as_ref().unwrap();
6578        let files = release.extra_files.as_ref().unwrap();
6579        assert_eq!(files.len(), 2);
6580        assert_eq!(files[0].glob(), "dist/*.sig");
6581        assert_eq!(files[0].name_template(), Some("{{ .ArtifactName }}.sig"));
6582        assert_eq!(files[1].glob(), "docs/*.pdf");
6583        assert_eq!(files[1].name_template(), None);
6584    }
6585
6586    #[test]
6587    fn test_release_extra_files_mixed() {
6588        let yaml = r#"
6589project_name: test
6590crates:
6591  - name: a
6592    path: "."
6593    tag_template: "v{{ .Version }}"
6594    release:
6595      extra_files:
6596        - "dist/*.sig"
6597        - glob: "docs/*.pdf"
6598          name_template: "{{ .ArtifactName }}"
6599"#;
6600        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6601        let release = config.crates[0].release.as_ref().unwrap();
6602        let files = release.extra_files.as_ref().unwrap();
6603        assert_eq!(files.len(), 2);
6604        assert_eq!(files[0], ExtraFileSpec::Glob("dist/*.sig".to_string()));
6605        assert_eq!(files[1].glob(), "docs/*.pdf");
6606    }
6607
6608    #[test]
6609    fn test_release_extra_files_omitted() {
6610        let yaml = r#"
6611project_name: test
6612crates:
6613  - name: a
6614    path: "."
6615    tag_template: "v{{ .Version }}"
6616    release:
6617      draft: true
6618"#;
6619        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6620        let release = config.crates[0].release.as_ref().unwrap();
6621        assert_eq!(release.extra_files, None);
6622    }
6623
6624    // ---- ReleaseConfig templated_extra_files tests ----
6625
6626    #[test]
6627    fn test_release_templated_extra_files_parsed() {
6628        let yaml = r#"
6629project_name: test
6630crates:
6631  - name: a
6632    path: "."
6633    tag_template: "v{{ .Version }}"
6634    release:
6635      templated_extra_files:
6636        - src: LICENSE.tpl
6637          dst: LICENSE.txt
6638        - src: README.md.tpl
6639"#;
6640        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6641        let release = config.crates[0].release.as_ref().unwrap();
6642        let tpl = release.templated_extra_files.as_ref().unwrap();
6643        assert_eq!(tpl.len(), 2);
6644        assert_eq!(tpl[0].src, "LICENSE.tpl");
6645        assert_eq!(tpl[0].dst.as_deref(), Some("LICENSE.txt"));
6646        assert_eq!(tpl[1].src, "README.md.tpl");
6647        assert_eq!(tpl[1].dst, None);
6648    }
6649
6650    #[test]
6651    fn test_release_templated_extra_files_defaults_to_none() {
6652        let yaml = r#"
6653project_name: test
6654crates:
6655  - name: a
6656    path: "."
6657    tag_template: "v{{ .Version }}"
6658    release:
6659      draft: true
6660"#;
6661        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6662        let release = config.crates[0].release.as_ref().unwrap();
6663        assert_eq!(release.templated_extra_files, None);
6664    }
6665
6666    #[test]
6667    fn test_checksum_templated_extra_files_parsed() {
6668        let yaml = r#"
6669name_template: "checksums.txt"
6670templated_extra_files:
6671  - src: "notes.tpl"
6672    dst: "RELEASE_NOTES.txt"
6673"#;
6674        let cfg: ChecksumConfig = serde_yaml_ng::from_str(yaml).unwrap();
6675        let tpl = cfg.templated_extra_files.as_ref().unwrap();
6676        assert_eq!(tpl.len(), 1);
6677        assert_eq!(tpl[0].src, "notes.tpl");
6678        assert_eq!(tpl[0].dst.as_deref(), Some("RELEASE_NOTES.txt"));
6679    }
6680
6681    // ---- ReleaseConfig skip_upload tests ----
6682
6683    #[test]
6684    fn test_release_skip_upload() {
6685        let yaml = r#"
6686project_name: test
6687crates:
6688  - name: a
6689    path: "."
6690    tag_template: "v{{ .Version }}"
6691    release:
6692      skip_upload: true
6693"#;
6694        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6695        let release = config.crates[0].release.as_ref().unwrap();
6696        assert_eq!(release.skip_upload, Some(StringOrBool::Bool(true)));
6697    }
6698
6699    #[test]
6700    fn test_release_skip_upload_false() {
6701        let yaml = r#"
6702project_name: test
6703crates:
6704  - name: a
6705    path: "."
6706    tag_template: "v{{ .Version }}"
6707    release:
6708      skip_upload: false
6709"#;
6710        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6711        let release = config.crates[0].release.as_ref().unwrap();
6712        assert_eq!(release.skip_upload, Some(StringOrBool::Bool(false)));
6713    }
6714
6715    #[test]
6716    fn test_release_skip_upload_auto() {
6717        let yaml = r#"
6718project_name: test
6719crates:
6720  - name: a
6721    path: "."
6722    tag_template: "v{{ .Version }}"
6723    release:
6724      skip_upload: "auto"
6725"#;
6726        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6727        let release = config.crates[0].release.as_ref().unwrap();
6728        assert_eq!(
6729            release.skip_upload,
6730            Some(StringOrBool::String("auto".to_string()))
6731        );
6732    }
6733
6734    // ---- ReleaseConfig replace_existing_draft / replace_existing_artifacts tests ----
6735
6736    #[test]
6737    fn test_release_replace_existing_draft() {
6738        let yaml = r#"
6739project_name: test
6740crates:
6741  - name: a
6742    path: "."
6743    tag_template: "v{{ .Version }}"
6744    release:
6745      replace_existing_draft: true
6746"#;
6747        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6748        let release = config.crates[0].release.as_ref().unwrap();
6749        assert_eq!(release.replace_existing_draft, Some(true));
6750    }
6751
6752    #[test]
6753    fn test_release_replace_existing_artifacts() {
6754        let yaml = r#"
6755project_name: test
6756crates:
6757  - name: a
6758    path: "."
6759    tag_template: "v{{ .Version }}"
6760    release:
6761      replace_existing_artifacts: true
6762"#;
6763        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6764        let release = config.crates[0].release.as_ref().unwrap();
6765        assert_eq!(release.replace_existing_artifacts, Some(true));
6766    }
6767
6768    // ---- ReleaseConfig tag override tests ----
6769
6770    #[test]
6771    fn test_release_tag_override_parsed() {
6772        let yaml = r#"
6773project_name: test
6774crates:
6775  - name: a
6776    path: "."
6777    tag_template: "myapp/v{{ .Version }}"
6778    release:
6779      tag: "v{{ .Version }}"
6780"#;
6781        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6782        let release = config.crates[0].release.as_ref().unwrap();
6783        assert_eq!(release.tag, Some("v{{ .Version }}".to_string()));
6784    }
6785
6786    #[test]
6787    fn test_release_tag_override_omitted() {
6788        let yaml = r#"
6789project_name: test
6790crates:
6791  - name: a
6792    path: "."
6793    tag_template: "v{{ .Version }}"
6794    release:
6795      draft: false
6796"#;
6797        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6798        let release = config.crates[0].release.as_ref().unwrap();
6799        assert_eq!(release.tag, None);
6800    }
6801
6802    #[test]
6803    fn test_release_all_new_fields() {
6804        let yaml = r##"
6805project_name: test
6806crates:
6807  - name: a
6808    path: "."
6809    tag_template: "v{{ .Version }}"
6810    release:
6811      github:
6812        owner: myorg
6813        name: myrepo
6814      draft: true
6815      make_latest: auto
6816      header: "# Release Notes"
6817      footer: "Thank you!"
6818      extra_files:
6819        - "dist/extra.zip"
6820      skip_upload: false
6821      replace_existing_draft: true
6822      replace_existing_artifacts: false
6823      target_commitish: main
6824      discussion_category_name: Announcements
6825      include_meta: true
6826      use_existing_draft: false
6827      tag: "v{{ .Version }}"
6828"##;
6829        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6830        let release = config.crates[0].release.as_ref().unwrap();
6831        assert_eq!(
6832            release.header,
6833            Some(ContentSource::Inline("# Release Notes".to_string()))
6834        );
6835        assert_eq!(
6836            release.footer,
6837            Some(ContentSource::Inline("Thank you!".to_string()))
6838        );
6839        assert_eq!(
6840            release.extra_files.as_ref().unwrap(),
6841            &[ExtraFileSpec::Glob("dist/extra.zip".to_string())]
6842        );
6843        assert_eq!(release.skip_upload, Some(StringOrBool::Bool(false)));
6844        assert_eq!(release.replace_existing_draft, Some(true));
6845        assert_eq!(release.replace_existing_artifacts, Some(false));
6846        assert_eq!(release.make_latest, Some(MakeLatestConfig::Auto));
6847        assert_eq!(release.target_commitish, Some("main".to_string()));
6848        assert_eq!(
6849            release.discussion_category_name,
6850            Some("Announcements".to_string())
6851        );
6852        assert_eq!(release.include_meta, Some(true));
6853        assert_eq!(release.use_existing_draft, Some(false));
6854        assert_eq!(release.tag, Some("v{{ .Version }}".to_string()));
6855    }
6856
6857    // ---- SignConfig / signs migration tests ----
6858
6859    #[test]
6860    fn test_signs_single_object_backward_compat() {
6861        let yaml = r#"
6862project_name: test
6863sign:
6864  artifacts: all
6865  cmd: gpg
6866  args:
6867    - "--detach-sig"
6868crates:
6869  - name: a
6870    path: "."
6871    tag_template: "v{{ .Version }}"
6872"#;
6873        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6874        assert_eq!(config.signs.len(), 1);
6875        assert_eq!(config.signs[0].artifacts, Some("all".to_string()));
6876        assert_eq!(config.signs[0].cmd, Some("gpg".to_string()));
6877        assert_eq!(config.signs[0].args.as_ref().unwrap().len(), 1);
6878    }
6879
6880    #[test]
6881    fn test_signs_array_format() {
6882        let yaml = r#"
6883project_name: test
6884signs:
6885  - id: gpg-sign
6886    artifacts: checksum
6887    cmd: gpg
6888    args:
6889      - "--detach-sig"
6890  - id: cosign-sign
6891    artifacts: binary
6892    cmd: cosign
6893    args:
6894      - "sign"
6895crates:
6896  - name: a
6897    path: "."
6898    tag_template: "v{{ .Version }}"
6899"#;
6900        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6901        assert_eq!(config.signs.len(), 2);
6902        assert_eq!(config.signs[0].id, Some("gpg-sign".to_string()));
6903        assert_eq!(config.signs[0].artifacts, Some("checksum".to_string()));
6904        assert_eq!(config.signs[1].id, Some("cosign-sign".to_string()));
6905        assert_eq!(config.signs[1].artifacts, Some("binary".to_string()));
6906    }
6907
6908    #[test]
6909    fn test_signs_omitted_is_empty() {
6910        let yaml = r#"
6911project_name: test
6912crates:
6913  - name: a
6914    path: "."
6915    tag_template: "v{{ .Version }}"
6916"#;
6917        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6918        assert!(config.signs.is_empty());
6919    }
6920
6921    #[test]
6922    fn test_signs_new_fields() {
6923        let yaml = r#"
6924project_name: test
6925signs:
6926  - id: my-signer
6927    artifacts: archive
6928    cmd: gpg
6929    args:
6930      - "--detach-sig"
6931    signature: "{{ .Artifact }}.asc"
6932    stdin: "my-passphrase"
6933    ids:
6934      - my-archive
6935      - my-binary
6936crates:
6937  - name: a
6938    path: "."
6939    tag_template: "v{{ .Version }}"
6940"#;
6941        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6942        assert_eq!(config.signs.len(), 1);
6943        let sign = &config.signs[0];
6944        assert_eq!(sign.id, Some("my-signer".to_string()));
6945        assert_eq!(sign.artifacts, Some("archive".to_string()));
6946        assert_eq!(sign.signature, Some("{{ .Artifact }}.asc".to_string()));
6947        assert_eq!(sign.stdin, Some("my-passphrase".to_string()));
6948        assert_eq!(sign.ids.as_ref().unwrap().len(), 2);
6949        assert_eq!(sign.ids.as_ref().unwrap()[0], "my-archive");
6950    }
6951
6952    #[test]
6953    fn test_signs_stdin_file_field() {
6954        let yaml = r#"
6955project_name: test
6956signs:
6957  - artifacts: all
6958    cmd: gpg
6959    stdin_file: "/path/to/passphrase.txt"
6960crates:
6961  - name: a
6962    path: "."
6963    tag_template: "v{{ .Version }}"
6964"#;
6965        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6966        assert_eq!(config.signs.len(), 1);
6967        assert_eq!(
6968            config.signs[0].stdin_file,
6969            Some("/path/to/passphrase.txt".to_string())
6970        );
6971    }
6972
6973    #[test]
6974    fn test_signs_single_object_with_new_fields() {
6975        let yaml = r#"
6976project_name: test
6977sign:
6978  id: default
6979  artifacts: package
6980  cmd: gpg
6981  signature: "{{ .Artifact }}.sig"
6982  stdin: "pass"
6983  ids:
6984    - pkg-id
6985crates:
6986  - name: a
6987    path: "."
6988    tag_template: "v{{ .Version }}"
6989"#;
6990        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
6991        assert_eq!(config.signs.len(), 1);
6992        let sign = &config.signs[0];
6993        assert_eq!(sign.id, Some("default".to_string()));
6994        assert_eq!(sign.artifacts, Some("package".to_string()));
6995        assert_eq!(sign.signature, Some("{{ .Artifact }}.sig".to_string()));
6996        assert_eq!(sign.stdin, Some("pass".to_string()));
6997        assert_eq!(sign.ids.as_ref().unwrap(), &["pkg-id"]);
6998    }
6999
7000    #[test]
7001    fn test_signs_toml_single_object() {
7002        let toml_str = r#"
7003project_name = "test"
7004
7005[sign]
7006artifacts = "checksum"
7007cmd = "gpg"
7008
7009[[crates]]
7010name = "a"
7011path = "."
7012tag_template = "v{{ .Version }}"
7013"#;
7014        let config: Config = toml::from_str(toml_str).unwrap();
7015        assert_eq!(config.signs.len(), 1);
7016        assert_eq!(config.signs[0].artifacts, Some("checksum".to_string()));
7017    }
7018
7019    #[test]
7020    fn test_signs_toml_array() {
7021        let toml_str = r#"
7022project_name = "test"
7023
7024[[signs]]
7025id = "first"
7026artifacts = "all"
7027cmd = "gpg"
7028
7029[[signs]]
7030id = "second"
7031artifacts = "binary"
7032cmd = "cosign"
7033
7034[[crates]]
7035name = "a"
7036path = "."
7037tag_template = "v{{ .Version }}"
7038"#;
7039        let config: Config = toml::from_str(toml_str).unwrap();
7040        assert_eq!(config.signs.len(), 2);
7041        assert_eq!(config.signs[0].id, Some("first".to_string()));
7042        assert_eq!(config.signs[1].id, Some("second".to_string()));
7043    }
7044
7045    #[test]
7046    fn test_signs_default_config_has_empty_signs() {
7047        let config = Config::default();
7048        assert!(config.signs.is_empty());
7049    }
7050
7051    // ---- report_sizes tests ----
7052
7053    #[test]
7054    fn test_report_sizes_true() {
7055        let yaml = r#"
7056project_name: test
7057report_sizes: true
7058crates:
7059  - name: a
7060    path: "."
7061    tag_template: "v{{ .Version }}"
7062"#;
7063        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7064        assert_eq!(config.report_sizes, Some(true));
7065    }
7066
7067    #[test]
7068    fn test_report_sizes_false() {
7069        let yaml = r#"
7070project_name: test
7071report_sizes: false
7072crates:
7073  - name: a
7074    path: "."
7075    tag_template: "v{{ .Version }}"
7076"#;
7077        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7078        assert_eq!(config.report_sizes, Some(false));
7079    }
7080
7081    #[test]
7082    fn test_report_sizes_omitted() {
7083        let yaml = r#"
7084project_name: test
7085crates:
7086  - name: a
7087    path: "."
7088    tag_template: "v{{ .Version }}"
7089"#;
7090        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7091        assert_eq!(config.report_sizes, None);
7092    }
7093
7094    // ---- env tests ----
7095
7096    #[test]
7097    fn test_env_field_parsed() {
7098        let yaml = r#"
7099project_name: test
7100env:
7101  MY_VAR: hello
7102  DEPLOY_ENV: staging
7103crates:
7104  - name: a
7105    path: "."
7106    tag_template: "v{{ .Version }}"
7107"#;
7108        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7109        let env = config.env.as_ref().unwrap();
7110        assert_eq!(env.get("MY_VAR").unwrap(), "hello");
7111        assert_eq!(env.get("DEPLOY_ENV").unwrap(), "staging");
7112    }
7113
7114    #[test]
7115    fn test_env_field_omitted() {
7116        let yaml = r#"
7117project_name: test
7118crates:
7119  - name: a
7120    path: "."
7121    tag_template: "v{{ .Version }}"
7122"#;
7123        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7124        assert_eq!(config.env, None);
7125    }
7126
7127    #[test]
7128    fn test_env_field_toml() {
7129        let toml_str = r#"
7130project_name = "test"
7131
7132[env]
7133API_KEY = "secret123"
7134STAGE = "prod"
7135
7136[[crates]]
7137name = "a"
7138path = "."
7139tag_template = "v{{ .Version }}"
7140"#;
7141        let config: Config = toml::from_str(toml_str).unwrap();
7142        let env = config.env.as_ref().unwrap();
7143        assert_eq!(env.get("API_KEY").unwrap(), "secret123");
7144        assert_eq!(env.get("STAGE").unwrap(), "prod");
7145    }
7146
7147    #[test]
7148    fn test_env_list_form_toml() {
7149        let toml_str = r#"
7150project_name = "test"
7151env = ["MY_VAR=hello", "STAGE=prod"]
7152crates = []
7153"#;
7154        let config: Config = toml::from_str(toml_str).unwrap();
7155        let env = config.env.as_ref().unwrap();
7156        assert_eq!(env.get("MY_VAR").unwrap(), "hello");
7157        assert_eq!(env.get("STAGE").unwrap(), "prod");
7158    }
7159
7160    // ---- env list form tests (GoReleaser parity) ----
7161
7162    #[test]
7163    fn test_env_list_form_parsed() {
7164        let yaml = r#"
7165project_name: test
7166env:
7167  - MY_VAR=hello
7168  - DEPLOY_ENV=staging
7169crates: []
7170"#;
7171        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7172        let env = config.env.as_ref().unwrap();
7173        assert_eq!(env.get("MY_VAR").unwrap(), "hello");
7174        assert_eq!(env.get("DEPLOY_ENV").unwrap(), "staging");
7175    }
7176
7177    #[test]
7178    fn test_env_list_form_with_template_expressions() {
7179        let yaml = r#"
7180project_name: test
7181env:
7182  - "MY_VERSION={{ .Tag }}"
7183  - "BUILD_DATE={{ .Date }}"
7184crates: []
7185"#;
7186        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7187        let env = config.env.as_ref().unwrap();
7188        // Values are stored raw; template rendering happens at setup_env time.
7189        assert_eq!(env.get("MY_VERSION").unwrap(), "{{ .Tag }}");
7190        assert_eq!(env.get("BUILD_DATE").unwrap(), "{{ .Date }}");
7191    }
7192
7193    #[test]
7194    fn test_env_list_form_value_with_equals() {
7195        // Values can contain = signs (only the first = splits key from value).
7196        let yaml = r#"
7197project_name: test
7198env:
7199  - "LDFLAGS=-X main.version=1.0.0"
7200crates: []
7201"#;
7202        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7203        let env = config.env.as_ref().unwrap();
7204        assert_eq!(
7205            env.get("LDFLAGS").unwrap(),
7206            "-X main.version=1.0.0",
7207            "only first = should split key from value"
7208        );
7209    }
7210
7211    #[test]
7212    fn test_env_list_form_empty_value() {
7213        let yaml = r#"
7214project_name: test
7215env:
7216  - "EMPTY_VAR="
7217crates: []
7218"#;
7219        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7220        let env = config.env.as_ref().unwrap();
7221        assert_eq!(env.get("EMPTY_VAR").unwrap(), "");
7222    }
7223
7224    #[test]
7225    fn test_env_list_form_no_equals_is_error() {
7226        let yaml = r#"
7227project_name: test
7228env:
7229  - "NO_EQUALS"
7230crates: []
7231"#;
7232        let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
7233        assert!(result.is_err(), "list entries without = should be rejected");
7234        let err = result.unwrap_err().to_string();
7235        assert!(
7236            err.contains("KEY=VALUE"),
7237            "error should mention KEY=VALUE format, got: {}",
7238            err
7239        );
7240    }
7241
7242    #[test]
7243    fn test_env_list_form_empty_key_is_error() {
7244        let yaml = r#"
7245project_name: test
7246env:
7247  - "=orphan_value"
7248crates: []
7249"#;
7250        let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
7251        assert!(
7252            result.is_err(),
7253            "list entries with empty key should be rejected"
7254        );
7255        let err = result.unwrap_err().to_string();
7256        assert!(
7257            err.contains("empty key"),
7258            "error should mention empty key, got: {}",
7259            err
7260        );
7261    }
7262
7263    #[test]
7264    fn test_env_list_form_last_wins_on_duplicates() {
7265        let yaml = r#"
7266project_name: test
7267env:
7268  - "DUPED=first"
7269  - "DUPED=second"
7270crates: []
7271"#;
7272        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7273        let env = config.env.as_ref().unwrap();
7274        assert_eq!(
7275            env.get("DUPED").unwrap(),
7276            "second",
7277            "later entries should override earlier ones"
7278        );
7279    }
7280
7281    #[test]
7282    fn test_workspace_env_list_form() {
7283        let yaml = r#"
7284project_name: test
7285crates: []
7286workspaces:
7287  - name: ws1
7288    crates: []
7289    env:
7290      - "WS_VAR=from-workspace"
7291      - "WS_BUILD={{ .Tag }}"
7292"#;
7293        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7294        let ws = &config.workspaces.as_ref().unwrap()[0];
7295        let env = ws.env.as_ref().unwrap();
7296        assert_eq!(env.get("WS_VAR").unwrap(), "from-workspace");
7297        assert_eq!(env.get("WS_BUILD").unwrap(), "{{ .Tag }}");
7298    }
7299
7300    // ---- Error path tests (Task 3B) ----
7301
7302    #[test]
7303    fn test_malformed_yaml_syntax_error() {
7304        let yaml = r#"
7305project_name: test
7306crates:
7307  - name: a
7308    path: "."
7309    tag_template: "v{{ .Version }}"
7310  invalid_indentation
7311    this_is_broken: [
7312"#;
7313        let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
7314        assert!(result.is_err(), "malformed YAML should fail to parse");
7315        let err = result.unwrap_err().to_string();
7316        // Serde_yaml errors include line/column info
7317        assert!(!err.is_empty(), "error message should not be empty");
7318    }
7319
7320    #[test]
7321    fn test_type_mismatch_string_where_array_expected() {
7322        let yaml = r#"
7323project_name: test
7324crates: "this should be an array"
7325"#;
7326        let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
7327        assert!(result.is_err(), "string where array expected should fail");
7328        let err = result.unwrap_err().to_string();
7329        assert!(
7330            err.contains("invalid type") || err.contains("expected a sequence"),
7331            "error should mention type mismatch, got: {err}"
7332        );
7333    }
7334
7335    #[test]
7336    fn test_type_mismatch_object_where_string_expected() {
7337        // An object (mapping) where a string is expected for project_name
7338        // should be rejected by serde_yaml_ng, unlike a number which gets coerced.
7339        let yaml = r#"
7340project_name:
7341  nested: object
7342  another: field
7343crates:
7344  - name: a
7345    path: "."
7346    tag_template: "v{{ .Version }}"
7347"#;
7348        let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
7349        assert!(
7350            result.is_err(),
7351            "mapping where string expected should fail to parse"
7352        );
7353        let err = result.unwrap_err().to_string();
7354        assert!(
7355            err.contains("invalid type") || err.contains("expected a string"),
7356            "error should mention type mismatch, got: {err}"
7357        );
7358    }
7359
7360    #[test]
7361    fn test_type_mismatch_bool_where_array_expected_for_targets() {
7362        let yaml = r#"
7363project_name: test
7364defaults:
7365  targets: true
7366crates:
7367  - name: a
7368    path: "."
7369    tag_template: "v{{ .Version }}"
7370"#;
7371        let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
7372        assert!(
7373            result.is_err(),
7374            "bool where array expected for targets should fail"
7375        );
7376        let err = result.unwrap_err().to_string();
7377        assert!(
7378            err.contains("invalid type")
7379                || err.contains("expected a sequence")
7380                || err.contains("targets"),
7381            "error should mention type mismatch for targets, got: {err}"
7382        );
7383    }
7384
7385    #[test]
7386    fn test_invalid_cross_strategy_value() {
7387        let yaml = r#"
7388project_name: test
7389defaults:
7390  cross: invalid_strategy
7391crates:
7392  - name: a
7393    path: "."
7394    tag_template: "v{{ .Version }}"
7395"#;
7396        let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
7397        assert!(
7398            result.is_err(),
7399            "invalid cross strategy should fail to parse"
7400        );
7401        let err = result.unwrap_err().to_string();
7402        assert!(
7403            err.contains("unknown variant") || err.contains("invalid_strategy"),
7404            "error should mention the invalid variant, got: {err}"
7405        );
7406    }
7407
7408    #[test]
7409    fn test_prerelease_invalid_string_value() {
7410        let yaml = r#"
7411project_name: test
7412crates:
7413  - name: a
7414    path: "."
7415    tag_template: "v{{ .Version }}"
7416    release:
7417      prerelease: "always"
7418"#;
7419        let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
7420        assert!(
7421            result.is_err(),
7422            "prerelease: 'always' should fail (only 'auto' or bool accepted)"
7423        );
7424        let err = result.unwrap_err().to_string();
7425        assert!(
7426            err.contains("auto") || err.contains("always"),
7427            "error should mention expected values, got: {err}"
7428        );
7429    }
7430
7431    #[test]
7432    fn test_archives_true_is_invalid() {
7433        let yaml = r#"
7434project_name: test
7435crates:
7436  - name: a
7437    path: "."
7438    tag_template: "v{{ .Version }}"
7439    archives: true
7440"#;
7441        let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
7442        assert!(
7443            result.is_err(),
7444            "archives: true should be rejected (only false or array accepted)"
7445        );
7446        let err = result.unwrap_err().to_string();
7447        assert!(
7448            err.contains("true is not valid") || err.contains("false or a list"),
7449            "error should explain valid archives values, got: {err}"
7450        );
7451    }
7452
7453    #[test]
7454    fn test_completely_empty_yaml() {
7455        // Empty YAML deserializes to defaults because Config uses #[serde(default)].
7456        // serde_yaml_ng treats empty input as `null`, which the default impl handles.
7457        let yaml = "";
7458        let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
7459        let config =
7460            result.unwrap_or_else(|e| panic!("empty YAML should parse to Config defaults: {e}"));
7461        assert!(
7462            config.project_name.is_empty(),
7463            "default project_name should be empty"
7464        );
7465        assert!(config.crates.is_empty(), "default crates should be empty");
7466        assert_eq!(
7467            config.dist,
7468            std::path::PathBuf::from("./dist"),
7469            "default dist should be ./dist"
7470        );
7471    }
7472
7473    // ---- Unknown fields tests ----
7474
7475    // ---- BinstallConfig / VersionSyncConfig tests ----
7476
7477    #[test]
7478    fn test_binstall_config_parsed() {
7479        let yaml = r#"
7480project_name: test
7481crates:
7482  - name: myapp
7483    path: "."
7484    tag_template: "v{{ .Version }}"
7485    binstall:
7486      enabled: true
7487      pkg_url: "https://example.com/{{ .Version }}/{ target }"
7488      bin_dir: "{ bin }{ binary-ext }"
7489      pkg_fmt: tgz
7490"#;
7491        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7492        let bs = config.crates[0].binstall.as_ref().unwrap();
7493        assert_eq!(bs.enabled, Some(true));
7494        assert_eq!(
7495            bs.pkg_url,
7496            Some("https://example.com/{{ .Version }}/{ target }".to_string())
7497        );
7498        assert_eq!(bs.bin_dir, Some("{ bin }{ binary-ext }".to_string()));
7499        assert_eq!(bs.pkg_fmt, Some("tgz".to_string()));
7500    }
7501
7502    #[test]
7503    fn test_binstall_config_omitted() {
7504        let yaml = r#"
7505project_name: test
7506crates:
7507  - name: myapp
7508    path: "."
7509    tag_template: "v{{ .Version }}"
7510"#;
7511        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7512        assert!(config.crates[0].binstall.is_none());
7513    }
7514
7515    #[test]
7516    fn test_binstall_config_partial() {
7517        let yaml = r#"
7518project_name: test
7519crates:
7520  - name: myapp
7521    path: "."
7522    tag_template: "v{{ .Version }}"
7523    binstall:
7524      enabled: true
7525"#;
7526        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7527        let bs = config.crates[0].binstall.as_ref().unwrap();
7528        assert_eq!(bs.enabled, Some(true));
7529        assert_eq!(bs.pkg_url, None);
7530        assert_eq!(bs.bin_dir, None);
7531        assert_eq!(bs.pkg_fmt, None);
7532    }
7533
7534    #[test]
7535    fn test_version_sync_config_parsed() {
7536        let yaml = r#"
7537project_name: test
7538crates:
7539  - name: myapp
7540    path: "."
7541    tag_template: "v{{ .Version }}"
7542    version_sync:
7543      enabled: true
7544      mode: tag
7545"#;
7546        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7547        let vs = config.crates[0].version_sync.as_ref().unwrap();
7548        assert_eq!(vs.enabled, Some(true));
7549        assert_eq!(vs.mode, Some("tag".to_string()));
7550    }
7551
7552    #[test]
7553    fn test_version_sync_config_explicit_mode() {
7554        let yaml = r#"
7555project_name: test
7556crates:
7557  - name: myapp
7558    path: "."
7559    tag_template: "v{{ .Version }}"
7560    version_sync:
7561      enabled: true
7562      mode: explicit
7563"#;
7564        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7565        let vs = config.crates[0].version_sync.as_ref().unwrap();
7566        assert_eq!(vs.mode, Some("explicit".to_string()));
7567    }
7568
7569    #[test]
7570    fn test_version_sync_config_omitted() {
7571        let yaml = r#"
7572project_name: test
7573crates:
7574  - name: myapp
7575    path: "."
7576    tag_template: "v{{ .Version }}"
7577"#;
7578        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7579        assert!(config.crates[0].version_sync.is_none());
7580    }
7581
7582    #[test]
7583    fn test_binstall_and_version_sync_together() {
7584        let yaml = r#"
7585project_name: test
7586crates:
7587  - name: myapp
7588    path: "."
7589    tag_template: "v{{ .Version }}"
7590    binstall:
7591      enabled: true
7592      pkg_fmt: zip
7593    version_sync:
7594      enabled: true
7595      mode: tag
7596"#;
7597        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7598        assert!(config.crates[0].binstall.is_some());
7599        assert!(config.crates[0].version_sync.is_some());
7600    }
7601
7602    #[test]
7603    fn test_binstall_config_toml() {
7604        let toml_str = r#"
7605project_name = "test"
7606
7607[[crates]]
7608name = "myapp"
7609path = "."
7610tag_template = "v{{ .Version }}"
7611
7612[crates.binstall]
7613enabled = true
7614pkg_url = "https://example.com"
7615pkg_fmt = "tgz"
7616"#;
7617        let config: Config = toml::from_str(toml_str).unwrap();
7618        let bs = config.crates[0].binstall.as_ref().unwrap();
7619        assert_eq!(bs.enabled, Some(true));
7620        assert_eq!(bs.pkg_url, Some("https://example.com".to_string()));
7621    }
7622
7623    #[test]
7624    fn test_version_sync_config_toml() {
7625        let toml_str = r#"
7626project_name = "test"
7627
7628[[crates]]
7629name = "myapp"
7630path = "."
7631tag_template = "v{{ .Version }}"
7632
7633[crates.version_sync]
7634enabled = true
7635mode = "tag"
7636"#;
7637        let config: Config = toml::from_str(toml_str).unwrap();
7638        let vs = config.crates[0].version_sync.as_ref().unwrap();
7639        assert_eq!(vs.enabled, Some(true));
7640        assert_eq!(vs.mode, Some("tag".to_string()));
7641    }
7642
7643    #[test]
7644    fn test_crate_config_default_has_none_binstall_version_sync() {
7645        let config = CrateConfig::default();
7646        assert!(config.binstall.is_none());
7647        assert!(config.version_sync.is_none());
7648    }
7649
7650    // ---- Unknown fields tests ----
7651
7652    #[test]
7653    fn test_unknown_top_level_fields_rejected() {
7654        // strict YAML parsing rejects unknown fields
7655        let yaml = r#"
7656project_name: test
7657unknown_top_level_field: "this should be rejected"
7658crates:
7659  - name: a
7660    path: "."
7661    tag_template: "v{{ .Version }}"
7662"#;
7663        let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
7664        assert!(
7665            result.is_err(),
7666            "unknown top-level fields should be rejected"
7667        );
7668        assert!(
7669            result.unwrap_err().to_string().contains("unknown field"),
7670            "error should mention unknown field"
7671        );
7672    }
7673
7674    #[test]
7675    fn test_unknown_crate_level_fields_ignored() {
7676        let yaml = r#"
7677project_name: test
7678crates:
7679  - name: a
7680    path: "."
7681    tag_template: "v{{ .Version }}"
7682    nonexistent_field: true
7683    something_else: "hello"
7684"#;
7685        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7686        assert_eq!(config.crates[0].name, "a");
7687    }
7688
7689    #[test]
7690    fn test_unknown_nested_fields_ignored() {
7691        let yaml = r#"
7692project_name: test
7693defaults:
7694  targets:
7695    - x86_64-unknown-linux-gnu
7696  unknown_default_field: "ignored"
7697changelog:
7698  sort: asc
7699  mystery_option: true
7700crates:
7701  - name: a
7702    path: "."
7703    tag_template: "v{{ .Version }}"
7704    checksum:
7705      algorithm: sha256
7706      future_field: "ignored"
7707"#;
7708        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7709        assert_eq!(
7710            config
7711                .defaults
7712                .as_ref()
7713                .unwrap()
7714                .targets
7715                .as_ref()
7716                .unwrap()
7717                .len(),
7718            1
7719        );
7720        assert_eq!(
7721            config.changelog.as_ref().unwrap().sort,
7722            Some("asc".to_string())
7723        );
7724        assert_eq!(
7725            config.crates[0].checksum.as_ref().unwrap().algorithm,
7726            Some("sha256".to_string())
7727        );
7728    }
7729
7730    // ---- BuildConfig reproducible field tests ----
7731
7732    #[test]
7733    fn test_build_config_reproducible_true() {
7734        let yaml = r#"
7735project_name: test
7736crates:
7737  - name: myapp
7738    path: "."
7739    tag_template: "v{{ .Version }}"
7740    builds:
7741      - binary: myapp
7742        reproducible: true
7743"#;
7744        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7745        let build = &config.crates[0].builds.as_ref().unwrap()[0];
7746        assert_eq!(build.reproducible, Some(true));
7747    }
7748
7749    #[test]
7750    fn test_build_config_reproducible_false() {
7751        let yaml = r#"
7752project_name: test
7753crates:
7754  - name: myapp
7755    path: "."
7756    tag_template: "v{{ .Version }}"
7757    builds:
7758      - binary: myapp
7759        reproducible: false
7760"#;
7761        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7762        let build = &config.crates[0].builds.as_ref().unwrap()[0];
7763        assert_eq!(build.reproducible, Some(false));
7764    }
7765
7766    #[test]
7767    fn test_build_config_reproducible_omitted() {
7768        let yaml = r#"
7769project_name: test
7770crates:
7771  - name: myapp
7772    path: "."
7773    tag_template: "v{{ .Version }}"
7774    builds:
7775      - binary: myapp
7776"#;
7777        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7778        let build = &config.crates[0].builds.as_ref().unwrap()[0];
7779        assert_eq!(build.reproducible, None);
7780    }
7781
7782    // ---- WorkspaceConfig tests ----
7783
7784    #[test]
7785    fn test_workspace_config_parses() {
7786        let yaml = r#"
7787project_name: monorepo
7788crates: []
7789workspaces:
7790  - name: frontend
7791    crates:
7792      - name: frontend-app
7793        path: "apps/frontend"
7794        tag_template: "frontend-v{{ .Version }}"
7795    changelog:
7796      sort: asc
7797  - name: backend
7798    crates:
7799      - name: backend-api
7800        path: "apps/backend"
7801        tag_template: "backend-v{{ .Version }}"
7802      - name: backend-worker
7803        path: "apps/worker"
7804        tag_template: "worker-v{{ .Version }}"
7805"#;
7806        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7807        let workspaces = config.workspaces.as_ref().unwrap();
7808        assert_eq!(workspaces.len(), 2);
7809        assert_eq!(workspaces[0].name, "frontend");
7810        assert_eq!(workspaces[0].crates.len(), 1);
7811        assert_eq!(workspaces[0].crates[0].name, "frontend-app");
7812        assert!(workspaces[0].changelog.is_some());
7813        assert_eq!(workspaces[1].name, "backend");
7814        assert_eq!(workspaces[1].crates.len(), 2);
7815    }
7816
7817    #[test]
7818    fn test_workspace_config_with_signs_and_hooks() {
7819        let yaml = r#"
7820project_name: monorepo
7821crates: []
7822workspaces:
7823  - name: myws
7824    crates:
7825      - name: mylib
7826        path: "."
7827        tag_template: "v{{ .Version }}"
7828    signs:
7829      - artifacts: all
7830        cmd: gpg
7831    before:
7832      hooks:
7833        - echo before
7834    after:
7835      hooks:
7836        - echo after
7837    env:
7838      MY_VAR: hello
7839"#;
7840        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7841        let ws = &config.workspaces.as_ref().unwrap()[0];
7842        assert_eq!(ws.name, "myws");
7843        assert_eq!(ws.signs.len(), 1);
7844        assert!(ws.before.is_some());
7845        assert!(ws.after.is_some());
7846        assert_eq!(ws.env.as_ref().unwrap().get("MY_VAR").unwrap(), "hello");
7847    }
7848
7849    #[test]
7850    fn test_workspace_config_omitted() {
7851        let yaml = r#"
7852project_name: simple
7853crates:
7854  - name: myapp
7855    path: "."
7856    tag_template: "v{{ .Version }}"
7857"#;
7858        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7859        assert!(config.workspaces.is_none());
7860    }
7861
7862    #[test]
7863    fn test_workspace_config_empty_array() {
7864        let yaml = r#"
7865project_name: test
7866crates: []
7867workspaces: []
7868"#;
7869        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7870        let workspaces = config.workspaces.as_ref().unwrap();
7871        assert!(workspaces.is_empty());
7872    }
7873
7874    // ---- ChocolateyConfig tests ----
7875
7876    #[test]
7877    fn test_chocolatey_config_yaml() {
7878        let yaml = r#"
7879project_name: test
7880crates:
7881  - name: mytool
7882    path: "."
7883    tag_template: "v{{ .Version }}"
7884    publish:
7885      chocolatey:
7886        project_repo:
7887          owner: myorg
7888          name: mytool
7889        description: "A great tool"
7890        license: MIT
7891        tags:
7892          - cli
7893          - tool
7894        authors: "Test Author"
7895        project_url: "https://github.com/myorg/mytool"
7896        icon_url: "https://example.com/icon.png"
7897"#;
7898        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7899        let choco = config.crates[0]
7900            .publish
7901            .as_ref()
7902            .unwrap()
7903            .chocolatey
7904            .as_ref()
7905            .unwrap();
7906
7907        let repo = choco.project_repo.as_ref().unwrap();
7908        assert_eq!(repo.owner, "myorg");
7909        assert_eq!(repo.name, "mytool");
7910        assert_eq!(choco.description, Some("A great tool".to_string()));
7911        assert_eq!(choco.license, Some("MIT".to_string()));
7912        assert_eq!(
7913            choco.tags,
7914            Some(vec!["cli".to_string(), "tool".to_string()])
7915        );
7916        assert_eq!(choco.authors, Some("Test Author".to_string()));
7917        assert_eq!(
7918            choco.project_url,
7919            Some("https://github.com/myorg/mytool".to_string())
7920        );
7921        assert_eq!(
7922            choco.icon_url,
7923            Some("https://example.com/icon.png".to_string())
7924        );
7925    }
7926
7927    #[test]
7928    fn test_chocolatey_config_minimal() {
7929        let yaml = r#"
7930project_name: test
7931crates:
7932  - name: mytool
7933    path: "."
7934    tag_template: "v{{ .Version }}"
7935    publish:
7936      chocolatey:
7937        project_repo:
7938          owner: myorg
7939          name: mytool
7940"#;
7941        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
7942        let choco = config.crates[0]
7943            .publish
7944            .as_ref()
7945            .unwrap()
7946            .chocolatey
7947            .as_ref()
7948            .unwrap();
7949
7950        let repo = choco.project_repo.as_ref().unwrap();
7951        assert_eq!(repo.owner, "myorg");
7952        assert_eq!(repo.name, "mytool");
7953        assert!(choco.description.is_none());
7954        assert!(choco.license.is_none());
7955        assert!(choco.tags.is_none());
7956        assert!(choco.authors.is_none());
7957        assert!(choco.project_url.is_none());
7958        assert!(choco.icon_url.is_none());
7959    }
7960
7961    #[test]
7962    fn test_chocolatey_config_toml() {
7963        let toml_str = r#"
7964project_name = "test"
7965
7966[[crates]]
7967name = "mytool"
7968path = "."
7969tag_template = "v{{ .Version }}"
7970
7971[crates.publish.chocolatey]
7972description = "A tool"
7973license = "MIT"
7974authors = "Author"
7975tags = ["cli"]
7976
7977[crates.publish.chocolatey.project_repo]
7978owner = "org"
7979name = "tool"
7980"#;
7981        let config: Config = toml::from_str(toml_str).unwrap();
7982        let choco = config.crates[0]
7983            .publish
7984            .as_ref()
7985            .unwrap()
7986            .chocolatey
7987            .as_ref()
7988            .unwrap();
7989
7990        assert_eq!(choco.description, Some("A tool".to_string()));
7991        let repo = choco.project_repo.as_ref().unwrap();
7992        assert_eq!(repo.owner, "org");
7993    }
7994
7995    #[test]
7996    fn test_chocolatey_tags_space_separated_string() {
7997        // GoReleaser uses a plain space-separated string for tags.
7998        let yaml = r#"
7999project_name: test
8000crates:
8001  - name: mytool
8002    path: "."
8003    tag_template: "v{{ .Version }}"
8004    publish:
8005      chocolatey:
8006        project_repo:
8007          owner: myorg
8008          name: mytool
8009        tags: "cli tool automation"
8010"#;
8011        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8012        let choco = config.crates[0]
8013            .publish
8014            .as_ref()
8015            .unwrap()
8016            .chocolatey
8017            .as_ref()
8018            .unwrap();
8019
8020        assert_eq!(
8021            choco.tags,
8022            Some(vec![
8023                "cli".to_string(),
8024                "tool".to_string(),
8025                "automation".to_string()
8026            ])
8027        );
8028    }
8029
8030    #[test]
8031    fn test_chocolatey_tags_empty_string_is_none() {
8032        let yaml = r#"
8033project_name: test
8034crates:
8035  - name: mytool
8036    path: "."
8037    tag_template: "v{{ .Version }}"
8038    publish:
8039      chocolatey:
8040        project_repo:
8041          owner: myorg
8042          name: mytool
8043        tags: ""
8044"#;
8045        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8046        let choco = config.crates[0]
8047            .publish
8048            .as_ref()
8049            .unwrap()
8050            .chocolatey
8051            .as_ref()
8052            .unwrap();
8053
8054        assert!(choco.tags.is_none());
8055    }
8056
8057    // ---- WingetConfig tests ----
8058
8059    #[test]
8060    fn test_winget_config_yaml() {
8061        let yaml = r#"
8062project_name: test
8063crates:
8064  - name: mytool
8065    path: "."
8066    tag_template: "v{{ .Version }}"
8067    publish:
8068      winget:
8069        manifests_repo:
8070          owner: myorg
8071          name: winget-pkgs
8072        description: "A great tool"
8073        license: MIT
8074        package_identifier: "MyOrg.MyTool"
8075        publisher: "My Org"
8076        publisher_url: "https://github.com/myorg"
8077"#;
8078        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8079        let winget = config.crates[0]
8080            .publish
8081            .as_ref()
8082            .unwrap()
8083            .winget
8084            .as_ref()
8085            .unwrap();
8086
8087        let repo = winget.manifests_repo.as_ref().unwrap();
8088        assert_eq!(repo.owner, "myorg");
8089        assert_eq!(repo.name, "winget-pkgs");
8090        assert_eq!(winget.description, Some("A great tool".to_string()));
8091        assert_eq!(winget.license, Some("MIT".to_string()));
8092        assert_eq!(winget.package_identifier, Some("MyOrg.MyTool".to_string()));
8093        assert_eq!(winget.publisher, Some("My Org".to_string()));
8094        assert_eq!(
8095            winget.publisher_url,
8096            Some("https://github.com/myorg".to_string())
8097        );
8098    }
8099
8100    #[test]
8101    fn test_winget_config_minimal() {
8102        let yaml = r#"
8103project_name: test
8104crates:
8105  - name: mytool
8106    path: "."
8107    tag_template: "v{{ .Version }}"
8108    publish:
8109      winget:
8110        manifests_repo:
8111          owner: myorg
8112          name: winget-pkgs
8113        package_identifier: "MyOrg.MyTool"
8114"#;
8115        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8116        let winget = config.crates[0]
8117            .publish
8118            .as_ref()
8119            .unwrap()
8120            .winget
8121            .as_ref()
8122            .unwrap();
8123
8124        let repo = winget.manifests_repo.as_ref().unwrap();
8125        assert_eq!(repo.owner, "myorg");
8126        assert_eq!(repo.name, "winget-pkgs");
8127        assert_eq!(winget.package_identifier, Some("MyOrg.MyTool".to_string()));
8128        assert!(winget.description.is_none());
8129        assert!(winget.license.is_none());
8130        assert!(winget.publisher.is_none());
8131        assert!(winget.publisher_url.is_none());
8132    }
8133
8134    #[test]
8135    fn test_winget_config_toml() {
8136        let toml_str = r#"
8137project_name = "test"
8138
8139[[crates]]
8140name = "mytool"
8141path = "."
8142tag_template = "v{{ .Version }}"
8143
8144[crates.publish.winget]
8145description = "A tool"
8146license = "MIT"
8147package_identifier = "Org.Tool"
8148publisher = "Org"
8149
8150[crates.publish.winget.manifests_repo]
8151owner = "org"
8152name = "winget-pkgs"
8153"#;
8154        let config: Config = toml::from_str(toml_str).unwrap();
8155        let winget = config.crates[0]
8156            .publish
8157            .as_ref()
8158            .unwrap()
8159            .winget
8160            .as_ref()
8161            .unwrap();
8162
8163        assert_eq!(winget.description, Some("A tool".to_string()));
8164        assert_eq!(winget.package_identifier, Some("Org.Tool".to_string()));
8165        let repo = winget.manifests_repo.as_ref().unwrap();
8166        assert_eq!(repo.owner, "org");
8167    }
8168
8169    // ---- AurConfig tests ----
8170
8171    #[test]
8172    fn test_aur_config_yaml() {
8173        let yaml = r#"
8174project_name: test
8175crates:
8176  - name: mytool
8177    path: "."
8178    tag_template: "v{{ .Version }}"
8179    publish:
8180      aur:
8181        git_url: "ssh://aur@aur.archlinux.org/mytool.git"
8182        package_name: mytool-bin
8183        description: "A great tool"
8184        license: MIT
8185        maintainers:
8186          - "Jane Doe <jane@example.com>"
8187        depends:
8188          - glibc
8189          - openssl
8190        optdepends:
8191          - "git: for VCS support"
8192        conflicts:
8193          - mytool-git
8194        provides:
8195          - mytool
8196        replaces:
8197          - old-mytool
8198        backup:
8199          - etc/mytool/config.toml
8200        url: "https://github.com/org/mytool"
8201"#;
8202        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8203        let aur = config.crates[0]
8204            .publish
8205            .as_ref()
8206            .unwrap()
8207            .aur
8208            .as_ref()
8209            .unwrap();
8210
8211        assert_eq!(
8212            aur.git_url,
8213            Some("ssh://aur@aur.archlinux.org/mytool.git".to_string())
8214        );
8215        assert_eq!(aur.name, Some("mytool-bin".to_string()));
8216        assert_eq!(aur.description, Some("A great tool".to_string()));
8217        assert_eq!(aur.license, Some("MIT".to_string()));
8218        assert_eq!(
8219            aur.maintainers,
8220            Some(vec!["Jane Doe <jane@example.com>".to_string()])
8221        );
8222        assert_eq!(
8223            aur.depends,
8224            Some(vec!["glibc".to_string(), "openssl".to_string()])
8225        );
8226        assert_eq!(
8227            aur.optdepends,
8228            Some(vec!["git: for VCS support".to_string()])
8229        );
8230        assert_eq!(aur.conflicts, Some(vec!["mytool-git".to_string()]));
8231        assert_eq!(aur.provides, Some(vec!["mytool".to_string()]));
8232        assert_eq!(aur.replaces, Some(vec!["old-mytool".to_string()]));
8233        assert_eq!(aur.backup, Some(vec!["etc/mytool/config.toml".to_string()]));
8234        assert_eq!(aur.url, Some("https://github.com/org/mytool".to_string()));
8235    }
8236
8237    #[test]
8238    fn test_aur_config_minimal() {
8239        let yaml = r#"
8240project_name: test
8241crates:
8242  - name: mytool
8243    path: "."
8244    tag_template: "v{{ .Version }}"
8245    publish:
8246      aur:
8247        git_url: "ssh://aur@aur.archlinux.org/mytool.git"
8248"#;
8249        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8250        let aur = config.crates[0]
8251            .publish
8252            .as_ref()
8253            .unwrap()
8254            .aur
8255            .as_ref()
8256            .unwrap();
8257
8258        assert_eq!(
8259            aur.git_url,
8260            Some("ssh://aur@aur.archlinux.org/mytool.git".to_string())
8261        );
8262        assert!(aur.name.is_none());
8263        assert!(aur.description.is_none());
8264        assert!(aur.license.is_none());
8265        assert!(aur.maintainers.is_none());
8266        assert!(aur.depends.is_none());
8267        assert!(aur.optdepends.is_none());
8268        assert!(aur.conflicts.is_none());
8269        assert!(aur.provides.is_none());
8270        assert!(aur.replaces.is_none());
8271        assert!(aur.backup.is_none());
8272    }
8273
8274    #[test]
8275    fn test_aur_config_toml() {
8276        let toml_str = r#"
8277project_name = "test"
8278
8279[[crates]]
8280name = "mytool"
8281path = "."
8282tag_template = "v{{ .Version }}"
8283
8284[crates.publish.aur]
8285git_url = "ssh://aur@aur.archlinux.org/mytool.git"
8286description = "A tool"
8287license = "MIT"
8288depends = ["glibc"]
8289"#;
8290        let config: Config = toml::from_str(toml_str).unwrap();
8291        let aur = config.crates[0]
8292            .publish
8293            .as_ref()
8294            .unwrap()
8295            .aur
8296            .as_ref()
8297            .unwrap();
8298
8299        assert_eq!(
8300            aur.git_url,
8301            Some("ssh://aur@aur.archlinux.org/mytool.git".to_string())
8302        );
8303        assert_eq!(aur.description, Some("A tool".to_string()));
8304        assert_eq!(aur.depends, Some(vec!["glibc".to_string()]));
8305    }
8306
8307    // ---- KrewConfig tests ----
8308
8309    #[test]
8310    fn test_krew_config_yaml() {
8311        let yaml = r#"
8312project_name: test
8313crates:
8314  - name: kubectl-mytool
8315    path: "."
8316    tag_template: "v{{ .Version }}"
8317    publish:
8318      krew:
8319        manifests_repo:
8320          owner: myorg
8321          name: krew-index
8322        description: "A comprehensive kubectl plugin"
8323        short_description: "A kubectl plugin"
8324        homepage: "https://github.com/myorg/kubectl-mytool"
8325        caveats: "Run 'kubectl mytool init' after installation."
8326"#;
8327        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8328        let krew = config.crates[0]
8329            .publish
8330            .as_ref()
8331            .unwrap()
8332            .krew
8333            .as_ref()
8334            .unwrap();
8335
8336        let repo = krew.manifests_repo.as_ref().unwrap();
8337        assert_eq!(repo.owner, "myorg");
8338        assert_eq!(repo.name, "krew-index");
8339        assert_eq!(
8340            krew.description,
8341            Some("A comprehensive kubectl plugin".to_string())
8342        );
8343        assert_eq!(krew.short_description, Some("A kubectl plugin".to_string()));
8344        assert_eq!(
8345            krew.homepage,
8346            Some("https://github.com/myorg/kubectl-mytool".to_string())
8347        );
8348        assert_eq!(
8349            krew.caveats,
8350            Some("Run 'kubectl mytool init' after installation.".to_string())
8351        );
8352    }
8353
8354    #[test]
8355    fn test_krew_config_minimal() {
8356        let yaml = r#"
8357project_name: test
8358crates:
8359  - name: kubectl-mytool
8360    path: "."
8361    tag_template: "v{{ .Version }}"
8362    publish:
8363      krew:
8364        manifests_repo:
8365          owner: myorg
8366          name: krew-index
8367"#;
8368        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8369        let krew = config.crates[0]
8370            .publish
8371            .as_ref()
8372            .unwrap()
8373            .krew
8374            .as_ref()
8375            .unwrap();
8376
8377        let repo = krew.manifests_repo.as_ref().unwrap();
8378        assert_eq!(repo.owner, "myorg");
8379        assert_eq!(repo.name, "krew-index");
8380        assert!(krew.description.is_none());
8381        assert!(krew.short_description.is_none());
8382        assert!(krew.homepage.is_none());
8383        assert!(krew.caveats.is_none());
8384    }
8385
8386    #[test]
8387    fn test_krew_config_toml() {
8388        let toml_str = r#"
8389project_name = "test"
8390
8391[[crates]]
8392name = "kubectl-mytool"
8393path = "."
8394tag_template = "v{{ .Version }}"
8395
8396[crates.publish.krew]
8397short_description = "A kubectl plugin"
8398homepage = "https://example.com"
8399
8400[crates.publish.krew.manifests_repo]
8401owner = "org"
8402name = "krew-index"
8403"#;
8404        let config: Config = toml::from_str(toml_str).unwrap();
8405        let krew = config.crates[0]
8406            .publish
8407            .as_ref()
8408            .unwrap()
8409            .krew
8410            .as_ref()
8411            .unwrap();
8412
8413        assert_eq!(krew.short_description, Some("A kubectl plugin".to_string()));
8414        let repo = krew.manifests_repo.as_ref().unwrap();
8415        assert_eq!(repo.owner, "org");
8416    }
8417
8418    // ---- Combined all publishers ----
8419
8420    #[test]
8421    fn test_all_seven_publishers_config() {
8422        let yaml = r#"
8423project_name: test
8424crates:
8425  - name: mytool
8426    path: "."
8427    tag_template: "v{{ .Version }}"
8428    publish:
8429      crates: true
8430      homebrew:
8431        tap:
8432          owner: org
8433          name: homebrew-tap
8434      scoop:
8435        bucket:
8436          owner: org
8437          name: scoop-bucket
8438      chocolatey:
8439        project_repo:
8440          owner: org
8441          name: mytool
8442      winget:
8443        manifests_repo:
8444          owner: org
8445          name: winget-pkgs
8446        package_identifier: "Org.MyTool"
8447      aur:
8448        git_url: "ssh://aur@aur.archlinux.org/mytool.git"
8449      krew:
8450        manifests_repo:
8451          owner: org
8452          name: krew-index
8453"#;
8454        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8455        let publish = config.crates[0].publish.as_ref().unwrap();
8456
8457        assert!(publish.crates.is_some());
8458        assert!(publish.homebrew.is_some());
8459        assert!(publish.scoop.is_some());
8460        assert!(publish.chocolatey.is_some());
8461        assert!(publish.winget.is_some());
8462        assert!(publish.aur.is_some());
8463        assert!(publish.krew.is_some());
8464    }
8465
8466    // ---- Config version tests ----
8467
8468    #[test]
8469    fn test_version_field_none_is_valid() {
8470        let config = Config::default();
8471        assert!(validate_version(&config).is_ok());
8472    }
8473
8474    #[test]
8475    fn test_version_field_1_is_valid() {
8476        let yaml = r#"
8477project_name: test
8478version: 1
8479crates: []
8480"#;
8481        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8482        assert_eq!(config.version, Some(1));
8483        assert!(validate_version(&config).is_ok());
8484    }
8485
8486    #[test]
8487    fn test_version_field_2_is_valid() {
8488        let yaml = r#"
8489project_name: test
8490version: 2
8491crates: []
8492"#;
8493        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8494        assert_eq!(config.version, Some(2));
8495        assert!(validate_version(&config).is_ok());
8496    }
8497
8498    #[test]
8499    fn test_version_field_99_is_rejected() {
8500        let config = Config {
8501            version: Some(99),
8502            ..Default::default()
8503        };
8504        let result = validate_version(&config);
8505        assert!(result.is_err());
8506        assert!(
8507            result
8508                .unwrap_err()
8509                .contains("unsupported config version: 99")
8510        );
8511    }
8512
8513    // ---- env_files tests ----
8514
8515    #[test]
8516    fn test_env_files_list_form_parses() {
8517        let yaml = r#"
8518project_name: test
8519env_files:
8520  - ".env"
8521  - ".release.env"
8522crates: []
8523"#;
8524        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8525        let env_files = config.env_files.unwrap();
8526        let files = env_files
8527            .as_list()
8528            .unwrap_or_else(|| panic!("expected List variant"));
8529        assert_eq!(files.len(), 2);
8530        assert_eq!(files[0], ".env");
8531        assert_eq!(files[1], ".release.env");
8532    }
8533
8534    #[test]
8535    fn test_env_files_struct_form_parses() {
8536        let yaml = r#"
8537project_name: test
8538env_files:
8539  github_token: "~/.config/goreleaser/github_token"
8540  gitlab_token: "/etc/tokens/gitlab"
8541crates: []
8542"#;
8543        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8544        let env_files = config.env_files.unwrap();
8545        let tokens = env_files
8546            .as_token_files()
8547            .unwrap_or_else(|| panic!("expected TokenFiles variant"));
8548        assert_eq!(
8549            tokens.github_token.as_deref(),
8550            Some("~/.config/goreleaser/github_token")
8551        );
8552        assert_eq!(tokens.gitlab_token.as_deref(), Some("/etc/tokens/gitlab"));
8553        assert!(tokens.gitea_token.is_none());
8554    }
8555
8556    #[test]
8557    fn test_env_files_struct_form_empty_mapping() {
8558        let yaml = r#"
8559project_name: test
8560env_files:
8561  gitea_token: "/tmp/gitea"
8562crates: []
8563"#;
8564        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8565        let env_files = config.env_files.unwrap();
8566        let tokens = env_files
8567            .as_token_files()
8568            .unwrap_or_else(|| panic!("expected TokenFiles variant"));
8569        assert!(tokens.github_token.is_none());
8570        assert!(tokens.gitlab_token.is_none());
8571        assert_eq!(tokens.gitea_token.as_deref(), Some("/tmp/gitea"));
8572    }
8573
8574    #[test]
8575    fn test_env_files_field_omitted() {
8576        let yaml = r#"
8577project_name: test
8578crates: []
8579"#;
8580        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8581        assert!(config.env_files.is_none());
8582    }
8583
8584    #[test]
8585    fn test_read_token_file_reads_first_line() {
8586        use std::io::Write;
8587        let dir = tempfile::TempDir::new().unwrap();
8588        let token_path = dir.path().join("github_token");
8589        let mut f = std::fs::File::create(&token_path).unwrap();
8590        writeln!(f, "ghp_abc123xyz").unwrap();
8591        writeln!(f, "this line should be ignored").unwrap();
8592        drop(f);
8593
8594        let result = read_token_file(&token_path.to_string_lossy()).unwrap();
8595        assert_eq!(result, Some("ghp_abc123xyz".to_string()));
8596    }
8597
8598    #[test]
8599    fn test_read_token_file_trims_whitespace() {
8600        use std::io::Write;
8601        let dir = tempfile::TempDir::new().unwrap();
8602        let token_path = dir.path().join("token");
8603        let mut f = std::fs::File::create(&token_path).unwrap();
8604        writeln!(f, "  spaced_token  ").unwrap();
8605        drop(f);
8606
8607        let result = read_token_file(&token_path.to_string_lossy()).unwrap();
8608        assert_eq!(result, Some("spaced_token".to_string()));
8609    }
8610
8611    #[test]
8612    fn test_read_token_file_nonexistent_returns_none() {
8613        let result = read_token_file("/tmp/nonexistent_token_file_99999").unwrap();
8614        assert!(result.is_none());
8615    }
8616
8617    #[test]
8618    fn test_read_token_file_empty_returns_none() {
8619        let dir = tempfile::TempDir::new().unwrap();
8620        let token_path = dir.path().join("empty_token");
8621        std::fs::write(&token_path, "").unwrap();
8622
8623        let result = read_token_file(&token_path.to_string_lossy()).unwrap();
8624        assert!(result.is_none());
8625    }
8626
8627    #[test]
8628    #[serial_test::serial]
8629    fn test_load_token_files_reads_tokens() {
8630        use std::io::Write;
8631        let dir = tempfile::TempDir::new().unwrap();
8632
8633        let gh_path = dir.path().join("github_token");
8634        let mut f = std::fs::File::create(&gh_path).unwrap();
8635        writeln!(f, "ghp_test123").unwrap();
8636        drop(f);
8637
8638        let gl_path = dir.path().join("gitlab_token");
8639        let mut f = std::fs::File::create(&gl_path).unwrap();
8640        writeln!(f, "glpat-test456").unwrap();
8641        drop(f);
8642
8643        let config = EnvFilesTokenConfig {
8644            github_token: Some(gh_path.to_string_lossy().to_string()),
8645            gitlab_token: Some(gl_path.to_string_lossy().to_string()),
8646            gitea_token: None, // uses default path which won't exist
8647        };
8648
8649        // Temporarily unset any existing tokens to avoid interference
8650        let orig_gh = std::env::var("GITHUB_TOKEN").ok();
8651        let orig_gl = std::env::var("GITLAB_TOKEN").ok();
8652        let orig_gt = std::env::var("GITEA_TOKEN").ok();
8653        // SAFETY: test runs serially
8654        unsafe {
8655            std::env::remove_var("GITHUB_TOKEN");
8656            std::env::remove_var("GITLAB_TOKEN");
8657            std::env::remove_var("GITEA_TOKEN");
8658        }
8659
8660        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Normal);
8661        let vars = load_token_files(&config, &log).unwrap();
8662
8663        // Restore original env
8664        unsafe {
8665            if let Some(v) = orig_gh {
8666                std::env::set_var("GITHUB_TOKEN", v);
8667            }
8668            if let Some(v) = orig_gl {
8669                std::env::set_var("GITLAB_TOKEN", v);
8670            }
8671            if let Some(v) = orig_gt {
8672                std::env::set_var("GITEA_TOKEN", v);
8673            }
8674        }
8675
8676        assert_eq!(vars.get("GITHUB_TOKEN").unwrap(), "ghp_test123");
8677        assert_eq!(vars.get("GITLAB_TOKEN").unwrap(), "glpat-test456");
8678        // GITEA_TOKEN not present — default file doesn't exist
8679        assert!(!vars.contains_key("GITEA_TOKEN"));
8680    }
8681
8682    #[test]
8683    #[serial_test::serial]
8684    fn test_load_token_files_env_var_takes_precedence() {
8685        use std::io::Write;
8686        let dir = tempfile::TempDir::new().unwrap();
8687
8688        let gh_path = dir.path().join("github_token");
8689        let mut f = std::fs::File::create(&gh_path).unwrap();
8690        writeln!(f, "file_token").unwrap();
8691        drop(f);
8692
8693        let config = EnvFilesTokenConfig {
8694            github_token: Some(gh_path.to_string_lossy().to_string()),
8695            gitlab_token: None,
8696            gitea_token: None,
8697        };
8698
8699        // Set GITHUB_TOKEN env var — should take precedence over file
8700        let orig = std::env::var("GITHUB_TOKEN").ok();
8701        // SAFETY: test runs serially
8702        unsafe {
8703            std::env::set_var("GITHUB_TOKEN", "env_token");
8704        }
8705
8706        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Normal);
8707        let vars = load_token_files(&config, &log).unwrap();
8708
8709        // Restore
8710        unsafe {
8711            match orig {
8712                Some(v) => std::env::set_var("GITHUB_TOKEN", v),
8713                None => std::env::remove_var("GITHUB_TOKEN"),
8714            }
8715        }
8716
8717        // File token should NOT be loaded because env var was set
8718        assert!(
8719            !vars.contains_key("GITHUB_TOKEN"),
8720            "env var should take precedence; file should not be loaded"
8721        );
8722    }
8723
8724    #[test]
8725    fn test_read_token_file_tilde_expansion() {
8726        // Test that tilde expansion uses HOME env var
8727        let dir = tempfile::TempDir::new().unwrap();
8728        let token_path = dir.path().join(".config/goreleaser/github_token");
8729        std::fs::create_dir_all(token_path.parent().unwrap()).unwrap();
8730        std::fs::write(&token_path, "tilde_token\n").unwrap();
8731
8732        let orig_home = std::env::var("HOME").ok();
8733        // SAFETY: test runs serially
8734        unsafe {
8735            std::env::set_var("HOME", dir.path());
8736        }
8737
8738        let result = read_token_file("~/.config/goreleaser/github_token").unwrap();
8739
8740        unsafe {
8741            match orig_home {
8742                Some(v) => std::env::set_var("HOME", v),
8743                None => std::env::remove_var("HOME"),
8744            }
8745        }
8746
8747        assert_eq!(result, Some("tilde_token".to_string()));
8748    }
8749
8750    #[test]
8751    fn test_load_env_files_sets_vars() {
8752        use std::io::Write;
8753        let dir = tempfile::TempDir::new().unwrap();
8754        let env_path = dir.path().join(".env");
8755        let mut f = std::fs::File::create(&env_path).unwrap();
8756        writeln!(f, "# comment line").unwrap();
8757        writeln!(f).unwrap();
8758        writeln!(f, "TEST_ANODIZER_KEY=hello_world").unwrap();
8759        writeln!(f, "TEST_ANODIZER_QUOTED=\"with quotes\"").unwrap();
8760        writeln!(f, "TEST_ANODIZER_SINGLE='single_quoted'").unwrap();
8761        writeln!(f, "export TEST_ANODIZER_EXPORT=exported_val").unwrap();
8762        drop(f);
8763
8764        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Normal);
8765        let vars = load_env_files(&[env_path.to_string_lossy().to_string()], &log, false).unwrap();
8766        assert_eq!(vars.get("TEST_ANODIZER_KEY").unwrap(), "hello_world");
8767        assert_eq!(vars.get("TEST_ANODIZER_QUOTED").unwrap(), "with quotes");
8768        assert_eq!(
8769            vars.get("TEST_ANODIZER_SINGLE").unwrap(),
8770            "single_quoted",
8771            "single-quoted values should have quotes stripped"
8772        );
8773        assert_eq!(
8774            vars.get("TEST_ANODIZER_EXPORT").unwrap(),
8775            "exported_val",
8776            "export prefix should be stripped"
8777        );
8778    }
8779
8780    #[test]
8781    fn test_load_env_files_edge_cases() {
8782        use std::io::Write;
8783        let dir = tempfile::TempDir::new().unwrap();
8784        let env_path = dir.path().join(".env-edge");
8785        let mut f = std::fs::File::create(&env_path).unwrap();
8786        // Single quote char as value should not panic
8787        writeln!(f, "TEST_ANODIZER_SINGLEQ=\"").unwrap();
8788        // Empty key line (=value) should be skipped
8789        writeln!(f, "=orphan_value").unwrap();
8790        // Line without = should be skipped with warning
8791        writeln!(f, "NO_EQUALS_HERE").unwrap();
8792        drop(f);
8793
8794        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Normal);
8795        let vars = load_env_files(&[env_path.to_string_lossy().to_string()], &log, false).unwrap();
8796        // The single-quote value should be kept as-is (not stripped, length < 2 for
8797        // matching quotes)
8798        assert_eq!(vars.get("TEST_ANODIZER_SINGLEQ").unwrap(), "\"");
8799        // Empty key and no-equals lines should have been skipped
8800        assert!(!vars.contains_key(""), "empty key should be skipped");
8801    }
8802
8803    #[test]
8804    fn test_load_env_files_nonexistent_skips_with_warning() {
8805        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Normal);
8806        let result = load_env_files(
8807            &["/tmp/nonexistent_anodizer_env_file_12345".to_string()],
8808            &log,
8809            false,
8810        );
8811        // Missing env files should be skipped (not an error), returning empty vars.
8812        assert!(result.is_ok());
8813        assert!(result.unwrap().is_empty());
8814    }
8815
8816    #[test]
8817    fn test_load_env_files_nonexistent_strict_mode_errors() {
8818        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Normal);
8819        let result = load_env_files(
8820            &["/tmp/nonexistent_anodizer_env_file_12345".to_string()],
8821            &log,
8822            true,
8823        );
8824        assert!(result.is_err());
8825        assert!(result.unwrap_err().contains("strict mode"));
8826    }
8827
8828    // ---- env_files TOML tests ----
8829
8830    // NOTE: EnvFilesConfig uses a custom Deserialize impl that reads into
8831    // serde_yaml_ng::Value as an intermediate. Since serde_yaml_ng::Value
8832    // implements generic Deserialize, this works across formats (YAML, TOML,
8833    // JSON) -- the intermediate is populated via serde's data model, not
8834    // from literal YAML text.
8835
8836    #[test]
8837    fn test_env_files_list_form_toml() {
8838        // TOML array should deserialize to EnvFilesConfig::List via the
8839        // serde_yaml_ng::Value intermediate.
8840        #[derive(Deserialize)]
8841        struct Wrapper {
8842            env_files: EnvFilesConfig,
8843        }
8844        let toml_str = r#"env_files = [".env", ".env.local"]"#;
8845        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
8846        let files = wrapper
8847            .env_files
8848            .as_list()
8849            .unwrap_or_else(|| panic!("expected List variant"));
8850        assert_eq!(files.len(), 2);
8851        assert_eq!(files[0], ".env");
8852        assert_eq!(files[1], ".env.local");
8853    }
8854
8855    #[test]
8856    fn test_env_files_struct_form_toml() {
8857        // TOML table should deserialize to EnvFilesConfig::TokenFiles via
8858        // the serde_yaml_ng::Value intermediate.
8859        #[derive(Deserialize)]
8860        struct Wrapper {
8861            env_files: EnvFilesConfig,
8862        }
8863        let toml_str = r#"
8864[env_files]
8865github_token = "~/.config/goreleaser/github_token"
8866gitlab_token = "/etc/tokens/gitlab"
8867"#;
8868        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
8869        let tokens = wrapper
8870            .env_files
8871            .as_token_files()
8872            .unwrap_or_else(|| panic!("expected TokenFiles variant"));
8873        assert_eq!(
8874            tokens.github_token.as_deref(),
8875            Some("~/.config/goreleaser/github_token")
8876        );
8877        assert_eq!(tokens.gitlab_token.as_deref(), Some("/etc/tokens/gitlab"));
8878        assert!(tokens.gitea_token.is_none());
8879    }
8880
8881    #[test]
8882    fn test_env_files_token_config_toml_rejects_unknown_fields() {
8883        // Verify deny_unknown_fields works: a typo like `github_tokne` must fail.
8884        let toml_str = r#"github_tokne = "~/.config/goreleaser/github_token""#;
8885        let result = toml::from_str::<EnvFilesTokenConfig>(toml_str);
8886        assert!(
8887            result.is_err(),
8888            "EnvFilesTokenConfig should reject unknown fields like 'github_tokne'"
8889        );
8890    }
8891
8892    // ---- BuildIgnore tests ----
8893
8894    #[test]
8895    fn test_build_ignore_parses() {
8896        let yaml = r#"
8897project_name: test
8898defaults:
8899  targets:
8900    - x86_64-unknown-linux-gnu
8901    - aarch64-unknown-linux-gnu
8902  ignore:
8903    - os: windows
8904      arch: arm64
8905    - os: linux
8906      arch: "386"
8907crates: []
8908"#;
8909        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8910        let defaults = config.defaults.unwrap();
8911        let ignores = defaults.ignore.unwrap();
8912        assert_eq!(ignores.len(), 2);
8913        assert_eq!(ignores[0].os, "windows");
8914        assert_eq!(ignores[0].arch, "arm64");
8915        assert_eq!(ignores[1].os, "linux");
8916        assert_eq!(ignores[1].arch, "386");
8917    }
8918
8919    #[test]
8920    fn test_build_ignore_omitted() {
8921        let yaml = r#"
8922project_name: test
8923defaults:
8924  targets:
8925    - x86_64-unknown-linux-gnu
8926crates: []
8927"#;
8928        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8929        let defaults = config.defaults.unwrap();
8930        assert!(defaults.ignore.is_none());
8931    }
8932
8933    // ---- BuildOverride tests ----
8934
8935    #[test]
8936    fn test_build_override_parses() {
8937        let yaml = r#"
8938project_name: test
8939defaults:
8940  overrides:
8941    - targets:
8942        - "x86_64-*"
8943      features:
8944        - simd
8945      flags: "--release"
8946      env:
8947        CC: gcc
8948    - targets:
8949        - "*-apple-darwin"
8950      features:
8951        - metal
8952crates: []
8953"#;
8954        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8955        let defaults = config.defaults.unwrap();
8956        let overrides = defaults.overrides.unwrap();
8957        assert_eq!(overrides.len(), 2);
8958        assert_eq!(overrides[0].targets, vec!["x86_64-*"]);
8959        assert_eq!(overrides[0].features, Some(vec!["simd".to_string()]));
8960        assert_eq!(overrides[0].flags, Some("--release".to_string()));
8961        assert_eq!(overrides[0].env.as_ref().unwrap().get("CC").unwrap(), "gcc");
8962        assert_eq!(overrides[1].targets, vec!["*-apple-darwin"]);
8963        assert_eq!(overrides[1].features, Some(vec!["metal".to_string()]));
8964        assert!(overrides[1].env.is_none());
8965    }
8966
8967    #[test]
8968    fn test_build_override_omitted() {
8969        let yaml = r#"
8970project_name: test
8971defaults:
8972  targets:
8973    - x86_64-unknown-linux-gnu
8974crates: []
8975"#;
8976        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
8977        let defaults = config.defaults.unwrap();
8978        assert!(defaults.overrides.is_none());
8979    }
8980
8981    // ---- JSON Schema generation test ----
8982
8983    #[test]
8984    fn test_json_schema_generation() {
8985        let schema = schemars::schema_for!(Config);
8986        let json = serde_json::to_string_pretty(&schema).unwrap();
8987        assert!(json.contains("project_name"));
8988        assert!(json.contains("env_files"));
8989        assert!(json.contains("version"));
8990        assert!(json.contains("BuildIgnore"));
8991        assert!(json.contains("BuildOverride"));
8992    }
8993
8994    // ---- Homebrew new fields parsing tests ----
8995
8996    #[test]
8997    fn test_homebrew_config_new_fields() {
8998        let yaml = r#"
8999project_name: test
9000crates:
9001  - name: a
9002    path: "."
9003    tag_template: "v{{ .Version }}"
9004    publish:
9005      homebrew:
9006        tap:
9007          owner: myorg
9008          name: homebrew-tap
9009        homepage: "https://example.com"
9010        dependencies:
9011          - name: openssl
9012          - name: libgit2
9013            os: mac
9014          - name: zlib
9015            type: optional
9016        conflicts:
9017          - other-tool
9018          - old-tool
9019        caveats: "Run `tool init` after installing."
9020        skip_upload: "auto"
9021"#;
9022        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9023        let hb = config.crates[0]
9024            .publish
9025            .as_ref()
9026            .unwrap()
9027            .homebrew
9028            .as_ref()
9029            .unwrap();
9030        assert_eq!(hb.homepage.as_deref(), Some("https://example.com"));
9031        assert_eq!(
9032            hb.skip_upload,
9033            Some(StringOrBool::String("auto".to_string()))
9034        );
9035        assert_eq!(
9036            hb.caveats.as_deref(),
9037            Some("Run `tool init` after installing.")
9038        );
9039
9040        let conflicts = hb.conflicts.as_ref().unwrap();
9041        assert_eq!(
9042            conflicts,
9043            &[
9044                HomebrewConflict::Name("other-tool".to_string()),
9045                HomebrewConflict::Name("old-tool".to_string()),
9046            ]
9047        );
9048
9049        let deps = hb.dependencies.as_ref().unwrap();
9050        assert_eq!(deps.len(), 3);
9051        assert_eq!(deps[0].name, "openssl");
9052        assert_eq!(deps[0].os, None);
9053        assert_eq!(deps[0].dep_type, None);
9054        assert_eq!(deps[1].name, "libgit2");
9055        assert_eq!(deps[1].os.as_deref(), Some("mac"));
9056        assert_eq!(deps[2].name, "zlib");
9057        assert_eq!(deps[2].dep_type.as_deref(), Some("optional"));
9058    }
9059
9060    #[test]
9061    fn test_homebrew_config_defaults_when_new_fields_omitted() {
9062        let yaml = r#"
9063project_name: test
9064crates:
9065  - name: a
9066    path: "."
9067    tag_template: "v{{ .Version }}"
9068    publish:
9069      homebrew:
9070        tap:
9071          owner: myorg
9072          name: homebrew-tap
9073"#;
9074        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9075        let hb = config.crates[0]
9076            .publish
9077            .as_ref()
9078            .unwrap()
9079            .homebrew
9080            .as_ref()
9081            .unwrap();
9082        assert!(hb.homepage.is_none());
9083        assert!(hb.dependencies.is_none());
9084        assert!(hb.conflicts.is_none());
9085        assert!(hb.caveats.is_none());
9086        assert!(hb.skip_upload.is_none());
9087    }
9088
9089    // ---- Scoop new fields parsing tests ----
9090
9091    #[test]
9092    fn test_scoop_config_new_fields() {
9093        let yaml = r#"
9094project_name: test
9095crates:
9096  - name: a
9097    path: "."
9098    tag_template: "v{{ .Version }}"
9099    publish:
9100      scoop:
9101        bucket:
9102          owner: myorg
9103          name: scoop-bucket
9104        homepage: "https://example.com"
9105        persist:
9106          - data
9107          - config.ini
9108        depends:
9109          - git
9110          - 7zip
9111        pre_install:
9112          - "Write-Host 'Installing...'"
9113        post_install:
9114          - "Write-Host 'Done!'"
9115        shortcuts:
9116          - ["myapp.exe", "My App"]
9117          - ["myapp.exe", "My App CLI", "--cli"]
9118        skip_upload: "true"
9119"#;
9120        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9121        let sc = config.crates[0]
9122            .publish
9123            .as_ref()
9124            .unwrap()
9125            .scoop
9126            .as_ref()
9127            .unwrap();
9128        assert_eq!(sc.homepage.as_deref(), Some("https://example.com"));
9129        assert_eq!(
9130            sc.skip_upload,
9131            Some(StringOrBool::String("true".to_string()))
9132        );
9133
9134        let persist = sc.persist.as_ref().unwrap();
9135        assert_eq!(persist, &["data", "config.ini"]);
9136
9137        let depends = sc.depends.as_ref().unwrap();
9138        assert_eq!(depends, &["git", "7zip"]);
9139
9140        let pre = sc.pre_install.as_ref().unwrap();
9141        assert_eq!(pre, &["Write-Host 'Installing...'"]);
9142
9143        let post = sc.post_install.as_ref().unwrap();
9144        assert_eq!(post, &["Write-Host 'Done!'"]);
9145
9146        let shortcuts = sc.shortcuts.as_ref().unwrap();
9147        assert_eq!(shortcuts.len(), 2);
9148        assert_eq!(shortcuts[0], vec!["myapp.exe", "My App"]);
9149        assert_eq!(shortcuts[1], vec!["myapp.exe", "My App CLI", "--cli"]);
9150    }
9151
9152    #[test]
9153    fn test_scoop_config_defaults_when_new_fields_omitted() {
9154        let yaml = r#"
9155project_name: test
9156crates:
9157  - name: a
9158    path: "."
9159    tag_template: "v{{ .Version }}"
9160    publish:
9161      scoop:
9162        bucket:
9163          owner: myorg
9164          name: scoop-bucket
9165"#;
9166        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9167        let sc = config.crates[0]
9168            .publish
9169            .as_ref()
9170            .unwrap()
9171            .scoop
9172            .as_ref()
9173            .unwrap();
9174        assert!(sc.homepage.is_none());
9175        assert!(sc.persist.is_none());
9176        assert!(sc.depends.is_none());
9177        assert!(sc.pre_install.is_none());
9178        assert!(sc.post_install.is_none());
9179        assert!(sc.shortcuts.is_none());
9180        assert!(sc.skip_upload.is_none());
9181    }
9182
9183    // -----------------------------------------------------------------------
9184    // GitConfig tests
9185    // -----------------------------------------------------------------------
9186
9187    #[test]
9188    fn test_git_config_all_fields() {
9189        let yaml = r#"
9190project_name: test
9191crates:
9192  - name: a
9193    path: "."
9194    tag_template: "v{{ .Version }}"
9195git:
9196  tag_sort: "-version:creatordate"
9197  ignore_tags:
9198    - "nightly*"
9199    - "legacy-*"
9200  ignore_tag_prefixes:
9201    - "internal/"
9202    - "test-"
9203  prerelease_suffix: "-rc"
9204"#;
9205        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9206        let git = config
9207            .git
9208            .unwrap_or_else(|| panic!("git section should be present"));
9209        assert_eq!(git.tag_sort.as_deref(), Some("-version:creatordate"));
9210        assert_eq!(
9211            git.ignore_tags.as_deref(),
9212            Some(&["nightly*".to_string(), "legacy-*".to_string()][..])
9213        );
9214        assert_eq!(
9215            git.ignore_tag_prefixes.as_deref(),
9216            Some(&["internal/".to_string(), "test-".to_string()][..])
9217        );
9218        assert_eq!(git.prerelease_suffix.as_deref(), Some("-rc"));
9219    }
9220
9221    #[test]
9222    fn test_git_config_omitted_is_none() {
9223        let yaml = r#"
9224project_name: test
9225crates:
9226  - name: a
9227    path: "."
9228    tag_template: "v{{ .Version }}"
9229"#;
9230        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9231        assert!(config.git.is_none());
9232    }
9233
9234    #[test]
9235    fn test_git_config_partial_only_tag_sort() {
9236        let yaml = r#"
9237project_name: test
9238crates:
9239  - name: a
9240    path: "."
9241    tag_template: "v{{ .Version }}"
9242git:
9243  tag_sort: "-version:refname"
9244"#;
9245        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9246        let git = config
9247            .git
9248            .unwrap_or_else(|| panic!("git section should be present"));
9249        assert_eq!(git.tag_sort.as_deref(), Some("-version:refname"));
9250        assert!(git.ignore_tags.is_none());
9251        assert!(git.ignore_tag_prefixes.is_none());
9252        assert!(git.prerelease_suffix.is_none());
9253    }
9254
9255    #[test]
9256    fn test_git_config_ignore_tags_accepts_array() {
9257        let yaml = r#"
9258project_name: test
9259crates:
9260  - name: a
9261    path: "."
9262    tag_template: "v{{ .Version }}"
9263git:
9264  ignore_tags:
9265    - "alpha*"
9266    - "beta*"
9267    - "rc-*"
9268"#;
9269        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9270        let tags = config.git.unwrap().ignore_tags.unwrap();
9271        assert_eq!(tags.len(), 3);
9272        assert_eq!(tags[0], "alpha*");
9273        assert_eq!(tags[1], "beta*");
9274        assert_eq!(tags[2], "rc-*");
9275    }
9276
9277    #[test]
9278    fn test_validate_tag_sort_valid_refname() {
9279        let config = Config {
9280            git: Some(GitConfig {
9281                tag_sort: Some("-version:refname".to_string()),
9282                ..Default::default()
9283            }),
9284            ..Default::default()
9285        };
9286        assert!(validate_tag_sort(&config).is_ok());
9287    }
9288
9289    #[test]
9290    fn test_validate_tag_sort_valid_creatordate() {
9291        let config = Config {
9292            git: Some(GitConfig {
9293                tag_sort: Some("-version:creatordate".to_string()),
9294                ..Default::default()
9295            }),
9296            ..Default::default()
9297        };
9298        assert!(validate_tag_sort(&config).is_ok());
9299    }
9300
9301    #[test]
9302    fn test_validate_tag_sort_none_is_valid() {
9303        let config = Config {
9304            git: Some(GitConfig::default()),
9305            ..Default::default()
9306        };
9307        assert!(validate_tag_sort(&config).is_ok());
9308    }
9309
9310    #[test]
9311    fn test_validate_tag_sort_no_git_config_is_valid() {
9312        let config = Config::default();
9313        assert!(validate_tag_sort(&config).is_ok());
9314    }
9315
9316    #[test]
9317    fn test_validate_tag_sort_invalid_rejected() {
9318        let config = Config {
9319            git: Some(GitConfig {
9320                tag_sort: Some("alphabetical".to_string()),
9321                ..Default::default()
9322            }),
9323            ..Default::default()
9324        };
9325        let result = validate_tag_sort(&config);
9326        assert!(result.is_err());
9327        let err = result.unwrap_err();
9328        assert!(
9329            err.contains("alphabetical"),
9330            "error should contain the bad value: {}",
9331            err
9332        );
9333        assert!(
9334            err.contains("-version:refname"),
9335            "error should list accepted values: {}",
9336            err
9337        );
9338    }
9339
9340    #[test]
9341    fn test_git_config_ignore_tag_prefixes_accepts_array() {
9342        let yaml = r#"
9343project_name: test
9344crates:
9345  - name: a
9346    path: "."
9347    tag_template: "v{{ .Version }}"
9348git:
9349  ignore_tag_prefixes:
9350    - "wip/"
9351    - "experiment/"
9352"#;
9353        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9354        let prefixes = config.git.unwrap().ignore_tag_prefixes.unwrap();
9355        assert_eq!(prefixes.len(), 2);
9356        assert_eq!(prefixes[0], "wip/");
9357        assert_eq!(prefixes[1], "experiment/");
9358    }
9359
9360    #[test]
9361    fn test_metadata_config_with_mod_timestamp() {
9362        let yaml = r#"
9363project_name: test
9364crates:
9365  - name: a
9366    path: "."
9367    tag_template: "v{{ .Version }}"
9368metadata:
9369  mod_timestamp: "{{ .CommitTimestamp }}"
9370"#;
9371        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9372        let meta = config.metadata.unwrap();
9373        assert_eq!(meta.mod_timestamp.unwrap(), "{{ .CommitTimestamp }}");
9374    }
9375
9376    #[test]
9377    fn test_metadata_config_omitted_is_none() {
9378        let yaml = r#"
9379project_name: test
9380crates:
9381  - name: a
9382    path: "."
9383    tag_template: "v{{ .Version }}"
9384"#;
9385        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9386        assert!(config.metadata.is_none());
9387    }
9388
9389    #[test]
9390    fn test_metadata_config_empty_section() {
9391        let yaml = r#"
9392project_name: test
9393crates:
9394  - name: a
9395    path: "."
9396    tag_template: "v{{ .Version }}"
9397metadata: {}
9398"#;
9399        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9400        let meta = config.metadata.unwrap();
9401        assert!(meta.mod_timestamp.is_none());
9402    }
9403
9404    #[test]
9405    fn test_variables_config_parsed() {
9406        let yaml = r#"
9407project_name: test
9408variables:
9409  description: "my project description"
9410  somethingElse: "yada yada yada"
9411  empty: ""
9412crates:
9413  - name: test
9414    path: "."
9415    tag_template: "v{{ .Version }}"
9416"#;
9417        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9418        let vars = config.variables.as_ref().unwrap();
9419        assert_eq!(vars.get("description").unwrap(), "my project description");
9420        assert_eq!(vars.get("somethingElse").unwrap(), "yada yada yada");
9421        assert_eq!(vars.get("empty").unwrap(), "");
9422        assert_eq!(vars.len(), 3);
9423    }
9424
9425    #[test]
9426    fn test_variables_config_omitted_is_none() {
9427        let yaml = r#"
9428project_name: test
9429crates:
9430  - name: test
9431    path: "."
9432    tag_template: "v{{ .Version }}"
9433"#;
9434        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9435        assert!(config.variables.is_none());
9436    }
9437
9438    // ---- SnapcraftConfig disable StringOrBool tests ----
9439
9440    #[test]
9441    fn test_snapcraft_disable_bool_true() {
9442        let yaml = r#"
9443project_name: test
9444crates:
9445  - name: a
9446    path: "."
9447    tag_template: "v{{ .Version }}"
9448    snapcrafts:
9449      - disable: true
9450"#;
9451        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9452        let snap = &config.crates[0].snapcrafts.as_ref().unwrap()[0];
9453        assert_eq!(snap.disable, Some(StringOrBool::Bool(true)));
9454    }
9455
9456    #[test]
9457    fn test_snapcraft_disable_bool_false() {
9458        let yaml = r#"
9459project_name: test
9460crates:
9461  - name: a
9462    path: "."
9463    tag_template: "v{{ .Version }}"
9464    snapcrafts:
9465      - disable: false
9466"#;
9467        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9468        let snap = &config.crates[0].snapcrafts.as_ref().unwrap()[0];
9469        assert_eq!(snap.disable, Some(StringOrBool::Bool(false)));
9470    }
9471
9472    #[test]
9473    fn test_snapcraft_disable_template_string() {
9474        let yaml = r#"
9475project_name: test
9476crates:
9477  - name: a
9478    path: "."
9479    tag_template: "v{{ .Version }}"
9480    snapcrafts:
9481      - disable: "{{ if .IsSnapshot }}true{{ end }}"
9482"#;
9483        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9484        let snap = &config.crates[0].snapcrafts.as_ref().unwrap()[0];
9485        match &snap.disable {
9486            Some(StringOrBool::String(s)) => {
9487                assert!(s.contains("IsSnapshot"));
9488            }
9489            other => panic!("expected StringOrBool::String, got {:?}", other),
9490        }
9491    }
9492
9493    #[test]
9494    fn test_snapcraft_disable_omitted() {
9495        let yaml = r#"
9496project_name: test
9497crates:
9498  - name: a
9499    path: "."
9500    tag_template: "v{{ .Version }}"
9501    snapcrafts:
9502      - name: mysnap
9503"#;
9504        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9505        let snap = &config.crates[0].snapcrafts.as_ref().unwrap()[0];
9506        assert!(snap.disable.is_none());
9507    }
9508
9509    // ---- AurConfig disable StringOrBool tests ----
9510
9511    #[test]
9512    fn test_aur_disable_bool_true() {
9513        let yaml = r#"
9514project_name: test
9515crates:
9516  - name: a
9517    path: "."
9518    tag_template: "v{{ .Version }}"
9519    publish:
9520      aur:
9521        disable: true
9522        git_url: "ssh://aur@aur.archlinux.org/a.git"
9523"#;
9524        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9525        let aur = config.crates[0]
9526            .publish
9527            .as_ref()
9528            .unwrap()
9529            .aur
9530            .as_ref()
9531            .unwrap();
9532        assert_eq!(aur.disable, Some(StringOrBool::Bool(true)));
9533    }
9534
9535    #[test]
9536    fn test_aur_disable_template_string() {
9537        let yaml = r#"
9538project_name: test
9539crates:
9540  - name: a
9541    path: "."
9542    tag_template: "v{{ .Version }}"
9543    publish:
9544      aur:
9545        disable: "{{ if .IsSnapshot }}true{{ end }}"
9546        git_url: "ssh://aur@aur.archlinux.org/a.git"
9547"#;
9548        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9549        let aur = config.crates[0]
9550            .publish
9551            .as_ref()
9552            .unwrap()
9553            .aur
9554            .as_ref()
9555            .unwrap();
9556        match &aur.disable {
9557            Some(StringOrBool::String(s)) => {
9558                assert!(s.contains("IsSnapshot"));
9559            }
9560            other => panic!("expected StringOrBool::String, got {:?}", other),
9561        }
9562    }
9563
9564    #[test]
9565    fn test_aur_disable_omitted() {
9566        let yaml = r#"
9567project_name: test
9568crates:
9569  - name: a
9570    path: "."
9571    tag_template: "v{{ .Version }}"
9572    publish:
9573      aur:
9574        git_url: "ssh://aur@aur.archlinux.org/a.git"
9575"#;
9576        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9577        let aur = config.crates[0]
9578            .publish
9579            .as_ref()
9580            .unwrap()
9581            .aur
9582            .as_ref()
9583            .unwrap();
9584        assert!(aur.disable.is_none());
9585    }
9586
9587    // ---- PublisherConfig disable StringOrBool tests ----
9588
9589    #[test]
9590    fn test_publisher_disable_bool_true() {
9591        let yaml = r#"
9592project_name: test
9593publishers:
9594  - cmd: "echo hello"
9595    disable: true
9596crates:
9597  - name: a
9598    path: "."
9599    tag_template: "v{{ .Version }}"
9600"#;
9601        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9602        let pub_cfg = &config.publishers.as_ref().unwrap()[0];
9603        assert_eq!(pub_cfg.disable, Some(StringOrBool::Bool(true)));
9604    }
9605
9606    #[test]
9607    fn test_publisher_disable_template_string() {
9608        let yaml = r#"
9609project_name: test
9610publishers:
9611  - cmd: "echo hello"
9612    disable: "{{ if .IsSnapshot }}true{{ end }}"
9613crates:
9614  - name: a
9615    path: "."
9616    tag_template: "v{{ .Version }}"
9617"#;
9618        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9619        let pub_cfg = &config.publishers.as_ref().unwrap()[0];
9620        match &pub_cfg.disable {
9621            Some(StringOrBool::String(s)) => {
9622                assert!(s.contains("IsSnapshot"));
9623            }
9624            other => panic!("expected StringOrBool::String, got {:?}", other),
9625        }
9626    }
9627
9628    #[test]
9629    fn test_publisher_disable_omitted() {
9630        let yaml = r#"
9631project_name: test
9632publishers:
9633  - cmd: "echo hello"
9634crates:
9635  - name: a
9636    path: "."
9637    tag_template: "v{{ .Version }}"
9638"#;
9639        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9640        let pub_cfg = &config.publishers.as_ref().unwrap()[0];
9641        assert!(pub_cfg.disable.is_none());
9642    }
9643
9644    // ---- skip_upload StringOrBool tests for publisher configs ----
9645
9646    #[test]
9647    fn test_homebrew_skip_upload_bool_true() {
9648        let yaml = r#"
9649project_name: test
9650crates:
9651  - name: a
9652    path: "."
9653    tag_template: "v{{ .Version }}"
9654    publish:
9655      homebrew:
9656        skip_upload: true
9657        tap:
9658          owner: org
9659          name: homebrew-tap
9660"#;
9661        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9662        let hb = config.crates[0]
9663            .publish
9664            .as_ref()
9665            .unwrap()
9666            .homebrew
9667            .as_ref()
9668            .unwrap();
9669        assert_eq!(hb.skip_upload, Some(StringOrBool::Bool(true)));
9670    }
9671
9672    #[test]
9673    fn test_scoop_skip_upload_bool_true() {
9674        let yaml = r#"
9675project_name: test
9676crates:
9677  - name: a
9678    path: "."
9679    tag_template: "v{{ .Version }}"
9680    publish:
9681      scoop:
9682        skip_upload: true
9683        bucket:
9684          owner: org
9685          name: scoop-bucket
9686"#;
9687        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9688        let sc = config.crates[0]
9689            .publish
9690            .as_ref()
9691            .unwrap()
9692            .scoop
9693            .as_ref()
9694            .unwrap();
9695        assert_eq!(sc.skip_upload, Some(StringOrBool::Bool(true)));
9696    }
9697
9698    #[test]
9699    fn test_aur_skip_upload_bool_true() {
9700        let yaml = r#"
9701project_name: test
9702crates:
9703  - name: a
9704    path: "."
9705    tag_template: "v{{ .Version }}"
9706    publish:
9707      aur:
9708        skip_upload: true
9709        git_url: "ssh://aur@aur.archlinux.org/a.git"
9710"#;
9711        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9712        let aur = config.crates[0]
9713            .publish
9714            .as_ref()
9715            .unwrap()
9716            .aur
9717            .as_ref()
9718            .unwrap();
9719        assert_eq!(aur.skip_upload, Some(StringOrBool::Bool(true)));
9720    }
9721
9722    #[test]
9723    fn test_winget_skip_upload_bool_true() {
9724        let yaml = r#"
9725project_name: test
9726crates:
9727  - name: a
9728    path: "."
9729    tag_template: "v{{ .Version }}"
9730    publish:
9731      winget:
9732        skip_upload: true
9733        manifests_repo:
9734          owner: org
9735          name: winget-pkgs
9736        package_identifier: "Org.App"
9737"#;
9738        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9739        let wg = config.crates[0]
9740            .publish
9741            .as_ref()
9742            .unwrap()
9743            .winget
9744            .as_ref()
9745            .unwrap();
9746        assert_eq!(wg.skip_upload, Some(StringOrBool::Bool(true)));
9747    }
9748
9749    #[test]
9750    fn test_krew_skip_upload_auto_string() {
9751        let yaml = r#"
9752project_name: test
9753crates:
9754  - name: a
9755    path: "."
9756    tag_template: "v{{ .Version }}"
9757    publish:
9758      krew:
9759        skip_upload: "auto"
9760        manifests_repo:
9761          owner: org
9762          name: krew-index
9763"#;
9764        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9765        let krew = config.crates[0]
9766            .publish
9767            .as_ref()
9768            .unwrap()
9769            .krew
9770            .as_ref()
9771            .unwrap();
9772        assert_eq!(
9773            krew.skip_upload,
9774            Some(StringOrBool::String("auto".to_string()))
9775        );
9776    }
9777
9778    #[test]
9779    fn test_nix_skip_upload_template() {
9780        let yaml = r#"
9781project_name: test
9782crates:
9783  - name: a
9784    path: "."
9785    tag_template: "v{{ .Version }}"
9786    publish:
9787      nix:
9788        skip_upload: "{{ .Env.SKIP }}"
9789        repository:
9790          owner: org
9791          name: nixpkgs
9792"#;
9793        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9794        let nix = config.crates[0]
9795            .publish
9796            .as_ref()
9797            .unwrap()
9798            .nix
9799            .as_ref()
9800            .unwrap();
9801        match &nix.skip_upload {
9802            Some(StringOrBool::String(s)) => {
9803                assert!(s.contains(".Env.SKIP"));
9804            }
9805            other => panic!("expected StringOrBool::String, got {:?}", other),
9806        }
9807    }
9808
9809    #[test]
9810    fn test_skip_upload_string_or_bool() {
9811        let yaml = r#"
9812project_name: test
9813crates:
9814  - name: test
9815    path: "."
9816    tag_template: "v{{ .Version }}"
9817    publish:
9818      homebrew:
9819        name: test
9820        skip_upload: "{{ if .IsSnapshot }}true{{ endif }}"
9821        tap:
9822          owner: org
9823          name: homebrew-tap
9824"#;
9825        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9826        let hb = config.crates[0]
9827            .publish
9828            .as_ref()
9829            .unwrap()
9830            .homebrew
9831            .as_ref()
9832            .unwrap();
9833        match &hb.skip_upload {
9834            Some(StringOrBool::String(s)) => {
9835                assert!(
9836                    s.contains(".IsSnapshot"),
9837                    "expected template with .IsSnapshot, got: {}",
9838                    s
9839                );
9840            }
9841            other => panic!(
9842                "expected StringOrBool::String with template, got {:?}",
9843                other
9844            ),
9845        }
9846    }
9847
9848    // -----------------------------------------------------------------------
9849    // TemplateFileConfig tests
9850    // -----------------------------------------------------------------------
9851
9852    #[test]
9853    fn test_template_files_parses_from_yaml() {
9854        let yaml = r#"
9855project_name: myproject
9856crates: []
9857template_files:
9858  - id: install-script
9859    src: install.sh.tpl
9860    dst: install.sh
9861    mode: "0755"
9862  - src: README.md.tpl
9863    dst: README.md
9864"#;
9865        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9866        let tfs = config.template_files.unwrap();
9867        assert_eq!(tfs.len(), 2);
9868
9869        assert_eq!(tfs[0].id.as_deref(), Some("install-script"));
9870        assert_eq!(tfs[0].src, "install.sh.tpl");
9871        assert_eq!(tfs[0].dst, "install.sh");
9872        assert_eq!(tfs[0].mode, Some("0755".to_string()));
9873
9874        assert_eq!(tfs[1].id, None);
9875        assert_eq!(tfs[1].src, "README.md.tpl");
9876        assert_eq!(tfs[1].dst, "README.md");
9877        assert_eq!(tfs[1].mode, None);
9878    }
9879
9880    #[test]
9881    fn test_template_files_defaults_to_none() {
9882        let yaml = r#"
9883project_name: myproject
9884crates: []
9885"#;
9886        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9887        assert!(config.template_files.is_none());
9888    }
9889
9890    // -----------------------------------------------------------------------
9891    // IncludeSpec parsing tests
9892    // -----------------------------------------------------------------------
9893
9894    #[test]
9895    fn test_include_spec_plain_string() {
9896        let yaml = r#"
9897project_name: test
9898includes:
9899  - ./defaults.yaml
9900  - extra.yaml
9901crates: []
9902"#;
9903        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9904        let includes = config.includes.unwrap();
9905        assert_eq!(includes.len(), 2);
9906        assert_eq!(
9907            includes[0],
9908            IncludeSpec::Path("./defaults.yaml".to_string())
9909        );
9910        assert_eq!(includes[1], IncludeSpec::Path("extra.yaml".to_string()));
9911    }
9912
9913    #[test]
9914    fn test_include_spec_from_file() {
9915        let yaml = r#"
9916project_name: test
9917includes:
9918  - from_file:
9919      path: ./config/goreleaser.yaml
9920crates: []
9921"#;
9922        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9923        let includes = config.includes.unwrap();
9924        assert_eq!(includes.len(), 1);
9925        assert_eq!(
9926            includes[0],
9927            IncludeSpec::FromFile {
9928                from_file: IncludeFilePath {
9929                    path: "./config/goreleaser.yaml".to_string(),
9930                },
9931            }
9932        );
9933    }
9934
9935    #[test]
9936    fn test_include_spec_from_url_without_headers() {
9937        let yaml = r#"
9938project_name: test
9939includes:
9940  - from_url:
9941      url: https://example.com/config.yaml
9942crates: []
9943"#;
9944        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9945        let includes = config.includes.unwrap();
9946        assert_eq!(includes.len(), 1);
9947        assert_eq!(
9948            includes[0],
9949            IncludeSpec::FromUrl {
9950                from_url: IncludeUrlConfig {
9951                    url: "https://example.com/config.yaml".to_string(),
9952                    headers: None,
9953                },
9954            }
9955        );
9956    }
9957
9958    #[test]
9959    fn test_include_spec_from_url_with_headers() {
9960        let yaml = r#"
9961project_name: test
9962includes:
9963  - from_url:
9964      url: https://api.mycompany.com/configs/release.yaml
9965      headers:
9966        x-api-token: "${MYCOMPANY_TOKEN}"
9967        Authorization: "Bearer ${GITHUB_TOKEN}"
9968crates: []
9969"#;
9970        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
9971        let includes = config.includes.unwrap();
9972        assert_eq!(includes.len(), 1);
9973        match &includes[0] {
9974            IncludeSpec::FromUrl { from_url } => {
9975                assert_eq!(
9976                    from_url.url,
9977                    "https://api.mycompany.com/configs/release.yaml"
9978                );
9979                let headers = from_url.headers.as_ref().unwrap();
9980                assert_eq!(headers.len(), 2);
9981                assert_eq!(headers["x-api-token"], "${MYCOMPANY_TOKEN}");
9982                assert_eq!(headers["Authorization"], "Bearer ${GITHUB_TOKEN}");
9983            }
9984            other => panic!("expected FromUrl, got: {:?}", other),
9985        }
9986    }
9987
9988    #[test]
9989    fn test_include_spec_mixed_forms() {
9990        let yaml = r#"
9991project_name: test
9992includes:
9993  - ./defaults.yaml
9994  - from_file:
9995      path: ./config/shared.yaml
9996  - from_url:
9997      url: https://example.com/config.yaml
9998      headers:
9999        x-token: secret
10000crates: []
10001"#;
10002        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
10003        let includes = config.includes.unwrap();
10004        assert_eq!(includes.len(), 3);
10005        assert!(matches!(&includes[0], IncludeSpec::Path(s) if s == "./defaults.yaml"));
10006        assert!(
10007            matches!(&includes[1], IncludeSpec::FromFile { from_file } if from_file.path == "./config/shared.yaml")
10008        );
10009        assert!(
10010            matches!(&includes[2], IncludeSpec::FromUrl { from_url } if from_url.url == "https://example.com/config.yaml")
10011        );
10012    }
10013
10014    #[test]
10015    fn test_include_spec_no_includes_field() {
10016        let yaml = r#"
10017project_name: test
10018crates: []
10019"#;
10020        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
10021        assert!(config.includes.is_none());
10022    }
10023
10024    #[test]
10025    fn test_include_spec_empty_includes() {
10026        let yaml = r#"
10027project_name: test
10028includes: []
10029crates: []
10030"#;
10031        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
10032        assert_eq!(config.includes, Some(vec![]));
10033    }
10034
10035    #[test]
10036    fn test_include_spec_github_shorthand_url() {
10037        // The GitHub shorthand (no https:// prefix) should parse fine as a URL
10038        // string — normalization happens at resolve time, not parse time.
10039        let yaml = r#"
10040project_name: test
10041includes:
10042  - from_url:
10043      url: caarlos0/goreleaserfiles/main/packages.yml
10044crates: []
10045"#;
10046        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
10047        let includes = config.includes.unwrap();
10048        assert_eq!(includes.len(), 1);
10049        match &includes[0] {
10050            IncludeSpec::FromUrl { from_url } => {
10051                assert_eq!(from_url.url, "caarlos0/goreleaserfiles/main/packages.yml");
10052            }
10053            other => panic!("expected FromUrl, got: {:?}", other),
10054        }
10055    }
10056
10057    // ---- Platform URL config tests ----
10058
10059    #[test]
10060    fn test_github_urls_config_all_fields() {
10061        let yaml = r#"
10062api: https://github.example.com/api/v3/
10063upload: https://github.example.com/api/uploads/
10064download: https://github.example.com/
10065skip_tls_verify: true
10066"#;
10067        let cfg: GitHubUrlsConfig = serde_yaml_ng::from_str(yaml).unwrap();
10068        assert_eq!(
10069            cfg.api.as_deref(),
10070            Some("https://github.example.com/api/v3/")
10071        );
10072        assert_eq!(
10073            cfg.upload.as_deref(),
10074            Some("https://github.example.com/api/uploads/")
10075        );
10076        assert_eq!(cfg.download.as_deref(), Some("https://github.example.com/"));
10077        assert_eq!(cfg.skip_tls_verify, Some(true));
10078    }
10079
10080    #[test]
10081    fn test_github_urls_config_defaults() {
10082        let yaml = "{}";
10083        let cfg: GitHubUrlsConfig = serde_yaml_ng::from_str(yaml).unwrap();
10084        assert_eq!(cfg.api, None);
10085        assert_eq!(cfg.upload, None);
10086        assert_eq!(cfg.download, None);
10087        assert_eq!(cfg.skip_tls_verify, None);
10088    }
10089
10090    #[test]
10091    fn test_gitlab_urls_config_all_fields() {
10092        let yaml = r#"
10093api: https://gitlab.example.com/api/v4/
10094download: https://gitlab.example.com/
10095skip_tls_verify: false
10096use_package_registry: true
10097use_job_token: true
10098"#;
10099        let cfg: GitLabUrlsConfig = serde_yaml_ng::from_str(yaml).unwrap();
10100        assert_eq!(
10101            cfg.api.as_deref(),
10102            Some("https://gitlab.example.com/api/v4/")
10103        );
10104        assert_eq!(cfg.download.as_deref(), Some("https://gitlab.example.com/"));
10105        assert_eq!(cfg.skip_tls_verify, Some(false));
10106        assert_eq!(cfg.use_package_registry, Some(true));
10107        assert_eq!(cfg.use_job_token, Some(true));
10108    }
10109
10110    #[test]
10111    fn test_gitlab_urls_config_defaults() {
10112        let yaml = "{}";
10113        let cfg: GitLabUrlsConfig = serde_yaml_ng::from_str(yaml).unwrap();
10114        assert_eq!(cfg.api, None);
10115        assert_eq!(cfg.download, None);
10116        assert_eq!(cfg.skip_tls_verify, None);
10117        assert_eq!(cfg.use_package_registry, None);
10118        assert_eq!(cfg.use_job_token, None);
10119    }
10120
10121    #[test]
10122    fn test_gitea_urls_config_all_fields() {
10123        let yaml = r#"
10124api: https://gitea.example.com/api/v1/
10125download: https://gitea.example.com/
10126skip_tls_verify: true
10127"#;
10128        let cfg: GiteaUrlsConfig = serde_yaml_ng::from_str(yaml).unwrap();
10129        assert_eq!(
10130            cfg.api.as_deref(),
10131            Some("https://gitea.example.com/api/v1/")
10132        );
10133        assert_eq!(cfg.download.as_deref(), Some("https://gitea.example.com/"));
10134        assert_eq!(cfg.skip_tls_verify, Some(true));
10135    }
10136
10137    #[test]
10138    fn test_gitea_urls_config_defaults() {
10139        let yaml = "{}";
10140        let cfg: GiteaUrlsConfig = serde_yaml_ng::from_str(yaml).unwrap();
10141        assert_eq!(cfg.api, None);
10142        assert_eq!(cfg.download, None);
10143        assert_eq!(cfg.skip_tls_verify, None);
10144    }
10145
10146    #[test]
10147    fn test_release_config_gitlab_gitea_fields() {
10148        let yaml = r#"
10149project_name: test
10150crates:
10151  - name: a
10152    path: "."
10153    tag_template: "v{{ .Version }}"
10154    release:
10155      github:
10156        owner: gh-owner
10157        name: gh-repo
10158      gitlab:
10159        owner: gitlab-owner
10160        name: gitlab-repo
10161      gitea:
10162        owner: gitea-owner
10163        name: gitea-repo
10164"#;
10165        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
10166        let release = config.crates[0].release.as_ref().unwrap();
10167        let github = release.github.as_ref().unwrap();
10168        assert_eq!(github.owner, "gh-owner");
10169        assert_eq!(github.name, "gh-repo");
10170        let gitlab = release.gitlab.as_ref().unwrap();
10171        assert_eq!(gitlab.owner, "gitlab-owner");
10172        assert_eq!(gitlab.name, "gitlab-repo");
10173        let gitea = release.gitea.as_ref().unwrap();
10174        assert_eq!(gitea.owner, "gitea-owner");
10175        assert_eq!(gitea.name, "gitea-repo");
10176    }
10177
10178    #[test]
10179    fn test_config_github_urls_field() {
10180        let yaml = r#"
10181project_name: test
10182github_urls:
10183  api: https://ghe.corp.com/api/v3/
10184  upload: https://ghe.corp.com/api/uploads/
10185  download: https://ghe.corp.com/
10186  skip_tls_verify: true
10187crates:
10188  - name: a
10189    path: "."
10190    tag_template: "v{{ .Version }}"
10191"#;
10192        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
10193        let urls = config.github_urls.as_ref().unwrap();
10194        assert_eq!(urls.api.as_deref(), Some("https://ghe.corp.com/api/v3/"));
10195        assert_eq!(
10196            urls.upload.as_deref(),
10197            Some("https://ghe.corp.com/api/uploads/")
10198        );
10199        assert_eq!(urls.download.as_deref(), Some("https://ghe.corp.com/"));
10200        assert_eq!(urls.skip_tls_verify, Some(true));
10201    }
10202
10203    #[test]
10204    fn test_config_gitlab_urls_field() {
10205        let yaml = r#"
10206project_name: test
10207gitlab_urls:
10208  api: https://gitlab.corp.com/api/v4/
10209  download: https://gitlab.corp.com/
10210  skip_tls_verify: false
10211  use_package_registry: true
10212  use_job_token: false
10213crates:
10214  - name: a
10215    path: "."
10216    tag_template: "v{{ .Version }}"
10217"#;
10218        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
10219        let urls = config.gitlab_urls.as_ref().unwrap();
10220        assert_eq!(urls.api.as_deref(), Some("https://gitlab.corp.com/api/v4/"));
10221        assert_eq!(urls.download.as_deref(), Some("https://gitlab.corp.com/"));
10222        assert_eq!(urls.skip_tls_verify, Some(false));
10223        assert_eq!(urls.use_package_registry, Some(true));
10224        assert_eq!(urls.use_job_token, Some(false));
10225    }
10226
10227    #[test]
10228    fn test_config_gitea_urls_field() {
10229        let yaml = r#"
10230project_name: test
10231gitea_urls:
10232  api: https://gitea.corp.com/api/v1/
10233  download: https://gitea.corp.com/
10234  skip_tls_verify: true
10235crates:
10236  - name: a
10237    path: "."
10238    tag_template: "v{{ .Version }}"
10239"#;
10240        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
10241        let urls = config.gitea_urls.as_ref().unwrap();
10242        assert_eq!(urls.api.as_deref(), Some("https://gitea.corp.com/api/v1/"));
10243        assert_eq!(urls.download.as_deref(), Some("https://gitea.corp.com/"));
10244        assert_eq!(urls.skip_tls_verify, Some(true));
10245    }
10246
10247    #[test]
10248    fn test_config_force_token_field() {
10249        let yaml = r#"
10250project_name: test
10251force_token: gitlab
10252crates:
10253  - name: a
10254    path: "."
10255    tag_template: "v{{ .Version }}"
10256"#;
10257        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
10258        assert_eq!(config.force_token, Some(ForceTokenKind::GitLab));
10259    }
10260
10261    #[test]
10262    fn test_config_force_token_omitted() {
10263        let yaml = r#"
10264project_name: test
10265crates:
10266  - name: a
10267    path: "."
10268    tag_template: "v{{ .Version }}"
10269"#;
10270        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
10271        assert_eq!(config.force_token, None::<ForceTokenKind>);
10272    }
10273
10274    #[test]
10275    fn test_config_all_platform_urls_and_force_token() {
10276        let yaml = r#"
10277project_name: test
10278github_urls:
10279  api: https://ghe.corp.com/api/v3/
10280gitlab_urls:
10281  api: https://gitlab.corp.com/api/v4/
10282  use_job_token: true
10283gitea_urls:
10284  api: https://gitea.corp.com/api/v1/
10285force_token: github
10286crates:
10287  - name: a
10288    path: "."
10289    tag_template: "v{{ .Version }}"
10290"#;
10291        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
10292        assert_eq!(
10293            config.github_urls.as_ref().unwrap().api.as_deref(),
10294            Some("https://ghe.corp.com/api/v3/")
10295        );
10296        assert_eq!(
10297            config.gitlab_urls.as_ref().unwrap().api.as_deref(),
10298            Some("https://gitlab.corp.com/api/v4/")
10299        );
10300        assert_eq!(
10301            config.gitlab_urls.as_ref().unwrap().use_job_token,
10302            Some(true)
10303        );
10304        assert_eq!(
10305            config.gitea_urls.as_ref().unwrap().api.as_deref(),
10306            Some("https://gitea.corp.com/api/v1/")
10307        );
10308        assert_eq!(config.force_token, Some(ForceTokenKind::GitHub));
10309    }
10310
10311    #[test]
10312    fn test_dockerhub_config_parse() {
10313        let yaml = r#"
10314project_name: test
10315dockerhub:
10316  - username: myuser
10317    secret_name: DOCKER_TOKEN
10318    images:
10319      - myorg/myapp
10320    description: "My app"
10321    disable: true
10322    full_description:
10323      from_file:
10324        path: ./README.md
10325"#;
10326        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10327        let dh = &cfg.dockerhub.unwrap()[0];
10328        assert_eq!(dh.username.as_deref(), Some("myuser"));
10329        assert_eq!(dh.secret_name.as_deref(), Some("DOCKER_TOKEN"));
10330        assert_eq!(dh.images.as_ref().unwrap(), &["myorg/myapp"]);
10331        assert_eq!(dh.description.as_deref(), Some("My app"));
10332        assert_eq!(dh.disable, Some(StringOrBool::Bool(true)));
10333        let fd = dh.full_description.as_ref().unwrap();
10334        assert!(fd.from_url.is_none());
10335        let ff = fd.from_file.as_ref().unwrap();
10336        assert_eq!(ff.path, "./README.md");
10337    }
10338
10339    #[test]
10340    fn test_dockerhub_from_url_parse() {
10341        let yaml = r#"
10342project_name: test
10343dockerhub:
10344  - username: myuser
10345    full_description:
10346      from_url:
10347        url: "https://raw.githubusercontent.com/org/repo/main/README.md"
10348        headers:
10349          Authorization: "Bearer {{ .Env.GH_TOKEN }}"
10350"#;
10351        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10352        let dh = &cfg.dockerhub.unwrap()[0];
10353        let fu = dh
10354            .full_description
10355            .as_ref()
10356            .unwrap()
10357            .from_url
10358            .as_ref()
10359            .unwrap();
10360        assert_eq!(
10361            fu.url,
10362            "https://raw.githubusercontent.com/org/repo/main/README.md"
10363        );
10364        let headers = fu.headers.as_ref().unwrap();
10365        assert_eq!(
10366            headers.get("Authorization").unwrap(),
10367            "Bearer {{ .Env.GH_TOKEN }}"
10368        );
10369    }
10370
10371    #[test]
10372    fn test_artifactory_config_parse() {
10373        let yaml = r#"
10374project_name: test
10375artifactories:
10376  - name: production
10377    target: "https://artifactory.example.com/repo/{{ .ProjectName }}/{{ .Version }}/"
10378    username: deployer
10379    mode: archive
10380    skip: "{{ .Env.SKIP }}"
10381    ids:
10382      - default
10383"#;
10384        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10385        let art = &cfg.artifactories.unwrap()[0];
10386        assert_eq!(art.name.as_deref(), Some("production"));
10387        assert_eq!(
10388            art.target.as_deref(),
10389            Some("https://artifactory.example.com/repo/{{ .ProjectName }}/{{ .Version }}/")
10390        );
10391        assert_eq!(art.username.as_deref(), Some("deployer"));
10392        assert_eq!(art.mode.as_deref(), Some("archive"));
10393        assert_eq!(
10394            art.skip,
10395            Some(StringOrBool::String("{{ .Env.SKIP }}".to_string()))
10396        );
10397        assert_eq!(art.ids.as_ref().unwrap(), &["default"]);
10398    }
10399
10400    #[test]
10401    fn test_cloudsmith_config_parse() {
10402        let yaml = r#"
10403project_name: test
10404cloudsmiths:
10405  - organization: myorg
10406    repository: myrepo
10407    formats:
10408      - deb
10409    distributions:
10410      deb: "ubuntu/focal"
10411"#;
10412        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10413        let cs = &cfg.cloudsmiths.unwrap()[0];
10414        assert_eq!(cs.organization.as_deref(), Some("myorg"));
10415        assert_eq!(cs.repository.as_deref(), Some("myrepo"));
10416        assert_eq!(cs.formats.as_ref().unwrap(), &["deb"]);
10417        let dists = cs.distributions.as_ref().unwrap();
10418        assert_eq!(dists.get("deb").unwrap(), "ubuntu/focal");
10419    }
10420
10421    // -----------------------------------------------------------------------
10422    // deserialize_env_map tests — map, list-of-strings, null/missing
10423    // -----------------------------------------------------------------------
10424
10425    #[test]
10426    fn test_docker_sign_env_map_format() {
10427        let yaml = r#"
10428project_name: test
10429docker_signs:
10430  - cmd: cosign
10431    env:
10432      COSIGN_PASSWORD: hunter2
10433      COSIGN_KEY: /path/to/key
10434"#;
10435        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10436        let ds = &cfg.docker_signs.as_ref().unwrap()[0];
10437        let env = ds
10438            .env
10439            .as_ref()
10440            .unwrap_or_else(|| panic!("env should be Some"));
10441        assert_eq!(env.get("COSIGN_PASSWORD").unwrap(), "hunter2");
10442        assert_eq!(env.get("COSIGN_KEY").unwrap(), "/path/to/key");
10443    }
10444
10445    #[test]
10446    fn test_docker_sign_env_list_format() {
10447        let yaml = r#"
10448project_name: test
10449docker_signs:
10450  - cmd: cosign
10451    env:
10452      - COSIGN_PASSWORD=hunter2
10453      - COSIGN_KEY=/path/to/key
10454"#;
10455        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10456        let ds = &cfg.docker_signs.as_ref().unwrap()[0];
10457        let env = ds
10458            .env
10459            .as_ref()
10460            .unwrap_or_else(|| panic!("env should be Some"));
10461        assert_eq!(env.get("COSIGN_PASSWORD").unwrap(), "hunter2");
10462        assert_eq!(env.get("COSIGN_KEY").unwrap(), "/path/to/key");
10463    }
10464
10465    #[test]
10466    fn test_docker_sign_env_list_split_on_first_equals() {
10467        let yaml = r#"
10468project_name: test
10469docker_signs:
10470  - cmd: cosign
10471    env:
10472      - FLAGS=--key=val --other=stuff
10473"#;
10474        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10475        let ds = &cfg.docker_signs.as_ref().unwrap()[0];
10476        let env = ds
10477            .env
10478            .as_ref()
10479            .unwrap_or_else(|| panic!("env should be Some"));
10480        assert_eq!(env.get("FLAGS").unwrap(), "--key=val --other=stuff");
10481    }
10482
10483    #[test]
10484    fn test_docker_sign_env_null() {
10485        let yaml = r#"
10486project_name: test
10487docker_signs:
10488  - cmd: cosign
10489    env: ~
10490"#;
10491        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10492        let ds = &cfg.docker_signs.as_ref().unwrap()[0];
10493        assert!(ds.env.is_none());
10494    }
10495
10496    #[test]
10497    fn test_docker_sign_env_missing() {
10498        let yaml = r#"
10499project_name: test
10500docker_signs:
10501  - cmd: cosign
10502"#;
10503        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10504        let ds = &cfg.docker_signs.as_ref().unwrap()[0];
10505        assert!(ds.env.is_none());
10506    }
10507
10508    #[test]
10509    fn test_docker_sign_env_list_invalid_no_equals() {
10510        let yaml = r#"
10511project_name: test
10512docker_signs:
10513  - cmd: cosign
10514    env:
10515      - COSIGN_PASSWORD
10516"#;
10517        let result = serde_yaml_ng::from_str::<Config>(yaml);
10518        assert!(result.is_err(), "entry without '=' should fail");
10519    }
10520
10521    #[test]
10522    fn test_sign_config_env_list_format() {
10523        let yaml = r#"
10524project_name: test
10525signs:
10526  - cmd: gpg
10527    env:
10528      - GPG_KEY=ABCDEF
10529      - GPG_TTY=/dev/pts/0
10530"#;
10531        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10532        let s = &cfg.signs[0];
10533        let env = s
10534            .env
10535            .as_ref()
10536            .unwrap_or_else(|| panic!("env should be Some"));
10537        assert_eq!(env.get("GPG_KEY").unwrap(), "ABCDEF");
10538        assert_eq!(env.get("GPG_TTY").unwrap(), "/dev/pts/0");
10539    }
10540
10541    #[test]
10542    fn test_publisher_env_list_format() {
10543        let yaml = r#"
10544project_name: test
10545publishers:
10546  - name: mypub
10547    cmd: publish.sh
10548    env:
10549      - API_TOKEN=secret123
10550"#;
10551        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10552        let p = &cfg.publishers.as_ref().unwrap()[0];
10553        let env = p
10554            .env
10555            .as_ref()
10556            .unwrap_or_else(|| panic!("env should be Some"));
10557        assert_eq!(env.get("API_TOKEN").unwrap(), "secret123");
10558    }
10559
10560    // -----------------------------------------------------------------------
10561    // BuildOverride.env — list and map format tests
10562    // -----------------------------------------------------------------------
10563
10564    #[test]
10565    fn test_build_override_env_list_format() {
10566        let yaml = r#"
10567project_name: test
10568defaults:
10569  targets:
10570    - x86_64-unknown-linux-gnu
10571  overrides:
10572    - targets:
10573        - "x86_64-*"
10574      env:
10575        - CC=gcc-12
10576        - CFLAGS=-O2 -Wall
10577crates: []
10578"#;
10579        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
10580        let overrides = config.defaults.unwrap().overrides.unwrap();
10581        let env = overrides[0]
10582            .env
10583            .as_ref()
10584            .unwrap_or_else(|| panic!("env should be Some"));
10585        assert_eq!(env.get("CC").unwrap(), "gcc-12");
10586        assert_eq!(env.get("CFLAGS").unwrap(), "-O2 -Wall");
10587    }
10588
10589    #[test]
10590    fn test_build_override_env_map_format() {
10591        let yaml = r#"
10592project_name: test
10593defaults:
10594  targets:
10595    - x86_64-unknown-linux-gnu
10596  overrides:
10597    - targets:
10598        - "x86_64-*"
10599      env:
10600        CC: gcc-12
10601        CFLAGS: "-O2 -Wall"
10602crates: []
10603"#;
10604        let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
10605        let overrides = config.defaults.unwrap().overrides.unwrap();
10606        let env = overrides[0]
10607            .env
10608            .as_ref()
10609            .unwrap_or_else(|| panic!("env should be Some"));
10610        assert_eq!(env.get("CC").unwrap(), "gcc-12");
10611        assert_eq!(env.get("CFLAGS").unwrap(), "-O2 -Wall");
10612    }
10613
10614    // -----------------------------------------------------------------------
10615    // StructuredHook.env — list and map format tests
10616    // -----------------------------------------------------------------------
10617
10618    #[test]
10619    fn test_structured_hook_env_list_format() {
10620        let yaml = r#"
10621project_name: test
10622before:
10623  hooks:
10624    - cmd: echo hello
10625      env:
10626        - MY_VAR=foo
10627        - OTHER=bar=baz
10628"#;
10629        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10630        let hooks = cfg.before.as_ref().unwrap().hooks.as_ref().unwrap();
10631        match &hooks[0] {
10632            HookEntry::Structured(h) => {
10633                let env = h
10634                    .env
10635                    .as_ref()
10636                    .unwrap_or_else(|| panic!("env should be Some"));
10637                assert_eq!(env.get("MY_VAR").unwrap(), "foo");
10638                assert_eq!(env.get("OTHER").unwrap(), "bar=baz");
10639            }
10640            HookEntry::Simple(_) => panic!("expected Structured hook"),
10641        }
10642    }
10643
10644    #[test]
10645    fn test_structured_hook_env_map_format() {
10646        let yaml = r#"
10647project_name: test
10648before:
10649  hooks:
10650    - cmd: echo hello
10651      env:
10652        MY_VAR: foo
10653        OTHER: "bar=baz"
10654"#;
10655        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10656        let hooks = cfg.before.as_ref().unwrap().hooks.as_ref().unwrap();
10657        match &hooks[0] {
10658            HookEntry::Structured(h) => {
10659                let env = h
10660                    .env
10661                    .as_ref()
10662                    .unwrap_or_else(|| panic!("env should be Some"));
10663                assert_eq!(env.get("MY_VAR").unwrap(), "foo");
10664                assert_eq!(env.get("OTHER").unwrap(), "bar=baz");
10665            }
10666            HookEntry::Simple(_) => panic!("expected Structured hook"),
10667        }
10668    }
10669
10670    // -----------------------------------------------------------------------
10671    // SignConfig.env — map format test (list already covered above)
10672    // -----------------------------------------------------------------------
10673
10674    #[test]
10675    fn test_sign_config_env_map_format() {
10676        let yaml = r#"
10677project_name: test
10678signs:
10679  - cmd: gpg
10680    env:
10681      GPG_KEY: ABCDEF
10682      GPG_TTY: /dev/pts/0
10683"#;
10684        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10685        let s = &cfg.signs[0];
10686        let env = s
10687            .env
10688            .as_ref()
10689            .unwrap_or_else(|| panic!("env should be Some"));
10690        assert_eq!(env.get("GPG_KEY").unwrap(), "ABCDEF");
10691        assert_eq!(env.get("GPG_TTY").unwrap(), "/dev/pts/0");
10692    }
10693
10694    // -----------------------------------------------------------------------
10695    // PublisherConfig.env — map format test (list already covered above)
10696    // -----------------------------------------------------------------------
10697
10698    #[test]
10699    fn test_publisher_env_map_format() {
10700        let yaml = r#"
10701project_name: test
10702publishers:
10703  - name: mypub
10704    cmd: publish.sh
10705    env:
10706      API_TOKEN: secret123
10707      DEPLOY_ENV: staging
10708"#;
10709        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10710        let p = &cfg.publishers.as_ref().unwrap()[0];
10711        let env = p
10712            .env
10713            .as_ref()
10714            .unwrap_or_else(|| panic!("env should be Some"));
10715        assert_eq!(env.get("API_TOKEN").unwrap(), "secret123");
10716        assert_eq!(env.get("DEPLOY_ENV").unwrap(), "staging");
10717    }
10718
10719    // -----------------------------------------------------------------------
10720    // SbomConfig.env — list and map format tests
10721    // -----------------------------------------------------------------------
10722
10723    #[test]
10724    fn test_sbom_config_env_map_format() {
10725        let yaml = r#"
10726project_name: test
10727sboms:
10728  - cmd: syft
10729    env:
10730      SYFT_FILE_METADATA_CATALOGER_ENABLED: "true"
10731      SYFT_SCOPE: all-layers
10732"#;
10733        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10734        let s = &cfg.sboms[0];
10735        let env = s
10736            .env
10737            .as_ref()
10738            .unwrap_or_else(|| panic!("env should be Some"));
10739        assert_eq!(
10740            env.get("SYFT_FILE_METADATA_CATALOGER_ENABLED").unwrap(),
10741            "true"
10742        );
10743        assert_eq!(env.get("SYFT_SCOPE").unwrap(), "all-layers");
10744    }
10745
10746    #[test]
10747    fn test_sbom_config_env_list_format() {
10748        let yaml = r#"
10749project_name: test
10750sboms:
10751  - cmd: syft
10752    env:
10753      - SYFT_FILE_METADATA_CATALOGER_ENABLED=true
10754      - SYFT_SCOPE=all-layers
10755"#;
10756        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10757        let s = &cfg.sboms[0];
10758        let env = s
10759            .env
10760            .as_ref()
10761            .unwrap_or_else(|| panic!("env should be Some"));
10762        assert_eq!(
10763            env.get("SYFT_FILE_METADATA_CATALOGER_ENABLED").unwrap(),
10764            "true"
10765        );
10766        assert_eq!(env.get("SYFT_SCOPE").unwrap(), "all-layers");
10767    }
10768
10769    #[test]
10770    fn test_sbom_config_env_missing() {
10771        let yaml = r#"
10772project_name: test
10773sboms:
10774  - cmd: syft
10775"#;
10776        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
10777        let s = &cfg.sboms[0];
10778        assert!(s.env.is_none());
10779    }
10780}