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, after rollback has been
183 /// attempted. Each entry is a standard hook (`cmd` / `dir` / `env` /
184 /// `output`); the template surface adds `{{ .Publisher }}`,
185 /// `{{ .Error }}`, `{{ .Version }}`, `{{ .Tag }}`, `{{ .Group }}`
186 /// (Assets/Manager/Submitter), `{{ .Required }}`,
187 /// `{{ .RolledBack }}` — true if any publisher was rolled back (or
188 /// rollback was attempted and failed) during this run — and
189 /// `{{ .RunReport }}`, the path of this run's already-written
190 /// `dist/run-<id>/report.json` (per-publisher outcomes including
191 /// rollback results; empty in snapshot/dry-run or when the report
192 /// could not be persisted). The same values
193 /// are also exported to the hook process as environment variables:
194 /// `ANODIZER_PUBLISHER`, `ANODIZER_ERROR`, `ANODIZER_VERSION`,
195 /// `ANODIZER_TAG`, `ANODIZER_GROUP`, `ANODIZER_REQUIRED`,
196 /// `ANODIZER_ROLLED_BACK`, `ANODIZER_RUN_REPORT`. A hook's own
197 /// failure is logged as a warning and never changes the release outcome.
198 ///
199 /// Security: the rendered `cmd` string is parsed by `sh -c`, and
200 /// `{{ .Error }}` carries untrusted remote text (HTTP error bodies, git
201 /// stderr) — interpolating it into `cmd` lets crafted error content
202 /// break quoting and execute. Read untrusted values from the env vars
203 /// instead (`$ANODIZER_ERROR`), and pass `anodizer notify --raw` so the
204 /// text is sent literally rather than Tera-rendered. The outbound
205 /// notification body is secret-redacted by default, so a secret reference
206 /// smuggled into the error body is masked (sent as `$NAME`) even without
207 /// `--raw`; `--raw` stays recommended because it avoids re-rendering
208 /// already-final text and keeps untrusted content out of the shell-parsed
209 /// `cmd` string:
210 ///
211 /// ```yaml
212 /// publish:
213 /// on_error:
214 /// - cmd: 'anodizer notify --raw "anodizer: $ANODIZER_PUBLISHER failed @ $ANODIZER_VERSION: $ANODIZER_ERROR"'
215 /// ```
216 pub on_error: Option<Vec<HookEntry>>,
217
218 /// Hooks that fire once per publisher that a triggered rollback REVERTED —
219 /// including a publisher that itself `Succeeded` and was only reverted
220 /// because a sibling required publisher failed (the case `on_error`, which
221 /// fires solely for the failed publisher, never reaches). A publisher whose
222 /// rollback was attempted but could not complete fires this too, with
223 /// `{{ .RollbackFailed }}` set to `true` so the hook can escalate the
224 /// orphaned-artifact case. Each entry is a standard hook (`cmd` / `dir` /
225 /// `env` / `output`); the template surface adds `{{ .Publisher }}`,
226 /// `{{ .Version }}`, `{{ .Tag }}`, `{{ .Group }}`
227 /// (Assets/Manager/Submitter), `{{ .Required }}`, `{{ .RollbackFailed }}`
228 /// (`true` when the revert itself failed), `{{ .Error }}` (the rollback
229 /// failure message, empty on a clean revert), and `{{ .Reason }}` (the
230 /// run-wide sibling failure(s) that triggered the unwind — distinct from
231 /// `{{ .Error }}`; empty on a `--rollback-only` replay). The same values are
232 /// exported to the hook process as `ANODIZER_PUBLISHER`, `ANODIZER_VERSION`,
233 /// `ANODIZER_TAG`, `ANODIZER_GROUP`, `ANODIZER_REQUIRED`,
234 /// `ANODIZER_ROLLBACK_FAILED`, `ANODIZER_ERROR`, and
235 /// `ANODIZER_ROLLBACK_REASON`. A hook's own failure is
236 /// logged as a warning and never changes the release outcome or aborts the
237 /// remaining rollbacks. It is independent of `on_error`: a publisher that
238 /// both failed and was rolled back fires both.
239 ///
240 /// ```yaml
241 /// publish:
242 /// on_rollback:
243 /// - cmd: 'anodizer notify --raw "anodizer: $ANODIZER_PUBLISHER reverted @ $ANODIZER_VERSION (rollback_failed=$ANODIZER_ROLLBACK_FAILED)"'
244 /// ```
245 pub on_rollback: Option<Vec<HookEntry>>,
246}
247
248/// `cargo publish` flag surface.
249///
250/// Presence under `publish:` opts the crate in; use `skip: true` (or a
251/// truthy template) to opt out. There is no `enabled` field — presence is
252/// the on-switch.
253///
254/// Fields intentionally omitted because anodizer owns them:
255/// - `--package` / `--workspace` / `--exclude`: the top-level `crates[]`
256/// axis owns crate selection.
257/// - `--dry-run`: pipeline-level CLI ergonomics (`anodizer release --dry-run`).
258/// - `-v` / `-q` / `--color`: CLI ergonomics, not config.
259/// - `--config` / `-Z`: cargo CLI escape hatches; out of scope.
260#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
261#[serde(default, deny_unknown_fields)]
262pub struct CargoPublishConfig {
263 // ----- Registry selection -----
264 /// Alternate registry name from `~/.cargo/config.toml` (`--registry`).
265 pub registry: Option<String>,
266 /// Registry index URL (`--index`).
267 pub index: Option<String>,
268 /// Seconds to wait for the crates.io sparse index to publish a crate
269 /// before its dependents are pushed (anodizer-original — no `cargo
270 /// publish` equivalent).
271 pub index_timeout: Option<u64>,
272 /// Pre-publish gate that polls crates.io for every workspace-internal
273 /// dep of the crate being published, blocking until each is queryable
274 /// at its expected version. Required for multi-tag-multi-crate
275 /// workspaces (e.g. cfgd) where per-crate tags fire independent
276 /// `Release.yml` runs that would otherwise race the sparse-index
277 /// propagation.
278 ///
279 /// Single-crate workspaces and lockstep-bumped monorepos (anodizer
280 /// itself) leave this off — there is no inter-tag race to gate on.
281 pub wait_for_workspace_deps: Option<WaitForWorkspaceDepsConfig>,
282
283 // ----- Verify / dirty -----
284 /// Skip the local `cargo build --release` verification step (`--no-verify`).
285 pub no_verify: Option<bool>,
286 /// Allow publishing with an uncommitted working tree (`--allow-dirty`).
287 pub allow_dirty: Option<bool>,
288
289 // ----- Feature selection -----
290 /// Crate features to activate (`--features`).
291 pub features: Option<Vec<String>>,
292 /// Activate every feature, including `default` (`--all-features`).
293 pub all_features: Option<bool>,
294 /// Disable the `default` feature set (`--no-default-features`).
295 pub no_default_features: Option<bool>,
296
297 // ----- Compilation -----
298 /// Build target triple for the verification step (`--target`).
299 pub target: Option<String>,
300 /// Override the cargo target directory (`--target-dir`).
301 pub target_dir: Option<PathBuf>,
302 /// Number of parallel compile jobs for verification (`--jobs`).
303 pub jobs: Option<u32>,
304 /// Continue on errors when verifying multiple crates (`--keep-going`).
305 pub keep_going: Option<bool>,
306
307 // ----- Manifest -----
308 /// Path to the crate's `Cargo.toml` (`--manifest-path`).
309 pub manifest_path: Option<PathBuf>,
310 /// Require an up-to-date `Cargo.lock` matching the resolver (`--locked`).
311 pub locked: Option<bool>,
312 /// Require offline resolution; never hit the network (`--offline`).
313 pub offline: Option<bool>,
314 /// Both `--locked` and `--offline` (`--frozen`).
315 pub frozen: Option<bool>,
316
317 // ----- Authentication -----
318 /// Whether the publish authenticates with a long-lived API token
319 /// (`CARGO_REGISTRY_TOKEN`) or with GitHub Actions OIDC (crates.io
320 /// Trusted Publishing). Default [`Auto`]: a token when one is present,
321 /// otherwise a Trusted-Publishing exchange when an OIDC context is
322 /// available.
323 ///
324 /// `oidc` mints a short-lived crates.io token from a GitHub Actions
325 /// id-token — no stored `CARGO_REGISTRY_TOKEN` is needed — and revokes it
326 /// after the publish loop. The mint is workspace-scoped: one token
327 /// authorizes every crate whose Trusted-Publisher config matches this
328 /// repository/workflow, so a lockstep workspace mints once and reuses it
329 /// for all crates.
330 ///
331 /// When omitted, resolves to [`Auto`]. Optional (rather than a bare enum
332 /// with `#[serde(default)]`) so a `defaults.publish.cargo.auth` value is
333 /// inherited by a per-crate `publish.cargo` block that omits `auth`: a
334 /// bare enum always serializes to a concrete value, and the defaults
335 /// deep-merge (fill-by-missing-key) never overwrites a present scalar, so
336 /// a strict `oidc` default would silently degrade to `auto`. Read through
337 /// [`CargoPublishConfig::resolved_auth`] so every call site agrees on the
338 /// `None` → `Auto` collapse.
339 ///
340 /// [`Auto`]: CargoAuthMode::Auto
341 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub auth: Option<CargoAuthMode>,
343
344 // ----- Peer-publisher pattern -----
345 /// Skip this publisher; supports template strings or bool.
346 /// Truthy renders disable the publisher without removing the block.
347 #[serde(default, deserialize_with = "deserialize_string_or_bool_opt")]
348 pub skip: Option<StringOrBool>,
349 /// Override whether this publisher failing should fail the overall release.
350 ///
351 /// Default: `true` — a failure here aborts the release.
352 /// Set to `false` to log failures but continue.
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub required: Option<bool>,
355 /// Template-conditional gate: when the rendered result is falsy
356 /// (`"false"` / `"0"` / `"no"` / empty), the cargo publisher is
357 /// skipped. Render failure hard-errors. Config key: the publisher's `if:`.
358 #[serde(rename = "if")]
359 pub if_condition: Option<String>,
360 /// When `true`, a triggered rollback leaves this publisher's work in
361 /// place rather than attempting to undo it. Default `false`.
362 pub retain_on_rollback: Option<bool>,
363}
364
365impl CargoPublishConfig {
366 /// The effective authentication mode: the configured [`CargoAuthMode`] or
367 /// [`CargoAuthMode::Auto`] when `auth` was omitted. The single collapse
368 /// point every read site shares so a missing `auth` resolves identically
369 /// in credential resolution and preflight requirements.
370 pub fn resolved_auth(&self) -> CargoAuthMode {
371 self.auth.unwrap_or_default()
372 }
373}
374
375/// How a `publish.cargo` block authenticates its `cargo publish`: a
376/// long-lived crates.io API token, or GitHub Actions OIDC (crates.io Trusted
377/// Publishing, which mints a short-lived token per run — no stored secret).
378///
379/// crates.io publishes through the `cargo` CLI, but the Trusted-Publishing
380/// exchange (Actions id-token → crates.io mint-token) is performed by
381/// anodizer itself: it mints one token before the dependency-order publish
382/// loop, supplies it to every `cargo publish` via `CARGO_REGISTRY_TOKEN`, and
383/// revokes it (best-effort) after the loop. The minted token is
384/// workspace-scoped, so a single mint authorizes every crate whose
385/// Trusted-Publisher config matches this repository/workflow.
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
387#[serde(rename_all = "kebab-case")]
388pub enum CargoAuthMode {
389 /// Use a token when one is available (`CARGO_REGISTRY_TOKEN`); otherwise,
390 /// when an OIDC context is present, mint a Trusted-Publishing token.
391 /// Errors only when neither is available.
392 #[default]
393 Auto,
394 /// Always authenticate with the token; never attempt OIDC. Errors if
395 /// `CARGO_REGISTRY_TOKEN` is unset. This is anodizer's historical
396 /// behaviour.
397 Token,
398 /// Always authenticate with OIDC (Trusted Publishing); never fall back to
399 /// the token. Errors if the GitHub Actions OIDC request env
400 /// (`ACTIONS_ID_TOKEN_REQUEST_URL` / `_TOKEN`) is absent, so a
401 /// misconfigured Trusted Publisher fails the release loudly instead of
402 /// silently falling back to a token.
403 Oidc,
404}
405
406/// Pre-publish polling gate for `cargo publish`. When `enabled`, the cargo
407/// publisher reads its crate's manifest, identifies every dep that points
408/// at another crate in the same anodize workspace, and polls
409/// `https://index.crates.io/<prefix>/<name>` until each `(name, version)`
410/// pair is queryable. Only then does `cargo publish` run.
411///
412/// Default: disabled. Anodize's own workspaces publish lockstep with one
413/// tag; this feature only kicks in for multi-tag-multi-crate workspaces
414/// like cfgd where downstream crates can otherwise race the sparse-index
415/// propagation of their upstream deps.
416///
417/// Complementary to `cargo.index_timeout`: this gate runs BEFORE publish
418/// (waits for *upstream* deps to land), while `index_timeout` runs AFTER
419/// publish (waits for the *just-published* crate to land before the next
420/// dependent in the same run starts).
421///
422/// ```yaml
423/// publish:
424/// cargo:
425/// wait_for_workspace_deps:
426/// enabled: true
427/// poll_interval: 5s
428/// max_wait: 5m
429/// ```
430#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
431#[serde(default, deny_unknown_fields)]
432pub struct WaitForWorkspaceDepsConfig {
433 /// Master switch. Default `false` preserves today's behavior for
434 /// single-crate workspaces and lockstep monorepos.
435 pub enabled: Option<bool>,
436 /// Time between successive index probes. Humantime-style string
437 /// (e.g. `"5s"`, `"500ms"`, `"1m"`). Default: `"5s"`.
438 pub poll_interval: Option<HumanDuration>,
439 /// Hard ceiling on the total wait. The publisher bails with a clear
440 /// error once `max_wait` elapses without every dep appearing.
441 /// Humantime-style string (e.g. `"5m"`, `"30s"`). Default: `"5m"`.
442 pub max_wait: Option<HumanDuration>,
443}
444
445impl WaitForWorkspaceDepsConfig {
446 /// Default poll interval — short enough to feel snappy when the
447 /// upstream's publish lands quickly, long enough that a 5-minute
448 /// wait window costs at most 60 HTTP probes.
449 pub const DEFAULT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
450
451 /// Default ceiling — five minutes matches the historical
452 /// `index_timeout` default and covers the worst-case sparse-index
453 /// CDN propagation window observed in practice.
454 pub const DEFAULT_MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(300);
455
456 /// Resolve `enabled`, defaulting to `false` (master switch off).
457 pub fn resolved_enabled(&self) -> bool {
458 self.enabled.unwrap_or(false)
459 }
460
461 /// Resolve `poll_interval`, falling back to
462 /// [`Self::DEFAULT_POLL_INTERVAL`].
463 pub fn resolved_poll_interval(&self) -> std::time::Duration {
464 self.poll_interval
465 .map(|d| d.duration())
466 .unwrap_or(Self::DEFAULT_POLL_INTERVAL)
467 }
468
469 /// Resolve `max_wait`, falling back to [`Self::DEFAULT_MAX_WAIT`].
470 pub fn resolved_max_wait(&self) -> std::time::Duration {
471 self.max_wait
472 .map(|d| d.duration())
473 .unwrap_or(Self::DEFAULT_MAX_WAIT)
474 }
475}
476
477// ---------------------------------------------------------------------------
478// Publisher gate overrides
479// ---------------------------------------------------------------------------
480
481/// Uniform access to the `required` / `retain_on_rollback` release-gate
482/// overrides that every publisher config block carries.
483///
484/// The publish registry collapses these overrides across all of a
485/// publisher's config sources (per-crate blocks over the full crate
486/// universe, top-level entry lists) into a single publisher-level value.
487/// Implementing this trait — rather than hand-copying the field accessor
488/// chain per publisher — makes it impossible for a publisher to collapse
489/// `required` but forget the parallel `retain_on_rollback` collapse (or
490/// vice versa).
491pub trait PublisherGateOverrides {
492 /// Config-level `required:` override. `None` keeps the publisher's
493 /// built-in default; `Some(true)` anywhere escalates the release gate.
494 fn required_override(&self) -> Option<bool>;
495 /// Config-level `retain_on_rollback:` override. `Some(true)` anywhere
496 /// opts the publisher's successful work out of rollback.
497 fn retain_on_rollback_override(&self) -> Option<bool>;
498}
499
500macro_rules! impl_publisher_gate_overrides {
501 ($($t:ty),+ $(,)?) => {
502 $(impl PublisherGateOverrides for $t {
503 fn required_override(&self) -> Option<bool> {
504 self.required
505 }
506 fn retain_on_rollback_override(&self) -> Option<bool> {
507 self.retain_on_rollback
508 }
509 })+
510 };
511}
512
513impl_publisher_gate_overrides!(
514 CargoPublishConfig,
515 HomebrewConfig,
516 HomebrewCaskConfig,
517 ScoopConfig,
518 ChocolateyConfig,
519 WingetConfig,
520 AurConfig,
521 AurSourceConfig,
522 KrewConfig,
523 NixConfig,
524 super::ReleaseConfig,
525 super::DockerHubConfig,
526 super::ArtifactoryConfig,
527 super::UploadConfig,
528 super::CloudSmithConfig,
529 super::NpmConfig,
530 super::GemFuryConfig,
531 super::PypiConfig,
532 super::HomebrewCoreConfig,
533);
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use std::time::Duration;
539
540 // --- CommitAuthorConfig::normalize_defaults ---------------------------
541
542 #[test]
543 fn normalize_defaults_fills_missing_name_and_email() {
544 let mut c = CommitAuthorConfig::default();
545 c.normalize_defaults();
546 assert_eq!(c.name.as_deref(), Some("anodizer"));
547 assert_eq!(c.email.as_deref(), Some("bot@anodizer.dev"));
548 }
549
550 #[test]
551 fn normalize_defaults_treats_empty_string_as_missing() {
552 let mut c = CommitAuthorConfig {
553 name: Some(String::new()),
554 email: Some(String::new()),
555 ..Default::default()
556 };
557 c.normalize_defaults();
558 assert_eq!(c.name.as_deref(), Some("anodizer"));
559 assert_eq!(c.email.as_deref(), Some("bot@anodizer.dev"));
560 }
561
562 #[test]
563 fn normalize_defaults_preserves_user_supplied_identity() {
564 let mut c = CommitAuthorConfig {
565 name: Some("Release Bot".into()),
566 email: Some("release@corp.example".into()),
567 ..Default::default()
568 };
569 c.normalize_defaults();
570 assert_eq!(c.name.as_deref(), Some("Release Bot"));
571 assert_eq!(c.email.as_deref(), Some("release@corp.example"));
572 }
573
574 // --- CargoAuthMode / resolved_auth ------------------------------------
575
576 #[test]
577 fn cargo_auth_mode_defaults_to_auto() {
578 assert_eq!(CargoAuthMode::default(), CargoAuthMode::Auto);
579 // An omitted `auth` collapses to Auto through the accessor.
580 assert_eq!(
581 CargoPublishConfig::default().resolved_auth(),
582 CargoAuthMode::Auto
583 );
584 }
585
586 #[test]
587 fn cargo_auth_mode_deserializes_kebab_case() {
588 assert_eq!(
589 serde_yaml_ng::from_str::<CargoAuthMode>("auto").unwrap(),
590 CargoAuthMode::Auto
591 );
592 assert_eq!(
593 serde_yaml_ng::from_str::<CargoAuthMode>("token").unwrap(),
594 CargoAuthMode::Token
595 );
596 assert_eq!(
597 serde_yaml_ng::from_str::<CargoAuthMode>("oidc").unwrap(),
598 CargoAuthMode::Oidc
599 );
600 assert!(serde_yaml_ng::from_str::<CargoAuthMode>("bogus").is_err());
601 }
602
603 #[test]
604 fn resolved_auth_returns_explicit_value() {
605 let cfg = CargoPublishConfig {
606 auth: Some(CargoAuthMode::Oidc),
607 ..Default::default()
608 };
609 assert_eq!(cfg.resolved_auth(), CargoAuthMode::Oidc);
610 }
611
612 // --- WaitForWorkspaceDepsConfig resolvers -----------------------------
613
614 #[test]
615 fn wait_for_workspace_deps_defaults() {
616 let d = WaitForWorkspaceDepsConfig::default();
617 assert!(!d.resolved_enabled());
618 assert_eq!(d.resolved_poll_interval(), Duration::from_secs(5));
619 assert_eq!(d.resolved_max_wait(), Duration::from_secs(300));
620 }
621
622 #[test]
623 fn wait_for_workspace_deps_user_values_win() {
624 let cfg: WaitForWorkspaceDepsConfig =
625 serde_yaml_ng::from_str("enabled: true\npoll_interval: 10s\nmax_wait: 2m").unwrap();
626 assert!(cfg.resolved_enabled());
627 assert_eq!(cfg.resolved_poll_interval(), Duration::from_secs(10));
628 assert_eq!(cfg.resolved_max_wait(), Duration::from_secs(120));
629 }
630
631 // --- PublisherGateOverrides trait -------------------------------------
632
633 #[test]
634 fn gate_overrides_expose_required_and_retain() {
635 let cfg = CargoPublishConfig {
636 required: Some(false),
637 retain_on_rollback: Some(true),
638 ..Default::default()
639 };
640 assert_eq!(cfg.required_override(), Some(false));
641 assert_eq!(cfg.retain_on_rollback_override(), Some(true));
642 // Defaults leave both as None (keep the publisher's built-in gate).
643 let bare = CargoPublishConfig::default();
644 assert_eq!(bare.required_override(), None);
645 assert_eq!(bare.retain_on_rollback_override(), None);
646 }
647}