1
//! CSS list styling properties (`list-style-type` and `list-style-position`)
2

            
3
use alloc::string::{String, ToString};
4
use core::fmt;
5
use crate::corety::AzString;
6

            
7
use 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)]
15
pub 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

            
32
impl PrintAsCssValue for StyleListStyleType {
33
75
    fn print_as_css_value(&self) -> String {
34
        use StyleListStyleType::{None, Disc, Circle, Square, Decimal, DecimalLeadingZero, LowerRoman, UpperRoman, LowerGreek, UpperGreek, LowerAlpha, UpperAlpha};
35
75
        String::from(match self {
36
6
            None => "none",
37
7
            Disc => "disc",
38
6
            Circle => "circle",
39
6
            Square => "square",
40
6
            Decimal => "decimal",
41
6
            DecimalLeadingZero => "decimal-leading-zero",
42
6
            LowerRoman => "lower-roman",
43
6
            UpperRoman => "upper-roman",
44
6
            LowerGreek => "lower-greek",
45
6
            UpperGreek => "upper-greek",
46
7
            LowerAlpha => "lower-alpha",
47
7
            UpperAlpha => "upper-alpha",
48
        })
49
75
    }
50
}
51

            
52
impl FormatAsRustCode for StyleListStyleType {
53
24
    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
24
        format!(
56
24
            "StyleListStyleType::{}",
57
24
            match self {
58
2
                None => "None",
59
2
                Disc => "Disc",
60
2
                Circle => "Circle",
61
2
                Square => "Square",
62
2
                Decimal => "Decimal",
63
2
                DecimalLeadingZero => "DecimalLeadingZero",
64
2
                LowerRoman => "LowerRoman",
65
2
                UpperRoman => "UpperRoman",
66
2
                LowerGreek => "LowerGreek",
67
2
                UpperGreek => "UpperGreek",
68
2
                LowerAlpha => "LowerAlpha",
69
2
                UpperAlpha => "UpperAlpha",
70
            }
71
        )
72
24
    }
73
}
74

            
75
impl fmt::Display for StyleListStyleType {
76
13
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77
13
        write!(f, "{}", self.print_as_css_value())
78
13
    }
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)]
87
pub enum StyleListStylePosition {
88
    Inside,
89
    #[default]
90
    Outside,
91
}
92

            
93

            
94
impl PrintAsCssValue for StyleListStylePosition {
95
11
    fn print_as_css_value(&self) -> String {
96
        use StyleListStylePosition::{Inside, Outside};
97
11
        String::from(match self {
98
5
            Inside => "inside",
99
6
            Outside => "outside",
100
        })
101
11
    }
102
}
103

            
104
impl FormatAsRustCode for StyleListStylePosition {
105
4
    fn format_as_rust_code(&self, _tabs: usize) -> String {
106
        use StyleListStylePosition::{Inside, Outside};
107
4
        format!(
108
4
            "StyleListStylePosition::{}",
109
4
            match self {
110
2
                Inside => "Inside",
111
2
                Outside => "Outside",
112
            }
113
        )
114
4
    }
115
}
116

            
117
impl fmt::Display for StyleListStylePosition {
118
3
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119
3
        write!(f, "{}", self.print_as_css_value())
120
3
    }
121
}
122

            
123
// --- Parsing Logic ---
124

            
125
#[cfg(feature = "parser")]
126
#[derive(Clone, PartialEq, Eq)]
127
pub enum StyleListStyleTypeParseError<'a> {
128
    InvalidValue(&'a str),
129
}
130

            
131
#[cfg(feature = "parser")]
132
impl_debug_as_display!(StyleListStyleTypeParseError<'a>);
133

            
134
#[cfg(feature = "parser")]
135
impl_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)]
142
pub enum StyleListStyleTypeParseErrorOwned {
143
    InvalidValue(AzString),
144
}
145

            
146
#[cfg(feature = "parser")]
147
impl StyleListStyleTypeParseError<'_> {
148
8
    #[must_use] pub fn to_contained(&self) -> StyleListStyleTypeParseErrorOwned {
