Repository: rust-cli/config-rs (language: rust)

Issue:
Add `try_deserialize` deserialization path error tracking
Hi!
I'm using `config-rs`, but I'm having some difficulties finding where my JSON file lacks fields.
I have a nested configuration struct like the one below:
```rust
#[derive(Debug, Deserialize)]
struct InnerSettings {
    #[allow(dead_code)]
    value: u32,
    #[allow(dead_code)]
    value2: u32,
}

#[derive(Debug, Deserialize)]
struct Settings {
    #[allow(dead_code)]
    inner: InnerSettings,
}
```

NB: my use case involves more complex structures

So, if you try to deserialize a wrong JSON, the error will contains the JSON path:
```rust

let c = Config::builder()
    .add_source(File::from_str(
        r#"
{
"inner": { "value": 42 }
}
    "#,
        FileFormat::Json,
    ))
    .build()
    .unwrap();

let without_path = c.clone().try_deserialize::<Settings>();
assert_data_eq!(
    without_path.unwrap_err().to_string(),
    str!["inner: missing field `value2`"] // prepended with JSON path
);
```

This PR:
- adds [`serde_path_to_error`](https://crates.io/crates/serde_path_to_error)
- add a JSON path if not empty in front of the error.
- adds a test
- add method documentation




Discussion / hints:
And feel free to squash commits that are just for review feedback
## Pull Request Test Coverage Report for [Build 12705981021](https://coveralls.io/builds/71677098)


### Details

* **9** of **10**   **(90.0%)**  changed or added relevant lines in **1** file are covered.
* **1** unchanged line in **1** file lost coverage.
* Overall coverage increased (+**0.1%**) to **64.927%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [src/config.rs](https://coveralls.io/builds/71677098/source?filename=src%2Fconfig.rs#L155) | 9 | 10 | 90.0%
<!-- | **Total:** | **9** | **10** | **90.0%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [src/format.rs](https://coveralls.io/builds/71677098/source?filename=src%2Fformat.rs#L16) | 1 | 40.0% |
<!-- | **Total:** | **1** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/71677098/badge)](https://coveralls.io/builds/71677098) |
| :-- | --: |
| Change from base [Build 12695788504](https://coveralls.io/builds/71661361): |  0.1% |
| Covered Lines: | 933 |
| Relevant Lines: | 1437 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Fix patch:
diff --git a/src/error.rs b/src/error.rs
index 2d738b94..c8c1435d 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -78,6 +78,21 @@ pub enum ConfigError {
         key: Option<String>,
     },
 
+    /// Custom message
+    At {
+        /// Error being extended with a path
+        error: Box<ConfigError>,
+
+        /// The URI that references the source that the value came from.
+        /// Example: `/path/to/config.json` or `Environment` or `etcd://localhost`
+        // TODO: Why is this called Origin but FileParse has a uri field?
+        origin: Option<String>,
+
+        /// The key in the configuration hash of this value (if available where the
+        /// error is generated).
+        key: Option<String>,
+    },
+
     /// Custom message
     Message(String),
 
@@ -130,7 +145,17 @@ impl ConfigError {
                 key: Some(key.into()),
             },
 
-            _ => self,
+            Self::At { origin, error, .. } => Self::At {
+                error,
+                origin,
+                key: Some(key.into()),
+            },
+
+            other => Self::At {
+                error: Box::new(other),
+                origin: None,
+                key: Some(key.into()),
+            },
         }
     }
 
@@ -157,8 +182,17 @@ impl ConfigError {
                 expected,
                 key: Some(concat(key)),
             },
+            Self::At { error, origin, key } => Self::At {
+                error,
+                origin,
+                key: Some(concat(key)),
+            },
             Self::NotFound(key) => Self::NotFound(concat(Some(key))),
-            _ => self,
+            other => Self::At {
+                error: Box::new(other),
+                origin: None,
+                key: Some(concat(None)),
+            },
         }
     }
 
