anodizer_core/config/notarize.rs
1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::{HumanDuration, StringOrBool, deserialize_string_or_bool_opt, evaluate_if_condition};
5
6// ---------------------------------------------------------------------------
7// NotarizeConfig (macOS code signing and notarization)
8// ---------------------------------------------------------------------------
9
10/// Top-level notarization configuration supporting both cross-platform
11/// (`rcodesign`) and native macOS (`codesign` + `xcrun notarytool`) modes.
12#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
13#[serde(default, deny_unknown_fields)]
14pub struct NotarizeConfig {
15 /// Skip all notarization. Accepts bool or template string.
16 #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
17 pub skip: Option<StringOrBool>,
18 /// Cross-platform signing/notarization (rcodesign-based, works on any OS).
19 pub macos: Option<Vec<MacOSSignNotarizeConfig>>,
20 /// Native signing/notarization (codesign + xcrun, macOS only).
21 pub macos_native: Option<Vec<MacOSNativeSignNotarizeConfig>>,
22}
23
24/// Cross-platform macOS signing and notarization via `rcodesign`.
25#[derive(Debug, Clone, Serialize, Default, JsonSchema)]
26#[serde(default, deny_unknown_fields)]
27pub struct MacOSSignNotarizeConfig {
28 /// Build IDs to filter. Default: project name.
29 pub ids: Option<Vec<String>>,
30 /// Skip this configuration. Accepts bool or template string.
31 /// Replaces the previous `enabled:` toggle with the canonical
32 /// `skip:` (inverted semantic) to align with every other publisher /
33 /// pipe in anodizer.
34 ///
35 /// Back-compat: the upstream uses `enabled:` (opt-in, default false).
36 /// A YAML carrying `enabled:` is accepted via the wire-level
37 /// `enabled:` alias that inverts the bool — `enabled: true` becomes
38 /// `skip: false`, `enabled: false` becomes `skip: true`. The
39 /// canonical field at runtime is `skip:`.
40 #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
41 pub skip: Option<StringOrBool>,
42 /// Back-compat `enabled:` alias (opt-in, default false). Inverted into
43 /// [`Self::skip`] at deserialize time. Surfaced here purely so the
44 /// generated JSON schema documents the field — editors/IDEs validating
45 /// against the schema must recognize `enabled:` rather than flag it as
46 /// unknown. Always `None` at runtime (the deserializer folds it into
47 /// `skip`); runtime logic never reads it.
48 #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
49 pub enabled: Option<StringOrBool>,
50 /// `true` when [`Self::skip`] holds a templated `enabled:` value that
51 /// must be rendered verbatim and have its truthiness NEGATED at
52 /// evaluation (a falsy `enabled` → skip). Bool / literal `enabled:`
53 /// values are inverted at parse time and leave this `false`. Not part of
54 /// the YAML surface — set only by the `enabled:`-alias deserializer.
55 #[serde(skip)]
56 pub skip_inverts_enabled: bool,
57 /// Signing configuration (P12 certificate).
58 pub sign: Option<MacOSSignConfig>,
59 /// Notarization configuration (App Store Connect API key). Omit for sign-only.
60 pub notarize: Option<MacOSNotarizeApiConfig>,
61}
62
63/// Wire-format mirror used to accept the `enabled:` field as a
64/// deserialize-time alias for the canonical `skip:`. `enabled: true`
65/// inverts to `skip: false` (run); `enabled: false` inverts to
66/// `skip: true` (skip).
67#[derive(Default, Deserialize)]
68#[serde(default, deny_unknown_fields)]
69struct MacOSSignNotarizeConfigWire {
70 ids: Option<Vec<String>>,
71 #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
72 skip: Option<StringOrBool>,
73 #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
74 enabled: Option<StringOrBool>,
75 sign: Option<MacOSSignConfig>,
76 notarize: Option<MacOSNotarizeApiConfig>,
77}
78
79impl<'de> Deserialize<'de> for MacOSSignNotarizeConfig {
80 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
81 let wire = MacOSSignNotarizeConfigWire::deserialize(deserializer)?;
82 let ResolvedSkip {
83 skip,
84 inverts_enabled,
85 } = resolve_skip_with_enabled_alias(wire.skip, wire.enabled)
86 .map_err(serde::de::Error::custom)?;
87 Ok(Self {
88 ids: wire.ids,
89 skip,
90 // `enabled:` is folded into `skip` above; the canonical field is
91 // schema-only and always `None` at runtime.
92 enabled: None,
93 skip_inverts_enabled: inverts_enabled,
94 sign: wire.sign,
95 notarize: wire.notarize,
96 })
97 }
98}
99
100impl MacOSSignNotarizeConfig {
101 /// Whether this entry should be SKIPPED (not signed / notarized).
102 ///
103 /// Renders the [`Self::skip`] template via `render` and applies the
104 /// inversion convention recorded in [`Self::skip_inverts_enabled`]:
105 /// when the value came from a templated `enabled:`, the rendered
106 /// truthiness is negated (a falsy `enabled` → skip). A render failure is
107 /// propagated as `Err` so callers FAIL CLOSED rather than silently
108 /// enabling a stage the operator meant to disable.
109 pub fn should_skip(
110 &self,
111 render: impl Fn(&str) -> anyhow::Result<String>,
112 ) -> anyhow::Result<bool> {
113 resolve_notarize_skip(&self.skip, self.skip_inverts_enabled, render)
114 }
115}
116
117/// P12-certificate signing configuration for `rcodesign sign`.
118#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
119#[serde(default, deny_unknown_fields)]
120pub struct MacOSSignConfig {
121 /// Path to .p12 certificate file or base64-encoded contents. Templates allowed.
122 pub certificate: Option<String>,
123 /// Password for the .p12 certificate. Templates allowed.
124 pub password: Option<String>,
125 /// Path to entitlements XML file. Templates allowed.
126 pub entitlements: Option<String>,
127 /// RFC-3161 timestamp service URL passed to `rcodesign sign --timestamp-url`.
128 /// Defaults to Apple's public timestamp service. Override when running
129 /// behind a corporate proxy or when Apple's service is unreachable.
130 pub timestamp_url: Option<String>,
131}
132
133impl MacOSSignConfig {
134 /// Apple's public RFC-3161 timestamp service. Used so the signature
135 /// carries a trusted timestamp rather than the host clock; override via
136 /// `notarize.macos[*].sign.timestamp_url` when running behind a corporate
137 /// proxy or when Apple's service is unreachable.
138 pub const DEFAULT_TIMESTAMP_URL: &'static str = "http://timestamp.apple.com/ts01";
139
140 /// Resolve the timestamp URL, ignoring whitespace-only overrides and
141 /// falling back to [`Self::DEFAULT_TIMESTAMP_URL`].
142 pub fn resolved_timestamp_url(&self) -> &str {
143 self.timestamp_url
144 .as_deref()
145 .map(|u| u.trim())
146 .filter(|u| !u.is_empty())
147 .unwrap_or(Self::DEFAULT_TIMESTAMP_URL)
148 }
149}
150
151/// App Store Connect API key configuration for `rcodesign notary-submit`.
152#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
153#[serde(default, deny_unknown_fields)]
154pub struct MacOSNotarizeApiConfig {
155 /// App Store Connect API key issuer UUID. Templates allowed.
156 pub issuer_id: Option<String>,
157 /// Path to .p8 key file or base64-encoded contents. Templates allowed.
158 pub key: Option<String>,
159 /// API key ID. Templates allowed.
160 pub key_id: Option<String>,
161 /// Timeout for notarization status polling. Humantime-style string
162 /// (e.g. `"10m"`, `"15s"`, `"1h"`). Default when omitted: `"10m"`.
163 pub timeout: Option<HumanDuration>,
164 /// Whether to wait for notarization to complete.
165 pub wait: Option<bool>,
166}
167
168impl MacOSNotarizeApiConfig {
169 /// Default notarization wait window (10 minutes).
170 pub const DEFAULT_TIMEOUT: &'static str = "10m";
171
172 /// Resolve `wait`, falling back to `false` (don't block on notary).
173 pub fn resolved_wait(&self) -> bool {
174 self.wait.unwrap_or(false)
175 }
176
177 /// Resolve `timeout` as a humantime string, falling back to
178 /// [`Self::DEFAULT_TIMEOUT`]. Returns an owned `String` because the
179 /// stored representation (`HumanDuration`) needs to be re-serialized
180 /// when materializing — there's no zero-cost view into it.
181 pub fn resolved_timeout(&self) -> String {
182 self.timeout
183 .map(|d| d.as_humantime_string())
184 .unwrap_or_else(|| Self::DEFAULT_TIMEOUT.to_string())
185 }
186}
187
188/// Artifact-type selector for native macOS notarization. Constrains the YAML
189/// `use:` field on `notarize.macos_native` so an unsupported value fails at
190/// parse time. Only `dmg` and `pkg` are valid — `notarytool` (the only
191/// supported tool) is implicit.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
193#[serde(rename_all = "snake_case")]
194pub enum MacOSNativeArtifactKind {
195 Dmg,
196 Pkg,
197}
198
199/// Native macOS signing and notarization via `codesign` + `xcrun notarytool`.
200#[derive(Debug, Clone, Serialize, Default, JsonSchema)]
201#[serde(default, deny_unknown_fields)]
202pub struct MacOSNativeSignNotarizeConfig {
203 /// Build IDs to filter. Default: project name.
204 pub ids: Option<Vec<String>>,
205 /// Skip this configuration. Accepts bool or template string.
206 /// Replaces `enabled:` with the canonical `skip:`. Imported
207 /// configs may continue to write `enabled:` — the deserializer
208 /// inverts it into `skip:` so both spellings work.
209 #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
210 pub skip: Option<StringOrBool>,
211 /// Back-compat `enabled:` alias (opt-in, default false). Inverted into
212 /// [`Self::skip`] at deserialize time. Surfaced here only so the
213 /// generated JSON schema documents the field; always `None` at runtime.
214 /// See [`MacOSSignNotarizeConfig::enabled`].
215 #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
216 pub enabled: Option<StringOrBool>,
217 /// `true` when [`Self::skip`] holds a templated `enabled:` value to be
218 /// rendered verbatim and NEGATED at evaluation. See
219 /// [`MacOSSignNotarizeConfig::skip_inverts_enabled`].
220 #[serde(skip)]
221 pub skip_inverts_enabled: bool,
222 /// Artifact type to sign and notarize: `dmg` (default) or `pkg`.
223 ///
224 /// Anodizer-original (signs
225 /// binaries directly via rcodesign). Constrained to a typed enum at
226 /// parse time so an unsupported value (`zip`, `app`, etc.) fails fast
227 /// instead of producing a silent no-op signing pipe.
228 #[serde(rename = "use")]
229 pub use_: Option<MacOSNativeArtifactKind>,
230 /// Native signing configuration (Keychain).
231 pub sign: Option<MacOSNativeSignConfig>,
232 /// Native notarization configuration (xcrun notarytool).
233 pub notarize: Option<MacOSNativeNotarizeConfig>,
234}
235
236#[derive(Default, Deserialize)]
237#[serde(default, deny_unknown_fields)]
238struct MacOSNativeSignNotarizeConfigWire {
239 ids: Option<Vec<String>>,
240 #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
241 skip: Option<StringOrBool>,
242 #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
243 enabled: Option<StringOrBool>,
244 #[serde(rename = "use")]
245 use_: Option<MacOSNativeArtifactKind>,
246 sign: Option<MacOSNativeSignConfig>,
247 notarize: Option<MacOSNativeNotarizeConfig>,
248}
249
250impl<'de> Deserialize<'de> for MacOSNativeSignNotarizeConfig {
251 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
252 let wire = MacOSNativeSignNotarizeConfigWire::deserialize(deserializer)?;
253 let ResolvedSkip {
254 skip,
255 inverts_enabled,
256 } = resolve_skip_with_enabled_alias(wire.skip, wire.enabled)
257 .map_err(serde::de::Error::custom)?;
258 Ok(Self {
259 ids: wire.ids,
260 skip,
261 // `enabled:` is folded into `skip` above; schema-only at runtime.
262 enabled: None,
263 skip_inverts_enabled: inverts_enabled,
264 use_: wire.use_,
265 sign: wire.sign,
266 notarize: wire.notarize,
267 })
268 }
269}
270
271impl MacOSNativeSignNotarizeConfig {
272 /// Whether this native entry should be SKIPPED. Mirrors
273 /// [`MacOSSignNotarizeConfig::should_skip`]: renders the [`Self::skip`]
274 /// template, negates when it came from a templated `enabled:`, and
275 /// FAILS CLOSED (propagates `Err`) on a render failure.
276 pub fn should_skip(
277 &self,
278 render: impl Fn(&str) -> anyhow::Result<String>,
279 ) -> anyhow::Result<bool> {
280 resolve_notarize_skip(&self.skip, self.skip_inverts_enabled, render)
281 }
282}
283
284/// Resolved per-config skip plus whether its evaluation must invert (the
285/// value originated from a templated `enabled:`).
286struct ResolvedSkip {
287 skip: Option<StringOrBool>,
288 /// `true` only when `skip` carries a templated `enabled:` value that
289 /// must be rendered verbatim and have its truthiness negated.
290 inverts_enabled: bool,
291}
292
293/// Invert the `enabled:` (opt-in, default false) into anodizer's
294/// canonical `skip:` (opt-out, default false). Both keys may be present;
295/// if they conflict (e.g. `skip: true` AND `enabled: true`), surface a
296/// clear error.
297///
298/// Bool and literal `"true"`/`"false"` `enabled:` values are inverted right
299/// here. A *templated* `enabled:` (e.g. `{{ IsSnapshot }}`) cannot be
300/// inverted by rewriting the template string — splicing it into a `{% if %}`
301/// head yields malformed Tera. Instead the raw template is kept and the
302/// returned [`ResolvedSkip::inverts_enabled`] flag tells the evaluator to
303/// render it verbatim and negate the result's truthiness.
304fn resolve_skip_with_enabled_alias(
305 skip: Option<StringOrBool>,
306 enabled: Option<StringOrBool>,
307) -> Result<ResolvedSkip, String> {
308 match (skip, enabled) {
309 (Some(s), None) => Ok(ResolvedSkip {
310 skip: Some(s),
311 inverts_enabled: false,
312 }),
313 (None, Some(e)) => Ok(invert_enabled(e)),
314 (None, None) => Ok(ResolvedSkip {
315 skip: None,
316 inverts_enabled: false,
317 }),
318 (Some(s), Some(e)) => {
319 // Both spellings present. Allow the case where they agree on
320 // intent ("don't run") to be lenient with imported configs;
321 // disagreement is a config error. Only comparable when the
322 // `enabled:` value resolves to a plain bool — a templated
323 // `enabled:` cannot be statically compared to a `skip:` value.
324 let resolved = invert_enabled(e);
325 if !resolved.inverts_enabled && string_or_bool_eq(&s, resolved.skip.as_ref()) {
326 Ok(ResolvedSkip {
327 skip: Some(s),
328 inverts_enabled: false,
329 })
330 } else {
331 Err(format!(
332 "notarize: both `skip:` and `enabled:` are set and disagree (`skip={:?}` / inverted `enabled={:?}`); use one or the other",
333 s, resolved.skip
334 ))
335 }
336 }
337 }
338}
339
340/// Invert a single `enabled:` value into a [`ResolvedSkip`].
341///
342/// `Bool(b)` flips to `Bool(!b)`; literal `"true"`/`"false"` strings map to
343/// the inverse bool; any other string is treated as a template, kept verbatim
344/// with `inverts_enabled = true` so the evaluator renders it on its own and
345/// negates the rendered truthiness (no `{{ }}` is ever spliced into a
346/// condition head).
347fn invert_enabled(v: StringOrBool) -> ResolvedSkip {
348 match v {
349 StringOrBool::Bool(b) => ResolvedSkip {
350 skip: Some(StringOrBool::Bool(!b)),
351 inverts_enabled: false,
352 },
353 StringOrBool::String(s) => match s.trim() {
354 "true" => ResolvedSkip {
355 skip: Some(StringOrBool::Bool(false)),
356 inverts_enabled: false,
357 },
358 "false" => ResolvedSkip {
359 skip: Some(StringOrBool::Bool(true)),
360 inverts_enabled: false,
361 },
362 _ => ResolvedSkip {
363 skip: Some(StringOrBool::String(s)),
364 inverts_enabled: true,
365 },
366 },
367 }
368}
369
370/// Resolve a per-config notarize `skip` to a "should skip" bool.
371///
372/// A `Bool` short-circuits without rendering. A template renders first, then:
373///
374/// - **direct `skip:`** (`inverts_enabled == false`) → skip when the rendered
375/// value is truthy under the sibling convention ([`StringOrBool::try_evaluates_to_true`]:
376/// `"true"`/`"1"` are truthy). This keeps notarize's `skip:` evaluation
377/// identical to `should_skip_upload` and every other publisher gate.
378/// - **inverted `enabled:`** (`inverts_enabled == true`) → the value is a
379/// templated `enabled:` kept verbatim; skip when its rendered value is
380/// FALSY under the shared `if:`-style convention (`""`/`false`/`0`/`no` =
381/// falsy, so any other rendered value means "enabled → run"). The wider
382/// falsy set is required here so an `enabled: "{{ … }}"` rendering to
383/// `yes`/`on`/`1`/etc. correctly enables.
384///
385/// A render failure propagates as `Err` — callers FAIL CLOSED (treat the
386/// entry as skipped) rather than silently signing/notarizing a stage the
387/// operator meant to disable.
388fn resolve_notarize_skip(
389 skip: &Option<StringOrBool>,
390 inverts_enabled: bool,
391 render: impl Fn(&str) -> anyhow::Result<String>,
392) -> anyhow::Result<bool> {
393 let Some(value) = skip else {
394 return Ok(false);
395 };
396 if inverts_enabled {
397 // Inverted `enabled:`: skip when the enabled expression is falsy.
398 // `evaluate_if_condition` returns `true` (proceed/enabled) for any
399 // non-falsy render and `Err` on render failure — negate to get
400 // "should skip".
401 let enabled = evaluate_if_condition(Some(value.as_str()), "notarize: enabled", render)?;
402 Ok(!enabled)
403 } else {
404 // Direct `skip:`: sibling truthy semantics (`"true"`/`"1"` skip).
405 value.try_evaluates_to_true(render)
406 }
407}
408
409fn string_or_bool_eq(a: &StringOrBool, b: Option<&StringOrBool>) -> bool {
410 match (a, b) {
411 (StringOrBool::Bool(a), Some(StringOrBool::Bool(b))) => a == b,
412 (StringOrBool::String(a), Some(StringOrBool::String(b))) => a == b,
413 _ => false,
414 }
415}
416
417impl MacOSNativeSignNotarizeConfig {
418 /// Default `use:` selector. Anodize-original — no native
419 /// notarize. DMG is the canonical signed-app distribution format
420 /// for macOS releases; PKG opt-in handles installers.
421 pub const DEFAULT_USE: MacOSNativeArtifactKind = MacOSNativeArtifactKind::Dmg;
422
423 /// Resolve the `use:` selector, falling back to [`Self::DEFAULT_USE`].
424 pub fn resolved_use(&self) -> MacOSNativeArtifactKind {
425 self.use_.unwrap_or(Self::DEFAULT_USE)
426 }
427}
428
429/// Keychain-based signing configuration for native `codesign`.
430#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
431#[serde(default, deny_unknown_fields)]
432pub struct MacOSNativeSignConfig {
433 /// Keychain identity (e.g., "Developer ID Application: Name"). Templates allowed.
434 pub identity: Option<String>,
435 /// Path to Keychain file. Templates allowed.
436 pub keychain: Option<String>,
437 /// Options to pass to codesign (e.g., ["runtime"]). Only used for DMGs.
438 pub options: Option<Vec<String>>,
439 /// Path to entitlements XML file. Only used for DMGs. Templates allowed.
440 pub entitlements: Option<String>,
441}
442
443/// Native notarization configuration for `xcrun notarytool`.
444#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
445#[serde(default, deny_unknown_fields)]
446pub struct MacOSNativeNotarizeConfig {
447 /// Notarytool stored credentials profile name. Templates allowed.
448 pub profile_name: Option<String>,
449 /// Whether to wait for notarization to complete.
450 pub wait: Option<bool>,
451 /// Timeout for `xcrun notarytool submit --timeout`. Humantime-style
452 /// string (e.g. `"10m"`, `"15s"`, `"1h"`).
453 pub timeout: Option<HumanDuration>,
454}
455
456impl MacOSNativeNotarizeConfig {
457 /// Default notarization wait window. Aligns with the cross-platform
458 /// rcodesign path (a 10-minute wait window).
459 pub const DEFAULT_TIMEOUT: &'static str = "10m";
460
461 /// Resolve `wait`, falling back to `false`. The native xcrun path
462 /// prints a "submit only" message instead of polling when `wait`
463 /// is false; the unwrap at this accessor pins that fallback in one
464 /// place.
465 pub fn resolved_wait(&self) -> bool {
466 self.wait.unwrap_or(false)
467 }
468
469 /// Resolve `timeout` as a humantime string, falling back to
470 /// [`Self::DEFAULT_TIMEOUT`].
471 pub fn resolved_timeout(&self) -> String {
472 self.timeout
473 .map(|d| d.as_humantime_string())
474 .unwrap_or_else(|| Self::DEFAULT_TIMEOUT.to_string())
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 // Renders a template to itself (Tera would leave literals unchanged); used
483 // to drive `should_skip` without a real engine.
484 fn identity(s: &str) -> anyhow::Result<String> {
485 Ok(s.to_string())
486 }
487
488 fn parse(yaml: &str) -> MacOSSignNotarizeConfig {
489 serde_yaml_ng::from_str(yaml).expect("notarize config should parse")
490 }
491
492 // --- direct `skip:` semantics -----------------------------------------
493
494 #[test]
495 fn should_skip_direct_bool_short_circuits() {
496 // Bool skip never renders; a panicking closure proves it.
497 assert!(
498 parse("skip: true")
499 .should_skip(|_| panic!("render must not run for a bool skip"))
500 .unwrap()
501 );
502 assert!(
503 !parse("skip: false")
504 .should_skip(|_| unreachable!())
505 .unwrap()
506 );
507 }
508
509 #[test]
510 fn should_skip_missing_is_false() {
511 // No `skip:`/`enabled:` at all → run (don't skip).
512 assert!(
513 !parse("sign:\n certificate: c")
514 .should_skip(identity)
515 .unwrap()
516 );
517 }
518
519 #[test]
520 fn should_skip_direct_template_uses_truthy_semantics() {
521 // Direct `skip:` uses `try_evaluates_to_true` — only "true"/"1" skip.
522 let cfg = parse("skip: \"{{ flag }}\"");
523 assert!(cfg.should_skip(|_| Ok("true".to_string())).unwrap());
524 assert!(cfg.should_skip(|_| Ok("1".to_string())).unwrap());
525 assert!(!cfg.should_skip(|_| Ok("no".to_string())).unwrap());
526 }
527
528 #[test]
529 fn should_skip_fails_closed_on_render_error() {
530 // A render failure must propagate as Err so callers treat the entry as
531 // undecided rather than silently signing.
532 let cfg = parse("skip: \"{{ broken\"");
533 assert!(cfg.should_skip(|_| anyhow::bail!("render boom")).is_err());
534 }
535
536 // --- `enabled:` alias inversion ---------------------------------------
537
538 #[test]
539 fn enabled_bool_inverts_to_skip_and_evaluates() {
540 // `enabled: false` → skip:true (don't run).
541 assert!(parse("enabled: false").should_skip(identity).unwrap());
542 // `enabled: true` → skip:false (run).
543 assert!(!parse("enabled: true").should_skip(identity).unwrap());
544 }
545
546 #[test]
547 fn templated_enabled_sets_inversion_flag_and_negates() {
548 // A non-literal `enabled:` template is kept verbatim with the inversion
549 // flag set (no `{{ }}` spliced into a condition head).
550 let cfg = parse("enabled: \"{{ e }}\"");
551 assert!(
552 cfg.skip_inverts_enabled,
553 "templated enabled must set the inversion flag"
554 );
555 // enabled renders truthy → run (should_skip false).
556 assert!(!cfg.should_skip(|_| Ok("yes".to_string())).unwrap());
557 // enabled renders falsy → skip. The wider falsy set applies here.
558 assert!(cfg.should_skip(|_| Ok("false".to_string())).unwrap());
559 // Empty render is also falsy → skip.
560 assert!(cfg.should_skip(|_| Ok(String::new())).unwrap());
561 }
562
563 #[test]
564 fn literal_enabled_string_inverts_without_flag() {
565 // Literal "true"/"false" strings invert at parse time — no runtime flag.
566 let run = parse("enabled: \"true\"");
567 assert!(!run.skip_inverts_enabled);
568 assert!(
569 !run.should_skip(|_| panic!("literal enabled must not render"))
570 .unwrap()
571 );
572 let skip = parse("enabled: \"false\"");
573 assert!(skip.should_skip(|_| unreachable!()).unwrap());
574 }
575
576 // --- skip/enabled conflict resolution ---------------------------------
577
578 #[test]
579 fn both_skip_and_enabled_agreeing_is_accepted() {
580 // skip:true and enabled:false both say "don't run" — lenient accept.
581 let cfg = parse("skip: true\nenabled: false");
582 assert_eq!(cfg.skip, Some(StringOrBool::Bool(true)));
583 }
584
585 #[test]
586 fn both_skip_and_enabled_disagreeing_is_rejected() {
587 // skip:true and enabled:true disagree (skip vs run) — config error.
588 let r: Result<MacOSSignNotarizeConfig, _> =
589 serde_yaml_ng::from_str("skip: true\nenabled: true");
590 let err = r.unwrap_err().to_string();
591 assert!(
592 err.contains("disagree"),
593 "expected disagree error, got: {err}"
594 );
595 }
596
597 // --- native config mirrors the same behavior --------------------------
598
599 #[test]
600 fn native_should_skip_mirrors_cross_platform() {
601 let cfg: MacOSNativeSignNotarizeConfig = serde_yaml_ng::from_str("enabled: false").unwrap();
602 assert!(cfg.should_skip(identity).unwrap());
603 let run: MacOSNativeSignNotarizeConfig = serde_yaml_ng::from_str("skip: false").unwrap();
604 assert!(!run.should_skip(identity).unwrap());
605 }
606}