Skip to main content

anodizer_core/config/
string_or_bool.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4// ---------------------------------------------------------------------------
5// StringOrBool — accepts bool or template string in YAML
6// ---------------------------------------------------------------------------
7
8/// A value that can be either a bool or a template string.
9/// Used by `skip`, `skip_upload`, and similar fields across multiple config
10/// structs to support both `skip: true` and template conditionals like
11/// `skip: "{{ if .IsSnapshot }}true{{ endif }}"`.
12#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
13#[serde(untagged)]
14pub enum StringOrBool {
15    Bool(bool),
16    String(String),
17}
18
19impl StringOrBool {
20    /// Evaluate this value to a bool. If it's a string, treat "true" / "1" as true,
21    /// everything else as false.
22    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    /// Return the raw string value for template rendering, or the bool as a string.
30    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    /// Whether this value contains a template expression that needs rendering.
39    pub fn is_template(&self) -> bool {
40        matches!(self, StringOrBool::String(s) if s.contains('{'))
41    }
42
43    /// Evaluate whether this value resolves to `true`.
44    ///
45    /// The value is always run through `render` (Tera leaves plain literals
46    /// unchanged, so this is a no-op for non-templated values). The rendered
47    /// result is then compared to `"true"` / `"1"` after trimming. A `Bool`
48    /// variant short-circuits without rendering.
49    ///
50    /// Always-rendering keeps this helper consistent with sibling
51    /// `should_skip_upload` (which always renders) — a literal `"{{ broken"`
52    /// surfaces as an `Err` instead of being silently treated as a false-y
53    /// non-template string.
54    ///
55    /// Used for both `skip:` evaluation (most callers) and `output:` / `sbom:`
56    /// bool-or-template fields — there is no separate alias; call this directly.
57    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
78/// Hard-error when a conditional template compares a typed (bool / number)
79/// injected variable to a quoted string (`IsSnapshot == "false"`,
80/// `eq .IsHarness "true"`, `NightlyBuild != "0"`, …).
81///
82/// Those variables are real Tera bools/numbers, and Tera does not coerce
83/// `Bool`/`Number` ↔ `str`, so the compare evaluates to `false` in *every*
84/// mode and the guarded resource silently skips. Failing loud here matches
85/// the render-failure contract below: a condition that can never do what it
86/// says is a config bug, not a skip.
87fn 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
101/// Evaluate an `if:` conditional template.
102///
103/// Returns `Ok(true)` when the caller should proceed with the resource and
104/// `Ok(false)` when the resource must be skipped. Mirrors the contract every
105/// existing `if_condition` consumer in anodizer applies:
106///
107/// - `None` → proceed (no gate set).
108/// - `Some("")` → proceed (empty literal is a no-op gate; the
109///   "no `if:` = always run" behavior — keeps round-tripping clean for
110///   configs that emit empty strings).
111/// - Template render failure → hard `Err` (matches every existing
112///   `if_condition` site; silent-skip on a typo'd template was the W1
113///   release-resilience footgun and is intentionally NOT replicated).
114/// - Rendered value (trimmed) equal to `"false"`, `"0"`, `"no"`, or empty
115///   → skip.
116/// - Any other rendered value → proceed.
117///
118/// `label` is the resource-identifying string woven into the error context
119/// chain (e.g. `"publisher 'upload-artifacts'"` or `"blob 's3-cache'"`).
120pub 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
141/// Custom deserializer for `Option<StringOrBool>`.
142pub(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/// A typed duration value parsed from a humantime-style string in YAML.
184///
185/// Accepts `"10m"`, `"15s"`, `"1h30m"`, `"500ms"`, etc. Used by notarize
186/// timeouts so the schema is typed and validation catches malformed values
187/// at config-load time instead of during the notarize stage.
188#[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    /// Get the underlying `Duration` value.
195    pub fn duration(&self) -> std::time::Duration {
196        self.0
197    }
198
199    /// Format the duration back to its canonical string form (`{seconds}s` or
200    /// `{minutes}m{seconds}s` depending on whole-minute alignment). Matches
201    /// the form `xcrun notarytool --timeout` accepts (a unit-suffixed integer).
202    pub fn as_humantime_string(&self) -> String {
203        let total_secs = self.0.as_secs();
204        if total_secs == 0 {
205            // Sub-second; fall back to ms.
206            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
265/// Parse a humantime-style duration string. Recognizes `ms`, `s`, `m`, `h`,
266/// `d` units and concatenated forms like `"1h30m"`. Whitespace between
267/// components is tolerated.
268pub(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        // Read unit (1 or 2 chars: ms, s, m, h, d).
291        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/// A value that can be either a `u32` or a string parsed as octal/decimal.
328///
329/// Used by `NfpmConfig.umask` (and any future field specified
330/// as `int OR string` in YAML — the parser canonicalizes both forms to a
331/// `u32`). Accepts: `0o022`, `"0o022"`, `"022"`, `"18"`, `18`. Bare numeric
332/// YAML values are interpreted as decimal; YAML-string forms accept the
333/// `0o`/`0O` prefix to spell octal explicitly.
334#[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    /// Get the underlying `u32` value.
340    pub fn value(&self) -> u32 {
341        self.0
342    }
343}
344
345/// Deserialize a `u32` from either a YAML int or a string in octal/decimal.
346fn 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            // Bare leading-zero strings (e.g. "022") are octal — match the
379            // typical convention for unix file mode strings.
380            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
397/// Custom deserializer for `Option<Vec<String>>` that accepts either a single
398/// string or an array of strings. Used by `BlobConfig.cache_control`.
399pub(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    // --- as_bool -----------------------------------------------------------
450
451    #[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        // Leading/trailing whitespace is trimmed before the truthy check.
462        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    // --- Default -----------------------------------------------------------
473
474    #[test]
475    fn default_is_bool_false() {
476        // `skip`/`output`/`sbom` fields default to "off" — a missing key must
477        // not silently behave as `true`.
478        assert_eq!(StringOrBool::default(), StringOrBool::Bool(false));
479        assert!(!StringOrBool::default().as_bool());
480    }
481
482    // --- as_str ------------------------------------------------------------
483
484    #[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    // --- is_template -------------------------------------------------------
496
497    #[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        // A Bool never carries a template, regardless of value.
502        assert!(!StringOrBool::Bool(true).is_template());
503    }
504
505    // --- try_evaluates_to_true --------------------------------------------
506
507    #[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        // Bool arm must NOT invoke the render closure.
516        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        // Render closure should never be reached; the stale-compare guard
542        // fires first and returns Err.
543        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    // --- evaluate_if_condition --------------------------------------------
549
550    #[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    // --- deserialize_string_or_bool_opt -----------------------------------
599
600    #[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    // --- HumanDuration::as_humantime_string -------------------------------
625
626    #[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        // secs == 0 and out non-empty: the trailing `s` is suppressed.
661        assert_eq!(
662            HumanDuration(Duration::from_secs(120)).as_humantime_string(),
663            "2m"
664        );
665    }
666
667    // --- parse_humantime_duration -----------------------------------------
668
669    #[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        // Trailing digits with no unit.
707        assert!(parse_humantime_duration("10").is_err());
708    }
709
710    // --- StringOrU32 / deserialize_u32_from_string_or_int -----------------
711
712    #[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        // 9 is not a valid octal digit.
739        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    // --- deserialize_string_or_vec_opt ------------------------------------
750
751    #[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}