Skip to main content

anodizer_core/config/
release.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::{
5    ContentSource, ExtraFileSpec, HumanDuration, StringOrBool, TemplatedExtraFile,
6    deserialize_string_or_bool_opt,
7};
8
9// ---------------------------------------------------------------------------
10// ReleaseConfig
11// ---------------------------------------------------------------------------
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
14#[serde(default, deny_unknown_fields)]
15pub struct ReleaseConfig {
16    /// GitHub repository to release to (owner and name).
17    pub github: Option<ScmRepoConfig>,
18    /// GitLab repository to release to (owner and name).
19    pub gitlab: Option<ScmRepoConfig>,
20    /// Gitea repository to release to (owner and name).
21    pub gitea: Option<ScmRepoConfig>,
22    /// When true, create the release as a draft (unpublished).
23    pub draft: Option<bool>,
24    #[schemars(schema_with = "prerelease_schema")]
25    /// Mark release as pre-release: true, false, or "auto" (inferred from tag).
26    pub prerelease: Option<PrereleaseConfig>,
27    #[schemars(schema_with = "make_latest_schema")]
28    /// Mark release as latest: true, false, or "auto" (latest non-prerelease).
29    pub make_latest: Option<MakeLatestConfig>,
30    /// Release title template (supports templates).
31    pub name_template: Option<String>,
32    /// Text prepended to the release body (inline string, from_file, or from_url).
33    pub header: Option<ContentSource>,
34    /// Text appended to the release body (inline string, from_file, or from_url).
35    pub footer: Option<ContentSource>,
36    /// Extra files to upload to the release beyond build artifacts.
37    ///
38    /// Paths / globs are resolved relative to the project root. `..`
39    /// segments are accepted, so an entry
40    /// like `../sibling/dist/*` will reach outside the project tree —
41    /// security-conscious users should keep the entries inside the repo or
42    /// canonicalise them before invoking the release pipeline.
43    pub extra_files: Option<Vec<ExtraFileSpec>>,
44    /// Extra files whose contents are rendered through the template engine before upload.
45    /// Unlike `extra_files` which copy as-is, template variables like `{{ Tag }}` are expanded.
46    ///
47    /// Same path-traversal caveat as `extra_files`: `..` segments reach
48    /// outside the project tree.
49    pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
50    /// Skip uploading artifacts: true, false, or "auto" (skip for snapshots).
51    /// Accepts bool or template string.
52    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
53    pub skip_upload: Option<StringOrBool>,
54    /// When true, replace an existing draft release instead of failing.
55    pub replace_existing_draft: Option<bool>,
56    /// When true, replace existing release artifacts with the same name.
57    pub replace_existing_artifacts: Option<bool>,
58    /// Skip the release stage. Accepts bool or template string
59    /// (e.g. `"{{ if IsSnapshot }}true{{ endif }}"` for conditional skip).
60    /// Template strings are supported here.
61    /// Accepts the legacy `disable:` spelling via serde alias for back-compat
62    /// with imported configs (the legacy `disable:` spelling).
63    #[serde(
64        default,
65        alias = "disable",
66        deserialize_with = "deserialize_string_or_bool_opt"
67    )]
68    pub skip: Option<StringOrBool>,
69    /// Release mode: "keep-existing", "append", "prepend", or "replace".
70    pub mode: Option<String>,
71    /// Artifact IDs filter for uploads. Release-wide artifacts (checksums,
72    /// source archive, extra files, metadata) always upload regardless of
73    /// the filter, and derived artifacts (signatures, certificates, SBOMs)
74    /// inherit the verdict of the artifact they derive from — a signature
75    /// uploads iff the artifact it signs uploads.
76    pub ids: Option<Vec<String>>,
77    /// Glob patterns matched against each release asset's file name; anodizer
78    /// drops any asset whose name matches at least one glob before attaching it
79    /// to THIS GitHub release only (a mirror configured elsewhere is
80    /// unaffected). Use it to keep heavy sidecars (checksums, signatures,
81    /// SBOMs) off the GitHub release while archives still attach. Composes with
82    /// `ids:` (both filters apply). `None`/empty keeps everything.
83    ///
84    /// ```yaml
85    /// release:
86    ///   github: { owner: my-org, name: my-repo }
87    ///   exclude: ["*.sha256", "*.sig", "*.cdx.json"]
88    /// ```
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub exclude: Option<Vec<String>>,
91    /// Target branch or SHA for the release tag.
92    pub target_commitish: Option<String>,
93    /// GitHub Discussion category name for the release.
94    pub discussion_category_name: Option<String>,
95    /// Upload `dist/metadata.json` as a release asset. The sibling
96    /// `dist/artifacts.json` manifest is never uploaded — it stays local to
97    /// the dist directory.
98    pub include_meta: Option<bool>,
99    /// Reuse an existing draft release instead of creating a new one.
100    pub use_existing_draft: Option<bool>,
101    /// Override the release tag (template string). When set, this tag is used
102    /// as the `tag_name` in the GitHub release API instead of the crate's
103    /// `tag_template`. Useful in monorepo setups to strip a tag prefix
104    /// (e.g. `"{{ Tag }}"` to publish `v1.0.0` instead of `myapp/v1.0.0`).
105    /// A cross-platform publishing feature provided for free by anodizer.
106    pub tag: Option<String>,
107    /// Maximum number of asset-upload requests in flight simultaneously.
108    /// Applies to asset uploads on every release forge (GitHub, GitLab,
109    /// Gitea).
110    ///
111    /// GitHub's secondary rate-limit is triggered by burst traffic. Keeping
112    /// this value low avoids tripping the limit even for releases with many
113    /// artifacts. Default: 4. Override at runtime with
114    /// `ANODIZER_GITHUB_UPLOAD_CONCURRENCY`.
115    pub upload_concurrency: Option<u32>,
116    /// Minimum interval between successive asset-upload *starts* (a humantime
117    /// string, e.g. `"200ms"`, `"1s"`, `"0s"`). Applies to asset uploads on
118    /// every release forge (GitHub, GitLab, Gitea).
119    ///
120    /// This is a *proactive* pace that smooths the initial burst of upload
121    /// requests, layered on top of [`Self::upload_concurrency`] (the
122    /// concurrency cap) and the reactive secondary-rate-limit backoff. With
123    /// the concurrency cap alone, the first N uploads fire in the same instant
124    /// — exactly the burst pattern that trips GitHub's secondary rate limit.
125    /// Spacing each upload's *start* by this interval (with ±20% jitter so
126    /// concurrent releases don't synchronise) makes the burst far less likely
127    /// to trip the limit in the first place.
128    ///
129    /// Default: `"200ms"` — at the default concurrency of 4 this caps the
130    /// initial start rate at ~5/s, which is below the burst threshold yet adds
131    /// negligible wall-clock to a normal release (upload time is dominated by
132    /// transfer, not start-spacing). Set to `"0s"` to disable pacing entirely
133    /// (rely on the concurrency cap + reactive backoff). Override at runtime
134    /// with `ANODIZER_GITHUB_UPLOAD_PACE_MS` (integer milliseconds; `0`
135    /// disables).
136    pub upload_pace: Option<HumanDuration>,
137    /// Override whether this publisher failing should fail the overall release.
138    ///
139    /// Default: `true` — a failure here aborts the release.
140    /// Set to `false` to log failures but continue.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub required: Option<bool>,
143    /// Explicit publish target — the SCM provider whose `release.<provider>`
144    /// block the publisher uses. When set, overrides the implicit
145    /// token-type fallback chain in
146    /// [`crate::scm::resolve_token_type`].
147    ///
148    /// Use this for **cross-platform publishing**
149    /// pattern: source repo on one provider (e.g. GitLab) but releases
150    /// land on another (e.g. GitHub). Without it, the publish target
151    /// is inferred from which `*_TOKEN` env-var is set — fine for
152    /// single-provider setups but ambiguous when both tokens are
153    /// available.
154    ///
155    /// ```yaml
156    /// release:
157    ///   provider: github
158    ///   github:
159    ///     owner: my-org
160    ///     name: my-app
161    /// ```
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub provider: Option<ForceTokenKind>,
164    /// When `true`, a triggered rollback leaves this publisher's work in
165    /// place rather than attempting to undo it. Default `false`.
166    pub retain_on_rollback: Option<bool>,
167    /// In-process failure policy: what `anodizer release` does after a
168    /// release-pipeline failure. `hold` is the only accepted value, and it
169    /// describes what the pipeline now does unconditionally — leave
170    /// everything in place for forensics. Recovery is a re-run (publishers
171    /// reconcile and self-skip, so an identical command converges on
172    /// already-published state) or, for deliberate withdrawal,
173    /// `anodizer tag rollback`. `rollback` is rejected at config load
174    /// (`validate_on_failure_not_rollback`) — automatic rollback was
175    /// removed. Because the value drives no branch, nothing reads this
176    /// field at runtime; it exists so a config carrying the removed policy
177    /// fails loudly instead of being silently downgraded. Root-level policy
178    /// — in workspace configs (lockstep or per-crate) the top-level
179    /// `release.on_failure` governs the whole run; setting it in a
180    /// crate-level `release:` block is rejected at config load
181    /// (`validate_on_failure_root_only`).
182    pub on_failure: Option<OnFailureConfig>,
183}
184
185impl ReleaseConfig {
186    /// Default release-name template (`"{{Tag}}"`).
187    /// Anodize uses Tera-style `{{ Tag }}` (no dot prefix); the rendered
188    /// value is identical for any tag the project produces.
189    pub const DEFAULT_NAME_TEMPLATE: &'static str = "{{ Tag }}";
190
191    /// Default release `mode` (empty string is treated as
192    /// "keep-existing" — keep current release notes, don't overwrite).
193    pub const DEFAULT_MODE: &'static str = "keep-existing";
194
195    /// Default minimum interval between successive asset-upload starts
196    /// (see [`Self::upload_pace`]). 200 ms smooths the initial burst at the
197    /// default concurrency of 4 without meaningfully slowing a release.
198    pub const DEFAULT_UPLOAD_PACE: std::time::Duration = std::time::Duration::from_millis(200);
199
200    /// Valid `mode:` values. Anything else is a config error.
201    pub const VALID_MODES: &[&'static str] = &["keep-existing", "append", "prepend", "replace"];
202
203    /// Resolve the `name_template`, falling back to
204    /// [`Self::DEFAULT_NAME_TEMPLATE`].
205    pub fn resolved_name_template(&self) -> &str {
206        self.name_template
207            .as_deref()
208            .unwrap_or(Self::DEFAULT_NAME_TEMPLATE)
209    }
210
211    /// Resolve the release `mode`, validating and falling back to
212    /// [`Self::DEFAULT_MODE`] when unset or empty. Returns an error when
213    /// the user supplied a value outside [`Self::VALID_MODES`] so the
214    /// invalid mode surfaces at the call site instead of producing a
215    /// silent no-op publish.
216    pub fn resolved_mode(&self) -> anyhow::Result<&str> {
217        match self.mode.as_deref() {
218            None | Some("") => Ok(Self::DEFAULT_MODE),
219            Some(m) if Self::VALID_MODES.contains(&m) => Ok(m),
220            Some(other) => Err(anyhow::anyhow!(
221                "release: invalid mode '{}', must be one of: {}",
222                other,
223                Self::VALID_MODES.join(", ")
224            )),
225        }
226    }
227
228    /// Resolve `draft`, falling back to `false`.
229    pub fn resolved_draft(&self) -> bool {
230        self.draft.unwrap_or(false)
231    }
232
233    /// Resolve `replace_existing_draft`, falling back to `false`.
234    pub fn resolved_replace_existing_draft(&self) -> bool {
235        self.replace_existing_draft.unwrap_or(false)
236    }
237
238    /// Resolve `replace_existing_artifacts`, falling back to `false`.
239    pub fn resolved_replace_existing_artifacts(&self) -> bool {
240        self.replace_existing_artifacts.unwrap_or(false)
241    }
242
243    /// Resolve `include_meta`, falling back to `false` (don't upload
244    /// metadata.json as a release asset by default).
245    pub fn resolved_include_meta(&self) -> bool {
246        self.include_meta.unwrap_or(false)
247    }
248
249    /// Resolve `use_existing_draft`, falling back to `false` (always
250    /// create a fresh draft when one isn't found by default).
251    pub fn resolved_use_existing_draft(&self) -> bool {
252        self.use_existing_draft.unwrap_or(false)
253    }
254
255    /// Resolve the upload pace (minimum inter-upload-start interval) from the
256    /// config, applying [`Self::DEFAULT_UPLOAD_PACE`] when unset. A configured
257    /// `"0s"` resolves to `Duration::ZERO`, which the upload loop treats as
258    /// "pacing disabled".
259    ///
260    /// Note: the runtime env override `ANODIZER_GITHUB_UPLOAD_PACE_MS` takes
261    /// precedence and is applied at the call site (it needs the request-scoped
262    /// [`crate::context::Context`]), mirroring how `ANODIZER_GITHUB_UPLOAD_CONCURRENCY`
263    /// overrides [`Self::upload_concurrency`].
264    pub fn resolved_upload_pace(&self) -> std::time::Duration {
265        self.upload_pace
266            .map(|d| d.duration())
267            .unwrap_or(Self::DEFAULT_UPLOAD_PACE)
268    }
269}
270
271/// In-process failure policy for `anodizer release`. See
272/// [`ReleaseConfig::on_failure`].
273#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
274#[serde(rename_all = "lowercase")]
275pub enum OnFailureConfig {
276    /// Removed: automatic rollback (delete the run's release tags, revert
277    /// the version-bump commit). Rejected at config load
278    /// (`validate_on_failure_not_rollback`) — re-running `anodizer release`
279    /// converges on already-published state, and `anodizer tag rollback`
280    /// handles deliberate withdrawal. Kept as a parseable variant so the
281    /// validator can name it explicitly in the migration error rather than
282    /// failing an opaque deserialize.
283    Rollback,
284    /// Leave everything in place for forensics; exit nonzero with a
285    /// pointer at `anodizer tag rollback`.
286    #[default]
287    Hold,
288}
289
290impl std::fmt::Display for OnFailureConfig {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        match self {
293            OnFailureConfig::Rollback => f.write_str("rollback"),
294            OnFailureConfig::Hold => f.write_str("hold"),
295        }
296    }
297}
298
299/// Schema for prerelease: "auto" or boolean.
300fn prerelease_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
301    auto_or_bool_schema()
302}
303
304/// Schema for make_latest: "auto" or boolean.
305pub(super) fn make_latest_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
306    auto_or_bool_schema()
307}
308
309/// Schema for skip_push: "auto" or boolean.
310pub(super) fn skip_push_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
311    auto_or_bool_schema()
312}
313
314/// A `oneOf` schema accepting the literal string `"auto"` or any boolean — the
315/// shape shared by `prerelease`, `make_latest`, and `skip_push` (each a
316/// tri-state where `"auto"` defers the value to release-time inference).
317fn auto_or_bool_schema() -> schemars::Schema {
318    schemars::json_schema!({
319        "oneOf": [
320            { "type": "string", "enum": ["auto"] },
321            { "type": "boolean" }
322        ]
323    })
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
327#[serde(deny_unknown_fields)]
328pub struct ScmRepoConfig {
329    /// Repository owner (user or organization).
330    pub owner: String,
331    /// Repository name.
332    pub name: String,
333    /// Auth token override for this repository, instead of the default one.
334    /// Useful when the release repository requires a different token than
335    /// the one used to build from — for instance, when publishing to a
336    /// repository in another organization. Supports templates
337    /// (environment variables). Unset falls back to the pipeline token.
338    ///
339    /// ```yaml
340    /// release:
341    ///   github:
342    ///     owner: my-org
343    ///     name: my-repo
344    ///     token: "{{ .Env.RELEASE_GITHUB_TOKEN }}"
345    /// ```
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub token: Option<String>,
348}
349
350/// Backward-compatible alias — existing code can continue to use `GitHubConfig`.
351pub type GitHubConfig = ScmRepoConfig;
352
353// ---------------------------------------------------------------------------
354// ForceTokenKind
355// ---------------------------------------------------------------------------
356
357/// Which SCM token to force for authentication, overriding automatic detection.
358#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
359#[serde(rename_all = "lowercase")]
360pub enum ForceTokenKind {
361    GitHub,
362    GitLab,
363    Gitea,
364}
365
366// ---------------------------------------------------------------------------
367// Platform URL configs (GitHub Enterprise, GitLab self-hosted, Gitea)
368// ---------------------------------------------------------------------------
369
370/// Custom GitHub API/upload/download URLs for GitHub Enterprise installations.
371/// GitHub API/download URL overrides.
372#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
373#[serde(default, deny_unknown_fields)]
374pub struct GitHubUrlsConfig {
375    /// GitHub API base URL (e.g. `https://github.example.com/api/v3/`).
376    pub api: Option<String>,
377    /// GitHub upload URL for release assets (e.g. `https://github.example.com/api/uploads/`).
378    pub upload: Option<String>,
379    /// GitHub download URL for release assets (e.g. `https://github.example.com/`).
380    pub download: Option<String>,
381    /// When true, skip TLS certificate verification for the custom URLs.
382    pub skip_tls_verify: Option<bool>,
383}
384
385/// Custom GitLab API/download URLs for self-hosted GitLab installations.
386/// GitLab API/download URL overrides.
387#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
388#[serde(default, deny_unknown_fields)]
389pub struct GitLabUrlsConfig {
390    /// GitLab API base URL (e.g. `https://gitlab.example.com/api/v4/`).
391    pub api: Option<String>,
392    /// GitLab download URL for release assets.
393    pub download: Option<String>,
394    /// When true, skip TLS certificate verification for the custom URLs.
395    pub skip_tls_verify: Option<bool>,
396    /// When true, use the GitLab Package Registry for uploads instead of Generic Packages.
397    pub use_package_registry: Option<bool>,
398    /// When true, use the CI_JOB_TOKEN for authentication instead of a personal token.
399    pub use_job_token: Option<bool>,
400}
401
402/// Custom Gitea API/download URLs for self-hosted Gitea installations.
403/// Gitea API/download URL overrides.
404#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
405#[serde(default, deny_unknown_fields)]
406pub struct GiteaUrlsConfig {
407    /// Gitea API base URL (e.g. `https://gitea.example.com/api/v1/`).
408    pub api: Option<String>,
409    /// Gitea download URL for release assets.
410    pub download: Option<String>,
411    /// When true, skip TLS certificate verification for the custom URLs.
412    pub skip_tls_verify: Option<bool>,
413}
414
415// ---------------------------------------------------------------------------
416// "auto" | bool enum — shared serde implementation
417// ---------------------------------------------------------------------------
418
419/// Generates `Serialize` and `Deserialize` impls for enums with `Auto` and
420/// `Bool(bool)` variants that accept the string `"auto"` or a boolean in YAML.
421macro_rules! impl_auto_or_bool_serde {
422    ($ty:ty, $auto:path, $bool_variant:path) => {
423        impl Serialize for $ty {
424            fn serialize<S: serde::Serializer>(
425                &self,
426                serializer: S,
427            ) -> std::result::Result<S::Ok, S::Error> {
428                match self {
429                    $auto => serializer.serialize_str("auto"),
430                    $bool_variant(b) => serializer.serialize_bool(*b),
431                }
432            }
433        }
434
435        impl<'de> Deserialize<'de> for $ty {
436            fn deserialize<D: serde::Deserializer<'de>>(
437                deserializer: D,
438            ) -> std::result::Result<Self, D::Error> {
439                struct Visitor;
440                impl serde::de::Visitor<'_> for Visitor {
441                    type Value = $ty;
442                    fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
443                        write!(f, "\"auto\" or a boolean")
444                    }
445                    fn visit_bool<E: serde::de::Error>(
446                        self,
447                        v: bool,
448                    ) -> std::result::Result<$ty, E> {
449                        Ok($bool_variant(v))
450                    }
451                    fn visit_str<E: serde::de::Error>(
452                        self,
453                        v: &str,
454                    ) -> std::result::Result<$ty, E> {
455                        if v == "auto" {
456                            Ok($auto)
457                        } else {
458                            Err(E::custom(format!("expected \"auto\", got \"{}\"", v)))
459                        }
460                    }
461                }
462                deserializer.deserialize_any(Visitor)
463            }
464        }
465    };
466}
467
468/// `prerelease` can be the string `"auto"` or a boolean.
469#[derive(Debug, Clone, PartialEq, Eq)]
470pub enum PrereleaseConfig {
471    Auto,
472    Bool(bool),
473}
474
475impl_auto_or_bool_serde!(
476    PrereleaseConfig,
477    PrereleaseConfig::Auto,
478    PrereleaseConfig::Bool
479);
480
481/// `make_latest` can be the string `"auto"`, a boolean, or a template string.
482/// This field is rendered through the template engine at publish time,
483/// so we accept arbitrary strings (e.g. `"{{ if .IsSnapshot }}false{{ else }}true{{ end }}"`)
484/// and defer resolution to the release stage.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub enum MakeLatestConfig {
487    Auto,
488    Bool(bool),
489    /// An arbitrary template string to be rendered at publish time.
490    String(String),
491}
492
493impl Serialize for MakeLatestConfig {
494    fn serialize<S: serde::Serializer>(
495        &self,
496        serializer: S,
497    ) -> std::result::Result<S::Ok, S::Error> {
498        match self {
499            MakeLatestConfig::Auto => serializer.serialize_str("auto"),
500            MakeLatestConfig::Bool(b) => serializer.serialize_bool(*b),
501            MakeLatestConfig::String(s) => serializer.serialize_str(s),
502        }
503    }
504}
505
506impl<'de> Deserialize<'de> for MakeLatestConfig {
507    fn deserialize<D: serde::Deserializer<'de>>(
508        deserializer: D,
509    ) -> std::result::Result<Self, D::Error> {
510        struct Visitor;
511        impl serde::de::Visitor<'_> for Visitor {
512            type Value = MakeLatestConfig;
513            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
514                write!(f, "\"auto\", a boolean, or a template string")
515            }
516            fn visit_bool<E: serde::de::Error>(
517                self,
518                v: bool,
519            ) -> std::result::Result<MakeLatestConfig, E> {
520                Ok(MakeLatestConfig::Bool(v))
521            }
522            fn visit_str<E: serde::de::Error>(
523                self,
524                v: &str,
525            ) -> std::result::Result<MakeLatestConfig, E> {
526                match v {
527                    "auto" => Ok(MakeLatestConfig::Auto),
528                    "true" => Ok(MakeLatestConfig::Bool(true)),
529                    "false" => Ok(MakeLatestConfig::Bool(false)),
530                    other => Ok(MakeLatestConfig::String(other.to_string())),
531                }
532            }
533        }
534        deserializer.deserialize_any(Visitor)
535    }
536}
537
538/// `skip_push` can be `"auto"` (skip for prereleases), a boolean, or a template string.
539/// Template expressions like `"{{ if .IsSnapshot }}true{{ end }}"` are accepted.
540#[derive(Debug, Clone, PartialEq, Eq)]
541pub enum SkipPushConfig {
542    Auto,
543    Bool(bool),
544    /// Arbitrary template string — rendered at runtime, truthy result means skip push.
545    Template(String),
546}
547
548impl Serialize for SkipPushConfig {
549    fn serialize<S: serde::Serializer>(
550        &self,
551        serializer: S,
552    ) -> std::result::Result<S::Ok, S::Error> {
553        match self {
554            SkipPushConfig::Auto => serializer.serialize_str("auto"),
555            SkipPushConfig::Bool(b) => serializer.serialize_bool(*b),
556            SkipPushConfig::Template(s) => serializer.serialize_str(s),
557        }
558    }
559}
560
561impl<'de> Deserialize<'de> for SkipPushConfig {
562    fn deserialize<D: serde::Deserializer<'de>>(
563        deserializer: D,
564    ) -> std::result::Result<Self, D::Error> {
565        struct Visitor;
566        impl serde::de::Visitor<'_> for Visitor {
567            type Value = SkipPushConfig;
568            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
569                write!(f, "\"auto\", a boolean, or a template string")
570            }
571            fn visit_bool<E: serde::de::Error>(
572                self,
573                v: bool,
574            ) -> std::result::Result<SkipPushConfig, E> {
575                Ok(SkipPushConfig::Bool(v))
576            }
577            fn visit_str<E: serde::de::Error>(
578                self,
579                v: &str,
580            ) -> std::result::Result<SkipPushConfig, E> {
581                match v {
582                    "auto" => Ok(SkipPushConfig::Auto),
583                    "true" => Ok(SkipPushConfig::Bool(true)),
584                    "false" => Ok(SkipPushConfig::Bool(false)),
585                    other => Ok(SkipPushConfig::Template(other.to_string())),
586                }
587            }
588        }
589        deserializer.deserialize_any(Visitor)
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    // The three `"auto" | bool | [template]` config enums share a hand-written
598    // serde `Visitor` per type; each arm (string-auto, bool, template fallback,
599    // and the bad-string error) is exercised here through a YAML round-trip.
600
601    #[test]
602    fn prerelease_config_parses_auto_and_bools() {
603        assert_eq!(
604            serde_yaml_ng::from_str::<PrereleaseConfig>("auto").unwrap(),
605            PrereleaseConfig::Auto
606        );
607        assert_eq!(
608            serde_yaml_ng::from_str::<PrereleaseConfig>("true").unwrap(),
609            PrereleaseConfig::Bool(true)
610        );
611        assert_eq!(
612            serde_yaml_ng::from_str::<PrereleaseConfig>("false").unwrap(),
613            PrereleaseConfig::Bool(false)
614        );
615    }
616
617    #[test]
618    fn prerelease_config_rejects_other_strings() {
619        // Unlike the templated siblings, `prerelease` only accepts "auto".
620        assert!(serde_yaml_ng::from_str::<PrereleaseConfig>("maybe").is_err());
621    }
622
623    #[test]
624    fn prerelease_config_round_trips_through_serialize() {
625        for v in [
626            PrereleaseConfig::Auto,
627            PrereleaseConfig::Bool(true),
628            PrereleaseConfig::Bool(false),
629        ] {
630            let yaml = serde_yaml_ng::to_string(&v).unwrap();
631            assert_eq!(
632                serde_yaml_ng::from_str::<PrereleaseConfig>(&yaml).unwrap(),
633                v
634            );
635        }
636    }
637
638    #[test]
639    fn make_latest_config_parses_all_arms() {
640        assert_eq!(
641            serde_yaml_ng::from_str::<MakeLatestConfig>("auto").unwrap(),
642            MakeLatestConfig::Auto
643        );
644        assert_eq!(
645            serde_yaml_ng::from_str::<MakeLatestConfig>("true").unwrap(),
646            MakeLatestConfig::Bool(true)
647        );
648        assert_eq!(
649            serde_yaml_ng::from_str::<MakeLatestConfig>("false").unwrap(),
650            MakeLatestConfig::Bool(false)
651        );
652        // A non-keyword string falls through to a deferred template.
653        assert_eq!(
654            serde_yaml_ng::from_str::<MakeLatestConfig>(
655                "\"{{ if .IsSnapshot }}false{{ else }}true{{ end }}\""
656            )
657            .unwrap(),
658            MakeLatestConfig::String(
659                "{{ if .IsSnapshot }}false{{ else }}true{{ end }}".to_string()
660            )
661        );
662    }
663
664    #[test]
665    fn make_latest_config_round_trips_through_serialize() {
666        for v in [
667            MakeLatestConfig::Auto,
668            MakeLatestConfig::Bool(false),
669            MakeLatestConfig::String("{{ .Env.LATEST }}".to_string()),
670        ] {
671            let yaml = serde_yaml_ng::to_string(&v).unwrap();
672            assert_eq!(
673                serde_yaml_ng::from_str::<MakeLatestConfig>(&yaml).unwrap(),
674                v
675            );
676        }
677    }
678
679    #[test]
680    fn skip_push_config_parses_all_arms() {
681        assert_eq!(
682            serde_yaml_ng::from_str::<SkipPushConfig>("auto").unwrap(),
683            SkipPushConfig::Auto
684        );
685        assert_eq!(
686            serde_yaml_ng::from_str::<SkipPushConfig>("true").unwrap(),
687            SkipPushConfig::Bool(true)
688        );
689        assert_eq!(
690            serde_yaml_ng::from_str::<SkipPushConfig>("false").unwrap(),
691            SkipPushConfig::Bool(false)
692        );
693        assert_eq!(
694            serde_yaml_ng::from_str::<SkipPushConfig>("\"{{ .IsSnapshot }}\"").unwrap(),
695            SkipPushConfig::Template("{{ .IsSnapshot }}".to_string())
696        );
697    }
698
699    #[test]
700    fn skip_push_config_round_trips_through_serialize() {
701        for v in [
702            SkipPushConfig::Auto,
703            SkipPushConfig::Bool(true),
704            SkipPushConfig::Template("{{ .IsSnapshot }}".to_string()),
705        ] {
706            let yaml = serde_yaml_ng::to_string(&v).unwrap();
707            assert_eq!(serde_yaml_ng::from_str::<SkipPushConfig>(&yaml).unwrap(), v);
708        }
709    }
710
711    #[test]
712    fn resolved_upload_pace_defaults_and_honors_override() {
713        // Unset → the 200ms default that smooths the initial upload burst.
714        assert_eq!(
715            ReleaseConfig::default().resolved_upload_pace(),
716            ReleaseConfig::DEFAULT_UPLOAD_PACE
717        );
718        // A configured humantime value is used verbatim.
719        let cfg = ReleaseConfig {
720            upload_pace: Some(HumanDuration(std::time::Duration::from_secs(1))),
721            ..Default::default()
722        };
723        assert_eq!(
724            cfg.resolved_upload_pace(),
725            std::time::Duration::from_secs(1)
726        );
727        // "0s" disables pacing (resolves to ZERO, not the default).
728        let disabled = ReleaseConfig {
729            upload_pace: Some(HumanDuration(std::time::Duration::ZERO)),
730            ..Default::default()
731        };
732        assert_eq!(disabled.resolved_upload_pace(), std::time::Duration::ZERO);
733    }
734
735    #[test]
736    fn on_failure_config_display_and_default() {
737        // `Hold` is the only surviving behavior; `Rollback` still parses
738        // (so config validation can name it in the migration error) but
739        // is no longer the default and is rejected at load time.
740        assert_eq!(OnFailureConfig::default(), OnFailureConfig::Hold);
741        assert_eq!(OnFailureConfig::Rollback.to_string(), "rollback");
742        assert_eq!(OnFailureConfig::Hold.to_string(), "hold");
743        // The lowercase serde form round-trips.
744        assert_eq!(
745            serde_yaml_ng::from_str::<OnFailureConfig>("hold").unwrap(),
746            OnFailureConfig::Hold
747        );
748        assert_eq!(
749            serde_yaml_ng::from_str::<OnFailureConfig>("rollback").unwrap(),
750            OnFailureConfig::Rollback
751        );
752    }
753}