149
8
        match self {
150
8
            Self::InvalidValue(s) => StyleListStyleTypeParseErrorOwned::InvalidValue((*s).to_string().into()),
151
        }
152
8
    }
153
}
154

            
155
#[cfg(feature = "parser")]
156
impl StyleListStyleTypeParseErrorOwned {
157
9
    #[must_use] pub fn to_shared(&self) -> StyleListStyleTypeParseError<'_> {
158
9
        match self {
159
9
            Self::InvalidValue(s) => StyleListStyleTypeParseError::InvalidValue(s.as_str()),
160
        }
161
9
    }
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.
169
127
pub fn parse_style_list_style_type(
170
127
    input: &str,
171
127
) -> Result<StyleListStyleType, StyleListStyleTypeParseError<'_>> {
172
127
    let input = input.trim();
173
127
    match input {
174
127
        "none" => Ok(StyleListStyleType::None),
175
123
        "disc" => Ok(StyleListStyleType::Disc),
176
111
        "circle" => Ok(StyleListStyleType::Circle),
177
107
        "square" => Ok(StyleListStyleType::Square),
178
103
        "decimal" => Ok(StyleListStyleType::Decimal),
179
99
        "decimal-leading-zero" => Ok(StyleListStyleType::DecimalLeadingZero),
180
95
        "lower-roman" => Ok(StyleListStyleType::LowerRoman),
181
91
        "upper-roman" => Ok(StyleListStyleType::UpperRoman),
182
87
        "lower-greek" => Ok(StyleListStyleType::LowerGreek),
183
83
        "upper-greek" => Ok(StyleListStyleType::UpperGreek),
184
79
        "lower-alpha" | "lower-latin" => Ok(StyleListStyleType::LowerAlpha),
185
72
        "upper-alpha" | "upper-latin" => Ok(StyleListStyleType::UpperAlpha),
186
65
        _ => Err(StyleListStyleTypeParseError::InvalidValue(input)),
187
    }
188
127
}
189

            
190
#[cfg(feature = "parser")]
191
#[derive(Clone, PartialEq, Eq)]
192
pub enum StyleListStylePositionParseError<'a> {
193
    InvalidValue(&'a str),
194
}
195

            
196
#[cfg(feature = "parser")]
197
impl_debug_as_display!(StyleListStylePositionParseError<'a>);
198

            
199
#[cfg(feature = "parser")]
200
impl_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)]
207
pub enum StyleListStylePositionParseErrorOwned {
208
    InvalidValue(AzString),
209
}
210

            
211
#[cfg(feature = "parser")]
212
impl StyleListStylePositionParseError<'_> {
213
7
    #[must_use] pub fn to_contained(&self) -> StyleListStylePositionParseErrorOwned {
214
7
        match self {
215
7
            Self::InvalidValue(s) => {
216
7
                StyleListStylePositionParseErrorOwned::InvalidValue((*s).to_string().into())
217
            }
218
        }
219
7
    }
220
}
221

            
222
#[cfg(feature = "parser")]
223
impl StyleListStylePositionParseErrorOwned {
224
6
    #[must_use] pub fn to_shared(&self) -> StyleListStylePositionParseError<'_> {
225
6
        match self {
226
6
            Self::InvalidValue(s) => StyleListStylePositionParseError::InvalidValue(s.as_str()),
227
        }
228
6
    }
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.
236
60
pub fn parse_style_list_style_position(
237
60
    input: &str,
238
60
) -> Result<StyleListStylePosition, StyleListStylePositionParseError<'_>> {
239
60
    let input = input.trim();
240
60
    match input {
241
60
        "inside" => Ok(StyleListStylePosition::Inside),
242
58
        "outside" => Ok(StyleListStylePosition::Outside),
243
55
        _ => Err(StyleListStylePositionParseError::InvalidValue(input)),
244
    }
245
60
}
246

            
247
#[cfg(test)]
248
mod 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
}