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 metadata.json and artifacts.json as release assets.
96    pub include_meta: Option<bool>,
97    /// Reuse an existing draft release instead of creating a new one.
98    pub use_existing_draft: Option<bool>,
99    /// Override the release tag (template string). When set, this tag is used
100    /// as the `tag_name` in the GitHub release API instead of the crate's
101    /// `tag_template`. Useful in monorepo setups to strip a tag prefix
102    /// (e.g. `"{{ Tag }}"` to publish `v1.0.0` instead of `myapp/v1.0.0`).
103    /// A cross-platform publishing feature provided for free by anodizer.
104    pub tag: Option<String>,
105    /// Maximum number of asset-upload requests in flight simultaneously.
106    ///
107    /// GitHub's secondary rate-limit is triggered by burst traffic. Keeping
108    /// this value low avoids tripping the limit even for releases with many
109    /// artifacts. Default: 4. Override at runtime with
110    /// `ANODIZER_GITHUB_UPLOAD_CONCURRENCY`.
111    pub upload_concurrency: Option<u32>,
112    /// Minimum interval between successive asset-upload *starts* (a humantime
113    /// string, e.g. `"200ms"`, `"1s"`, `"0s"`).
114    ///
115    /// This is a *proactive* pace that smooths the initial burst of upload
116    /// requests, layered on top of [`Self::upload_concurrency`] (the
117    /// concurrency cap) and the reactive secondary-rate-limit backoff. With
118    /// the concurrency cap alone, the first N uploads fire in the same instant
119    /// — exactly the burst pattern that trips GitHub's secondary rate limit.
120    /// Spacing each upload's *start* by this interval (with ±20% jitter so
121    /// concurrent releases don't synchronise) makes the burst far less likely
122    /// to trip the limit in the first place.
123    ///
124    /// Default: `"200ms"` — at the default concurrency of 4 this caps the
125    /// initial start rate at ~5/s, which is below the burst threshold yet adds
126    /// negligible wall-clock to a normal release (upload time is dominated by
127    /// transfer, not start-spacing). Set to `"0s"` to disable pacing entirely
128    /// (rely on the concurrency cap + reactive backoff). Override at runtime
129    /// with `ANODIZER_GITHUB_UPLOAD_PACE_MS` (integer milliseconds; `0`
130    /// disables).
131    pub upload_pace: Option<HumanDuration>,
132    /// Override whether this publisher failing should fail the overall release.
133    ///
134    /// Default: `true` — a failure here aborts the release.
135    /// Set to `false` to log failures but continue.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub required: Option<bool>,
138    /// Explicit publish target — the SCM provider whose `release.<provider>`
139    /// block the publisher uses. When set, overrides the implicit
140    /// token-type fallback chain in
141    /// [`crate::scm::resolve_token_type`].
142    ///
143    /// Use this for **cross-platform publishing**
144    /// pattern: source repo on one provider (e.g. GitLab) but releases
145    /// land on another (e.g. GitHub). Without it, the publish target
146    /// is inferred from which `*_TOKEN` env-var is set — fine for
147    /// single-provider setups but ambiguous when both tokens are
148    /// available.
149    ///
150    /// ```yaml
151    /// release:
152    ///   provider: github
153    ///   github:
154    ///     owner: my-org
155    ///     name: my-app
156    /// ```
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub provider: Option<ForceTokenKind>,
159    /// When `true`, a triggered rollback leaves this publisher's work in
160    /// place rather than attempting to undo it. Default `false`.
161    pub retain_on_rollback: Option<bool>,
162    /// In-process failure policy: what `anodizer release` does after a
163    /// release-pipeline failure. `rollback` (default) deletes the run's
164    /// release tag(s) and reverts the version-bump commit so the same
165    /// version can be re-cut; `hold` leaves everything in place for
166    /// forensics and manual recovery (`release --rollback-only
167    /// --from-run=<id>`). `rollback` automatically degrades to `hold`
168    /// the moment any one-way-door (Submitter) publisher has landed:
169    /// the version is burned at a registry that never accepts it twice,
170    /// so destructive rollback is refused and fix-forward is the only
171    /// path. Root-level policy — in workspace configs (lockstep or
172    /// per-crate) the top-level `release.on_failure` governs the whole
173    /// run; setting it in a crate-level `release:` block is rejected at
174    /// config load (`validate_on_failure_root_only`).
175    pub on_failure: Option<OnFailureConfig>,
176}
177
178impl ReleaseConfig {
179    /// Default release-name template (`"{{Tag}}"`).
180    /// Anodize uses Tera-style `{{ Tag }}` (no dot prefix); the rendered
181    /// value is identical for any tag the project produces.
182    pub const DEFAULT_NAME_TEMPLATE: &'static str = "{{ Tag }}";
183
184    /// Default release `mode` (empty string is treated as
185    /// "keep-existing" — keep current release notes, don't overwrite).
186    pub const DEFAULT_MODE: &'static str = "keep-existing";
187
188    /// Default minimum interval between successive asset-upload starts
189    /// (see [`Self::upload_pace`]). 200 ms smooths the initial burst at the
190    /// default concurrency of 4 without meaningfully slowing a release.
191    pub const DEFAULT_UPLOAD_PACE: std::time::Duration = std::time::Duration::from_millis(200);
192
193    /// Valid `mode:` values. Anything else is a config error.
194    pub const VALID_MODES: &[&'static str] = &["keep-existing", "append", "prepend", "replace"];
195
196    /// Resolve the `name_template`, falling back to
197    /// [`Self::DEFAULT_NAME_TEMPLATE`].
198    pub fn resolved_name_template(&self) -> &str {
199        self.name_template
200            .as_deref()
201            .unwrap_or(Self::DEFAULT_NAME_TEMPLATE)
202    }
203
204    /// Resolve the release `mode`, validating and falling back to
205    /// [`Self::DEFAULT_MODE`] when unset or empty. Returns an error when
206    /// the user supplied a value outside [`Self::VALID_MODES`] so the
207    /// invalid mode surfaces at the call site instead of producing a
208    /// silent no-op publish.
209    pub fn resolved_mode(&self) -> anyhow::Result<&str> {
210        match self.mode.as_deref() {
211            None | Some("") => Ok(Self::DEFAULT_MODE),
212            Some(m) if Self::VALID_MODES.contains(&m) => Ok(m),
213            Some(other) => Err(anyhow::anyhow!(
214                "release: invalid mode '{}', must be one of: {}",
215                other,
216                Self::VALID_MODES.join(", ")
217            )),
218        }
219    }
220
221    /// Resolve `draft`, falling back to `false`.
222    pub fn resolved_draft(&self) -> bool {
223        self.draft.unwrap_or(false)
224    }
225
226    /// Resolve `replace_existing_draft`, falling back to `false`.
227    pub fn resolved_replace_existing_draft(&self) -> bool {
228        self.replace_existing_draft.unwrap_or(false)
229    }
230
231    /// Resolve `replace_existing_artifacts`, falling back to `false`.
232    pub fn resolved_replace_existing_artifacts(&self) -> bool {
233        self.replace_existing_artifacts.unwrap_or(false)
234    }
235
236    /// Resolve `include_meta`, falling back to `false` (don't upload
237    /// metadata.json / artifacts.json as release assets by default).
238    pub fn resolved_include_meta(&self) -> bool {
239        self.include_meta.unwrap_or(false)
240    }
241
242    /// Resolve `use_existing_draft`, falling back to `false` (always
243    /// create a fresh draft when one isn't found by default).
244    pub fn resolved_use_existing_draft(&self) -> bool {
245        self.use_existing_draft.unwrap_or(false)
246    }
247
248    /// Resolve `on_failure`, falling back to
249    /// [`OnFailureConfig::Rollback`].
250    pub fn resolved_on_failure(&self) -> OnFailureConfig {
251        self.on_failure.unwrap_or_default()
252    }
253
254    /// Resolve the upload pace (minimum inter-upload-start interval) from the
255    /// config, applying [`Self::DEFAULT_UPLOAD_PACE`] when unset. A configured
256    /// `"0s"` resolves to `Duration::ZERO`, which the upload loop treats as
257    /// "pacing disabled".
258    ///
259    /// Note: the runtime env override `ANODIZER_GITHUB_UPLOAD_PACE_MS` takes
260    /// precedence and is applied at the call site (it needs the request-scoped
261    /// [`crate::Context`]), mirroring how `ANODIZER_GITHUB_UPLOAD_CONCURRENCY`
262    /// overrides [`Self::upload_concurrency`].
263    pub fn resolved_upload_pace(&self) -> std::time::Duration {
264        self.upload_pace
265            .map(|d| d.duration())
266            .unwrap_or(Self::DEFAULT_UPLOAD_PACE)
267    }
268}
269
270/// In-process failure policy for `anodizer release`. See
271/// [`ReleaseConfig::on_failure`].
272#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
273#[serde(rename_all = "lowercase")]
274pub enum OnFailureConfig {
275    /// Roll back reversible state (delete the run's release tags, revert
276    /// the version-bump commit) so the version can be re-cut. Degrades to
277    /// `Hold` when any one-way-door publisher already landed.
278    #[default]
279    Rollback,
280    /// Leave everything in place for forensics; exit nonzero with a
281    /// pointer at `release --rollback-only --from-run=<id>`.
282    Hold,
283}
284
285impl std::fmt::Display for OnFailureConfig {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        match self {
288            OnFailureConfig::Rollback => f.write_str("rollback"),
289            OnFailureConfig::Hold => f.write_str("hold"),
290        }
291    }
292}
293
294/// Schema for prerelease: "auto" or boolean.
295fn prerelease_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
296    auto_or_bool_schema()
297}
298
299/// Schema for make_latest: "auto" or boolean.
300fn make_latest_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
301    auto_or_bool_schema()
302}
303
304/// Schema for skip_push: "auto" or boolean.
305pub(super) fn skip_push_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
306    auto_or_bool_schema()
307}
308
309/// A `oneOf` schema accepting the literal string `"auto"` or any boolean — the
310/// shape shared by `prerelease`, `make_latest`, and `skip_push` (each a
311/// tri-state where `"auto"` defers the value to release-time inference).
312fn auto_or_bool_schema() -> schemars::Schema {
313    schemars::json_schema!({
314        "oneOf": [
315            { "type": "string", "enum": ["auto"] },
316            { "type": "boolean" }
317        ]
318    })
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
322#[serde(deny_unknown_fields)]
323pub struct ScmRepoConfig {
324    /// Repository owner (user or organization).
325    pub owner: String,
326    /// Repository name.
327    pub name: String,
328}
329
330/// Backward-compatible alias — existing code can continue to use `GitHubConfig`.
331pub type GitHubConfig = ScmRepoConfig;
332
333// ---------------------------------------------------------------------------
334// ForceTokenKind
335// ---------------------------------------------------------------------------
336
337/// Which SCM token to force for authentication, overriding automatic detection.
338#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
339#[serde(rename_all = "lowercase")]
340pub enum ForceTokenKind {
341    GitHub,
342    GitLab,
343    Gitea,
344}
345
346// ---------------------------------------------------------------------------
347// Platform URL configs (GitHub Enterprise, GitLab self-hosted, Gitea)
348// ---------------------------------------------------------------------------
349
350/// Custom GitHub API/upload/download URLs for GitHub Enterprise installations.
351/// GitHub API/download URL overrides.
352#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
353#[serde(default, deny_unknown_fields)]
354pub struct GitHubUrlsConfig {
355    /// GitHub API base URL (e.g. `https://github.example.com/api/v3/`).
356    pub api: Option<String>,
357    /// GitHub upload URL for release assets (e.g. `https://github.example.com/api/uploads/`).
358    pub upload: Option<String>,
359    /// GitHub download URL for release assets (e.g. `https://github.example.com/`).
360    pub download: Option<String>,
361    /// When true, skip TLS certificate verification for the custom URLs.
362    pub skip_tls_verify: Option<bool>,
363}
364
365/// Custom GitLab API/download URLs for self-hosted GitLab installations.
366/// GitLab API/download URL overrides.
367#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
368#[serde(default, deny_unknown_fields)]
369pub struct GitLabUrlsConfig {
370    /// GitLab API base URL (e.g. `https://gitlab.example.com/api/v4/`).
371    pub api: Option<String>,
372    /// GitLab download URL for release assets.
373    pub download: Option<String>,
374    /// When true, skip TLS certificate verification for the custom URLs.
375    pub skip_tls_verify: Option<bool>,
376    /// When true, use the GitLab Package Registry for uploads instead of Generic Packages.
377    pub use_package_registry: Option<bool>,
378    /// When true, use the CI_JOB_TOKEN for authentication instead of a personal token.
379    pub use_job_token: Option<bool>,
380}
381
382/// Custom Gitea API/download URLs for self-hosted Gitea installations.
383/// Gitea API/download URL overrides.
384#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
385#[serde(default, deny_unknown_fields)]
386pub struct GiteaUrlsConfig {
387    /// Gitea API base URL (e.g. `https://gitea.example.com/api/v1/`).
388    pub api: Option<String>,
389    /// Gitea download URL for release assets.
390    pub download: Option<String>,
391    /// When true, skip TLS certificate verification for the custom URLs.
392    pub skip_tls_verify: Option<bool>,
393}
394
395// ---------------------------------------------------------------------------
396// "auto" | bool enum — shared serde implementation
397// ---------------------------------------------------------------------------
398
399/// Generates `Serialize` and `Deserialize` impls for enums with `Auto` and
400/// `Bool(bool)` variants that accept the string `"auto"` or a boolean in YAML.
401macro_rules! impl_auto_or_bool_serde {
402    ($ty:ty, $auto:path, $bool_variant:path) => {
403        impl Serialize for $ty {
404            fn serialize<S: serde::Serializer>(
405                &self,
406                serializer: S,
407            ) -> std::result::Result<S::Ok, S::Error> {
408                match self {
409                    $auto => serializer.serialize_str("auto"),
410                    $bool_variant(b) => serializer.serialize_bool(*b),
411                }
412            }
413        }
414
415        impl<'de> Deserialize<'de> for $ty {
416            fn deserialize<D: serde::Deserializer<'de>>(
417                deserializer: D,
418            ) -> std::result::Result<Self, D::Error> {
419                struct Visitor;
420                impl serde::de::Visitor<'_> for Visitor {
421                    type Value = $ty;
422                    fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423                        write!(f, "\"auto\" or a boolean")
424                    }
425                    fn visit_bool<E: serde::de::Error>(
426                        self,
427                        v: bool,
428                    ) -> std::result::Result<$ty, E> {
429                        Ok($bool_variant(v))
430                    }
431                    fn visit_str<E: serde::de::Error>(
432                        self,
433                        v: &str,
434                    ) -> std::result::Result<$ty, E> {
435                        if v == "auto" {
436                            Ok($auto)
437                        } else {
438                            Err(E::custom(format!("expected \"auto\", got \"{}\"", v)))
439                        }
440                    }
441                }
442                deserializer.deserialize_any(Visitor)
443            }
444        }
445    };
446}
447
448/// `prerelease` can be the string `"auto"` or a boolean.
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub enum PrereleaseConfig {
451    Auto,
452    Bool(bool),
453}
454
455impl_auto_or_bool_serde!(
456    PrereleaseConfig,
457    PrereleaseConfig::Auto,
458    PrereleaseConfig::Bool
459);
460
461/// `make_latest` can be the string `"auto"`, a boolean, or a template string.
462/// This field is rendered through the template engine at publish time,
463/// so we accept arbitrary strings (e.g. `"{{ if .IsSnapshot }}false{{ else }}true{{ end }}"`)
464/// and defer resolution to the release stage.
465#[derive(Debug, Clone, PartialEq, Eq)]
466pub enum MakeLatestConfig {
467    Auto,
468    Bool(bool),
469    /// An arbitrary template string to be rendered at publish time.
470    String(String),
471}
472
473impl Serialize for MakeLatestConfig {
474    fn serialize<S: serde::Serializer>(
475        &self,
476        serializer: S,
477    ) -> std::result::Result<S::Ok, S::Error> {
478        match self {
479            MakeLatestConfig::Auto => serializer.serialize_str("auto"),
480            MakeLatestConfig::Bool(b) => serializer.serialize_bool(*b),
481            MakeLatestConfig::String(s) => serializer.serialize_str(s),
482        }
483    }
484}
485
486impl<'de> Deserialize<'de> for MakeLatestConfig {
487    fn deserialize<D: serde::Deserializer<'de>>(
488        deserializer: D,
489    ) -> std::result::Result<Self, D::Error> {
490        struct Visitor;
491        impl serde::de::Visitor<'_> for Visitor {
492            type Value = MakeLatestConfig;
493            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
494                write!(f, "\"auto\", a boolean, or a template string")
495            }
496            fn visit_bool<E: serde::de::Error>(
497                self,
498                v: bool,
499            ) -> std::result::Result<MakeLatestConfig, E> {
500                Ok(MakeLatestConfig::Bool(v))
501            }
502            fn visit_str<E: serde::de::Error>(
503                self,
504                v: &str,
505            ) -> std::result::Result<MakeLatestConfig, E> {
506                match v {
507                    "auto" => Ok(MakeLatestConfig::Auto),
508                    "true" => Ok(MakeLatestConfig::Bool(true)),
509                    "false" => Ok(MakeLatestConfig::Bool(false)),
510                    other => Ok(MakeLatestConfig::String(other.to_string())),
511                }
512            }
513        }
514        deserializer.deserialize_any(Visitor)
515    }
516}
517
518/// `skip_push` can be `"auto"` (skip for prereleases), a boolean, or a template string.
519/// Template expressions like `"{{ if .IsSnapshot }}true{{ end }}"` are accepted.
520#[derive(Debug, Clone, PartialEq, Eq)]
521pub enum SkipPushConfig {
522    Auto,
523    Bool(bool),
524    /// Arbitrary template string — rendered at runtime, truthy result means skip push.
525    Template(String),
526}
527
528impl Serialize for SkipPushConfig {
529    fn serialize<S: serde::Serializer>(
530        &self,
531        serializer: S,
532    ) -> std::result::Result<S::Ok, S::Error> {
533        match self {
534            SkipPushConfig::Auto => serializer.serialize_str("auto"),
535            SkipPushConfig::Bool(b) => serializer.serialize_bool(*b),
536            SkipPushConfig::Template(s) => serializer.serialize_str(s),
537        }
538    }
539}
540
541impl<'de> Deserialize<'de> for SkipPushConfig {
542    fn deserialize<D: serde::Deserializer<'de>>(
543        deserializer: D,
544    ) -> std::result::Result<Self, D::Error> {
545        struct Visitor;
546        impl serde::de::Visitor<'_> for Visitor {
547            type Value = SkipPushConfig;
548            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
549                write!(f, "\"auto\", a boolean, or a template string")
550            }
551            fn visit_bool<E: serde::de::Error>(
552                self,
553                v: bool,
554            ) -> std::result::Result<SkipPushConfig, E> {
555                Ok(SkipPushConfig::Bool(v))
556            }
557            fn visit_str<E: serde::de::Error>(
558                self,
559                v: &str,
560            ) -> std::result::Result<SkipPushConfig, E> {
561                match v {
562                    "auto" => Ok(SkipPushConfig::Auto),
563                    "true" => Ok(SkipPushConfig::Bool(true)),
564                    "false" => Ok(SkipPushConfig::Bool(false)),
565                    other => Ok(SkipPushConfig::Template(other.to_string())),
566                }
567            }
568        }
569        deserializer.deserialize_any(Visitor)
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    // The three `"auto" | bool | [template]` config enums share a hand-written
578    // serde `Visitor` per type; each arm (string-auto, bool, template fallback,
579    // and the bad-string error) is exercised here through a YAML round-trip.
580
581    #[test]
582    fn prerelease_config_parses_auto_and_bools() {
583        assert_eq!(
584            serde_yaml_ng::from_str::<PrereleaseConfig>("auto").unwrap(),
585            PrereleaseConfig::Auto
586        );
587        assert_eq!(
588            serde_yaml_ng::from_str::<PrereleaseConfig>("true").unwrap(),
589            PrereleaseConfig::Bool(true)
590        );
591        assert_eq!(
592            serde_yaml_ng::from_str::<PrereleaseConfig>("false").unwrap(),
593            PrereleaseConfig::Bool(false)
594        );
595    }
596
597    #[test]
598    fn prerelease_config_rejects_other_strings() {
599        // Unlike the templated siblings, `prerelease` only accepts "auto".
600        assert!(serde_yaml_ng::from_str::<PrereleaseConfig>("maybe").is_err());
601    }
602
603    #[test]
604    fn prerelease_config_round_trips_through_serialize() {
605        for v in [
606            PrereleaseConfig::Auto,
607            PrereleaseConfig::Bool(true),
608            PrereleaseConfig::Bool(false),
609        ] {
610            let yaml = serde_yaml_ng::to_string(&v).unwrap();
611            assert_eq!(
612                serde_yaml_ng::from_str::<PrereleaseConfig>(&yaml).unwrap(),
613                v
614            );
615        }
616    }
617
618    #[test]
619    fn make_latest_config_parses_all_arms() {
620        assert_eq!(
621            serde_yaml_ng::from_str::<MakeLatestConfig>("auto").unwrap(),
622            MakeLatestConfig::Auto
623        );
624        assert_eq!(
625            serde_yaml_ng::from_str::<MakeLatestConfig>("true").unwrap(),
626            MakeLatestConfig::Bool(true)
627        );
628        assert_eq!(
629            serde_yaml_ng::from_str::<MakeLatestConfig>("false").unwrap(),
630            MakeLatestConfig::Bool(false)
631        );
632        // A non-keyword string falls through to a deferred template.
633        assert_eq!(
634            serde_yaml_ng::from_str::<MakeLatestConfig>(
635                "\"{{ if .IsSnapshot }}false{{ else }}true{{ end }}\""
636            )
637            .unwrap(),
638            MakeLatestConfig::String(
639                "{{ if .IsSnapshot }}false{{ else }}true{{ end }}".to_string()
640            )
641        );
642    }
643
644    #[test]
645    fn make_latest_config_round_trips_through_serialize() {
646        for v in [
647            MakeLatestConfig::Auto,
648            MakeLatestConfig::Bool(false),
649            MakeLatestConfig::String("{{ .Env.LATEST }}".to_string()),
650        ] {
651            let yaml = serde_yaml_ng::to_string(&v).unwrap();
652            assert_eq!(
653                serde_yaml_ng::from_str::<MakeLatestConfig>(&yaml).unwrap(),
654                v
655            );
656        }
657    }
658
659    #[test]
660    fn skip_push_config_parses_all_arms() {
661        assert_eq!(
662            serde_yaml_ng::from_str::<SkipPushConfig>("auto").unwrap(),
663            SkipPushConfig::Auto
664        );
665        assert_eq!(
666            serde_yaml_ng::from_str::<SkipPushConfig>("true").unwrap(),
667            SkipPushConfig::Bool(true)
668        );
669        assert_eq!(
670            serde_yaml_ng::from_str::<SkipPushConfig>("false").unwrap(),
671            SkipPushConfig::Bool(false)
672        );
673        assert_eq!(
674            serde_yaml_ng::from_str::<SkipPushConfig>("\"{{ .IsSnapshot }}\"").unwrap(),
675            SkipPushConfig::Template("{{ .IsSnapshot }}".to_string())
676        );
677    }
678
679    #[test]
680    fn skip_push_config_round_trips_through_serialize() {
681        for v in [
682            SkipPushConfig::Auto,
683            SkipPushConfig::Bool(true),
684            SkipPushConfig::Template("{{ .IsSnapshot }}".to_string()),
685        ] {
686            let yaml = serde_yaml_ng::to_string(&v).unwrap();
687            assert_eq!(serde_yaml_ng::from_str::<SkipPushConfig>(&yaml).unwrap(), v);
688        }
689    }
690}