1
//! CSS `text-justify` property.
2
//!
3
//! Defines [`LayoutTextJustify`] and its parser [`parse_layout_text_justify`],
4
//! used by the CSS property parsing pipeline.
5

            
6
use alloc::string::{String, ToString};
7
use core::fmt;
8
use crate::corety::AzString;
9

            
10
use crate::{codegen::format::FormatAsRustCode, props::formatter::PrintAsCssValue};
11

            
12
/// CSS `text-justify` property value.
13
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
14
#[repr(C)]
15
#[derive(Default)]
16
pub enum LayoutTextJustify {
17
    #[default]
18
    Auto,
19
    None,
20
    InterWord,
21
    InterCharacter,
22
    /// Legacy value; the parser maps `"distribute"` to `InterCharacter` per spec.
23
    /// Retained for `#[repr(C)]` FFI backward compatibility.
24
    Distribute,
25
}
26

            
27

            
28
impl PrintAsCssValue for LayoutTextJustify {
29
12
    fn print_as_css_value(&self) -> String {
30
12
        match self {
31
3
            Self::Auto => "auto",
32
2
            Self::None => "none",
33
2
            Self::InterWord => "inter-word",
34
2
            Self::InterCharacter => "inter-character",
35
3
            Self::Distribute => "distribute",
36
        }
37
12
        .to_string()
38
12
    }
39
}
40

            
41
impl FormatAsRustCode for LayoutTextJustify {
42
15
    fn format_as_rust_code(&self, _tabs: usize) -> String {
43
15
        format!("LayoutTextJustify::{self:?}")
44
15
    }
45
}
46

            
47
#[derive(Debug, Clone, PartialEq, Eq)]
48
pub enum TextJustifyParseError<'a> {
49
    InvalidValue(&'a str),
50
}
51

            
52
impl fmt::Display for TextJustifyParseError<'_> {
53
31
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54
31
        match self {
55
31
            TextJustifyParseError::InvalidValue(s) => {
56
31
                write!(f, "Invalid text-justify value: '{s}'.")
57
            }
58
        }
59
31
    }
60
}
61

            
62
#[derive(Debug, Clone, PartialEq, Eq)]
63
#[repr(C, u8)]
64
pub enum TextJustifyParseErrorOwned {
65
    InvalidValue(AzString),
66
}
67

            
68
impl TextJustifyParseError<'_> {
69
25
    #[must_use] pub fn to_owned(&self) -> TextJustifyParseErrorOwned {
70
25
        match self {
71
25
            TextJustifyParseError::InvalidValue(s) => {
72
25
                TextJustifyParseErrorOwned::InvalidValue((*s).to_string().into())
73
            }
74
        }
75
25
    }
76
}
77

            
78
impl TextJustifyParseErrorOwned {
79
34
    #[must_use] pub fn to_borrowed(&self) -> TextJustifyParseError<'_> {
80
34
        match self {
81
34
            Self::InvalidValue(s) => {
82
34
                TextJustifyParseError::InvalidValue(s.as_str())
83
            }
84
        }
85
34
    }
86
}
87

            
88
/// Parses a `text-justify` CSS value string into a [`LayoutTextJustify`].
89
/// # Errors
90
///
91
/// Returns an error if `input` is not a valid CSS `text-justify` value.
92
133
pub fn parse_layout_text_justify(
93
133
    input: &str,
94
133
) -> Result<LayoutTextJustify, TextJustifyParseError<'_>> {
95
133
    match input.trim() {
96
133
        "auto" => Ok(LayoutTextJustify::Auto),
97
125
        "none" => Ok(LayoutTextJustify::None),
98
119
        "inter-word" => Ok(LayoutTextJustify::InterWord),
99
        // "distribute" is a legacy alias that computes to inter-character:
100
        // +spec:text-alignment-spacing:4a88c2  +spec:text-alignment-spacing:58c33f
101
113
        "inter-character" | "distribute" => Ok(LayoutTextJustify::InterCharacter),
102
101
        other => Err(TextJustifyParseError::InvalidValue(other)),
103
    }
