Skip to main content

azul_css/props/style/
lists.rs

1//! CSS list styling properties (`list-style-type` and `list-style-position`)
2
3use alloc::string::{String, ToString};
4use core::fmt;
5use crate::corety::AzString;
6
7use crate::{codegen::format::FormatAsRustCode, props::formatter::PrintAsCssValue};
8
9// --- list-style-type ---
10
11/// CSS `list-style-type` property — controls the marker style for list items.
12#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
13#[repr(C)]
14#[derive(Default)]
15pub enum StyleListStyleType {
16    None,
17    #[default]
18    Disc,
19    Circle,
20    Square,
21    Decimal,
22    DecimalLeadingZero,
23    LowerRoman,
24    UpperRoman,
25    LowerGreek,
26    UpperGreek,
27    LowerAlpha,
28    UpperAlpha,
29}
30
31
32impl PrintAsCssValue for StyleListStyleType {
33    fn print_as_css_value(&self) -> String {
34        use StyleListStyleType::{None, Disc, Circle, Square, Decimal, DecimalLeadingZero, LowerRoman, UpperRoman, LowerGreek, UpperGreek, LowerAlpha, UpperAlpha};
35        String::from(match self {
36            None => "none",
37            Disc => "disc",
38            Circle => "circle",
39            Square => "square",
40            Decimal => "decimal",
41            DecimalLeadingZero => "decimal-leading-zero",
42            LowerRoman => "lower-roman",
43            UpperRoman => "upper-roman",
44            LowerGreek => "lower-greek",
45            UpperGreek => "upper-greek",
46            LowerAlpha => "lower-alpha",
47            UpperAlpha => "upper-alpha",
48        })
49    }
50}
51
52impl FormatAsRustCode for StyleListStyleType {
53    fn format_as_rust_code(&self, _tabs: usize) -> String {
54        use StyleListStyleType::{None, Disc, Circle, Square, Decimal, DecimalLeadingZero, LowerRoman, UpperRoman, LowerGreek, UpperGreek, LowerAlpha, UpperAlpha};
55        format!(
56            "StyleListStyleType::{}",
57            match self {
58                None => "None",
59                Disc => "Disc",
60                Circle => "Circle",
61                Square => "Square",
62                Decimal => "Decimal",
63                DecimalLeadingZero => "DecimalLeadingZero",
64                LowerRoman => "LowerRoman",
65                UpperRoman => "UpperRoman",
66                LowerGreek => "LowerGreek",
67                UpperGreek => "UpperGreek",
68                LowerAlpha => "LowerAlpha",
69                UpperAlpha => "UpperAlpha",
70            }
71        )
72    }
73}
74
75impl fmt::Display for StyleListStyleType {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        write!(f, "{}", self.print_as_css_value())
78    }
79}
80
81// --- list-style-position ---
82
83/// CSS `list-style-position` property — controls whether the marker is inside or outside the list item box.
84#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
85#[repr(C)]
86#[derive(Default)]
87pub enum StyleListStylePosition {
88    Inside,
89    #[default]
90    Outside,
91}
92
93
94impl PrintAsCssValue for StyleListStylePosition {
95    fn print_as_css_value(&self) -> String {
96        use StyleListStylePosition::{Inside, Outside};
97        String::from(match self {
98            Inside => "inside",
99            Outside => "outside",
100        })
101    }
102}
103
104impl FormatAsRustCode for StyleListStylePosition {
105    fn format_as_rust_code(&self, _tabs: usize) -> String {
106        use StyleListStylePosition::{Inside, Outside};
107        format!(
108            "StyleListStylePosition::{}",
109            match self {
110                Inside => "Inside",
111                Outside => "Outside",
112            }
113        )
114    }
115}
116
117impl fmt::Display for StyleListStylePosition {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        write!(f, "{}", self.print_as_css_value())
120    }
121}
122
123// --- Parsing Logic ---
124
125#[cfg(feature = "parser")]
126#[derive(Clone, PartialEq, Eq)]
127pub enum StyleListStyleTypeParseError<'a> {
128    InvalidValue(&'a str),
129}
130
131#[cfg(feature = "parser")]
132impl_debug_as_display!(StyleListStyleTypeParseError<'a>);
133
134#[cfg(feature = "parser")]
135impl_display! { StyleListStyleTypeParseError<'a>, {
136    InvalidValue(val) => format!("Invalid list-style-type value: \"{}\"", val),
137}}
138
139#[cfg(feature = "parser")]
140#[derive(Debug, Clone, PartialEq, Eq)]
141#[repr(C, u8)]
142pub enum StyleListStyleTypeParseErrorOwned {
143    InvalidValue(AzString),
144}
145
146#[cfg(feature = "parser")]
147impl StyleListStyleTypeParseError<'_> {
148    #[must_use] pub fn to_contained(&self) -> StyleListStyleTypeParseErrorOwned {
149        match self {
150            Self::InvalidValue(s) => StyleListStyleTypeParseErrorOwned::InvalidValue((*s).to_string().into()),
151        }
152    }
153}
154
155#[cfg(feature = "parser")]
156impl StyleListStyleTypeParseErrorOwned {
157    #[must_use] pub fn to_shared(&self) -> StyleListStyleTypeParseError<'_> {
158        match self {
159            Self::InvalidValue(s) => StyleListStyleTypeParseError::InvalidValue(s.as_str()),
160        }
161    }
162}
163
164/// Parses a CSS `list-style-type` value from a string.
165#[cfg(feature = "parser")]
166/// # Errors
167///
168/// Returns an error if `input` is not a valid CSS `list-style-type` value.
169pub fn parse_style_list_style_type(
170    input: &str,
171) -> Result<StyleListStyleType, StyleListStyleTypeParseError<'_>> {
172    let input = input.trim();
173    match input {
174        "none" => Ok(StyleListStyleType::None),
175        "disc" => Ok(StyleListStyleType::Disc),
176        "circle" => Ok(StyleListStyleType::Circle),
177        "square" => Ok(StyleListStyleType::Square),
178        "decimal" => Ok(StyleListStyleType::Decimal),
179        "decimal-leading-zero" => Ok(StyleListStyleType::DecimalLeadingZero),
180        "lower-roman" => Ok(StyleListStyleType::LowerRoman),
181        "upper-roman" => Ok(StyleListStyleType::UpperRoman),
182        "lower-greek" => Ok(StyleListStyleType::LowerGreek),
183        "upper-greek" => Ok(StyleListStyleType::UpperGreek),
184        "lower-alpha" | "lower-latin" => Ok(StyleListStyleType::LowerAlpha),
185        "upper-alpha" | "upper-latin" => Ok(StyleListStyleType::UpperAlpha),
186        _ => Err(StyleListStyleTypeParseError::InvalidValue(input)),
187    }
188}
189
190#[cfg(feature = "parser")]
191#[derive(Clone, PartialEq, Eq)]
192pub enum StyleListStylePositionParseError<'a> {
193    InvalidValue(&'a str),
194}
195
196#[cfg(feature = "parser")]
197impl_debug_as_display!(StyleListStylePositionParseError<'a>);
198
199#[cfg(feature = "parser")]
200impl_display! { StyleListStylePositionParseError<'a>, {
201    InvalidValue(val) => format!("Invalid list-style-position value: \"{}\"", val),
202}}
203
204#[cfg(feature = "parser")]
205#[derive(Debug, Clone, PartialEq, Eq)]
206#[repr(C, u8)]
207pub enum StyleListStylePositionParseErrorOwned {
208    InvalidValue(AzString),
209}
210
211#[cfg(feature = "parser")]
212impl StyleListStylePositionParseError<'_> {
213    #[must_use] pub fn to_contained(&self) -> StyleListStylePositionParseErrorOwned {
214        match self {
215            Self::InvalidValue(s) => {
216                StyleListStylePositionParseErrorOwned::InvalidValue((*s).to_string().into())
217            }
218        }
219    }
220}
221
222#[cfg(feature = "parser")]
223impl StyleListStylePositionParseErrorOwned {
224    #[must_use] pub fn to_shared(&self) -> StyleListStylePositionParseError<'_> {
225        match self {
226            Self::InvalidValue(s) => StyleListStylePositionParseError::InvalidValue(s.as_str()),
227        }
228    }
229}
230
231/// Parses a CSS `list-style-position` value from a string.
232#[cfg(feature = "parser")]
233/// # Errors
234///
235/// Returns an error if `input` is not a valid CSS `list-style-position` value.
236pub fn parse_style_list_style_position(
237    input: &str,
238) -> Result<StyleListStylePosition, StyleListStylePositionParseError<'_>> {
239    let input = input.trim();
240    match input {
241        "inside" => Ok(StyleListStylePosition::Inside),
242        "outside" => Ok(StyleListStylePosition::Outside),
243        _ => Err(StyleListStylePositionParseError::InvalidValue(input)),
244    }
245}
246
247#[cfg(test)]
248mod autotest_generated {
249    //! Adversarial tests for the two list-style keyword enums: serializer
250    //! well-formedness, `print_as_css_value` -> `parse_*` round-trips over every
251    //! variant, parser abuse (empty / whitespace / garbage / megabyte-sized /
252    //! unicode / deeply nested input) and the `to_contained` / `to_shared` error
253    //! conversion invariants.
254    //!
255    //! Both parsers are pure keyword matchers over a trimmed `&str`, so the
256    //! interesting failure modes are (a) panicking or hanging on hostile input,
257    //! (b) losing or corrupting the borrowed error payload, and (c) a variant
258    //! whose serialized form does not parse back to itself.
259
260    use std::collections::BTreeSet;
261
262    use super::*;
263
264    const ALL_TYPES: [StyleListStyleType; 12] = [
265        StyleListStyleType::None,
266        StyleListStyleType::Disc,
267        StyleListStyleType::Circle,
268        StyleListStyleType::Square,
269        StyleListStyleType::Decimal,
270        StyleListStyleType::DecimalLeadingZero,
271        StyleListStyleType::LowerRoman,
272        StyleListStyleType::UpperRoman,
273        StyleListStyleType::LowerGreek,
274        StyleListStyleType::UpperGreek,
275        StyleListStyleType::LowerAlpha,
276        StyleListStyleType::UpperAlpha,
277    ];
278
279    const ALL_POSITIONS: [StyleListStylePosition; 2] =
280        [StyleListStylePosition::Inside, StyleListStylePosition::Outside];
281
282    /// Hostile inputs that must never be accepted, never panic and never hang.
283    #[cfg(feature = "parser")]
284    fn hostile_inputs() -> Vec<String> {
285        let mut v = vec![
286            String::new(),
287            " ".to_string(),
288            "   \t\n\r".to_string(),
289            "\u{0c}\u{0b}".to_string(),
290            "\0".to_string(),
291            "disc\0".to_string(),
292            "\0disc".to_string(),
293            "-".to_string(),
294            "--".to_string(),
295            ";".to_string(),
296            "{}".to_string(),
297            "/* disc */".to_string(),
298            "disc;garbage".to_string(),
299            "disc disc".to_string(),
300            "disc,circle".to_string(),
301            "disc!important".to_string(),
302            "inside;".to_string(),
303            "list-style-type: disc".to_string(),
304            "lower_roman".to_string(),
305            "lower - roman".to_string(),
306            "lowerroman".to_string(),
307            // boundary numerics
308            "0".to_string(),
309            "-0".to_string(),
310            "NaN".to_string(),
311            "nan".to_string(),
312            "inf".to_string(),
313            "-inf".to_string(),
314            "infinity".to_string(),
315            i64::MAX.to_string(),
316            i64::MIN.to_string(),
317            u64::MAX.to_string(),
318            f64::MAX.to_string(),
319            f64::MIN_POSITIVE.to_string(),
320            "1e308".to_string(),
321            "-1e-308".to_string(),
322            // unicode
323            "\u{1F600}".to_string(),
324            "disc\u{301}".to_string(),
325            "\u{301}".to_string(),
326            "\u{202E}disc".to_string(),
327            "di\u{200B}sc".to_string(),
328            "DISС".to_string(), // trailing char is Cyrillic U+0421, not ASCII C
329            "disc".to_string(), // fullwidth s
330            "круг".to_string(),
331            "\u{FFFD}".to_string(),
332        ];
333        // Deeply nested / recursive-looking input must not blow the stack: these
334        // parsers are non-recursive, so this is a regression guard.
335        v.push("(".repeat(10_000));
336        v.push("[".repeat(10_000));
337        v.push("disc(".repeat(10_000));
338        v.push(format!("{}disc{}", "(".repeat(10_000), ")".repeat(10_000)));
339        v
340    }
341
342    // --- serializers -------------------------------------------------------
343
344    #[test]
345    fn css_values_are_well_formed_for_every_type() {
346        for v in ALL_TYPES {
347            let s = v.print_as_css_value();
348            assert!(!s.is_empty(), "{v:?} serialized to an empty CSS value");
349            assert!(
350                s.chars()
351                    .all(|c| c.is_ascii_lowercase() || c == '-'),
352                "{v:?} serialized to {s:?}, which is not a bare lowercase CSS keyword"
353            );
354            assert!(!s.starts_with('-') && !s.ends_with('-'), "{v:?} -> {s:?}");
355        }
356    }
357
358    #[test]
359    fn css_values_are_well_formed_for_every_position() {
360        for v in ALL_POSITIONS {
361            let s = v.print_as_css_value();
362            assert!(!s.is_empty(), "{v:?} serialized to an empty CSS value");
363            assert!(s.chars().all(|c| c.is_ascii_lowercase()), "{v:?} -> {s:?}");
364        }
365    }
366
367    #[test]
368    fn css_values_are_unique() {
369        let types: BTreeSet<String> = ALL_TYPES.iter().map(PrintAsCssValue::print_as_css_value).collect();
370        assert_eq!(types.len(), ALL_TYPES.len(), "two list-style-type variants share a CSS keyword");
371
372        let positions: BTreeSet<String> =
373            ALL_POSITIONS.iter().map(PrintAsCssValue::print_as_css_value).collect();
374        assert_eq!(positions.len(), ALL_POSITIONS.len());
375    }
376
377    #[test]
378    fn display_agrees_with_print_as_css_value() {
379        for v in ALL_TYPES {
380            assert_eq!(v.to_string(), v.print_as_css_value(), "Display diverged for {v:?}");
381        }
382        for v in ALL_POSITIONS {
383            assert_eq!(v.to_string(), v.print_as_css_value(), "Display diverged for {v:?}");
384        }
385    }
386
387    #[test]
388    fn display_of_default_is_the_css_initial_value() {
389        // CSS initial values: list-style-type: disc, list-style-position: outside.
390        assert_eq!(StyleListStyleType::default(), StyleListStyleType::Disc);
391        assert_eq!(StyleListStyleType::default().to_string(), "disc");
392        assert_eq!(StyleListStylePosition::default(), StyleListStylePosition::Outside);
393        assert_eq!(StyleListStylePosition::default().to_string(), "outside");
394    }
395
396    #[test]
397    fn rust_code_names_the_variant_and_ignores_the_tab_argument() {
398        for v in ALL_TYPES {
399            // Debug is derived, so it yields the bare variant name.
400            let expected = format!("StyleListStyleType::{v:?}");
401            assert_eq!(v.format_as_rust_code(0), expected);
402            // `_tabs` is unused: no indentation must leak in, at any depth.
403            assert_eq!(v.format_as_rust_code(usize::MAX), expected);
404        }
405        for v in ALL_POSITIONS {
406            let expected = format!("StyleListStylePosition::{v:?}");
407            assert_eq!(v.format_as_rust_code(0), expected);
408            assert_eq!(v.format_as_rust_code(usize::MAX), expected);
409        }
410    }
411
412    // --- round-trips -------------------------------------------------------
413
414    #[test]
415    #[cfg(feature = "parser")]
416    fn every_type_round_trips_through_its_css_value() {
417        for v in ALL_TYPES {
418            let printed = v.print_as_css_value();
419            assert_eq!(
420                parse_style_list_style_type(&printed),
421                Ok(v),
422                "{v:?} serialized to {printed:?}, which does not parse back"
423            );
424        }
425    }
426
427    #[test]
428    #[cfg(feature = "parser")]
429    fn every_position_round_trips_through_its_css_value() {
430        for v in ALL_POSITIONS {
431            let printed = v.print_as_css_value();
432            assert_eq!(parse_style_list_style_position(&printed), Ok(v), "{v:?} -> {printed:?}");
433        }
434    }
435
436    #[test]
437    #[cfg(feature = "parser")]
438    fn parsing_is_idempotent_through_reserialization() {
439        // parse -> print -> parse must reach a fixed point, including for the
440        // alias spellings that normalize onto a different keyword.
441        for input in [
442            "disc", "none", "circle", "square", "decimal", "decimal-leading-zero", "lower-roman",
443            "upper-roman", "lower-greek", "upper-greek", "lower-alpha", "upper-alpha",
444            "lower-latin", "upper-latin",
445        ] {
446            let first = parse_style_list_style_type(input).expect("known-good keyword");
447            let printed = first.print_as_css_value();
448            let second = parse_style_list_style_type(&printed).expect("reserialized value must reparse");
449            assert_eq!(first, second, "{input:?} was not idempotent (printed {printed:?})");
450        }
451    }
452
453    // --- parser: positive controls ----------------------------------------
454
455    #[test]
456    #[cfg(feature = "parser")]
457    fn valid_keywords_map_to_the_expected_variants() {
458        let table = [
459            ("none", StyleListStyleType::None),
460            ("disc", StyleListStyleType::Disc),
461            ("circle", StyleListStyleType::Circle),
462            ("square", StyleListStyleType::Square),
463            ("decimal", StyleListStyleType::Decimal),
464            ("decimal-leading-zero", StyleListStyleType::DecimalLeadingZero),
465            ("lower-roman", StyleListStyleType::LowerRoman),
466            ("upper-roman", StyleListStyleType::UpperRoman),
467            ("lower-greek", StyleListStyleType::LowerGreek),
468            ("upper-greek", StyleListStyleType::UpperGreek),
469            ("lower-alpha", StyleListStyleType::LowerAlpha),
470            ("upper-alpha", StyleListStyleType::UpperAlpha),
471            // documented aliases
472            ("lower-latin", StyleListStyleType::LowerAlpha),
473            ("upper-latin", StyleListStyleType::UpperAlpha),
474        ];
475        for (input, expected) in table {
476            assert_eq!(parse_style_list_style_type(input), Ok(expected), "input {input:?}");
477        }
478
479        assert_eq!(parse_style_list_style_position("inside"), Ok(StyleListStylePosition::Inside));
480        assert_eq!(parse_style_list_style_position("outside"), Ok(StyleListStylePosition::Outside));
481    }
482
483    #[test]
484    #[cfg(feature = "parser")]
485    fn ascii_whitespace_padding_is_trimmed() {
486        for padded in ["  disc", "disc\n", "\t disc \r\n", "\u{0c}disc\u{0c}"] {
487            assert_eq!(
488                parse_style_list_style_type(padded),
489                Ok(StyleListStyleType::Disc),
490                "padding was not trimmed from {padded:?}"
491            );
492        }
493        assert_eq!(
494            parse_style_list_style_position("\n\t outside \t\n"),
495            Ok(StyleListStylePosition::Outside)
496        );
497    }
498
499    #[test]
500    #[cfg(feature = "parser")]
501    fn parsers_agree_with_str_trim_on_unicode_whitespace() {
502        // `str::trim` strips *Unicode* whitespace (U+00A0 NBSP, U+2003 EM SPACE,
503        // U+3000 IDEOGRAPHIC SPACE), which CSS does not consider whitespace. The
504        // parsers trim first, so whatever `trim` decides, they must stay
505        // consistent with it rather than accepting a keyword `trim` left dirty.
506        for pad in ["\u{a0}", "\u{2003}", "\u{3000}"] {
507            let padded = format!("{pad}disc{pad}");
508            assert_eq!(
509                parse_style_list_style_type(&padded).is_ok(),
510                padded.trim() == "disc",
511                "parser and str::trim disagree on {padded:?}"
512            );
513        }
514    }
515
516    // --- parser: hostile input --------------------------------------------
517
518    #[test]
519    #[cfg(feature = "parser")]
520    fn hostile_input_is_rejected_without_panicking() {
521        for input in hostile_inputs() {
522            let ty = parse_style_list_style_type(&input);
523            assert!(ty.is_err(), "list-style-type accepted hostile input {input:?} as {ty:?}");
524
525            let pos = parse_style_list_style_position(&input);
526            assert!(pos.is_err(), "list-style-position accepted hostile input {input:?} as {pos:?}");
527        }
528    }
529
530    #[test]
531    #[cfg(feature = "parser")]
532    fn keyword_matching_is_case_sensitive() {
533        // NOTE: CSS keyword values are ASCII case-insensitive, so `DISC` /
534        // `Inside` *should* parse per spec. These parsers are exact-match only;
535        // the assertions below pin the current (stricter than spec) behavior.
536        for input in ["Disc", "DISC", "dIsC", "LOWER-ROMAN", "Decimal-Leading-Zero", "NONE"] {
537            assert!(
538                parse_style_list_style_type(input).is_err(),
539                "unexpectedly case-insensitive for {input:?}"
540            );
541        }
542        for input in ["Inside", "OUTSIDE", "OuTsIdE"] {
543            assert!(
544                parse_style_list_style_position(input).is_err(),
545                "unexpectedly case-insensitive for {input:?}"
546            );
547        }
548    }
549
550    #[test]
551    #[cfg(feature = "parser")]
552    fn megabyte_sized_input_terminates_and_is_rejected() {
553        let long = "a".repeat(1_000_000);
554        assert!(parse_style_list_style_type(&long).is_err());
555        assert!(parse_style_list_style_position(&long).is_err());
556
557        // A valid keyword repeated a quarter-million times is still not a keyword.
558        let repeated = "disc".repeat(250_000);
559        assert!(parse_style_list_style_type(&repeated).is_err());
560
561        // Megabytes of padding around a valid keyword must still trim down to it.
562        let padded = format!("{}disc{}", " ".repeat(1_000_000), "\n".repeat(1_000_000));
563        assert_eq!(parse_style_list_style_type(&padded), Ok(StyleListStyleType::Disc));
564    }
565
566    #[test]
567    #[cfg(feature = "parser")]
568    fn multibyte_input_is_rejected_without_slicing_panics() {
569        // Trimming a string whose bytes straddle char boundaries must not panic
570        // and must not truncate the error payload mid-codepoint.
571        for input in ["🙂", " 🙂 ", "日本語", "e\u{301}", "\u{1F600}\u{1F600}\u{1F600}"] {
572            match parse_style_list_style_type(input) {
573                Ok(v) => panic!("multibyte input {input:?} parsed as {v:?}"),
574                Err(StyleListStyleTypeParseError::InvalidValue(s)) => {
575                    assert_eq!(s, input.trim(), "error payload was mangled for {input:?}");
576                }
577            }
578        }
579    }
580
581    // --- parser: error payloads -------------------------------------------
582
583    #[test]
584    #[cfg(feature = "parser")]
585    fn error_payload_is_the_trimmed_input() {
586        match parse_style_list_style_type("  bogus-keyword  ") {
587            Err(StyleListStyleTypeParseError::InvalidValue(s)) => assert_eq!(s, "bogus-keyword"),
588            other => panic!("expected InvalidValue, got {other:?}"),
589        }
590        match parse_style_list_style_position("\t bogus \n") {
591            Err(StyleListStylePositionParseError::InvalidValue(s)) => assert_eq!(s, "bogus"),
592            other => panic!("expected InvalidValue, got {other:?}"),
593        }
594        // Whitespace-only input trims to the empty string, not to a `None`-ish value.
595        match parse_style_list_style_type("   \t\n") {
596            Err(StyleListStyleTypeParseError::InvalidValue(s)) => assert!(s.is_empty()),
597            other => panic!("expected InvalidValue(\"\"), got {other:?}"),
598        }
599    }
600
601    #[test]
602    #[cfg(feature = "parser")]
603    fn error_display_quotes_the_offending_value() {
604        let err = parse_style_list_style_type("🙂").unwrap_err();
605        let msg = err.to_string();
606        assert!(msg.contains("list-style-type"), "{msg:?}");
607        assert!(msg.contains('🙂'), "error message dropped the offending value: {msg:?}");
608
609        let err = parse_style_list_style_position("").unwrap_err();
610        let msg = err.to_string();
611        assert!(msg.contains("list-style-position"), "{msg:?}");
612        assert!(!msg.is_empty());
613    }
614
615    // --- error conversions: to_contained / to_shared ------------------------
616
617    #[test]
618    #[cfg(feature = "parser")]
619    fn type_error_survives_a_contained_shared_round_trip() {
620        let payloads = [
621            "",
622            " ",
623            "bogus",
624            "🙂 combining\u{301}",
625            "\0embedded nul\0",
626            "line\nbreak\r\n",
627            "-",
628        ];
629        for payload in payloads {
630            let shared = StyleListStyleTypeParseError::InvalidValue(payload);
631            let owned = shared.to_contained();
632            match &owned {
633                StyleListStyleTypeParseErrorOwned::InvalidValue(s) => {
634                    assert_eq!(s.as_str(), payload, "to_contained mangled {payload:?}");
635                }
636            }
637            assert_eq!(owned.to_shared(), shared, "round-trip lost data for {payload:?}");
638        }
639    }
640
641    #[test]
642    #[cfg(feature = "parser")]
643    fn position_error_survives_a_contained_shared_round_trip() {
644        for payload in ["", "bogus", "🙂", "\0", "  interior  spaces  "] {
645            let shared = StyleListStylePositionParseError::InvalidValue(payload);
646            let owned = shared.to_contained();
647            match &owned {
648                StyleListStylePositionParseErrorOwned::InvalidValue(s) => {
649                    assert_eq!(s.as_str(), payload);
650                }
651            }
652            assert_eq!(owned.to_shared(), shared, "round-trip lost data for {payload:?}");
653        }
654    }
655
656    #[test]
657    #[cfg(feature = "parser")]
658    fn owned_errors_constructed_directly_convert_back_to_shared() {
659        let owned = StyleListStyleTypeParseErrorOwned::InvalidValue(String::new().into());
660        match owned.to_shared() {
661            StyleListStyleTypeParseError::InvalidValue(s) => assert_eq!(s, ""),
662        }
663
664        let owned = StyleListStylePositionParseErrorOwned::InvalidValue(String::from("x").into());
665        match owned.to_shared() {
666            StyleListStylePositionParseError::InvalidValue(s) => assert_eq!(s, "x"),
667        }
668    }
669
670    #[test]
671    #[cfg(feature = "parser")]
672    fn huge_error_payload_is_carried_without_truncation() {
673        let huge = "z".repeat(100_000);
674        let err = parse_style_list_style_type(&huge).unwrap_err();
675        let owned = err.to_contained();
676        match &owned {
677            StyleListStyleTypeParseErrorOwned::InvalidValue(s) => {
678                assert_eq!(s.as_str().len(), 100_000, "payload was truncated");
679            }
680        }
681        assert_eq!(owned.to_shared(), StyleListStyleTypeParseError::InvalidValue(&huge));
682    }
683
684    #[test]
685    #[cfg(feature = "parser")]
686    fn to_contained_is_repeatable_and_does_not_consume_the_error() {
687        let err = parse_style_list_style_position("nope").unwrap_err();
688        let a = err.to_contained();
689        let b = err.to_contained();
690        assert_eq!(a, b);
691        assert_eq!(err, StyleListStylePositionParseError::InvalidValue("nope"));
692    }
693}