Repository: toml-rs/toml
Pull request #757 (2024-07-17 16:21:37)

fix(encode): Correct encoding for key with mixed quotes


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index bd5245b8..397fbc6a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -97,12 +97,33 @@ version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
 
+[[package]]
+name = "bit-set"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
+
 [[package]]
 name = "bitflags"
 version = "1.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
 
+[[package]]
+name = "bitflags"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
+
 [[package]]
 name = "bstr"
 version = "1.9.1"
@@ -253,6 +274,29 @@ dependencies = [
  "rustversion",
 ]
 
+[[package]]
+name = "fastrand"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "getrandom"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
 [[package]]
 name = "globset"
 version = "0.4.14"
@@ -414,6 +458,12 @@ dependencies = [
  "once_cell",
 ]
 
+[[package]]
+name = "libm"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
+
 [[package]]
 name = "libtest-mimic"
 version = "0.7.3"
@@ -432,6 +482,12 @@ version = "0.3.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519"
 
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
+
 [[package]]
 name = "log"
 version = "0.4.21"
@@ -457,6 +513,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
 dependencies = [
  "autocfg",
+ "libm",
 ]
 
 [[package]]
@@ -475,6 +532,12 @@ version = "1.19.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
 
+[[package]]
+name = "ppv-lite86"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
+
 [[package]]
 name = "proc-macro2"
 version = "1.0.82"
@@ -484,6 +547,32 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "proptest"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4c2511913b88df1637da85cc8d96ec8e43a3f8bb8ccb71ee1ac240d6f3df58d"
+dependencies = [
+ "bit-set",
+ "bit-vec",
+ "bitflags 2.6.0",
+ "lazy_static",
+ "num-traits",
+ "rand",
+ "rand_chacha",
+ "rand_xorshift",
+ "regex-syntax",
+ "rusty-fork",
+ "tempfile",
+ "unarray",
+]
+
+[[package]]
+name = "quick-error"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
+
 [[package]]
 name = "quote"
 version = "1.0.36"
@@ -493,6 +582,45 @@ dependencies = [
  "proc-macro2",
 ]
 
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "rand_xorshift"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f"
+dependencies = [
+ "rand_core",
+]
+
 [[package]]
 name = "regex"
 version = "1.10.4"
