diff --git a/src/figment.rs b/src/figment.rs index fc9daf5..8b2534a 100644 --- a/src/figment.rs +++ b/src/figment.rs @@ -4,7 +4,7 @@ use serde::de::Deserialize; use crate::{Profile, Provider, Metadata}; use crate::error::{Kind, Result}; -use crate::value::{Value, Map, Dict, Tag, ConfiguredValueDe}; +use crate::value::{Value, Map, Dict, Tag, ConfiguredValueDe, DefaultInterpreter, LossyInterpreter}; use crate::coalesce::{Coalescible, Order}; /// Combiner of [`Provider`]s for configuration value extraction. @@ -482,7 +482,64 @@ impl Figment { /// }); /// ``` pub fn extract<'a, T: Deserialize<'a>>(&self) -> Result { - T::deserialize(ConfiguredValueDe::from(self, &self.merged()?)) + let value = self.merged()?; + T::deserialize(ConfiguredValueDe::<'_, DefaultInterpreter>::from(self, &value)) + } + + /// As [`extract`](Figment::extract_lossy), but interpret numbers and + /// booleans more flexibly. + /// + /// See [`Value::to_bool_lossy`] and [`Value::to_num_lossy`] for a full + /// explanation of the imputs accepted. + /// + /// + /// # Example + /// + /// ```rust + /// use serde::Deserialize; + /// + /// use figment::{Figment, providers::{Format, Toml, Json, Env}}; + /// + /// #[derive(Debug, PartialEq, Deserialize)] + /// struct Config { + /// name: String, + /// numbers: Option>, + /// debug: bool, + /// } + /// + /// figment::Jail::expect_with(|jail| { + /// jail.create_file("Config.toml", r#" + /// name = "test" + /// numbers = ["1", "2", "3", "10"] + /// "#)?; + /// + /// jail.set_env("config_name", "env-test"); + /// + /// jail.create_file("Config.json", r#" + /// { + /// "name": "json-test", + /// "debug": "yes" + /// } + /// "#)?; + /// + /// let config: Config = Figment::new() + /// .merge(Toml::file("Config.toml")) + /// .merge(Env::prefixed("CONFIG_")) + /// .join(Json::file("Config.json")) + /// .extract_lossy()?; + /// + /// assert_eq!(config, Config { + /// name: "env-test".into(), + /// numbers: vec![1, 2, 3, 10].into(), + /// debug: true + /// }); + /// + /// Ok(()) + /// }); + /// ``` + pub fn extract_lossy<'a, T: Deserialize<'a>>(&self) -> Result { + let value = self.merged()?; + T::deserialize(ConfiguredValueDe::<'_, LossyInterpreter>::from(self, &value)) } /// Deserializes the value at the `key` path in the collected value into @@ -511,8 +568,43 @@ impl Figment { /// }); /// ``` pub fn extract_inner<'a, T: Deserialize<'a>>(&self, path: &str) -> Result { - T::deserialize(ConfiguredValueDe::from(self, &self.find_value(path)?)) - .map_err(|e| e.with_path(path)) + let value = self.find_value(path)?; + let de = ConfiguredValueDe::<'_, DefaultInterpreter>::from(self, &value); + T::deserialize(de).map_err(|e| e.with_path(path)) + } + + /// As [`extract`](Figment::extract_lossy), but interpret numbers and + /// booleans more flexibly. + /// + /// See [`Value::to_bool_lossy`] and [`Value::to_num_lossy`] for a full + /// explanation of the imputs accepted. + /// + /// # Example + /// + /// ```rust + /// use figment::{Figment, providers::{Format, Toml, Json}}; + /// + /// figment::Jail::expect_with(|jail| { + /// jail.create_file("Config.toml", r#" + /// numbers = ["1", "2", "3", "10"] + /// "#)?; + /// + /// jail.create_file("Config.json", r#"{ "debug": true } "#)?; + /// + /// let numbers: Vec = Figment::new() + /// .merge(Toml::file("Config.toml")) + /// .join(Json::file("Config.json")) + /// .extract_inner_lossy("numbers")?; + /// + /// assert_eq!(numbers, vec![1, 2, 3, 10]); + /// + /// Ok(()) + /// }); + /// ``` + pub fn extract_inner_lossy<'a, T: Deserialize<'a>>(&self, path: &str) -> Result { + let value = self.find_value(path)?; + let de = ConfiguredValueDe::<'_, LossyInterpreter>::from(self, &value); + T::deserialize(de).map_err(|e| e.with_path(path)) } /// Returns an iterator over the metadata for all of the collected values in diff --git a/src/value/de.rs b/src/value/de.rs index 05258d4..e3a20ab 100644 --- a/src/value/de.rs +++ b/src/value/de.rs @@ -1,28 +1,76 @@ use std::fmt; use std::result; use std::cell::Cell; +use std::marker::PhantomData; +use std::borrow::Cow; use serde::Deserialize; -use serde::de::{self, Deserializer, IntoDeserializer}; -use serde::de::{Visitor, SeqAccess, MapAccess, VariantAccess}; +use serde::de::{self, Deserializer, IntoDeserializer, Visitor}; +use serde::de::{SeqAccess, MapAccess, VariantAccess}; use crate::Figment; use crate::error::{Error, Kind, Result}; use crate::value::{Value, Num, Empty, Dict, Tag}; -pub struct ConfiguredValueDe<'c> { +pub trait Interpreter { + fn interpret_as_bool(v: &Value) -> Cow<'_, Value> { + Cow::Borrowed(v) + } + + fn interpret_as_num(v: &Value) -> Cow<'_, Value> { + Cow::Borrowed(v) + } +} + +pub struct DefaultInterpreter; +impl Interpreter for DefaultInterpreter { } + +pub struct LossyInterpreter; +impl Interpreter for LossyInterpreter { + fn interpret_as_bool(v: &Value) -> Cow<'_, Value> { + v.to_bool_lossy() + .map(|b| Cow::Owned(Value::Bool(v.tag(), b))) + .unwrap_or(Cow::Borrowed(v)) + } + + fn interpret_as_num(v: &Value) -> Cow<'_, Value> { + v.to_num_lossy() + .map(|n| Cow::Owned(Value::Num(v.tag(), n))) + .unwrap_or(Cow::Borrowed(v)) + } +} + +pub struct ConfiguredValueDe<'c, I = DefaultInterpreter> { pub config: &'c Figment, pub value: &'c Value, pub readable: Cell, + _phantom: PhantomData } -impl<'c> ConfiguredValueDe<'c> { +impl<'c, I: Interpreter> ConfiguredValueDe<'c, I> { pub fn from(config: &'c Figment, value: &'c Value) -> Self { - Self { config, value, readable: Cell::from(true) } + Self { config, value, readable: Cell::from(true), _phantom: PhantomData } + } +} + +/// Like [`serde::forward_to_deserialize_any`] but applies `$apply` to +/// `&self` first, then calls `deserialize_any()` on the returned value, and +/// finally maps any error produced using `$errmap`: +/// - $apply(&self).deserialize_any(visitor).map_err($errmap) +macro_rules! apply_then_forward_to_deserialize_any { + ( $( $($f:ident),+ => |$this:pat| $apply:expr, $errmap:expr),* $(,)? ) => { + $( + $( + fn $f>(self, visitor: V) -> Result { + let $this = &self; + $apply.deserialize_any(visitor).map_err($errmap) + } + )+ + )* } } -impl<'de: 'c, 'c> Deserializer<'de> for ConfiguredValueDe<'c> { +impl<'de: 'c, 'c, I: Interpreter> Deserializer<'de> for ConfiguredValueDe<'c, I> { type Error = Error; fn deserialize_any(self, v: V) -> Result @@ -114,8 +162,19 @@ impl<'de: 'c, 'c> Deserializer<'de> for ConfiguredValueDe<'c> { val } + apply_then_forward_to_deserialize_any! { + deserialize_bool => + |de| I::interpret_as_bool(de.value), + |e| e.retagged(de.value.tag()).resolved(de.config), + deserialize_u8, deserialize_u16, deserialize_u32, deserialize_u64, + deserialize_i8, deserialize_i16, deserialize_i32, deserialize_i64, + deserialize_f32, deserialize_f64 => + |de| I::interpret_as_num(de.value), + |e| e.retagged(de.value.tag()).resolved(de.config), + } + serde::forward_to_deserialize_any! { - bool u8 u16 u32 u64 i8 i16 i32 i64 f32 f64 char str + char str string seq bytes byte_buf map unit ignored_any unit_struct tuple_struct tuple identifier } @@ -348,14 +407,14 @@ impl Value { "___figment_value_id", "___figment_value_value" ]; - fn deserialize_from<'de: 'c, 'c, V: de::Visitor<'de>>( - de: ConfiguredValueDe<'c>, + fn deserialize_from<'de: 'c, 'c, V: de::Visitor<'de>, I: Interpreter>( + de: ConfiguredValueDe<'c, I>, visitor: V ) -> Result { let mut map = Dict::new(); map.insert(Self::FIELDS[0].into(), de.value.tag().into()); map.insert(Self::FIELDS[1].into(), de.value.clone()); - visitor.visit_map(MapDe::new(&map, |v| ConfiguredValueDe::from(de.config, v))) + visitor.visit_map(MapDe::new(&map, |v| ConfiguredValueDe::<'_, I>::from(de.config, v))) } } diff --git a/src/value/magic.rs b/src/value/magic.rs index 675944b..3e1da38 100644 --- a/src/value/magic.rs +++ b/src/value/magic.rs @@ -6,7 +6,7 @@ use std::path::{PathBuf, Path}; use serde::{Deserialize, Serialize, de}; -use crate::{Error, value::{ConfiguredValueDe, MapDe, Tag}}; +use crate::{Error, value::{ConfiguredValueDe, Interpreter, MapDe, Tag}}; /// Marker trait for "magic" values. Primarily for use with [`Either`]. pub trait Magic: for<'de> Deserialize<'de> { @@ -16,8 +16,8 @@ pub trait Magic: for<'de> Deserialize<'de> { /// The fields of the pseudo-structure. The last one should be the value. #[doc(hidden)] const FIELDS: &'static [&'static str]; - #[doc(hidden)] fn deserialize_from<'de: 'c, 'c, V: de::Visitor<'de>>( - de: ConfiguredValueDe<'c>, + #[doc(hidden)] fn deserialize_from<'de: 'c, 'c, V: de::Visitor<'de>, I: Interpreter>( + de: ConfiguredValueDe<'c, I>, visitor: V ) -> Result; } @@ -177,8 +177,8 @@ impl Magic for RelativePathBuf { "___figment_relative_path" ]; - fn deserialize_from<'de: 'c, 'c, V: de::Visitor<'de>>( - de: ConfiguredValueDe<'c>, + fn deserialize_from<'de: 'c, 'c, V: de::Visitor<'de>, I: Interpreter>( + de: ConfiguredValueDe<'c, I>, visitor: V ) -> Result { // If we have this struct with a non-empty metadata_path, use it. @@ -186,7 +186,8 @@ impl Magic for RelativePathBuf { if let Some(d) = de.value.as_dict() { if let Some(mpv) = d.get(Self::FIELDS[0]) { if mpv.to_empty().is_none() { - return visitor.visit_map(MapDe::new(d, |v| ConfiguredValueDe::from(config, v))); + let map_de = MapDe::new(d, |v| ConfiguredValueDe::::from(config, v)); + return visitor.visit_map(map_de); } } } @@ -204,7 +205,7 @@ impl Magic for RelativePathBuf { // If we have this struct with no metadata_path, still use the value. let value = de.value.find_ref(Self::FIELDS[1]).unwrap_or(&de.value); map.insert(Self::FIELDS[1].into(), value.clone()); - visitor.visit_map(MapDe::new(&map, |v| ConfiguredValueDe::from(config, v))) + visitor.visit_map(MapDe::new(&map, |v| ConfiguredValueDe::::from(config, v))) } } @@ -406,7 +407,7 @@ impl RelativePathBuf { // ) -> Result{ // let mut map = crate::value::Map::new(); // map.insert(Self::FIELDS[0].into(), de.config.profile().to_string().into()); -// visitor.visit_map(MapDe::new(&map, |v| ConfiguredValueDe::from(de.config, v))) +// visitor.visit_map(MapDe::new(&map, |v| ConfiguredValueDe::::from(de.config, v))) // } // } // @@ -572,8 +573,8 @@ impl Deserialize<'de>> Magic for Tagged { "___figment_tagged_tag" , "___figment_tagged_value" ]; - fn deserialize_from<'de: 'c, 'c, V: de::Visitor<'de>>( - de: ConfiguredValueDe<'c>, + fn deserialize_from<'de: 'c, 'c, V: de::Visitor<'de>, I: Interpreter>( + de: ConfiguredValueDe<'c, I>, visitor: V ) -> Result{ let config = de.config; @@ -584,7 +585,7 @@ impl Deserialize<'de>> Magic for Tagged { if let Some(tagv) = dict.get(Self::FIELDS[0]) { if let Ok(false) = tagv.deserialize::().map(|t| t.is_default()) { return visitor.visit_map(MapDe::new(dict, |v| { - ConfiguredValueDe::from(config, v) + ConfiguredValueDe::::from(config, v) })); } } @@ -594,7 +595,7 @@ impl Deserialize<'de>> Magic for Tagged { let value = de.value.find_ref(Self::FIELDS[1]).unwrap_or(&de.value); map.insert(Self::FIELDS[0].into(), de.value.tag().into()); map.insert(Self::FIELDS[1].into(), value.clone()); - visitor.visit_map(MapDe::new(&map, |v| ConfiguredValueDe::from(config, v))) + visitor.visit_map(MapDe::new(&map, |v| ConfiguredValueDe::::from(config, v))) } } diff --git a/src/value/mod.rs b/src/value/mod.rs index e360508..2738629 100644 --- a/src/value/mod.rs +++ b/src/value/mod.rs @@ -14,6 +14,7 @@ mod escape; pub mod magic; pub(crate) use {self::ser::*, self::de::*}; + pub use tag::Tag; pub use value::{Value, Map, Num, Dict, Empty}; pub use uncased::{Uncased, UncasedStr}; diff --git a/src/value/value.rs b/src/value/value.rs index 41ad9b3..25655cd 100644 --- a/src/value/value.rs +++ b/src/value/value.rs @@ -1,9 +1,10 @@ -use std::str::Split; use std::collections::BTreeMap; +use std::num::{ParseFloatError, ParseIntError}; +use std::str::{FromStr, Split}; use serde::Serialize; -use crate::value::{Tag, ValueSerializer}; +use crate::value::{Tag, ValueSerializer, magic::Either}; use crate::error::{Error, Actual}; /// An alias to the type of map used in [`Value::Dict`]. @@ -280,6 +281,94 @@ impl Value { self.to_num()?.to_f64() } + /// Converts `self` to a `bool` if it is a [`Value::Bool`], or if it is a + /// [`Value::String`] or a [`Value::Num`] with a boolean interpretation. + /// + /// The case-insensitive strings "true", "yes", "1", and "on", and the + /// signed or unsigned integers `1` are interpreted as `true`. + /// + /// The case-insensitive strings "false", "no", "0", and "off", and the + /// signed or unsigned integers `0` are interpreted as false. + /// + /// # Example + /// + /// ``` + /// use figment::value::Value; + /// + /// let value = Value::from(true); + /// assert_eq!(value.to_bool_lossy(), Some(true)); + /// + /// let value = Value::from(1); + /// assert_eq!(value.to_bool_lossy(), Some(true)); + /// + /// let value = Value::from("YES"); + /// assert_eq!(value.to_bool_lossy(), Some(true)); + /// + /// let value = Value::from(false); + /// assert_eq!(value.to_bool_lossy(), Some(false)); + /// + /// let value = Value::from(0); + /// assert_eq!(value.to_bool_lossy(), Some(false)); + /// + /// let value = Value::from("no"); + /// assert_eq!(value.to_bool_lossy(), Some(false)); + /// + /// let value = Value::from("hello"); + /// assert_eq!(value.to_bool_lossy(), None); + /// ``` + pub fn to_bool_lossy(&self) -> Option { + match self { + Value::Bool(_, b) => Some(*b), + Value::Num(_, num) => match num.to_u128_lossy() { + Some(0) => Some(false), + Some(1) => Some(true), + _ => None + } + Value::String(_, s) => { + const TRUE: &[&str] = &["true", "yes", "1", "on"]; + const FALSE: &[&str] = &["false", "no", "0", "off"]; + + if TRUE.iter().any(|v| uncased::eq(v, s)) { + Some(true) + } else if FALSE.iter().any(|v| uncased::eq(v, s)) { + Some(false) + } else { + None + } + }, + _ => None, + } + } + + /// Converts `self` to a [`Num`] if it is a [`Value::Num`] or if it is a + /// [`Value::String`] that parses as a `usize` ([`Num::USize`]), `isize` + /// ([`Num::ISize`]), or `f64` ([`Num::F64`]), in that order of precendence. + /// + /// # Examples + /// + /// ``` + /// use figment::value::{Value, Num}; + /// + /// let value = Value::from(7_i32); + /// assert_eq!(value.to_num_lossy(), Some(Num::I32(7))); + /// + /// let value = Value::from("7"); + /// assert_eq!(value.to_num_lossy(), Some(Num::U8(7))); + /// + /// let value = Value::from("-7000"); + /// assert_eq!(value.to_num_lossy(), Some(Num::I16(-7000))); + /// + /// let value = Value::from("7000.5"); + /// assert_eq!(value.to_num_lossy(), Some(Num::F64(7000.5))); + /// ``` + pub fn to_num_lossy(&self) -> Option { + match self { + Value::Num(_, num) => Some(*num), + Value::String(_, s) => s.parse().ok(), + _ => None, + } + } + /// Converts `self` into the corresponding [`Actual`]. /// /// See also [`Num::to_actual()`] and [`Empty::to_actual()`], which are @@ -398,6 +487,37 @@ macro_rules! impl_from_for_value { )*) } +macro_rules! try_convert { + ($n:expr => $($T:ty),*) => {$( + if let Ok(n) = <$T as std::convert::TryFrom<_>>::try_from($n) { + return Ok(n.into()); + } + )*} +} + +impl FromStr for Num { + type Err = Either; + + fn from_str(string: &str) -> Result { + let string = string.trim(); + if string.contains('.') { + if string.len() <= (f32::DIGITS as usize + 1) { + Ok(string.parse::().map_err(Either::Right)?.into()) + } else { + Ok(string.parse::().map_err(Either::Right)?.into()) + } + } else if string.starts_with('-') { + let int = string.parse::().map_err(Either::Left)?; + try_convert![int => i8, i16, i32, i64]; + Ok(int.into()) + } else { + let uint = string.parse::().map_err(Either::Left)?; + try_convert![uint => u8, u16, u32, u64]; + Ok(uint.into()) + } + } +} + impl_from_for_value! { String: String, char: Char, bool: Bool, u8: Num, u16: Num, u32: Num, u64: Num, u128: Num, usize: Num, @@ -484,6 +604,38 @@ impl Num { }) } + /// Converts `self` into a `u128` if it is non-negative, even if `self` is + /// of a signed variant. + /// + /// # Example + /// + /// ``` + /// use figment::value::Num; + /// + /// let num: Num = 123u8.into(); + /// assert_eq!(num.to_u128_lossy(), Some(123)); + /// + /// let num: Num = 123i8.into(); + /// assert_eq!(num.to_u128_lossy(), Some(123)); + /// ``` + pub fn to_u128_lossy(self) -> Option { + Some(match self { + Num::U8(v) => v as u128, + Num::U16(v) => v as u128, + Num::U32(v) => v as u128, + Num::U64(v) => v as u128, + Num::U128(v) => v as u128, + Num::USize(v) => v as u128, + Num::I8(v) if v >= 0 => v as u128, + Num::I16(v) if v >= 0 => v as u128, + Num::I32(v) if v >= 0 => v as u128, + Num::I64(v) if v >= 0 => v as u128, + Num::I128(v) if v >= 0 => v as u128, + Num::ISize(v) if v >= 0 => v as u128, + _ => return None, + }) + } + /// Converts `self` into an `i128` if `self` is a signed `Value::Num` /// variant. /// diff --git a/tests/lossy_values.rs b/tests/lossy_values.rs new file mode 100644 index 0000000..7ea38e9 --- /dev/null +++ b/tests/lossy_values.rs @@ -0,0 +1,28 @@ +use serde::Deserialize; +use figment::{Figment, providers::{Toml, Format}}; + +#[derive(Debug, Deserialize, PartialEq)] +struct Config { + bs: Vec, + u8s: Vec, + i32s: Vec, + f64s: Vec, +} + +static TOML: &str = r##" + u8s = [1, 2, 3, "4", 5, "6"] + i32s = [-1, -2, 3, "-4", 5, "6"] + f64s = [1, "2", -3, -4.5, "5.0", "-6.0"] + bs = [true, false, "true", "false", "YES", "no", "on", "OFF", "1", "0", 1, 0] +"##; + +#[test] +fn lossy_values() { + let config: Config = Figment::from(Toml::string(TOML)).extract_lossy().unwrap(); + assert_eq!(&config.u8s, &[ 1, 2, 3, 4, 5, 6 ]); + assert_eq!(&config.i32s, &[-1, -2, 3, -4, 5, 6]); + assert_eq!(&config.f64s, &[1.0, 2.0, -3.0, -4.5, 5.0, -6.0]); + assert_eq!(&config.bs, &[ + true, false, true, false, true, false, true, false, true, false, true, false + ]); +}