Skip to main content

azul_css/props/style/
lists.rs

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