@@ -534,20 +662,45 @@ version = "0.37.27"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2"
 dependencies = [
- "bitflags",
+ "bitflags 1.3.2",
  "errno",
  "io-lifetimes",
  "libc",
- "linux-raw-sys",
+ "linux-raw-sys 0.3.8",
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "rustix"
+version = "0.38.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
+dependencies = [
+ "bitflags 2.6.0",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.14",
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 name = "rustversion"
 version = "1.0.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6"
 
+[[package]]
+name = "rusty-fork"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f"
+dependencies = [
+ "fnv",
+ "quick-error",
+ "tempfile",
+ "wait-timeout",
+]
+
 [[package]]
 name = "ryu"
 version = "1.0.18"
@@ -670,6 +823,18 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "tempfile"
+version = "3.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "rustix 0.38.34",
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 name = "termcolor"
 version = "1.4.1"
@@ -685,7 +850,7 @@ version = "0.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8e6bf6f19e9f8ed8d4048dc22981458ebcf406d67e94cd422e5ecd73d63b3237"
 dependencies = [
- "rustix",
+ "rustix 0.37.27",
  "windows-sys 0.48.0",
 ]
 
@@ -792,6 +957,7 @@ dependencies = [
  "indexmap",
  "kstring",
  "libtest-mimic",
+ "proptest",
  "serde",
  "serde_json",
  "serde_spanned",
@@ -816,6 +982,12 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "059d83cc991e7a42fc37bd50941885db0888e34209f8cfd9aab07ddec03bc9cf"
 
+[[package]]
+name = "unarray"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.12"
@@ -828,6 +1000,15 @@ version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
 
+[[package]]
+name = "wait-timeout"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "walkdir"
 version = "2.5.0"
@@ -838,6 +1019,12 @@ dependencies = [
  "winapi-util",
 ]
 
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
 [[package]]
 name = "winapi-util"
 version = "0.1.8"
diff --git a/crates/toml_edit/Cargo.toml b/crates/toml_edit/Cargo.toml
index 59352b6a..1049ef83 100644
--- a/crates/toml_edit/Cargo.toml
+++ b/crates/toml_edit/Cargo.toml
@@ -53,6 +53,7 @@ toml-test-harness = "0.4.8"
 toml-test-data = "1.11.0"
 libtest-mimic = "0.7.2"
 snapbox = "0.6.0"
+proptest = "1.5.0"
 
 [[test]]
 name = "testsuite"
diff --git a/crates/toml_edit/src/encode.rs b/crates/toml_edit/src/encode.rs
index b6142a6c..b45f0e34 100644
--- a/crates/toml_edit/src/encode.rs
+++ b/crates/toml_edit/src/encode.rs
@@ -332,12 +332,7 @@ pub(crate) fn to_string_repr(
     style: Option<StringStyle>,
     literal: Option<bool>,
 ) -> Repr {
-    let (style, literal) = match (style, literal) {
-        (Some(style), Some(literal)) => (style, literal),
-        (_, Some(literal)) => (infer_style(value).0, literal),
-        (Some(style), _) => (style, infer_style(value).1),
-        (_, _) => infer_style(value),
-    };
+    let (style, literal) = infer_style(value, style, literal);
 
     let mut output = String::with_capacity(value.len() * 2);
     if literal {
@@ -415,7 +410,38 @@ impl StringStyle {
     }
 }
 
-fn infer_style(value: &str) -> (StringStyle, bool) {
+fn infer_style(
+    value: &str,
+    style: Option<StringStyle>,
+    literal: Option<bool>,
+) -> (StringStyle, bool) {
+    match (style, literal) {
+        (Some(style), Some(literal)) => (style, literal),
+        (None, Some(literal)) => (infer_all_style(value).0, literal),
+        (Some(style), None) => {
+            let literal = infer_literal(value);
+            (style, literal)
+        }
+        (None, None) => infer_all_style(value),
+    }
+}
+
+fn infer_literal(value: &str) -> bool {
+    #[cfg(feature = "parse")]
+    {
+        use winnow::stream::ContainsToken as _;
+        (value.contains('"') | value.contains('\\'))
+            && value
+                .chars()
+                .all(|c| crate::parser::strings::LITERAL_CHAR.contains_token(c))
+    }
+    #[cfg(not(feature = "parse"))]
+    {
+        false
+    }
+}
+
+fn infer_all_style(value: &str) -> (StringStyle, bool) {
     // We need to determine:
     // - if we are a "multi-line" pretty (if there are \n)
     // - if ['''] appears if multi or ['] if single
@@ -527,3 +553,45 @@ impl ValueRepr for Datetime {
         Repr::new_unchecked(self.to_string())
     }
 }
+
+#[cfg(test)]
+mod test {
+    use super::*;
+    use proptest::prelude::*;
+
+    proptest! {
+        #[test]
+        #[cfg(feature = "parse")]
+        fn parseable_string(string in "\\PC*") {
+            let string = Value::from(string);
+            let encoded = string.to_string();
+            let _: Value = encoded.parse().unwrap_or_else(|err| {
+                panic!("error: {err}
+
+string:
+```
+{string}
+```
+")
+            });
+        }
+    }
+
+    proptest! {
+        #[test]
+        #[cfg(feature = "parse")]
+        fn parseable_key(string in "\\PC*") {
+            let string = Key::new(string);
+            let encoded = string.to_string();
+            let _: Key = encoded.parse().unwrap_or_else(|err| {
+                panic!("error: {err}
+
+string:
+```
+{string}
+```
+")
+            });
+        }
+    }
+}
diff --git a/crates/toml_edit_fuzz/parse_document.rs b/crates/toml_edit_fuzz/parse_document.rs
index a503df41..f8d16495 100644
--- a/crates/toml_edit_fuzz/parse_document.rs
+++ b/crates/toml_edit_fuzz/parse_document.rs
@@ -7,24 +7,48 @@ libfuzzer_sys::fuzz_target!(|data: &[u8]| -> libfuzzer_sys::Corpus {
         return libfuzzer_sys::Corpus::Reject;
     };
 
-    println!("parsing: {data:?}");
     let doc = match data.parse::<DocumentMut>() {
         Ok(doc) => doc,
         Err(err) => {
-            println!("{err}");
+            println!(
+                "parse error: {err}
+
+data:
+```toml
+{data}
+```
+"
+            );
             return libfuzzer_sys::Corpus::Keep;
         }
     };
     let toml = doc.to_string();
-    println!("parsing: {toml:?}");
     let doc = toml.parse::<DocumentMut>();
     assert!(
         doc.is_ok(),
-        "Failed to parse `doc.to_string()`: {}\n```\n{}\n```",
+        "parse error: {}
+
+data:
+```toml
+{data}
+```
+
+doc.to_string():
+```toml
+{}
+```",
         doc.unwrap_err(),
         toml
     );
     let doc = doc.unwrap();
-    assert_eq!(doc.to_string(), toml);
+    assert_eq!(
+        doc.to_string(),
+        toml,
+        "data:
+```toml
+{data}
+```
+"
+    );
     libfuzzer_sys::Corpus::Keep
 });


Test patch:
diff --git a/crates/toml/tests/testsuite/display.rs b/crates/toml/tests/testsuite/display.rs
index dddb4e04..a0e0cff2 100644
--- a/crates/toml/tests/testsuite/display.rs
+++ b/crates/toml/tests/testsuite/display.rs
@@ -1,9 +1,4 @@
-use serde::Deserialize;
-use snapbox::assert_data_eq;
-use snapbox::prelude::*;
-use snapbox::str;
 use toml::map::Map;
-use toml::Value;
 use toml::Value::{Array, Boolean, Float, Integer, String, Table};
 
 macro_rules! map( ($($k:expr => $v:expr),*) => ({
@@ -119,64 +114,3 @@ fn table() {
          test2 = 2\n"
     );
 }
-
-#[test]
-fn string_roundtrip() {
-    assert_string_round_trip(r#""""#, str![[r#""""#]]);
-    assert_string_round_trip(r#""a""#, str![[r#""a""#]]);
-
-    assert_string_round_trip(r#""tab \t tab""#, str![[r#""tab /t tab""#]]);
-    assert_string_round_trip(
-        r#""lf \n lf""#,
-        str![[r#"
-"""
-lf 
- lf"""
-"#]],
-    );
-    assert_string_round_trip(
-        r#""crlf \r\n crlf""#,
-        str![[r#"
-"""
-crlf /r
- crlf"""
-"#]],
-    );
-    assert_string_round_trip(r#""bell \b bell""#, str![[r#""bell /b bell""#]]);
-    assert_string_round_trip(r#""feed \f feed""#, str![[r#""feed /f feed""#]]);
-    assert_string_round_trip(
-        r#""backslash \\ backslash""#,
-        str!["'backslash / backslash'"],
-    );
-
-    assert_string_round_trip(r#""squote ' squote""#, str![[r#""squote ' squote""#]]);
-    assert_string_round_trip(
-        r#""triple squote ''' triple squote""#,
-        str![[r#""triple squote ''' triple squote""#]],
-    );
-    assert_string_round_trip(r#""end squote '""#, str![[r#""end squote '""#]]);
-
-    assert_string_round_trip(r#""quote \" quote""#, str![[r#"'quote " quote'"#]]);
-    assert_string_round_trip(
-        r#""triple quote \"\"\" triple quote""#,
-        str![[r#"'triple quote """ triple quote'"#]],
-    );
-    assert_string_round_trip(r#""end quote \"""#, str![[r#"'end quote "'"#]]);
-}
-
-#[track_caller]
-fn assert_string_round_trip(input: &str, expected: impl IntoData) {
-    let value = Value::deserialize(toml::de::ValueDeserializer::new(input)).unwrap();
-    let actual = value.to_string();
-    let _ = Value::deserialize(toml::de::ValueDeserializer::new(input)).unwrap_or_else(|_err| {
-        panic!(
-            "invalid value:
-```
-{actual}
-```
-"
-        )
-    });
-    let expected = expected.into_data();
-    assert_data_eq!(actual, expected);
-}
diff --git a/crates/toml_edit/tests/testsuite/parse.rs b/crates/toml_edit/tests/testsuite/parse.rs
index 7f40e7ed..62b33bdd 100644
--- a/crates/toml_edit/tests/testsuite/parse.rs
+++ b/crates/toml_edit/tests/testsuite/parse.rs
@@ -1607,42 +1607,247 @@ clippy.exhaustive_enums = "warn"
 }
 
 #[test]
-fn string_roundtrip() {
-    assert_string_round_trip(r#""""#, str![[r#""""#]]);
-    assert_string_round_trip(r#""a""#, str![[r#""a""#]]);
-
-    assert_string_round_trip(r#""tab \t tab""#, str![[r#""tab /t tab""#]]);
-    assert_string_round_trip(r#""lf \n lf""#, str![[r#""lf /n lf""#]]);
-    assert_string_round_trip(r#""crlf \r\n crlf""#, str![[r#""crlf /r/n crlf""#]]);
-    assert_string_round_trip(r#""bell \b bell""#, str![[r#""bell /b bell""#]]);
-    assert_string_round_trip(r#""feed \f feed""#, str![[r#""feed /f feed""#]]);
-    assert_string_round_trip(
+fn string_repr_roundtrip() {
+    assert_string_repr_roundtrip(r#""""#, str![[r#""""#]]);
+    assert_string_repr_roundtrip(r#""a""#, str![[r#""a""#]]);
+
+    assert_string_repr_roundtrip(r#""tab \t tab""#, str![[r#""tab /t tab""#]]);
+    assert_string_repr_roundtrip(r#""lf \n lf""#, str![[r#""lf /n lf""#]]);
+    assert_string_repr_roundtrip(r#""crlf \r\n crlf""#, str![[r#""crlf /r/n crlf""#]]);
+    assert_string_repr_roundtrip(r#""bell \b bell""#, str![[r#""bell /b bell""#]]);
+    assert_string_repr_roundtrip(r#""feed \f feed""#, str![[r#""feed /f feed""#]]);
+    assert_string_repr_roundtrip(
         r#""backslash \\ backslash""#,
         str![[r#""backslash // backslash""#]],
     );
 
-    assert_string_round_trip(r#""squote ' squote""#, str![[r#""squote ' squote""#]]);
-    assert_string_round_trip(
+    assert_string_repr_roundtrip(r#""squote ' squote""#, str![[r#""squote ' squote""#]]);
+    assert_string_repr_roundtrip(
         r#""triple squote ''' triple squote""#,
         str![[r#""triple squote ''' triple squote""#]],
     );
-    assert_string_round_trip(r#""end squote '""#, str![[r#""end squote '""#]]);
+    assert_string_repr_roundtrip(r#""end squote '""#, str![[r#""end squote '""#]]);
 
-    assert_string_round_trip(r#""quote \" quote""#, str![[r#""quote /" quote""#]]);
-    assert_string_round_trip(
+    assert_string_repr_roundtrip(r#""quote \" quote""#, str![[r#""quote /" quote""#]]);
+    assert_string_repr_roundtrip(
         r#""triple quote \"\"\" triple quote""#,
         str![[r#""triple quote /"/"/" triple quote""#]],
     );
-    assert_string_round_trip(r#""end quote \"""#, str![[r#""end quote /"""#]]);
+    assert_string_repr_roundtrip(r#""end quote \"""#, str![[r#""end quote /"""#]]);
+    assert_string_repr_roundtrip(
+        r#""quoted \"content\" quoted""#,
+        str![[r#""quoted /"content/" quoted""#]],
+    );
+    assert_string_repr_roundtrip(
+        r#""squoted 'content' squoted""#,
+        str![[r#""squoted 'content' squoted""#]],
+    );
+    assert_string_repr_roundtrip(
+        r#""mixed quoted \"start\" 'end'' mixed quote""#,
+        str![[r#""mixed quoted /"start/" 'end'' mixed quote""#]],
+    );
 }
 
 #[track_caller]
-fn assert_string_round_trip(input: &str, expected: impl IntoData) {
+fn assert_string_repr_roundtrip(input: &str, expected: impl IntoData) {
     let value: Value = input.parse().unwrap();
     let actual = value.to_string();
     let _: Value = actual.parse().unwrap_or_else(|_err| {
         panic!(
-            "invalid document:
+            "invalid `Value`:
+```
+{actual}
+```
+"
+        )
+    });
+    let expected = expected.into_data();
+    assert_data_eq!(actual, expected);
+}
+
+#[test]
+fn string_value_roundtrip() {
+    assert_string_value_roundtrip(r#""""#, str![[r#""""#]]);
+    assert_string_value_roundtrip(r#""a""#, str![[r#""a""#]]);
+
+    assert_string_value_roundtrip(r#""tab \t tab""#, str![[r#""tab /t tab""#]]);
+    assert_string_value_roundtrip(
+        r#""lf \n lf""#,
+        str![[r#"
+"""
+lf 
+ lf"""
+"#]],
+    );
+    assert_string_value_roundtrip(
+        r#""crlf \r\n crlf""#,
+        str![[r#"
+"""
+crlf /r
+ crlf"""
+"#]],
+    );
+    assert_string_value_roundtrip(r#""bell \b bell""#, str![[r#""bell /b bell""#]]);
+    assert_string_value_roundtrip(r#""feed \f feed""#, str![[r#""feed /f feed""#]]);
+    assert_string_value_roundtrip(
+        r#""backslash \\ backslash""#,
+        str!["'backslash / backslash'"],
+    );
+
+    assert_string_value_roundtrip(r#""squote ' squote""#, str![[r#""squote ' squote""#]]);
+    assert_string_value_roundtrip(
+        r#""triple squote ''' triple squote""#,
+        str![[r#""triple squote ''' triple squote""#]],
+    );
+    assert_string_value_roundtrip(r#""end squote '""#, str![[r#""end squote '""#]]);
+
+    assert_string_value_roundtrip(r#""quote \" quote""#, str![[r#"'quote " quote'"#]]);
+    assert_string_value_roundtrip(
+        r#""triple quote \"\"\" triple quote""#,
+        str![[r#"'triple quote """ triple quote'"#]],
+    );
+    assert_string_value_roundtrip(r#""end quote \"""#, str![[r#"'end quote "'"#]]);
+    assert_string_value_roundtrip(
+        r#""quoted \"content\" quoted""#,
+        str![[r#"'quoted "content" quoted'"#]],
+    );
+    assert_string_value_roundtrip(
+        r#""squoted 'content' squoted""#,
+        str![[r#""squoted 'content' squoted""#]],
+    );
+    assert_string_value_roundtrip(
+        r#""mixed quoted \"start\" 'end'' mixed quote""#,
+        str![[r#"'''mixed quoted "start" 'end'' mixed quote'''"#]],
+    );
+}
+
+#[track_caller]
+fn assert_string_value_roundtrip(input: &str, expected: impl IntoData) {
+    let value: Value = input.parse().unwrap();
+    let value = Value::from(value.as_str().unwrap()); // Remove repr
+    let actual = value.to_string();
+    let _: Value = actual.parse().unwrap_or_else(|_err| {
+        panic!(
+            "invalid `Value`:
+```
+{actual}
+```
+"
+        )
+    });
+    let expected = expected.into_data();
+    assert_data_eq!(actual, expected);
+}
+
+#[test]
+fn key_repr_roundtrip() {
+    assert_key_repr_roundtrip(r#""""#, str![[r#""""#]]);
+    assert_key_repr_roundtrip(r#""a""#, str![[r#""a""#]]);
+
+    assert_key_repr_roundtrip(r#""tab \t tab""#, str![[r#""tab /t tab""#]]);
+    assert_key_repr_roundtrip(r#""lf \n lf""#, str![[r#""lf /n lf""#]]);
+    assert_key_repr_roundtrip(r#""crlf \r\n crlf""#, str![[r#""crlf /r/n crlf""#]]);
+    assert_key_repr_roundtrip(r#""bell \b bell""#, str![[r#""bell /b bell""#]]);
+    assert_key_repr_roundtrip(r#""feed \f feed""#, str![[r#""feed /f feed""#]]);
+    assert_key_repr_roundtrip(
+        r#""backslash \\ backslash""#,
+        str![[r#""backslash // backslash""#]],
+    );
+
+    assert_key_repr_roundtrip(r#""squote ' squote""#, str![[r#""squote ' squote""#]]);
+    assert_key_repr_roundtrip(
+        r#""triple squote ''' triple squote""#,
+        str![[r#""triple squote ''' triple squote""#]],
+    );
+    assert_key_repr_roundtrip(r#""end squote '""#, str![[r#""end squote '""#]]);
+
+    assert_key_repr_roundtrip(r#""quote \" quote""#, str![[r#""quote /" quote""#]]);
+    assert_key_repr_roundtrip(
+        r#""triple quote \"\"\" triple quote""#,
+        str![[r#""triple quote /"/"/" triple quote""#]],
+    );
+    assert_key_repr_roundtrip(r#""end quote \"""#, str![[r#""end quote /"""#]]);
+    assert_key_repr_roundtrip(
+        r#""quoted \"content\" quoted""#,
+        str![[r#""quoted /"content/" quoted""#]],
+    );
+    assert_key_repr_roundtrip(
+        r#""squoted 'content' squoted""#,
+        str![[r#""squoted 'content' squoted""#]],
+    );
+    assert_key_repr_roundtrip(
+        r#""mixed quoted \"start\" 'end'' mixed quote""#,
+        str![[r#""mixed quoted /"start/" 'end'' mixed quote""#]],
+    );
+}
+
+#[track_caller]
+fn assert_key_repr_roundtrip(input: &str, expected: impl IntoData) {
+    let value: Key = input.parse().unwrap();
+    let actual = value.to_string();
+    let _: Key = actual.parse().unwrap_or_else(|_err| {
+        panic!(
+            "invalid `Key`:
+```
+{actual}
+```
+"
+        )
+    });
+    let expected = expected.into_data();
+    assert_data_eq!(actual, expected);
+}
+
+#[test]
+fn key_value_roundtrip() {
+    assert_key_value_roundtrip(r#""""#, str![[r#""""#]]);
+    assert_key_value_roundtrip(r#""a""#, str!["a"]);
+
+    assert_key_value_roundtrip(r#""tab \t tab""#, str![[r#""tab /t tab""#]]);
+    assert_key_value_roundtrip(r#""lf \n lf""#, str![[r#""lf /n lf""#]]);
+    assert_key_value_roundtrip(r#""crlf \r\n crlf""#, str![[r#""crlf /r/n crlf""#]]);
+    assert_key_value_roundtrip(r#""bell \b bell""#, str![[r#""bell /b bell""#]]);
+    assert_key_value_roundtrip(r#""feed \f feed""#, str![[r#""feed /f feed""#]]);
+    assert_key_value_roundtrip(
+        r#""backslash \\ backslash""#,
+        str!["'backslash / backslash'"],
+    );
+
+    assert_key_value_roundtrip(r#""squote ' squote""#, str![[r#""squote ' squote""#]]);
+    assert_key_value_roundtrip(
+        r#""triple squote ''' triple squote""#,
+        str![[r#""triple squote ''' triple squote""#]],
+    );
+    assert_key_value_roundtrip(r#""end squote '""#, str![[r#""end squote '""#]]);
+
+    assert_key_value_roundtrip(r#""quote \" quote""#, str![[r#"'quote " quote'"#]]);
+    assert_key_value_roundtrip(
+        r#""triple quote \"\"\" triple quote""#,
+        str![[r#"'triple quote """ triple quote'"#]],
+    );
+    assert_key_value_roundtrip(r#""end quote \"""#, str![[r#"'end quote "'"#]]);
+    assert_key_value_roundtrip(
+        r#""quoted \"content\" quoted""#,
+        str![[r#"'quoted "content" quoted'"#]],
+    );
+    assert_key_value_roundtrip(
+        r#""squoted 'content' squoted""#,
+        str![[r#""squoted 'content' squoted""#]],
+    );
+    assert_key_value_roundtrip(
+        r#""mixed quoted \"start\" 'end'' mixed quote""#,
+        str![[r#""mixed quoted /"start/" 'end'' mixed quote""#]],
+    );
+}
+
+#[track_caller]
+fn assert_key_value_roundtrip(input: &str, expected: impl IntoData) {
+    let value: Key = input.parse().unwrap();
+    let value = Key::new(value.get()); // Remove repr
+    let actual = value.to_string();
+    let _: Key = actual.parse().unwrap_or_else(|_err| {
+        panic!(
+            "invalid `Key`:
 ```
 {actual}
 ```