Skip to main content

azul_css/props/layout/
text.rs

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
6use alloc::string::{String, ToString};
7use core::fmt;
8use crate::corety::AzString;
9
10use 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)]
16pub 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
28impl PrintAsCssValue for LayoutTextJustify {
29    fn print_as_css_value(&self) -> String {
30        match self {
31            Self::Auto => "auto",
32            Self::None => "none",
33            Self::InterWord => "inter-word",
34            Self::InterCharacter => "inter-character",
35            Self::Distribute => "distribute",
36        }
37        .to_string()
38    }
39}
40
41impl FormatAsRustCode for LayoutTextJustify {
42    fn format_as_rust_code(&self, _tabs: usize) -> String {
43        format!("LayoutTextJustify::{self:?}")
44    }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum TextJustifyParseError<'a> {
49    InvalidValue(&'a str),
50}
51
52impl fmt::Display for TextJustifyParseError<'_> {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            TextJustifyParseError::InvalidValue(s) => {
56                write!(f, "Invalid text-justify value: '{s}'.")
57            }
58        }
59    }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63#[repr(C, u8)]
64pub enum TextJustifyParseErrorOwned {
65    InvalidValue(AzString),
66}
67
68impl TextJustifyParseError<'_> {
69    #[must_use] pub fn to_owned(&self) -> TextJustifyParseErrorOwned {
70        match self {
71            TextJustifyParseError::InvalidValue(s) => {
72                TextJustifyParseErrorOwned::InvalidValue((*s).to_string().into())
73            }
74        }
75    }
76}
77
78impl TextJustifyParseErrorOwned {
79    #[must_use] pub fn to_borrowed(&self) -> TextJustifyParseError<'_> {
80        match self {
81            Self::InvalidValue(s) => {
82                TextJustifyParseError::InvalidValue(s.as_str())
83            }
84        }
85    }
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.
92pub fn parse_layout_text_justify(
93    input: &str,
94) -> Result<LayoutTextJustify, TextJustifyParseError<'_>> {
95    match input.trim() {
96        "auto" => Ok(LayoutTextJustify::Auto),
97        "none" => Ok(LayoutTextJustify::None),
98        "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        "inter-character" | "distribute" => Ok(LayoutTextJustify::InterCharacter),
102        other => Err(TextJustifyParseError::InvalidValue(other)),
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    #[test]
110    fn test_parse_layout_text_justify() {
111        assert_eq!(
112            parse_layout_text_justify("auto"),
113            Ok(LayoutTextJustify::Auto)
114        );
115        assert_eq!(
116            parse_layout_text_justify("none"),
117            Ok(LayoutTextJustify::None)
118        );
119        assert_eq!(
120            parse_layout_text_justify("inter-word"),
121            Ok(LayoutTextJustify::InterWord)
122        );
123        assert_eq!(
124            parse_layout_text_justify("inter-character"),
125            Ok(LayoutTextJustify::InterCharacter)
126        );
127        assert_eq!(
128            parse_layout_text_justify("distribute"),
129            Ok(LayoutTextJustify::InterCharacter)
130        );
131        assert!(parse_layout_text_justify("invalid").is_err());
132    }
133}
134
135#[cfg(test)]
136mod 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}