Skip to main content

anodizer_core/config/publishers/
mod.rs

1use std::path::PathBuf;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use super::aur_source::AurSourceConfig;
7use super::hooks::HookEntry;
8use super::string_or_bool::HumanDuration;
9use super::{StringOrBool, deserialize_string_or_bool_opt};
10
11mod homebrew;
12pub use homebrew::*;
13
14mod chocolatey;
15pub use chocolatey::*;
16
17mod winget;
18pub use winget::*;
19
20mod aur;
21pub use aur::*;
22
23mod krew;
24pub use krew::*;
25
26mod nix;
27pub use nix::*;
28
29mod schemastore;
30pub use schemastore::{SchemaEntry, SchemaMode, SchemastoreConfig};
31
32// ---------------------------------------------------------------------------
33// Shared publisher config types: RepositoryConfig, CommitAuthorConfig
34// ---------------------------------------------------------------------------
35
36/// Shared repository configuration used by all git-based publishers
37/// (Homebrew, Scoop, Winget, Krew, Nix). A repository reference.
38#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
39#[serde(default, deny_unknown_fields)]
40pub struct RepositoryConfig {
41    /// Repository owner (GitHub user or organization).
42    pub owner: Option<String>,
43    /// Repository name.
44    pub name: Option<String>,
45    /// Auth token for the repository. Falls back to env-based resolution.
46    pub token: Option<String>,
47    /// Token type: "github" (default), "gitlab", "gitea".
48    pub token_type: Option<String>,
49    /// Branch to push to (default: repo default branch).
50    pub branch: Option<String>,
51    /// Git-specific settings for SSH-based publishing.
52    pub git: Option<GitRepoConfig>,
53    /// Pull request settings for fork-based workflows.
54    pub pull_request: Option<PullRequestConfig>,
55}
56
57/// Git-specific repository settings for SSH-based publishing.
58#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
59#[serde(default, deny_unknown_fields)]
60pub struct GitRepoConfig {
61    /// Git URL (e.g. `ssh://git@github.com/owner/repo.git`).
62    pub url: Option<String>,
63    /// Custom SSH command (e.g. `ssh -i /path/to/key`).
64    pub ssh_command: Option<String>,
65    /// Path to SSH private key file.
66    pub private_key: Option<String>,
67}
68
69/// Pull request configuration for fork-based publisher workflows.
70#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
71#[serde(default, deny_unknown_fields)]
72pub struct PullRequestConfig {
73    /// Enable PR creation instead of direct push.
74    pub enabled: Option<bool>,
75    /// Create PR as draft.
76    pub draft: Option<bool>,
77    /// Body text for the pull request.
78    pub body: Option<String>,
79    /// Target base repository/branch for the PR.
80    pub base: Option<PullRequestBaseConfig>,
81}
82
83/// Target base for pull requests (upstream repo to PR against).
84#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
85#[serde(default, deny_unknown_fields)]
86pub struct PullRequestBaseConfig {
87    /// Owner of the upstream repository to PR against.
88    pub owner: Option<String>,
89    /// Name of the upstream repository to PR against.
90    pub name: Option<String>,
91    /// Base branch of the upstream repository to target with the PR.
92    pub branch: Option<String>,
93}
94
95/// Shared commit author configuration with optional GPG/SSH signing.
96/// Commit-author identity for publisher commits.
97#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
98#[serde(default, deny_unknown_fields)]
99pub struct CommitAuthorConfig {
100    /// Git commit author display name.
101    pub name: Option<String>,
102    /// Git commit author email address.
103    pub email: Option<String>,
104    /// Commit signing configuration.
105    pub signing: Option<CommitSigningConfig>,
106    /// When true, omit the explicit `-c user.name=` / `-c user.email=`
107    /// overrides at commit time and let the running git client use the
108    /// invoking GitHub App's identity (i.e. the `<app-slug>[bot]@users.noreply.github.com`
109    /// account that the GitHub Actions checkout step has already configured
110    /// in the repo's local git config).
111    ///
112    /// The use-github-app-token toggle
113    /// uses the local git identity; the canonical use-case is
114    /// PRs against `homebrew/homebrew-core` / `kubernetes-sigs/krew-index`
115    /// / `microsoft/winget-pkgs` opened from a GitHub App workflow, where
116    /// EasyCLA / DCO / signed-commit policies require the App's identity
117    /// (rather than a per-user bot identity) to land the merge.
118    #[serde(default)]
119    pub use_github_app_token: bool,
120}
121
122impl CommitAuthorConfig {
123    /// Fill in the anodizer default name/email when either field is empty.
124    /// The commit-author defaulting, which
125    /// runs during the Default pass — so validation messages that reference
126    /// commit-author identity see non-empty strings rather than blanks.
127    pub fn normalize_defaults(&mut self) {
128        if self.name.as_deref().is_none_or(str::is_empty) {
129            self.name = Some("anodizer".to_string());
130        }
131        if self.email.as_deref().is_none_or(str::is_empty) {
132            self.email = Some("bot@anodizer.dev".to_string());
133        }
134    }
135}
136
137/// Commit signing configuration (GPG, x509, or SSH).
138#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
139#[serde(default, deny_unknown_fields)]
140pub struct CommitSigningConfig {
141    /// Enable commit signing.
142    pub enabled: Option<bool>,
143    /// Signing key identifier.
144    pub key: Option<String>,
145    /// Signing program (e.g. `gpg`, `gpg2`).
146    pub program: Option<String>,
147    /// Signing format: "openpgp" (default), "x509", or "ssh".
148    pub format: Option<String>,
149}
150
151// ---------------------------------------------------------------------------
152// PublishConfig
153// ---------------------------------------------------------------------------
154
155#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
156#[serde(default, deny_unknown_fields)]
157pub struct PublishConfig {
158    /// Publish to crates.io. Presence opts in; use `cargo: { skip: true }` to opt out.
159    pub cargo: Option<CargoPublishConfig>,
160    /// Homebrew formula publishing configuration.
161    pub homebrew: Option<HomebrewConfig>,
162    /// Homebrew Cask publishing configuration (macOS .app bundles).
163    ///
164    /// Uses the unified `HomebrewCaskConfig` which carries all fields from both
165    /// the per-crate cask config and the top-level `homebrew_casks:` config.
166    pub homebrew_cask: Option<HomebrewCaskConfig>,
167    /// Scoop manifest publishing configuration.
168    pub scoop: Option<ScoopConfig>,
169    /// Chocolatey package publishing configuration.
170    pub chocolatey: Option<ChocolateyConfig>,
171    /// WinGet manifest publishing configuration.
172    pub winget: Option<WingetConfig>,
173    /// AUR (Arch User Repository) binary package publishing configuration.
174    pub aur: Option<AurConfig>,
175    /// AUR source package publishing configuration (source-only PKGBUILD, not -bin).
176    pub aur_source: Option<AurSourceConfig>,
177    /// Krew (kubectl plugin manager) manifest publishing configuration.
178    pub krew: Option<KrewConfig>,
179    /// Nix derivation publishing configuration.
180    pub nix: Option<NixConfig>,
181
182    /// Hooks that fire once per FAILED publisher. Each entry is a standard
183    /// hook (`cmd` / `dir` / `env` / `output`); the template surface adds
184    /// `{{ .Publisher }}`,
185    /// `{{ .Error }}`, `{{ .Version }}`, `{{ .Tag }}`, `{{ .Group }}`
186    /// (Assets/Manager/Submitter), `{{ .Required }}`,
187    /// `{{ .RolledBack }}` — always `false` during a release, which never
188    /// withdraws anything on its own; withdrawal is `anodizer tag rollback`
189    /// — and
190    /// `{{ .RunReport }}`, the path of this run's already-written
191    /// `dist/run-<id>/report.json` (per-publisher outcomes including
192    /// rollback results; empty in snapshot/dry-run or when the report
193    /// could not be persisted). The same values
194    /// are also exported to the hook process as environment variables:
195    /// `ANODIZER_PUBLISHER`, `ANODIZER_ERROR`, `ANODIZER_VERSION`,
196    /// `ANODIZER_TAG`, `ANODIZER_GROUP`, `ANODIZER_REQUIRED`,
197    /// `ANODIZER_ROLLED_BACK`, `ANODIZER_RUN_REPORT`. A hook's own
198    /// failure is logged as a warning and never changes the release outcome.
199    ///
200    /// Security: the rendered `cmd` string is parsed by `sh -c`, and
201    /// `{{ .Error }}` carries untrusted remote text (HTTP error bodies, git
202    /// stderr) — interpolating it into `cmd` lets crafted error content
203    /// break quoting and execute. Read untrusted values from the env vars
204    /// instead (`$ANODIZER_ERROR`), and pass `anodizer notify --raw` so the
205    /// text is sent literally rather than Tera-rendered. The outbound
206    /// notification body is secret-redacted by default, so a secret reference
207    /// smuggled into the error body is masked (sent as `$NAME`) even without
208    /// `--raw`; `--raw` stays recommended because it avoids re-rendering
209    /// already-final text and keeps untrusted content out of the shell-parsed
210    /// `cmd` string:
211    ///
212    /// ```yaml
213    /// publish:
214    ///   on_error:
215    ///     - cmd: 'anodizer notify --raw "anodizer: $ANODIZER_PUBLISHER failed @ $ANODIZER_VERSION: $ANODIZER_ERROR"'
216    /// ```
217    pub on_error: Option<Vec<HookEntry>>,
218
219    /// Hooks that fire once per publisher `anodizer tag rollback` REVERTED —
220    /// including a publisher that itself `Succeeded` and is being withdrawn
221    /// because the whole release is (the case `on_error`, which fires solely
222    /// for a failed publisher, never reaches). A publisher whose
223    /// rollback was attempted but could not complete fires this too, with
224    /// `{{ .RollbackFailed }}` set to `true` so the hook can escalate the
225    /// orphaned-artifact case. Each entry is a standard hook (`cmd` / `dir` /
226    /// `env` / `output`); the template surface adds `{{ .Publisher }}`,
227    /// `{{ .Version }}`, `{{ .Tag }}`, `{{ .Group }}`
228    /// (Assets/Manager/Submitter), `{{ .Required }}`, `{{ .RollbackFailed }}`
229    /// (`true` when the revert itself failed), `{{ .Error }}` (the rollback
230    /// failure message, empty on a clean revert), and `{{ .Reason }}` (the
231    /// trigger cause, distinct from `{{ .Error }}` — empty here, because the
232    /// unwind replays state a prior process persisted). The same values are
233    /// exported to the hook process as `ANODIZER_PUBLISHER`, `ANODIZER_VERSION`,
234    /// `ANODIZER_TAG`, `ANODIZER_GROUP`, `ANODIZER_REQUIRED`,
235    /// `ANODIZER_ROLLBACK_FAILED`, `ANODIZER_ERROR`, and
236    /// `ANODIZER_ROLLBACK_REASON`. A hook's own failure is
237    /// logged as a warning and never changes the release outcome or aborts the
238    /// remaining rollbacks. It is independent of `on_error`: a publisher that
239    /// both failed and was rolled back fires both.
240    ///
241    /// ```yaml
242    /// publish:
243    ///   on_rollback:
244    ///     - cmd: 'anodizer notify --raw "anodizer: $ANODIZER_PUBLISHER reverted @ $ANODIZER_VERSION (rollback_failed=$ANODIZER_ROLLBACK_FAILED)"'
245    /// ```
246    pub on_rollback: Option<Vec<HookEntry>>,
247}
248
249/// `cargo publish` flag surface.
250///
251/// Presence under `publish:` opts the crate in; use `skip: true` (or a
252/// truthy template) to opt out. There is no `enabled` field — presence is
253/// the on-switch.
254///
255/// Fields intentionally omitted because anodizer owns them:
256/// - `--package` / `--workspace` / `--exclude`: the top-level `crates[]`
257///   axis owns crate selection.
258/// - `--dry-run`: pipeline-level CLI ergonomics (`anodizer release --dry-run`).
259/// - `-v` / `-q` / `--color`: CLI ergonomics, not config.
260/// - `--config` / `-Z`: cargo CLI escape hatches; out of scope.
261#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
262#[serde(default, deny_unknown_fields)]
263pub struct CargoPublishConfig {
264    // ----- Registry selection -----
265    /// Alternate registry name from `~/.cargo/config.toml` (`--registry`).
266    pub registry: Option<String>,
267    /// Registry index URL (`--index`).
268    pub index: Option<String>,
269    /// Seconds to wait for the crates.io sparse index to publish a crate
270    /// before its dependents are pushed (anodizer-original — no `cargo
271    /// publish` equivalent).
272    pub index_timeout: Option<u64>,
273    /// Pre-publish gate that polls crates.io for every workspace-internal
274    /// dep of the crate being published, blocking until each is queryable
275    /// at its expected version. Required for multi-tag-multi-crate
276    /// workspaces (e.g. cfgd) where per-crate tags fire independent
277    /// `Release.yml` runs that would otherwise race the sparse-index
278    /// propagation.
279    ///
280    /// Single-crate workspaces and lockstep-bumped monorepos (anodizer
281    /// itself) leave this off — there is no inter-tag race to gate on.
282    pub wait_for_workspace_deps: Option<WaitForWorkspaceDepsConfig>,
283
284    // ----- Verify / dirty -----
285    /// Skip the local `cargo build --release` verification step (`--no-verify`).
286    pub no_verify: Option<bool>,
287    /// Allow publishing with an uncommitted working tree (`--allow-dirty`).
288    pub allow_dirty: Option<bool>,
289
290    // ----- Feature selection -----
291    /// Crate features to activate (`--features`).
292    pub features: Option<Vec<String>>,
293    /// Activate every feature, including `default` (`--all-features`).
294    pub all_features: Option<bool>,
295    /// Disable the `default` feature set (`--no-default-features`).
296    pub no_default_features: Option<bool>,
297
298    // ----- Compilation -----
299    /// Build target triple for the verification step (`--target`).
300    pub target: Option<String>,
301    /// Override the cargo target directory (`--target-dir`).
302    pub target_dir: Option<PathBuf>,
303    /// Number of parallel compile jobs for verification (`--jobs`).
304    pub jobs: Option<u32>,
305    /// Continue on errors when verifying multiple crates (`--keep-going`).
306    pub keep_going: Option<bool>,
307
308    // ----- Manifest -----
309    /// Path to the crate's `Cargo.toml` (`--manifest-path`).
310    pub manifest_path: Option<PathBuf>,
311    /// Require an up-to-date `Cargo.lock` matching the resolver (`--locked`).
312    pub locked: Option<bool>,
313    /// Require offline resolution; never hit the network (`--offline`).
314    pub offline: Option<bool>,
315    /// Both `--locked` and `--offline` (`--frozen`).
316    pub frozen: Option<bool>,
317
318    // ----- Authentication -----
319    /// Whether the publish authenticates with a long-lived API token
320    /// (`CARGO_REGISTRY_TOKEN`) or with GitHub Actions OIDC (crates.io
321    /// Trusted Publishing). Default [`Auto`]: a token when one is present,
322    /// otherwise a Trusted-Publishing exchange when an OIDC context is
323    /// available.
324    ///
325    /// `oidc` mints a short-lived crates.io token from a GitHub Actions
326    /// id-token — no stored `CARGO_REGISTRY_TOKEN` is needed — and revokes it
327    /// after the publish loop. The mint is workspace-scoped: one token
328    /// authorizes every crate whose Trusted-Publisher config matches this
329    /// repository/workflow, so a lockstep workspace mints once and reuses it
330    /// for all crates.
331    ///
332    /// When omitted, resolves to [`Auto`]. Optional (rather than a bare enum
333    /// with `#[serde(default)]`) so a `defaults.publish.cargo.auth` value is
334    /// inherited by a per-crate `publish.cargo` block that omits `auth`: a
335    /// bare enum always serializes to a concrete value, and the defaults
336    /// deep-merge (fill-by-missing-key) never overwrites a present scalar, so
337    /// a strict `oidc` default would silently degrade to `auto`. Read through
338    /// [`CargoPublishConfig::resolved_auth`] so every call site agrees on the
339    /// `None` → `Auto` collapse.
340    ///
341    /// [`Auto`]: CargoAuthMode::Auto
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub auth: Option<CargoAuthMode>,
344
345    // ----- Peer-publisher pattern -----
346    /// Skip this publisher; supports template strings or bool.
347    /// Truthy renders disable the publisher without removing the block.
348    #[serde(default, deserialize_with = "deserialize_string_or_bool_opt")]
349    pub skip: Option<StringOrBool>,
350    /// Override whether this publisher failing should fail the overall release.
351    ///
352    /// Default: `true` — a failure here aborts the release.
353    /// Set to `false` to log failures but continue.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub required: Option<bool>,
356    /// Template-conditional gate: when the rendered result is falsy
357    /// (`"false"` / `"0"` / `"no"` / empty), the cargo publisher is
358    /// skipped. Render failure hard-errors. Config key: the publisher's `if:`.
359    #[serde(rename = "if")]
360    pub if_condition: Option<String>,
361    /// When `true`, a triggered rollback leaves this publisher's work in
362    /// place rather than attempting to undo it. Default `false`.
363    pub retain_on_rollback: Option<bool>,
364}
365
366impl CargoPublishConfig {
367    /// The effective authentication mode: the configured [`CargoAuthMode`] or
368    /// [`CargoAuthMode::Auto`] when `auth` was omitted. The single collapse
369    /// point every read site shares so a missing `auth` resolves identically
370    /// in credential resolution and preflight requirements.
371    pub fn resolved_auth(&self) -> CargoAuthMode {
372        self.auth.unwrap_or_default()
373    }
374}
375
376/// How a `publish.cargo` block authenticates its `cargo publish`: a
377/// long-lived crates.io API token, or GitHub Actions OIDC (crates.io Trusted
378/// Publishing, which mints a short-lived token per run — no stored secret).
379///
380/// crates.io publishes through the `cargo` CLI, but the Trusted-Publishing
381/// exchange (Actions id-token → crates.io mint-token) is performed by
382/// anodizer itself: it mints one token before the dependency-order publish
383/// loop, supplies it to every `cargo publish` via `CARGO_REGISTRY_TOKEN`, and
384/// revokes it (best-effort) after the loop. The minted token is
385/// workspace-scoped, so a single mint authorizes every crate whose
386/// Trusted-Publisher config matches this repository/workflow.
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
388#[serde(rename_all = "kebab-case")]
389pub enum CargoAuthMode {
390    /// Use a token when one is available (`CARGO_REGISTRY_TOKEN`); otherwise,
391    /// when an OIDC context is present, mint a Trusted-Publishing token.
392    /// Errors only when neither is available.
393    #[default]
394    Auto,
395    /// Always authenticate with the token; never attempt OIDC. Errors if
396    /// `CARGO_REGISTRY_TOKEN` is unset. This is anodizer's historical
397    /// behaviour.
398    Token,
399    /// Always authenticate with OIDC (Trusted Publishing); never fall back to
400    /// the token. Errors if the GitHub Actions OIDC request env
401    /// (`ACTIONS_ID_TOKEN_REQUEST_URL` / `_TOKEN`) is absent, so a
402    /// misconfigured Trusted Publisher fails the release loudly instead of
403    /// silently falling back to a token.
404    Oidc,
405}
406
407/// Pre-publish polling gate for `cargo publish`. When `enabled`, the cargo
408/// publisher reads its crate's manifest, identifies every dep that points
409/// at another crate in the same anodize workspace, and polls
410/// `https://index.crates.io/<prefix>/<name>` until each `(name, version)`
411/// pair is queryable. Only then does `cargo publish` run.
412///
413/// Default: disabled. Anodize's own workspaces publish lockstep with one
414/// tag; this feature only kicks in for multi-tag-multi-crate workspaces
415/// like cfgd where downstream crates can otherwise race the sparse-index
416/// propagation of their upstream deps.
417///
418/// Complementary to `cargo.index_timeout`: this gate runs BEFORE publish
419/// (waits for *upstream* deps to land), while `index_timeout` runs AFTER
420/// publish (waits for the *just-published* crate to land before the next
421/// dependent in the same run starts).
422///
423/// ```yaml
424/// publish:
425///   cargo:
426///     wait_for_workspace_deps:
427///       enabled: true
428///       poll_interval: 5s
429///       max_wait: 5m
430/// ```
431#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
432#[serde(default, deny_unknown_fields)]
433pub struct WaitForWorkspaceDepsConfig {
434    /// Master switch. Default `false` preserves today's behavior for
435    /// single-crate workspaces and lockstep monorepos.
436    pub enabled: Option<bool>,
437    /// Time between successive index probes. Humantime-style string
438    /// (e.g. `"5s"`, `"500ms"`, `"1m"`). Default: `"5s"`.
439    pub poll_interval: Option<HumanDuration>,
440    /// Hard ceiling on the total wait. The publisher bails with a clear
441    /// error once `max_wait` elapses without every dep appearing.
442    /// Humantime-style string (e.g. `"5m"`, `"30s"`). Default: `"5m"`.
443    pub max_wait: Option<HumanDuration>,
444}
445
446impl WaitForWorkspaceDepsConfig {
447    /// Default poll interval — short enough to feel snappy when the
448    /// upstream's publish lands quickly, long enough that a 5-minute
449    /// wait window costs at most 60 HTTP probes.
450    pub const DEFAULT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
451
452    /// Default ceiling — five minutes matches the historical
453    /// `index_timeout` default and covers the worst-case sparse-index
454    /// CDN propagation window observed in practice.
455    pub const DEFAULT_MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(300);
456
457    /// Resolve `enabled`, defaulting to `false` (master switch off).
458    pub fn resolved_enabled(&self) -> bool {
459        self.enabled.unwrap_or(false)
460    }
461
462    /// Resolve `poll_interval`, falling back to
463    /// [`Self::DEFAULT_POLL_INTERVAL`].
464    pub fn resolved_poll_interval(&self) -> std::time::Duration {
465        self.poll_interval
466            .map(|d| d.duration())
467            .unwrap_or(Self::DEFAULT_POLL_INTERVAL)
468    }
469
470    /// Resolve `max_wait`, falling back to [`Self::DEFAULT_MAX_WAIT`].
471    pub fn resolved_max_wait(&self) -> std::time::Duration {
472        self.max_wait
473            .map(|d| d.duration())
474            .unwrap_or(Self::DEFAULT_MAX_WAIT)
475    }
476}
477
478// ---------------------------------------------------------------------------
479// Publisher gate overrides
480// ---------------------------------------------------------------------------
481
482/// Uniform access to the `required` / `retain_on_rollback` release-gate
483/// overrides that every publisher config block carries.
484///
485/// The publish registry collapses these overrides across all of a
486/// publisher's config sources (per-crate blocks over the full crate
487/// universe, top-level entry lists) into a single publisher-level value.
488/// Implementing this trait — rather than hand-copying the field accessor
489/// chain per publisher — makes it impossible for a publisher to collapse
490/// `required` but forget the parallel `retain_on_rollback` collapse (or
491/// vice versa).
492pub trait PublisherGateOverrides {
493    /// Config-level `required:` override. `None` keeps the publisher's
494    /// built-in default; `Some(true)` anywhere escalates the release gate.
495    fn required_override(&self) -> Option<bool>;
496    /// Config-level `retain_on_rollback:` override. `Some(true)` anywhere
497    /// opts the publisher's successful work out of rollback.
498    fn retain_on_rollback_override(&self) -> Option<bool>;
499}
500
501macro_rules! impl_publisher_gate_overrides {
502    ($($t:ty),+ $(,)?) => {
503        $(impl PublisherGateOverrides for $t {
504            fn required_override(&self) -> Option<bool> {
505                self.required
506            }
507            fn retain_on_rollback_override(&self) -> Option<bool> {
508                self.retain_on_rollback
509            }
510        })+
511    };
512}
513
514impl_publisher_gate_overrides!(
515    CargoPublishConfig,
516    HomebrewConfig,
517    HomebrewCaskConfig,
518    ScoopConfig,
519    ChocolateyConfig,
520    WingetConfig,
521    AurConfig,
522    AurSourceConfig,
523    KrewConfig,
524    NixConfig,
525    super::ReleaseConfig,
526    super::DockerHubConfig,
527    super::ArtifactoryConfig,
528    super::UploadConfig,
529    super::CloudSmithConfig,
530    super::NpmConfig,
531    super::GemFuryConfig,
532    super::PypiConfig,
533    super::HomebrewCoreConfig,
534);
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use std::time::Duration;
540
541    // --- CommitAuthorConfig::normalize_defaults ---------------------------
542
543    #[test]
544    fn normalize_defaults_fills_missing_name_and_email() {
545        let mut c = CommitAuthorConfig::default();
546        c.normalize_defaults();
547        assert_eq!(c.name.as_deref(), Some("anodizer"));
548        assert_eq!(c.email.as_deref(), Some("bot@anodizer.dev"));
549    }
550
551    #[test]
552    fn normalize_defaults_treats_empty_string_as_missing() {
553        let mut c = CommitAuthorConfig {
554            name: Some(String::new()),
555            email: Some(String::new()),
556            ..Default::default()
557        };
558        c.normalize_defaults();
559        assert_eq!(c.name.as_deref(), Some("anodizer"));
560        assert_eq!(c.email.as_deref(), Some("bot@anodizer.dev"));
561    }
562
563    #[test]
564    fn normalize_defaults_preserves_user_supplied_identity() {
565        let mut c = CommitAuthorConfig {
566            name: Some("Release Bot".into()),
567            email: Some("release@corp.example".into()),
568            ..Default::default()
569        };
570        c.normalize_defaults();
571        assert_eq!(c.name.as_deref(), Some("Release Bot"));
572        assert_eq!(c.email.as_deref(), Some("release@corp.example"));
573    }
574
575    // --- CargoAuthMode / resolved_auth ------------------------------------
576
577    #[test]
578    fn cargo_auth_mode_defaults_to_auto() {
579        assert_eq!(CargoAuthMode::default(), CargoAuthMode::Auto);
580        // An omitted `auth` collapses to Auto through the accessor.
581        assert_eq!(
582            CargoPublishConfig::default().resolved_auth(),
583            CargoAuthMode::Auto
584        );
585    }
586
587    #[test]
588    fn cargo_auth_mode_deserializes_kebab_case() {
589        assert_eq!(
590            serde_yaml_ng::from_str::<CargoAuthMode>("auto").unwrap(),
591            CargoAuthMode::Auto
592        );
593        assert_eq!(
594            serde_yaml_ng::from_str::<CargoAuthMode>("token").unwrap(),
595            CargoAuthMode::Token
596        );
597        assert_eq!(
598            serde_yaml_ng::from_str::<CargoAuthMode>("oidc").unwrap(),
599            CargoAuthMode::Oidc
600        );
601        assert!(serde_yaml_ng::from_str::<CargoAuthMode>("bogus").is_err());
602    }
603
604    #[test]
605    fn resolved_auth_returns_explicit_value() {
606        let cfg = CargoPublishConfig {
607            auth: Some(CargoAuthMode::Oidc),
608            ..Default::default()
609        };
610        assert_eq!(cfg.resolved_auth(), CargoAuthMode::Oidc);
611    }
612
613    // --- WaitForWorkspaceDepsConfig resolvers -----------------------------
614
615    #[test]
616    fn wait_for_workspace_deps_defaults() {
617        let d = WaitForWorkspaceDepsConfig::default();
618        assert!(!d.resolved_enabled());
619        assert_eq!(d.resolved_poll_interval(), Duration::from_secs(5));
620        assert_eq!(d.resolved_max_wait(), Duration::from_secs(300));
621    }
622
623    #[test]
624    fn wait_for_workspace_deps_user_values_win() {
625        let cfg: WaitForWorkspaceDepsConfig =
626            serde_yaml_ng::from_str("enabled: true\npoll_interval: 10s\nmax_wait: 2m").unwrap();
627        assert!(cfg.resolved_enabled());
628        assert_eq!(cfg.resolved_poll_interval(), Duration::from_secs(10));
629        assert_eq!(cfg.resolved_max_wait(), Duration::from_secs(120));
630    }
631
632    // --- PublisherGateOverrides trait -------------------------------------
633
634    #[test]
635    fn gate_overrides_expose_required_and_retain() {
636        let cfg = CargoPublishConfig {
637            required: Some(false),
638            retain_on_rollback: Some(true),
639            ..Default::default()
640        };
641        assert_eq!(cfg.required_override(), Some(false));
642        assert_eq!(cfg.retain_on_rollback_override(), Some(true));
643        // Defaults leave both as None (keep the publisher's built-in gate).
644        let bare = CargoPublishConfig::default();
645        assert_eq!(bare.required_override(), None);
646        assert_eq!(bare.retain_on_rollback_override(), None);
647    }
648}