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