diff --git a/Cargo.lock b/Cargo.lock index 3adc517..538a9f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,6 +208,7 @@ dependencies = [ "humantime-serde", "indoc", "ipnetwork", + "js_option", "rust_decimal", "secrecy", "serde", @@ -647,6 +648,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "js_option" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68421373957a1593a767013698dbf206e2b221eefe97a44d98d18672ff38423c" +dependencies = [ + "serde", +] + [[package]] name = "libc" version = "0.2.171" diff --git a/confik/CHANGELOG.md b/confik/CHANGELOG.md index 123f52f..f947c0a 100644 --- a/confik/CHANGELOG.md +++ b/confik/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Implement `Configuration` for [`js_option::JsOption`](https://docs.rs/js_option/0.1.1/js_option/enum.JsOption.html) + ## 0.13.0 - Update `bytesize` dependency to `2`. diff --git a/confik/Cargo.toml b/confik/Cargo.toml index e661be3..f5fc0bd 100644 --- a/confik/Cargo.toml +++ b/confik/Cargo.toml @@ -35,6 +35,7 @@ camino = ["dep:camino"] chrono = ["dep:chrono"] common = [] ipnetwork = ["dep:ipnetwork"] +js_option = ["dep:js_option"] rust_decimal = ["dep:rust_decimal"] secrecy = ["dep:secrecy"] url = ["dep:url"] @@ -56,6 +57,7 @@ bytesize = { version = "2", optional = true, features = ["serde"] } camino = { version = "1", optional = true, features = ["serde1"] } chrono = { version = "0.4.40", optional = true, default-features = false, features = ["serde"] } ipnetwork = { version = "0.21", optional = true, features = ["serde"] } +js_option = { version = "0.1", optional = true, features = ["serde"] } rust_decimal = { version = "1", optional = true, features = ["serde"] } secrecy = { version = "0.10", optional = true, features = ["serde"] } url = { version = "2", optional = true, features = ["serde"] } diff --git a/confik/src/third_party.rs b/confik/src/third_party.rs index 8f7fdaf..9807ba7 100644 --- a/confik/src/third_party.rs +++ b/confik/src/third_party.rs @@ -119,6 +119,62 @@ mod ipnetwork { } } +#[cfg(feature = "js_option")] +mod js_option { + use js_option::JsOption; + use serde::de::DeserializeOwned; + + use crate::{Configuration, ConfigurationBuilder}; + + impl Configuration for JsOption + where + T: DeserializeOwned + Configuration, + { + type Builder = JsOption<::Builder>; + } + + impl ConfigurationBuilder for JsOption + where + T: DeserializeOwned + ConfigurationBuilder, + { + type Target = JsOption<::Target>; + + fn merge(self, other: Self) -> Self { + match (self, other) { + // If both `Some` then merge the contained builders + (Self::Some(us), Self::Some(other)) => Self::Some(us.merge(other)), + // If we don't have a value then always take the other + (Self::Undefined, other) => other, + // Either: + // - We're explicitly `Null` + // - We're explicitly `Some` and the other is `Undefined` or `Null` + // + // In either case, just take our value, which should be preferred to other. + (us, _) => us, + } + } + + fn try_build(self) -> Result { + match self { + Self::Undefined => Ok(Self::Target::Undefined), + Self::Null => Ok(Self::Target::Null), + Self::Some(val) => Ok(Self::Target::Some(val.try_build()?)), + } + } + + fn contains_non_secret_data(&self) -> Result { + match self { + Self::Some(data) => data.contains_non_secret_data(), + + // An explicit `Null` is counted as data, overriding any default. + Self::Null => Ok(true), + + Self::Undefined => Ok(false), + } + } + } +} + #[cfg(feature = "secrecy")] mod secrecy { use secrecy::SecretString; diff --git a/confik/tests/third_party.rs b/confik/tests/third_party.rs index 2204a96..17422ea 100644 --- a/confik/tests/third_party.rs +++ b/confik/tests/third_party.rs @@ -126,3 +126,76 @@ mod bigdecimal { } } } + +#[cfg(feature = "js_option")] +mod js_option { + use confik::{Configuration, TomlSource}; + use js_option::JsOption; + + #[derive(Configuration, Debug)] + struct Config { + opt: JsOption, + } + + #[test] + fn undefined() { + let config = Config::builder() + .try_build() + .expect("Should be valid without config"); + assert_eq!(config.opt, JsOption::Undefined); + } + + #[cfg(feature = "json")] + #[test] + fn null() { + let json = r#"{ "opt": null }"#; + + let config = Config::builder() + .override_with(confik::JsonSource::new(json)) + .try_build() + .expect("Failed to parse config"); + assert_eq!(config.opt, JsOption::Null); + } + + #[test] + fn present() { + let toml = "opt = 5"; + + let config = Config::builder() + .override_with(TomlSource::new(toml)) + .try_build() + .expect("Should be valid without config"); + assert_eq!(config.opt, JsOption::Some(5)); + } + + #[cfg(feature = "json")] + #[test] + fn merge() { + #[derive(Debug, Configuration, PartialEq, Eq)] + struct Config { + one: JsOption, + two: JsOption, + three: JsOption, + four: JsOption, + } + + let base = r#"{ "two": null, "three": 5 }"#; + let merge = r#"{ "one": 1, "two": 2, "three": 3}"#; + + let config = Config::builder() + .override_with(confik::JsonSource::new(merge)) + .override_with(confik::JsonSource::new(base)) + .try_build() + .expect("Failed to parse config"); + + assert_eq!( + config, + Config { + one: JsOption::Some(1), + two: JsOption::Null, + three: JsOption::Some(5), + four: JsOption::Undefined, + } + ); + } +}