1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
13#[serde(untagged)]
14pub enum StringOrBool {
15 Bool(bool),
16 String(String),
17}
18
19impl StringOrBool {
20 pub fn as_bool(&self) -> bool {
23 match self {
24 StringOrBool::Bool(b) => *b,
25 StringOrBool::String(s) => matches!(s.trim(), "true" | "1"),
26 }
27 }
28
29 pub fn as_str(&self) -> &str {
31 match self {
32 StringOrBool::Bool(true) => "true",
33 StringOrBool::Bool(false) => "false",
34 StringOrBool::String(s) => s,
35 }
36 }
37
38 pub fn is_template(&self) -> bool {
40 matches!(self, StringOrBool::String(s) if s.contains('{'))
41 }
42
43 pub fn try_evaluates_to_true(
58 &self,
59 render: impl Fn(&str) -> anyhow::Result<String>,
60 ) -> anyhow::Result<bool> {
61 match self {
62 StringOrBool::Bool(b) => Ok(*b),
63 StringOrBool::String(s) => {
64 reject_stale_typed_compare(s, "skip/enable expression")?;
65 let rendered = render(s)?;
66 Ok(matches!(rendered.trim(), "true" | "1"))
67 }
68 }
69 }
70}
71
72impl Default for StringOrBool {
73 fn default() -> Self {
74 StringOrBool::Bool(false)
75 }
76}
77
78fn reject_stale_typed_compare(template: &str, label: &str) -> anyhow::Result<()> {
88 if let Some(snippet) = crate::template::find_stale_typed_compare(template) {
89 anyhow::bail!(
90 "{label}: `{snippet}` compares a typed template variable to a quoted string; \
91 these variables are real booleans/numbers, so the comparison never matches and \
92 the condition would silently evaluate false in every mode. Use the variable \
93 directly instead — e.g. `not IsSnapshot` for `IsSnapshot == \"false\"`, \
94 `IsHarness` for `IsHarness == \"true\"`, or an unquoted numeric compare for \
95 `NightlyBuild`."
96 );
97 }
98 Ok(())
99}
100
101pub fn evaluate_if_condition(
121 condition: Option<&str>,
122 label: &str,
123 render: impl Fn(&str) -> anyhow::Result<String>,
124) -> anyhow::Result<bool> {
125 use anyhow::Context as _;
126 let Some(template) = condition else {
127 return Ok(true);
128 };
129 if template.is_empty() {
130 return Ok(true);
131 }
132 reject_stale_typed_compare(template, label)?;
133 let rendered = render(template).with_context(|| {
134 format!("{label}: `if` template render failed (expression: {template})")
135 })?;
136 let trimmed = rendered.trim();
137 let falsy = matches!(trimmed, "" | "false" | "0" | "no");
138 Ok(!falsy)
139}
140
141pub(crate) fn deserialize_string_or_bool_opt<'de, D>(
143 deserializer: D,
144) -> Result<Option<StringOrBool>, D::Error>
145where
146 D: Deserializer<'de>,
147{
148 use serde::de::{self, Visitor};
149
150 struct StringOrBoolVisitor;
151
152 impl<'de> Visitor<'de> for StringOrBoolVisitor {
153 type Value = Option<StringOrBool>;
154
155 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156 f.write_str("a bool, a string, or null")
157 }
158
159 fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
160 Ok(Some(StringOrBool::Bool(v)))
161 }
162
163 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
164 Ok(Some(StringOrBool::String(v.to_owned())))
165 }
166
167 fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
168 Ok(Some(StringOrBool::String(v)))
169 }
170
171 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
172 Ok(None)
173 }
174
175 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
176 Ok(None)
177 }
178 }
179
180 deserializer.deserialize_any(StringOrBoolVisitor)
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
189pub struct HumanDuration(
190 #[serde(serialize_with = "serialize_human_duration")] pub std::time::Duration,
191);
192
193impl HumanDuration {
194 pub fn duration(&self) -> std::time::Duration {
196 self.0
197 }
198
199 pub fn as_humantime_string(&self) -> String {
203 let total_secs = self.0.as_secs();
204 if total_secs == 0 {
205 return format!("{}ms", self.0.as_millis());
207 }
208 let hours = total_secs / 3600;
209 let mins = (total_secs % 3600) / 60;
210 let secs = total_secs % 60;
211 let mut out = String::new();
212 if hours > 0 {
213 out.push_str(&format!("{hours}h"));
214 }
215 if mins > 0 {
216 out.push_str(&format!("{mins}m"));
217 }
218 if secs > 0 || out.is_empty() {
219 out.push_str(&format!("{secs}s"));
220 }
221 out
222 }
223}
224
225impl<'de> Deserialize<'de> for HumanDuration {
226 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
227 where
228 D: Deserializer<'de>,
229 {
230 use serde::de::{self, Visitor};
231
232 struct DurVisitor;
233
234 impl<'de> Visitor<'de> for DurVisitor {
235 type Value = HumanDuration;
236
237 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238 f.write_str(
239 "a duration string with unit suffix (e.g. \"10m\", \"15s\", \"1h30m\", \"500ms\")",
240 )
241 }
242
243 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
244 parse_humantime_duration(v)
245 .map(HumanDuration)
246 .map_err(E::custom)
247 }
248
249 fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
250 self.visit_str(&v)
251 }
252 }
253
254 deserializer.deserialize_str(DurVisitor)
255 }
256}
257
258fn serialize_human_duration<S: serde::Serializer>(
259 d: &std::time::Duration,
260 serializer: S,
261) -> Result<S::Ok, S::Error> {
262 serializer.serialize_str(&HumanDuration(*d).as_humantime_string())
263}
264
265pub(super) fn parse_humantime_duration(input: &str) -> Result<std::time::Duration, String> {
269 let s = input.trim();
270 if s.is_empty() {
271 return Err("empty duration string".to_string());
272 }
273 let mut total = std::time::Duration::ZERO;
274 let mut number_buf = String::new();
275 let mut had_any = false;
276 let mut iter = s.chars().peekable();
277 while let Some(&c) = iter.peek() {
278 if c.is_whitespace() {
279 iter.next();
280 continue;
281 }
282 if c.is_ascii_digit() {
283 number_buf.push(c);
284 iter.next();
285 continue;
286 }
287 if number_buf.is_empty() {
288 return Err(format!("expected digit before unit in '{input}'"));
289 }
290 let mut unit = String::new();
292 unit.push(c);
293 iter.next();
294 if let Some(&next) = iter.peek()
295 && unit == "m"
296 && next == 's'
297 {
298 unit.push('s');
299 iter.next();
300 }
301 let n: u64 = number_buf
302 .parse()
303 .map_err(|e| format!("invalid number '{number_buf}' in '{input}': {e}"))?;
304 let segment = match unit.as_str() {
305 "ms" => std::time::Duration::from_millis(n),
306 "s" => std::time::Duration::from_secs(n),
307 "m" => std::time::Duration::from_secs(n * 60),
308 "h" => std::time::Duration::from_secs(n * 3600),
309 "d" => std::time::Duration::from_secs(n * 86_400),
310 other => return Err(format!("unknown duration unit '{other}' in '{input}'")),
311 };
312 total += segment;
313 number_buf.clear();
314 had_any = true;
315 }
316 if !number_buf.is_empty() {
317 return Err(format!(
318 "trailing number '{number_buf}' without a unit in '{input}'"
319 ));
320 }
321 if !had_any {
322 return Err(format!("no duration components found in '{input}'"));
323 }
324 Ok(total)
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
335#[serde(transparent)]
336pub struct StringOrU32(#[serde(deserialize_with = "deserialize_u32_from_string_or_int")] pub u32);
337
338impl StringOrU32 {
339 pub fn value(&self) -> u32 {
341 self.0
342 }
343}
344
345fn deserialize_u32_from_string_or_int<'de, D>(deserializer: D) -> Result<u32, D::Error>
347where
348 D: Deserializer<'de>,
349{
350 use serde::de::{self, Visitor};
351
352 struct U32Visitor;
353
354 impl<'de> Visitor<'de> for U32Visitor {
355 type Value = u32;
356
357 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
358 f.write_str("a u32 integer or a string parseable as octal/decimal (e.g. 18, \"0o022\", \"022\")")
359 }
360
361 fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
362 u32::try_from(v).map_err(|_| E::custom(format!("value {v} does not fit in u32")))
363 }
364
365 fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
366 u32::try_from(v).map_err(|_| E::custom(format!("value {v} does not fit in u32")))
367 }
368
369 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
370 let trimmed = v.trim();
371 if let Some(rest) = trimmed
372 .strip_prefix("0o")
373 .or_else(|| trimmed.strip_prefix("0O"))
374 {
375 return u32::from_str_radix(rest, 8)
376 .map_err(|e| E::custom(format!("invalid octal '{v}': {e}")));
377 }
378 if trimmed.starts_with('0') && trimmed.len() > 1 {
381 return u32::from_str_radix(trimmed, 8)
382 .map_err(|e| E::custom(format!("invalid octal '{v}': {e}")));
383 }
384 trimmed
385 .parse::<u32>()
386 .map_err(|e| E::custom(format!("invalid u32 '{v}': {e}")))
387 }
388
389 fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
390 self.visit_str(&v)
391 }
392 }
393
394 deserializer.deserialize_any(U32Visitor)
395}
396
397pub(super) fn deserialize_string_or_vec_opt<'de, D>(
400 deserializer: D,
401) -> Result<Option<Vec<String>>, D::Error>
402where
403 D: Deserializer<'de>,
404{
405 use serde::de::{self, Visitor};
406
407 struct StringOrVecVisitor;
408
409 impl<'de> Visitor<'de> for StringOrVecVisitor {
410 type Value = Option<Vec<String>>;
411
412 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
413 f.write_str("a string, a list of strings, or null")
414 }
415
416 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
417 Ok(Some(vec![v.to_owned()]))
418 }
419
420 fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
421 Ok(Some(vec![v]))
422 }
423
424 fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
425 let mut items = Vec::new();
426 while let Some(item) = seq.next_element::<String>()? {
427 items.push(item);
428 }
429 Ok(Some(items))
430 }
431
432 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
433 Ok(None)
434 }
435
436 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
437 Ok(None)
438 }
439 }
440
441 deserializer.deserialize_any(StringOrVecVisitor)
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use std::time::Duration;
448
449 #[test]
452 fn as_bool_bool_arms_passthrough() {
453 assert!(StringOrBool::Bool(true).as_bool());
454 assert!(!StringOrBool::Bool(false).as_bool());
455 }
456
457 #[test]
458 fn as_bool_string_truthy_and_trimmed() {
459 assert!(StringOrBool::String("true".into()).as_bool());
460 assert!(StringOrBool::String("1".into()).as_bool());
461 assert!(StringOrBool::String(" true ".into()).as_bool());
463 }
464
465 #[test]
466 fn as_bool_string_falsy() {
467 assert!(!StringOrBool::String("no".into()).as_bool());
468 assert!(!StringOrBool::String(String::new()).as_bool());
469 assert!(!StringOrBool::String("false".into()).as_bool());
470 }
471
472 #[test]
475 fn default_is_bool_false() {
476 assert_eq!(StringOrBool::default(), StringOrBool::Bool(false));
479 assert!(!StringOrBool::default().as_bool());
480 }
481
482 #[test]
485 fn as_str_bool_renders_word() {
486 assert_eq!(StringOrBool::Bool(true).as_str(), "true");
487 assert_eq!(StringOrBool::Bool(false).as_str(), "false");
488 }
489
490 #[test]
491 fn as_str_string_passthrough() {
492 assert_eq!(StringOrBool::String("{{ x }}".into()).as_str(), "{{ x }}");
493 }
494
495 #[test]
498 fn is_template_detects_brace_only_in_string() {
499 assert!(StringOrBool::String("{{ .IsSnapshot }}".into()).is_template());
500 assert!(!StringOrBool::String("plain".into()).is_template());
501 assert!(!StringOrBool::Bool(true).is_template());
503 }
504
505 #[test]
508 fn try_evaluates_bool_short_circuits_without_rendering() {
509 let rendered = std::cell::Cell::new(false);
510 let res = StringOrBool::Bool(true).try_evaluates_to_true(|s| {
511 rendered.set(true);
512 Ok(s.to_string())
513 });
514 assert!(res.unwrap());
515 assert!(!rendered.get());
517 }
518
519 #[test]
520 fn try_evaluates_string_renders_and_matches() {
521 let truthy = StringOrBool::String("1".into())
522 .try_evaluates_to_true(|s| Ok(s.to_string()))
523 .unwrap();
524 assert!(truthy);
525 let falsy = StringOrBool::String("nope".into())
526 .try_evaluates_to_true(|s| Ok(s.to_string()))
527 .unwrap();
528 assert!(!falsy);
529 }
530
531 #[test]
532 fn try_evaluates_propagates_render_error() {
533 let err =
534 StringOrBool::String("{{ x }}".into()).try_evaluates_to_true(|_| anyhow::bail!("boom"));
535 assert!(err.is_err());
536 assert!(err.unwrap_err().to_string().contains("boom"));
537 }
538
539 #[test]
540 fn try_evaluates_rejects_stale_typed_compare() {
541 let res = StringOrBool::String("{{ if IsSnapshot == \"false\" }}true{{ endif }}".into())
544 .try_evaluates_to_true(|_| panic!("render must not be called for stale compare"));
545 assert!(res.is_err());
546 }
547
548 #[test]
551 fn if_condition_none_proceeds() {
552 let r = evaluate_if_condition(None, "lbl", |s| Ok(s.to_string())).unwrap();
553 assert!(r);
554 }
555
556 #[test]
557 fn if_condition_empty_literal_proceeds() {
558 let r = evaluate_if_condition(Some(""), "lbl", |s| Ok(s.to_string())).unwrap();
559 assert!(r);
560 }
561
562 #[test]
563 fn if_condition_falsy_values_skip() {
564 for v in ["false", "0", "no", ""] {
565 let r = evaluate_if_condition(Some("tmpl"), "lbl", |_| Ok(v.to_string())).unwrap();
566 assert!(!r, "rendered {v:?} should skip");
567 }
568 }
569
570 #[test]
571 fn if_condition_truthy_values_proceed() {
572 for v in ["yes", "1", "true", "anything"] {
573 let r = evaluate_if_condition(Some("tmpl"), "lbl", |_| Ok(v.to_string())).unwrap();
574 assert!(r, "rendered {v:?} should proceed");
575 }
576 }
577
578 #[test]
579 fn if_condition_render_error_carries_context() {
580 let err = evaluate_if_condition(Some("badtmpl"), "publisher 'x'", |_| {
581 anyhow::bail!("render failed")
582 });
583 let msg = format!("{:#}", err.unwrap_err());
584 assert!(msg.contains("publisher 'x'"));
585 assert!(msg.contains("badtmpl"));
586 }
587
588 #[test]
589 fn if_condition_stale_typed_compare_errors() {
590 let err = evaluate_if_condition(
591 Some("{{ if IsSnapshot == \"false\" }}go{{ endif }}"),
592 "blob 's3'",
593 |_| panic!("render must not run for stale compare"),
594 );
595 assert!(err.is_err());
596 }
597
598 #[derive(Deserialize)]
601 struct BoolOptW {
602 #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
603 v: Option<StringOrBool>,
604 }
605
606 #[test]
607 fn deserialize_bool_opt_bool_input() {
608 let w: BoolOptW = serde_yaml_ng::from_str("v: true").unwrap();
609 assert_eq!(w.v, Some(StringOrBool::Bool(true)));
610 }
611
612 #[test]
613 fn deserialize_bool_opt_string_input() {
614 let w: BoolOptW = serde_yaml_ng::from_str("v: \"{{ x }}\"").unwrap();
615 assert_eq!(w.v, Some(StringOrBool::String("{{ x }}".into())));
616 }
617
618 #[test]
619 fn deserialize_bool_opt_null_input() {
620 let w: BoolOptW = serde_yaml_ng::from_str("v: null").unwrap();
621 assert_eq!(w.v, None);
622 }
623
624 #[test]
627 fn human_duration_sub_second_falls_back_to_ms() {
628 assert_eq!(
629 HumanDuration(Duration::from_millis(250)).as_humantime_string(),
630 "250ms"
631 );
632 }
633
634 #[test]
635 fn human_duration_whole_seconds() {
636 assert_eq!(
637 HumanDuration(Duration::from_secs(45)).as_humantime_string(),
638 "45s"
639 );
640 }
641
642 #[test]
643 fn human_duration_minutes_and_seconds() {
644 assert_eq!(
645 HumanDuration(Duration::from_secs(90)).as_humantime_string(),
646 "1m30s"
647 );
648 }
649
650 #[test]
651 fn human_duration_hours_component() {
652 let s = HumanDuration(Duration::from_secs(3661)).as_humantime_string();
653 assert!(s.contains("1h"), "{s} should contain hours");
654 assert!(s.contains("1m"));
655 assert!(s.contains("1s"));
656 }
657
658 #[test]
659 fn human_duration_whole_minute_omits_seconds() {
660 assert_eq!(
662 HumanDuration(Duration::from_secs(120)).as_humantime_string(),
663 "2m"
664 );
665 }
666
667 #[test]
670 fn parse_humantime_ok_values() {
671 assert_eq!(
672 parse_humantime_duration("10m").unwrap(),
673 Duration::from_secs(600)
674 );
675 assert_eq!(
676 parse_humantime_duration("15s").unwrap(),
677 Duration::from_secs(15)
678 );
679 assert_eq!(
680 parse_humantime_duration("1h30m").unwrap(),
681 Duration::from_secs(3600 + 1800)
682 );
683 assert_eq!(
684 parse_humantime_duration("500ms").unwrap(),
685 Duration::from_millis(500)
686 );
687 assert_eq!(
688 parse_humantime_duration("1d").unwrap(),
689 Duration::from_secs(86_400)
690 );
691 }
692
693 #[test]
694 fn parse_humantime_whitespace_tolerated() {
695 assert_eq!(
696 parse_humantime_duration("10 m").unwrap(),
697 Duration::from_secs(600)
698 );
699 }
700
701 #[test]
702 fn parse_humantime_errors() {
703 assert!(parse_humantime_duration("").is_err());
704 assert!(parse_humantime_duration("m").is_err());
705 assert!(parse_humantime_duration("10x").is_err());
706 assert!(parse_humantime_duration("10").is_err());
708 }
709
710 #[test]
713 fn u32_from_int_decimal() {
714 let v: StringOrU32 = serde_yaml_ng::from_str("18").unwrap();
715 assert_eq!(v.value(), 18);
716 }
717
718 #[test]
719 fn u32_from_prefixed_octal_string() {
720 let v: StringOrU32 = serde_yaml_ng::from_str("\"0o022\"").unwrap();
721 assert_eq!(v.value(), 18);
722 }
723
724 #[test]
725 fn u32_from_bare_leading_zero_is_octal() {
726 let v: StringOrU32 = serde_yaml_ng::from_str("\"022\"").unwrap();
727 assert_eq!(v.value(), 18);
728 }
729
730 #[test]
731 fn u32_from_plain_decimal_string() {
732 let v: StringOrU32 = serde_yaml_ng::from_str("\"18\"").unwrap();
733 assert_eq!(v.value(), 18);
734 }
735
736 #[test]
737 fn u32_invalid_octal_digit_errors() {
738 let r = serde_yaml_ng::from_str::<StringOrU32>("\"0o999\"");
740 assert!(r.is_err());
741 }
742
743 #[test]
744 fn u32_out_of_range_errors() {
745 let r = serde_yaml_ng::from_str::<StringOrU32>("5000000000");
746 assert!(r.is_err());
747 }
748
749 #[derive(Deserialize)]
752 struct VecOptW {
753 #[serde(deserialize_with = "deserialize_string_or_vec_opt", default)]
754 v: Option<Vec<String>>,
755 }
756
757 #[test]
758 fn string_or_vec_single_string_wraps() {
759 let w: VecOptW = serde_yaml_ng::from_str("v: max-age=60").unwrap();
760 assert_eq!(w.v, Some(vec!["max-age=60".to_string()]));
761 }
762
763 #[test]
764 fn string_or_vec_list_passthrough() {
765 let w: VecOptW = serde_yaml_ng::from_str("v:\n - a\n - b").unwrap();
766 assert_eq!(w.v, Some(vec!["a".to_string(), "b".to_string()]));
767 }
768
769 #[test]
770 fn string_or_vec_null_is_none() {
771 let w: VecOptW = serde_yaml_ng::from_str("v: null").unwrap();
772 assert_eq!(w.v, None);
773 }
774}