@@ -217,6 +251,24 @@ impl fmt::Display for ConfigError {
                 Ok(())
             }
 
+            ConfigError::At {
+                ref error,
+                ref origin,
+                ref key,
+            } => {
+                write!(f, "{error}")?;
+
+                if let Some(ref key) = *key {
+                    write!(f, " for key `{key}`")?;
+                }
+
+                if let Some(ref origin) = *origin {
+                    write!(f, " in {origin}")?;
+                }
+
+                Ok(())
+            }
+
             ConfigError::FileParse { ref cause, ref uri } => {
                 write!(f, "{cause}")?;
 


Test patch:
diff --git a/tests/testsuite/deserialize-invalid-type.json b/tests/testsuite/deserialize-invalid-type.json
new file mode 100644
index 00000000..6435ab7b
--- /dev/null
+++ b/tests/testsuite/deserialize-invalid-type.json
@@ -0,0 +1,5 @@
+{
+  "place": {
+    "name": "Torre di Pisa"
+  }
+}
diff --git a/tests/testsuite/deserialize-missing-field.json b/tests/testsuite/deserialize-missing-field.json
new file mode 100644
index 00000000..31bc3579
--- /dev/null
+++ b/tests/testsuite/deserialize-missing-field.json
@@ -0,0 +1,3 @@
+{
+    "inner": { "value": 42 }
+}
diff --git a/tests/testsuite/errors.rs b/tests/testsuite/errors.rs
index 74edd41a..e53b8fa6 100644
--- a/tests/testsuite/errors.rs
+++ b/tests/testsuite/errors.rs
@@ -5,7 +5,7 @@ use config::{Config, ConfigError, File, FileFormat, Map, Value};
 
 #[test]
 #[cfg(feature = "json")]
-fn test_error_path_index_bounds() {
+fn test_path_index_bounds() {
     let c = Config::builder()
         .add_source(File::from_str(
             r#"
@@ -28,7 +28,7 @@ fn test_error_path_index_bounds() {
 
 #[test]
 #[cfg(feature = "json")]
-fn test_error_path_index_negative_bounds() {
+fn test_path_index_negative_bounds() {
     let c = Config::builder()
         .add_source(File::from_str(
             r#"
@@ -51,7 +51,7 @@ fn test_error_path_index_negative_bounds() {
 
 #[test]
 #[cfg(feature = "json")]
-fn test_error_parse() {
+fn test_parse() {
     let res = Config::builder()
         .add_source(File::from_str(
             r#"
@@ -72,7 +72,23 @@ fn test_error_parse() {
 
 #[test]
 #[cfg(feature = "json")]
-fn test_error_type() {
+fn test_root_not_table() {
+    let e = Config::builder()
+        .add_source(File::from_str(r#"false"#, FileFormat::Json))
+        .build()
+        .unwrap_err();
+    match e {
+        ConfigError::FileParse { cause, .. } => assert_eq!(
+            "invalid type: boolean `false`, expected a map",
+            format!("{cause}")
+        ),
+        _ => panic!("Wrong error: {:?}", e),
+    }
+}
+
+#[test]
+#[cfg(feature = "json")]
+fn test_get_invalid_type() {
     let c = Config::builder()
         .add_source(File::from_str(
             r#"
@@ -96,43 +112,85 @@ fn test_error_type() {
 
 #[test]
 #[cfg(feature = "json")]
-fn test_error_deser_whole() {
-    #[derive(Deserialize, Debug)]
-    struct Place {
-        #[allow(dead_code)]
-        name: usize, // is actually s string
-    }
+fn test_get_invalid_type_file() {
+    let c = Config::builder()
+        .add_source(File::new(
+            "tests/testsuite/get-invalid-type.json",
+            FileFormat::Json,
+        ))
+        .build()
+        .unwrap();
 
-    #[derive(Deserialize, Debug)]
-    struct Output {
+    let res = c.get::<bool>("boolean_s_parse");
+
+    assert!(res.is_err());
+    assert_data_eq!(
+        res.unwrap_err().to_string(),
+        str![[
+            r#"invalid type: string "fals", expected a boolean for key `boolean_s_parse` in tests/testsuite/get-invalid-type.json"#
+        ]]
+    );
+}
+
+#[test]
+#[cfg(feature = "json")]
+fn test_get_missing_field() {
+    #[derive(Debug, Deserialize)]
+    struct InnerSettings {
         #[allow(dead_code)]
-        place: Place,
+        value: u32,
+        #[allow(dead_code)]
+        value2: u32,
     }
 
     let c = Config::builder()
         .add_source(File::from_str(
             r#"
 {
-  "place": {
-    "name": "Torre di Pisa"
-  }
+    "inner": { "value": 42 }
 }
-"#,
+        "#,
             FileFormat::Json,
         ))
         .build()
         .unwrap();
 
-    let res = c.try_deserialize::<Output>();
+    let res = c.get::<InnerSettings>("inner");
     assert_data_eq!(
         res.unwrap_err().to_string(),
-        str![[r#"invalid type: string "Torre di Pisa", expected an integer for key `place.name`"#]]
+        str!["missing field `value2` for key `inner`"]
+    );
+}
+
+#[test]
+#[cfg(feature = "json")]
+fn test_get_missing_field_file() {
+    #[derive(Debug, Deserialize)]
+    struct InnerSettings {
+        #[allow(dead_code)]
+        value: u32,
+        #[allow(dead_code)]
+        value2: u32,
+    }
+
+    let c = Config::builder()
+        .add_source(File::new(
+            "tests/testsuite/get-missing-field.json",
+            FileFormat::Json,
+        ))
+        .build()
+        .unwrap();
+
+    let res = c.get::<InnerSettings>("inner");
+    assert_data_eq!(
+        res.unwrap_err().to_string(),
+        str!["missing field `value2` for key `inner`"]
     );
 }
 
 #[test]
 #[cfg(feature = "json")]
-fn test_error_type_detached() {
+fn test_get_bool_invalid_type() {
     let c = Config::builder()
         .add_source(File::from_str(
             r#"
@@ -145,24 +203,23 @@ fn test_error_type_detached() {
         .build()
         .unwrap();
 
-    let value = c.get::<Value>("boolean_s_parse").unwrap();
-    let res = value.try_deserialize::<bool>();
+    let res = c.get_bool("boolean_s_parse");
 
     assert!(res.is_err());
     assert_data_eq!(
         res.unwrap_err().to_string(),
-        str![[r#"invalid type: string "fals", expected a boolean"#]]
+        str![[r#"invalid type: string "fals", expected a boolean for key `boolean_s_parse`"#]]
     );
 }
 
 #[test]
 #[cfg(feature = "json")]
-fn test_error_type_get_bool() {
+fn test_get_table_invalid_type() {
     let c = Config::builder()
         .add_source(File::from_str(
             r#"
 {
-  "boolean_s_parse": "fals"
+  "debug": true
 }
 "#,
             FileFormat::Json,
@@ -170,18 +227,18 @@ fn test_error_type_get_bool() {
         .build()
         .unwrap();
 
-    let res = c.get_bool("boolean_s_parse");
+    let res = c.get_table("debug");
 
     assert!(res.is_err());
     assert_data_eq!(
         res.unwrap_err().to_string(),
-        str![[r#"invalid type: string "fals", expected a boolean for key `boolean_s_parse`"#]]
+        str!["invalid type: boolean `true`, expected a map for key `debug`"]
     );
 }
 
 #[test]
 #[cfg(feature = "json")]
-fn test_error_type_get_table() {
+fn test_get_array_invalid_type() {
     let c = Config::builder()
         .add_source(File::from_str(
             r#"
@@ -194,23 +251,23 @@ fn test_error_type_get_table() {
         .build()
         .unwrap();
 
-    let res = c.get_table("debug");
+    let res = c.get_array("debug");
 
     assert!(res.is_err());
     assert_data_eq!(
         res.unwrap_err().to_string(),
-        str!["invalid type: boolean `true`, expected a map for key `debug`"]
+        str!["invalid type: boolean `true`, expected an array for key `debug`"]
     );
 }
 
 #[test]
 #[cfg(feature = "json")]
-fn test_error_type_get_array() {
+fn test_value_deserialize_invalid_type() {
     let c = Config::builder()
         .add_source(File::from_str(
             r#"
 {
-  "debug": true
+  "boolean_s_parse": "fals"
 }
 "#,
             FileFormat::Json,
@@ -218,17 +275,18 @@ fn test_error_type_get_array() {
         .build()
         .unwrap();
 
-    let res = c.get_array("debug");
+    let value = c.get::<Value>("boolean_s_parse").unwrap();
+    let res = value.try_deserialize::<bool>();
 
     assert!(res.is_err());
     assert_data_eq!(
         res.unwrap_err().to_string(),
-        str!["invalid type: boolean `true`, expected an array for key `debug`"]
+        str![[r#"invalid type: string "fals", expected a boolean"#]]
     );
 }
 
 #[test]
-fn test_error_enum_de() {
+fn test_value_deserialize_enum() {
     #[derive(Debug, Deserialize, PartialEq, Eq)]
     enum Diode {
         Off,
@@ -262,38 +320,44 @@ fn test_error_enum_de() {
 
 #[test]
 #[cfg(feature = "json")]
-fn error_with_path() {
-    #[derive(Debug, Deserialize)]
-    struct Inner {
+fn test_deserialize_invalid_type() {
+    #[derive(Deserialize, Debug)]
+    struct Place {
         #[allow(dead_code)]
-        test: i32,
+        name: usize, // is actually s string
     }
 
-    #[derive(Debug, Deserialize)]
-    struct Outer {
+    #[derive(Deserialize, Debug)]
+    struct Output {
         #[allow(dead_code)]
-        inner: Inner,
+        place: Place,
     }
-    const CFG: &str = r#"
+
+    let c = Config::builder()
+        .add_source(File::from_str(
+            r#"
 {
-  "inner": {
-    "test": "ABC"
+  "place": {
+    "name": "Torre di Pisa"
   }
 }
-"#;
-
-    let e = Config::builder()
-        .add_source(File::from_str(CFG, FileFormat::Json))
+"#,
+            FileFormat::Json,
+        ))
         .build()
-        .unwrap()
-        .try_deserialize::<Outer>()
-        .unwrap_err();
+        .unwrap();
 
+    let res = c.try_deserialize::<Output>();
+    let e = res.unwrap_err();
+    assert_data_eq!(
+        e.to_string(),
+        str![[r#"invalid type: string "Torre di Pisa", expected an integer for key `place.name`"#]]
+    );
     if let ConfigError::Type {
         key: Some(path), ..
     } = e
     {
-        assert_eq!(path, "inner.test");
+        assert_eq!(path, "place.name");
     } else {
         panic!("Wrong error {:?}", e);
     }
@@ -301,18 +365,109 @@ fn error_with_path() {
 
 #[test]
 #[cfg(feature = "json")]
-fn test_error_root_not_table() {
-    match Config::builder()
-        .add_source(File::from_str(r#"false"#, FileFormat::Json))
+fn test_deserialize_invalid_type_file() {
+    #[derive(Deserialize, Debug)]
+    struct Place {
+        #[allow(dead_code)]
+        name: usize, // is actually s string
+    }
+
+    #[derive(Deserialize, Debug)]
+    struct Output {
+        #[allow(dead_code)]
+        place: Place,
+    }
+
+    let c = Config::builder()
+        .add_source(File::new(
+            "tests/testsuite/deserialize-invalid-type.json",
+            FileFormat::Json,
+        ))
         .build()
+        .unwrap();
+
+    let res = c.try_deserialize::<Output>();
+    let e = res.unwrap_err();
+    assert_data_eq!(
+        e.to_string(),
+        str![[
+            r#"invalid type: string "Torre di Pisa", expected an integer for key `place.name` in tests/testsuite/deserialize-invalid-type.json"#
+        ]]
+    );
+    if let ConfigError::Type {
+        key: Some(path), ..
+    } = e
     {
-        Ok(_) => panic!("Should not merge if root is not a table"),
-        Err(e) => match e {
-            ConfigError::FileParse { cause, .. } => assert_eq!(
-                "invalid type: boolean `false`, expected a map",
-                format!("{cause}")
-            ),
-            _ => panic!("Wrong error: {:?}", e),
-        },
+        assert_eq!(path, "place.name");
+    } else {
+        panic!("Wrong error {:?}", e);
     }
 }
+
+#[test]
+#[cfg(feature = "json")]
+fn test_deserialize_missing_field() {
+    #[derive(Debug, Deserialize)]
+    struct Settings {
+        #[allow(dead_code)]
+        inner: InnerSettings,
+    }
+
+    #[derive(Debug, Deserialize)]
+    struct InnerSettings {
+        #[allow(dead_code)]
+        value: u32,
+        #[allow(dead_code)]
+        value2: u32,
+    }
+
+    let c = Config::builder()
+        .add_source(File::from_str(
+            r#"
+{
+    "inner": { "value": 42 }
+}
+        "#,
+            FileFormat::Json,
+        ))
+        .build()
+        .unwrap();
+
+    let res = c.try_deserialize::<Settings>();
+    assert_data_eq!(
+        res.unwrap_err().to_string(),
+        str!["missing field `value2` for key `inner`"]
+    );
+}
+
+#[test]
+#[cfg(feature = "json")]
+fn test_deserialize_missing_field_file() {
+    #[derive(Debug, Deserialize)]
+    struct Settings {
+        #[allow(dead_code)]
+        inner: InnerSettings,
+    }
+
+    #[derive(Debug, Deserialize)]
+    struct InnerSettings {
+        #[allow(dead_code)]
+        value: u32,
+        #[allow(dead_code)]
+        value2: u32,
+    }
+
+    let c = Config::builder()
+        .add_source(File::new(
+            "tests/testsuite/deserialize-missing-field.json",
+            FileFormat::Json,
+        ))
+        .build()
+        .unwrap();
+
+    let res = c.try_deserialize::<Settings>();
+    assert_data_eq!(
+        res.unwrap_err().to_string(),
+        str!["missing field `value2` for key `inner`"]
+    );
+}
diff --git a/tests/testsuite/get-invalid-type.json b/tests/testsuite/get-invalid-type.json
new file mode 100644
index 00000000..e1799c27
--- /dev/null
+++ b/tests/testsuite/get-invalid-type.json
@@ -0,0 +1,3 @@
+{
+  "boolean_s_parse": "fals"
+}
diff --git a/tests/testsuite/get-missing-field.json b/tests/testsuite/get-missing-field.json
new file mode 100644
index 00000000..31bc3579
--- /dev/null
+++ b/tests/testsuite/get-missing-field.json
@@ -0,0 +1,3 @@
+{
+    "inner": { "value": 42 }
+}
diff --git a/tests/testsuite/log.rs b/tests/testsuite/log.rs
index 29bf7eca..d8cda030 100644
--- a/tests/testsuite/log.rs
+++ b/tests/testsuite/log.rs
@@ -53,6 +53,6 @@ fn test_load_level_lowercase() {
     assert!(s.is_err());
     assert_data_eq!(
         s.unwrap_err().to_string(),
-        str!["enum Level does not have variant constructor error"]
+        str!["enum Level does not have variant constructor error for key `log`"]
     );
 }