104
133
}
105

            
106
#[cfg(test)]
107
mod tests {
108
    use super::*;
109
    #[test]
110
1
    fn test_parse_layout_text_justify() {
111
1
        assert_eq!(
112
1
            parse_layout_text_justify("auto"),
113
            Ok(LayoutTextJustify::Auto)
114
        );
115
1
        assert_eq!(
116
1
            parse_layout_text_justify("none"),
117
            Ok(LayoutTextJustify::None)
118
        );
119
1
        assert_eq!(
120
1
            parse_layout_text_justify("inter-word"),
121
            Ok(LayoutTextJustify::InterWord)
122
        );
123
1
        assert_eq!(
124
1
            parse_layout_text_justify("inter-character"),
125
            Ok(LayoutTextJustify::InterCharacter)
126
        );
127
1
        assert_eq!(
128
1
            parse_layout_text_justify("distribute"),
129
            Ok(LayoutTextJustify::InterCharacter)
130
        );
131
1
        assert!(parse_layout_text_justify("invalid").is_err());
132
1
    }
133
}
134

            
135
#[cfg(test)]
136
mod autotest_generated {
137
    use super::*;
138

            
139
    // ---------------------------------------------------------------------
140
    // Variant table, kept honest by `justify_variant_index`: the match below
141
    // is deliberately exhaustive (no `_` arm), so adding a variant to
142
    // `LayoutTextJustify` is a compile error here rather than a silently
143
    // untested variant in every loop of this module.
144
    // ---------------------------------------------------------------------
145

            
146
    const ALL_JUSTIFY: [LayoutTextJustify; 5] = [
147
        LayoutTextJustify::Auto,
148
        LayoutTextJustify::None,
149
        LayoutTextJustify::InterWord,
150
        LayoutTextJustify::InterCharacter,
151
        LayoutTextJustify::Distribute,
152
    ];
153

            
154
    const fn justify_variant_index(j: LayoutTextJustify) -> usize {
155
        match j {
156
            LayoutTextJustify::Auto => 0,
157
            LayoutTextJustify::None => 1,
158
            LayoutTextJustify::InterWord => 2,
159
            LayoutTextJustify::InterCharacter => 3,
160
            LayoutTextJustify::Distribute => 4,
161
        }
162
    }
163

            
164
    #[test]
165
    fn all_justify_lists_every_variant_exactly_once() {
166
        for (i, j) in ALL_JUSTIFY.iter().enumerate() {
167
            assert_eq!(
168
                justify_variant_index(*j),
169
                i,
170
                "ALL_JUSTIFY is out of sync at index {i} ({j:?})"
171
            );
172
        }
173
    }
174

            
175
    // ---------------------------------------------------------------------
176
    // parse_layout_text_justify — positive controls
177
    // ---------------------------------------------------------------------
178

            
179
    #[test]
180
    fn every_documented_keyword_parses_to_its_variant() {
181
        assert_eq!(
182
            parse_layout_text_justify("auto"),
183
            Ok(LayoutTextJustify::Auto)
184
        );
185
        assert_eq!(
186
            parse_layout_text_justify("none"),
187
            Ok(LayoutTextJustify::None)
188
        );
189
        assert_eq!(
190
            parse_layout_text_justify("inter-word"),
191
            Ok(LayoutTextJustify::InterWord)
192
        );
193
        assert_eq!(
194
            parse_layout_text_justify("inter-character"),
195
            Ok(LayoutTextJustify::InterCharacter)
196
        );
197
        // Legacy alias: "distribute" computes to inter-character.
198
        assert_eq!(
199
            parse_layout_text_justify("distribute"),
200
            Ok(LayoutTextJustify::InterCharacter)
201
        );
202
    }
203

            
204
    /// `Distribute` exists only so the `#[repr(C)]` discriminants stay
205
    /// backwards compatible; no input string may ever produce it, otherwise
206
    /// two distinct values would represent the same computed style.
207
    #[test]
208
    fn no_input_ever_parses_to_the_legacy_distribute_variant() {
209
        let candidates = [
210
            "distribute",
211
            "  distribute  ",
212
            "inter-character",
213
            "auto",
214
            "none",
215
            "inter-word",
216
        ];
217
        for input in candidates {
218
            assert_ne!(
219
                parse_layout_text_justify(input),
220
                Ok(LayoutTextJustify::Distribute),
221
                "{input:?} parsed to the FFI-only Distribute variant"
222
            );
223
        }
224
    }
225

            
226
    // ---------------------------------------------------------------------
227
    // parse_layout_text_justify — empty / whitespace / trimming
228
    // ---------------------------------------------------------------------
229

            
230
    #[test]
231
    fn empty_and_whitespace_only_input_is_rejected_with_an_empty_payload() {
232
        // Everything here trims down to "", so the error must carry "" (not
233
        // the original padding) and must not panic on the empty slice.
234
        for input in ["", " ", "   ", "\t", "\n", "\r\n", " \t\r\n\u{b}\u{c} "] {
235
            assert_eq!(
236
                parse_layout_text_justify(input),
237
                Err(TextJustifyParseError::InvalidValue("")),
238
                "whitespace-only input {input:?}"
239
            );
240
        }
241
    }
242

            
243
    #[test]
244
    fn surrounding_ascii_whitespace_is_trimmed_before_matching() {
245
        for (input, expected) in [
246
            ("  auto  ", LayoutTextJustify::Auto),
247
            ("\tnone\t", LayoutTextJustify::None),
248
            ("\n inter-word \r\n", LayoutTextJustify::InterWord),
249
            ("\r\n\tdistribute\r\n\t", LayoutTextJustify::InterCharacter),
250
        ] {
251
            assert_eq!(parse_layout_text_justify(input), Ok(expected), "{input:?}");
252
        }
253
    }
254

            
255
    /// `str::trim` is Unicode-aware, so keywords padded with Unicode
256
    /// `White_Space` characters (NBSP, ideographic space, NEL) are accepted
257
    /// even though CSS whitespace is only space/tab/LF/CR/FF. Pinned as the
258
    /// *current* behaviour — it is more lenient than the spec, never stricter.
259
    #[test]
260
    fn unicode_whitespace_padding_is_also_trimmed() {
261
        assert_eq!(
262
            parse_layout_text_justify("\u{a0}auto\u{a0}"),
263
            Ok(LayoutTextJustify::Auto),
264
            "NBSP-padded keyword"
265
        );
266
        assert_eq!(
267
            parse_layout_text_justify("\u{3000}none\u{3000}"),
268
            Ok(LayoutTextJustify::None),
269
            "ideographic-space-padded keyword"
270
        );
271
        assert_eq!(
272
            parse_layout_text_justify("\u{85}inter-word\u{85}"),
273
            Ok(LayoutTextJustify::InterWord),
274
            "NEL-padded keyword"
275
        );
276
        // ...but zero-width characters are NOT `White_Space`, so they survive
277
        // the trim and must be rejected rather than silently stripped.
278
        assert_eq!(
279
            parse_layout_text_justify("\u{200b}auto"),
280
            Err(TextJustifyParseError::InvalidValue("\u{200b}auto")),
281
            "zero-width space is not whitespace"
282
        );
283
        assert_eq!(
284
            parse_layout_text_justify("\u{feff}auto"),
285
            Err(TextJustifyParseError::InvalidValue("\u{feff}auto")),
286
            "BOM is not whitespace"
287
        );
288
    }
289

            
290
    #[test]
291
    fn interior_whitespace_is_never_collapsed() {
292
        for input in [
293
            "inter word",
294
            "inter -word",
295
            "inter- word",
296
            "inter - character",
297
            "auto auto",
298
            "auto none",
299
            "au to",
300
        ] {
301
            assert_eq!(
302
                parse_layout_text_justify(input),
303
                Err(TextJustifyParseError::InvalidValue(input.trim())),
304
                "interior whitespace in {input:?} must not be collapsed away"
305
            );
306
        }
307
    }
308

            
309
    // ---------------------------------------------------------------------
310
    // parse_layout_text_justify — malformed input
311
    // ---------------------------------------------------------------------
312

            
313
    /// CSS keywords are ASCII case-insensitive (css-values-4 §3.1), but this
314
    /// parser matches case-sensitively and `parse_css_property` only trims
315
    /// (it does not lower-case) before dispatching here. Pinned as the current
316
    /// behaviour; see the report accompanying these tests.
317
    #[test]
318
    fn keyword_matching_is_case_sensitive() {
319
        for input in [
320
            "AUTO",
321
            "Auto",
322
            "aUtO",
323
            "NONE",
324
            "Inter-Word",
325
            "INTER-CHARACTER",
326
            "Distribute",
327
        ] {
328
            assert!(
329
                parse_layout_text_justify(input).is_err(),
330
                "{input:?} unexpectedly parsed"
331
            );
332
        }
333
    }
334

            
335
    #[test]
336
    fn garbage_and_near_miss_input_is_rejected_without_panicking() {
337
        for input in [
338
            "invalid",
339
            "interword",
340
            "inter_word",
341
            "inter–word",     // en dash, not hyphen-minus
342
            "inter-",
343
            "-character",
344
            "inter-characters",
345
            "autos",
346
            "aut",
347
            "auto;",
348
            "auto;garbage",
349
            "auto !important",
350
            "auto/**/",
351
            "/*auto*/",
352
            "url(auto)",
353
            "attr(auto)",
354
            "\"auto\"",
355
            "'auto'",
356
            ";",
357
            "{}",
358
            "\\",
359
            "-",
360
            "--",
361
            "\0",
362
            "auto\0",
363
            "\0auto",
364
            "initial",
365
            "inherit",
366
            "unset",
367
            "revert",
368
        ] {
369
            assert_eq!(
370
                parse_layout_text_justify(input),
371
                Err(TextJustifyParseError::InvalidValue(input.trim())),
372
                "{input:?} must be rejected verbatim"
373
            );
374
        }
375
    }
376

            
377
    #[test]
378
    fn boundary_numeric_strings_are_rejected() {
379
        let big = i64::MAX.to_string();
380
        let small = i64::MIN.to_string();
381
        let umax = u64::MAX.to_string();
382
        let fmax = f64::MAX.to_string();
383
        let ftiny = f64::MIN_POSITIVE.to_string();
384
        let inputs = [
385
            "0",
386
            "-0",
387
            "+0",
388
            "0.0",
389
            "1",
390
            "1e400",
391
            "-1e-400",
392
            "NaN",
393
            "nan",
394
            "inf",
395
            "-inf",
396
            "Infinity",
397
            big.as_str(),
398
            small.as_str(),
399
            umax.as_str(),
400
            fmax.as_str(),
401
            ftiny.as_str(),
402
        ];
403
        for input in inputs {
404
            assert_eq!(
405
                parse_layout_text_justify(input),
406
                Err(TextJustifyParseError::InvalidValue(input)),
407
                "numeric-looking input {input:?} must not parse as a keyword"
408
            );
409
        }
410
    }
411

            
412
    #[test]
413
    fn unicode_input_is_rejected_and_preserved_verbatim_in_the_error() {
414
        for input in [
415
            "\u{1F600}",              // emoji
416
            "auto\u{301}",            // combining acute on the last char
417
            "\u{301}\u{301}\u{301}",  // lone combining marks
418
            "auto",               // fullwidth latin
419
            "аuto",                   // Cyrillic homoglyph 'а'
420
            "\u{202e}auto",           // RTL override
421
            "🙂🙃🙂",
422
            "日本語",
423
            "\u{fffd}",               // replacement char
424
        ] {
425
            assert_eq!(
426
                parse_layout_text_justify(input),
427
                Err(TextJustifyParseError::InvalidValue(input)),
428
                "unicode input {input:?}"
429
            );
430
            // The error must round-trip through the owned form untouched, i.e.
431
            // no char boundary was sliced through.
432
            let err = parse_layout_text_justify(input).unwrap_err();
433
            assert_eq!(err.to_owned().to_borrowed(), err);
434
        }
435
    }
436

            
437
    #[test]
438
    fn extremely_long_input_neither_panics_nor_hangs() {
439
        let million = "a".repeat(1_000_000);
440
        assert_eq!(
441
            parse_layout_text_justify(&million),
442
            Err(TextJustifyParseError::InvalidValue(million.as_str()))
443
        );
444

            
445
        // A valid keyword repeated until it is no longer a keyword.
446
        let repeated = "auto".repeat(250_000);
447
        assert!(parse_layout_text_justify(&repeated).is_err());
448

            
449
        // Huge padding around a valid keyword: trimming must still find it.
450
        let padded = format!("{}auto{}", " ".repeat(500_000), "\n".repeat(500_000));
451
        assert_eq!(
452
            parse_layout_text_justify(&padded),
453
            Ok(LayoutTextJustify::Auto)
454
        );
455

            
456
        // Huge padding around garbage: the error payload is the *trimmed*
457
        // slice, so it must not carry the megabyte of padding along.
458
        let padded_garbage = format!("{}garbage{}", " ".repeat(500_000), " ".repeat(500_000));
459
        let err = parse_layout_text_justify(&padded_garbage).unwrap_err();
460
        assert_eq!(err, TextJustifyParseError::InvalidValue("garbage"));
461
    }
462

            
463
    #[test]
464
    fn deeply_nested_brackets_do_not_stack_overflow() {
465
        // The parser is a flat match, but a future rewrite that recurses on
466
        // nested constructs would blow the stack here instead of shipping.
467
        let nested = format!("{}auto{}", "(".repeat(10_000), ")".repeat(10_000));
468
        assert!(parse_layout_text_justify(&nested).is_err());
469

            
470
        let braces = "{".repeat(50_000);
471
        assert_eq!(
472
            parse_layout_text_justify(&braces),
473
            Err(TextJustifyParseError::InvalidValue(braces.as_str()))
474
        );
475
    }
476

            
477
    /// The error borrows *into* the caller's string rather than copying it:
478
    /// the returned slice must point inside `input` and equal `input.trim()`.
479
    #[test]
480
    fn error_payload_is_a_borrowed_subslice_of_the_input() {
481
        let input = "   bogus-value   ";
482
        let Err(TextJustifyParseError::InvalidValue(slice)) = parse_layout_text_justify(input)
483
        else {
484
            panic!("expected an error for {input:?}");
485
        };
486
        assert_eq!(slice, "bogus-value");
487
        assert_eq!(slice, input.trim());
488

            
489
        let base = input.as_ptr() as usize;
490
        let borrowed = slice.as_ptr() as usize;
491
        assert!(
492
            borrowed >= base && borrowed + slice.len() <= base + input.len(),
493
            "error payload does not point into the input buffer"
494
        );
495
    }
496

            
497
    // ---------------------------------------------------------------------
498
    // Round trip: print_as_css_value <-> parse_layout_text_justify
499
    // ---------------------------------------------------------------------
500

            
501
    #[test]
502
    fn every_printed_value_parses_back_to_the_same_variant() {
503
        for j in ALL_JUSTIFY {
504
            let printed = j.print_as_css_value();
505
            let reparsed = parse_layout_text_justify(&printed);
506
            let expected = if j == LayoutTextJustify::Distribute {
507
                // Documented, deliberate asymmetry: "distribute" is a legacy
508
                // alias that computes to inter-character, so the round trip is
509
                // lossy for exactly this one (FFI-only) variant.
510
                LayoutTextJustify::InterCharacter
511
            } else {
512
                j
513
            };
514
            assert_eq!(reparsed, Ok(expected), "round trip of {j:?} via {printed:?}");
515
        }
516
    }
517

            
518
    #[test]
519
    fn distribute_round_trip_is_lossy_by_design() {
520
        let printed = LayoutTextJustify::Distribute.print_as_css_value();
521
        assert_eq!(printed, "distribute");
522
        assert_eq!(
523
            parse_layout_text_justify(&printed),
524
            Ok(LayoutTextJustify::InterCharacter)
525
        );
526
        assert_ne!(
527
            parse_layout_text_justify(&printed),
528
            Ok(LayoutTextJustify::Distribute)
529
        );
530
    }
531

            
532
    #[test]
533
    fn printed_values_are_distinct_well_formed_css_idents() {
534
        let mut seen: Vec<String> = Vec::new();
535
        for j in ALL_JUSTIFY {
536
            let printed = j.print_as_css_value();
537
            assert!(!printed.is_empty(), "{j:?} printed an empty value");
538
            assert_eq!(printed.trim(), printed, "{j:?} printed padded value");
539
            assert!(
540
                !printed.contains(char::is_whitespace),
541
                "{j:?} printed interior whitespace: {printed:?}"
542
            );
543
            assert!(
544
                printed
545
                    .chars()
546
                    .all(|c| c.is_ascii_lowercase() || c == '-'),
547
                "{j:?} printed a non-ident value: {printed:?}"
548
            );
549
            assert!(
550
                !seen.contains(&printed),
551
                "{j:?} printed a value already used by another variant: {printed:?}"
552
            );
553
            seen.push(printed);
554
        }
555
        assert_eq!(seen.len(), ALL_JUSTIFY.len());
556
    }
557

            
558
    // ---------------------------------------------------------------------
559
    // FormatAsRustCode
560
    // ---------------------------------------------------------------------
561

            
562
    #[test]
563
    fn format_as_rust_code_is_a_variant_path_and_ignores_indentation() {
564
        for j in ALL_JUSTIFY {
565
            let code = j.format_as_rust_code(0);
566
            assert_eq!(code, format!("LayoutTextJustify::{j:?}"));
567
            assert!(code.starts_with("LayoutTextJustify::"));
568
            assert!(!code.contains(char::is_whitespace), "{code:?}");
569
            // The `tabs` argument is unused; extreme values must not panic or
570
            // change the output.
571
            assert_eq!(j.format_as_rust_code(usize::MAX), code);
572
            assert_eq!(j.format_as_rust_code(usize::MIN), code);
573
        }
574
    }
575

            
576
    // ---------------------------------------------------------------------
577
    // Enum invariants: Default, FFI discriminants, Ord / Hash
578
    // ---------------------------------------------------------------------
579

            
580
    #[test]
581
    fn default_is_auto() {
582
        assert_eq!(LayoutTextJustify::default(), LayoutTextJustify::Auto);
583
        assert_eq!(
584
            parse_layout_text_justify(&LayoutTextJustify::default().print_as_css_value()),
585
            Ok(LayoutTextJustify::default())
586
        );
587
    }
588

            
589
    /// The `Distribute` variant is documented as retained for `#[repr(C)]` FFI
590
    /// backwards compatibility, which only holds if the discriminants keep
591
    /// their values. Reordering the enum breaks every compiled C/Python
592
    /// binding — so it must break this test first.
593
    #[test]
594
    fn repr_c_discriminants_are_ffi_stable() {
595
        assert_eq!(LayoutTextJustify::Auto as u8, 0);
596
        assert_eq!(LayoutTextJustify::None as u8, 1);
597
        assert_eq!(LayoutTextJustify::InterWord as u8, 2);
598
        assert_eq!(LayoutTextJustify::InterCharacter as u8, 3);
599
        assert_eq!(LayoutTextJustify::Distribute as u8, 4);
600
    }
601

            
602
    #[test]
603
    fn derived_ord_follows_declaration_order() {
604
        for (i, a) in ALL_JUSTIFY.iter().enumerate() {
605
            for (k, b) in ALL_JUSTIFY.iter().enumerate() {
606
                assert_eq!(
607
                    a.cmp(b),
608
                    i.cmp(&k),
609
                    "Ord disagrees with declaration order for {a:?} vs {b:?}"
610
                );
611
                assert_eq!(a.partial_cmp(b), Some(a.cmp(b)));
612
                assert_eq!(a == b, i == k);
613
            }
614
        }
615
    }
616

            
617
    #[test]
618
    fn equal_variants_hash_equally_and_distinct_variants_do_not_collide() {
619
        use std::collections::hash_map::DefaultHasher;
620
        use std::hash::{Hash, Hasher};
621

            
622
        fn hash_of(j: LayoutTextJustify) -> u64 {
623
            let mut hasher = DefaultHasher::new();
624
            j.hash(&mut hasher);
625
            hasher.finish()
626
        }
627

            
628
        for j in ALL_JUSTIFY {
629
            assert_eq!(hash_of(j), hash_of(j), "{j:?} hashes unstably");
630
        }
631
        let mut hashes: Vec<u64> = ALL_JUSTIFY.iter().copied().map(hash_of).collect();
632
        hashes.sort_unstable();
633
        hashes.dedup();
634
        assert_eq!(
635
            hashes.len(),
636
            ALL_JUSTIFY.len(),
637
            "two variants share a hash — HashMap<LayoutTextJustify, _> would be needlessly slow"
638
        );
639
    }
640

            
641
    // ---------------------------------------------------------------------
642
    // TextJustifyParseError: Display + owned/borrowed conversions
643
    // ---------------------------------------------------------------------
644

            
645
    /// Nasty payloads reused by the error tests below.
646
    const NASTY: [&str; 9] = [
647
        "",
648
        "auto",
649
        "\0",
650
        "line\nbreak",
651
        "🙂",
652
        "a\u{301}",
653
        "{}",
654
        "{0} {1} {{}}",
655
        "%s %d %n",
656
    ];
657

            
658
    #[test]
659
    fn display_is_non_empty_and_quotes_the_offending_value() {
660
        for value in NASTY {
661
            let msg = TextJustifyParseError::InvalidValue(value).to_string();
662
            assert!(!msg.is_empty(), "empty message for {value:?}");
663
            assert!(
664
                msg.starts_with("Invalid text-justify value: '"),
665
                "unexpected prefix: {msg:?}"
666
            );
667
            assert!(msg.ends_with("'."), "unexpected suffix: {msg:?}");
668
            assert!(
669
                msg.contains(value),
670
                "message {msg:?} dropped the offending value {value:?}"
671
            );
672
        }
673
    }
674

            
675
    /// The payload is interpolated as a *value*, never re-parsed as a format
676
    /// string: braces and printf-style escapes must survive literally.
677
    #[test]
678
    fn display_does_not_interpret_braces_in_the_payload() {
679
        let msg = TextJustifyParseError::InvalidValue("{0} {{}} %s").to_string();
680
        assert_eq!(msg, "Invalid text-justify value: '{0} {{}} %s'.");
681
    }
682

            
683
    #[test]
684
    fn display_survives_empty_and_megabyte_payloads() {
685
        assert_eq!(
686
            TextJustifyParseError::InvalidValue("").to_string(),
687
            "Invalid text-justify value: ''."
688
        );
689

            
690
        let huge = "x".repeat(1_000_000);
691
        let msg = TextJustifyParseError::InvalidValue(huge.as_str()).to_string();
692
        // prefix + payload + suffix, with nothing truncated.
693
        assert_eq!(msg.len(), "Invalid text-justify value: ''.".len() + huge.len());
694
        assert!(msg.contains(huge.as_str()));
695
    }
696

            
697
    #[test]
698
    fn to_owned_then_to_borrowed_is_the_identity() {
699
        for value in NASTY {
700
            let borrowed = TextJustifyParseError::InvalidValue(value);
701
            let owned = borrowed.to_owned();
702
            assert_eq!(
703
                owned,
704
                TextJustifyParseErrorOwned::InvalidValue(String::from(value).into())
705
            );
706
            assert_eq!(owned.to_borrowed(), borrowed, "round trip of {value:?}");
707
            // The message must survive the detour through the owned form.
708
            assert_eq!(owned.to_borrowed().to_string(), borrowed.to_string());
709
        }
710
    }
711

            
712
    #[test]
713
    fn to_owned_survives_a_large_multibyte_payload_and_keeps_its_length() {
714
        let huge = "\u{1F600}".repeat(100_000); // 400 kB of 4-byte chars
715
        let borrowed = TextJustifyParseError::InvalidValue(huge.as_str());
716
        let owned = borrowed.to_owned();
717
        let TextJustifyParseErrorOwned::InvalidValue(s) = &owned;
718
        assert_eq!(s.as_str().len(), huge.len());
719
        assert_eq!(owned.to_borrowed(), borrowed);
720
    }
721

            
722
    /// A parse failure carries the *trimmed* input, and that payload must
723
    /// survive the borrowed -> owned -> borrowed detour byte for byte.
724
    #[test]
725
    fn parse_error_payload_round_trips_through_the_owned_form() {
726
        for input in ["  💥  ", "\tnot-a-keyword\n", "", "   ", "\0\0"] {
727
            let err = parse_layout_text_justify(input).unwrap_err();
728
            let TextJustifyParseError::InvalidValue(borrowed) = &err;
729
            assert_eq!(*borrowed, input.trim());
730

            
731
            let owned = err.to_owned();
732
            let TextJustifyParseErrorOwned::InvalidValue(s) = &owned;
733
            assert_eq!(s.as_str(), input.trim());
734
            assert_eq!(owned.to_borrowed(), err);
735
        }
736
    }
737

            
738
    #[test]
739
    fn owned_error_is_independent_of_the_input_buffer() {
740
        // Build the error from a string that is dropped immediately after; the
741
        // owned form must not dangle or lose data.
742
        let owned = {
743
            let scratch = String::from("  transient-garbage  ");
744
            parse_layout_text_justify(&scratch).unwrap_err().to_owned()
745
        };
746
        let TextJustifyParseErrorOwned::InvalidValue(s) = &owned;
747
        assert_eq!(s.as_str(), "transient-garbage");
748
        assert_eq!(
749
            owned.to_borrowed().to_string(),
750
            "Invalid text-justify value: 'transient-garbage'."
751
        );
752
    }
753
}