use std::{any, cell::RefCell, collections::HashMap, marker::PhantomData, mem};
use crate::{
de::DeserializerOptions,
metadata::{ConfigMetadata, NestedConfigMetadata, ParamMetadata, RustType},
schema::ConfigSchema,
value::{Pointer, WithOrigin},
visit::{ConfigVisitor, VisitConfig},
ConfigRepository, ConfigSource, DeserializeConfig, ParseErrors,
};
thread_local! {
pub(crate) static MOCK_ENV_VARS: RefCell<HashMap<String, String>> = RefCell::default();
}
#[derive(Debug)]
pub(crate) struct MockEnvGuard {
_not_send: PhantomData<*mut ()>,
}
impl Default for MockEnvGuard {
fn default() -> Self {
MOCK_ENV_VARS.with_borrow(|vars| {
assert!(
vars.is_empty(),
"Cannot define mock env vars while another `Tester` is active"
);
});
Self {
_not_send: PhantomData,
}
}
}
impl MockEnvGuard {
#[allow(clippy::unused_self)] pub(crate) fn set_env(&self, name: String, value: String) {
MOCK_ENV_VARS.with_borrow_mut(|vars| vars.insert(name, value));
}
}
impl Drop for MockEnvGuard {
fn drop(&mut self) {
MOCK_ENV_VARS.take(); }
}
pub fn test<C: DeserializeConfig>(sample: impl ConfigSource) -> Result<C, ParseErrors> {
Tester::default().test(sample)
}
#[track_caller] pub fn test_complete<C: DeserializeConfig>(sample: impl ConfigSource) -> Result<C, ParseErrors> {
Tester::default().test_complete(sample)
}
#[track_caller]
pub fn test_minimal<C: DeserializeConfig>(sample: impl ConfigSource) -> Result<C, ParseErrors> {
Tester::default().test_minimal(sample)
}
#[derive(Debug)]
enum CompletenessCheckerMode {
Complete,
Minimal,
}
#[derive(Debug)]
#[must_use = "must be put back"]
struct Checkpoint {
prev_config: &'static ConfigMetadata,
prev_path: Option<String>,
}
#[derive(Debug)]
struct CompletenessChecker<'a> {
mode: CompletenessCheckerMode,
current_path: String,
sample: &'a WithOrigin,
config: &'static ConfigMetadata,
found_params: HashMap<String, RustType>,
}
impl<'a> CompletenessChecker<'a> {
fn new(
mode: CompletenessCheckerMode,
sample: &'a WithOrigin,
config: &'static ConfigMetadata,
config_prefix: &str,
) -> Self {
Self {
mode,
current_path: config_prefix.to_owned(),
sample,
config,
found_params: HashMap::new(),
}
}
fn check_param(&mut self, param: &ParamMetadata) {
let param_path = Pointer(&self.current_path).join(param.name);
let should_add_param = match self.mode {
CompletenessCheckerMode::Complete => self.sample.get(Pointer(¶m_path)).is_none(),
CompletenessCheckerMode::Minimal => {
param.default_value.is_some() && self.sample.get(Pointer(¶m_path)).is_some()
}
};
if should_add_param {
self.found_params.insert(param_path, param.rust_type);
}
}
fn insert_param(&mut self, param: &ParamMetadata) {
let param_path = Pointer(&self.current_path).join(param.name);
self.found_params.insert(param_path, param.rust_type);
}
fn push_config(&mut self, config_meta: &NestedConfigMetadata) -> Checkpoint {
let prev_config = mem::replace(&mut self.config, config_meta.meta);
let prev_path = if config_meta.name.is_empty() {
None
} else {
let nested_path = Pointer(&self.current_path).join(config_meta.name);
Some(mem::replace(&mut self.current_path, nested_path))
};
Checkpoint {
prev_config,
prev_path,
}
}
fn pop_config(&mut self, checkpoint: Checkpoint) {
self.config = checkpoint.prev_config;
if let Some(path) = checkpoint.prev_path {
self.current_path = path;
}
}
fn collect_all_params(&mut self) {
if let Some(tag) = &self.config.tag {
self.insert_param(tag.param);
return;
}
for param in self.config.params {
self.insert_param(param);
}
for config_meta in self.config.nested_configs {
let checkpoint = self.push_config(config_meta);
self.collect_all_params();
self.pop_config(checkpoint);
}
}
}
impl ConfigVisitor for CompletenessChecker<'_> {
fn visit_tag(&mut self, _variant_index: usize) {
let param = self.config.tag.unwrap().param;
self.check_param(param);
}
fn visit_param(&mut self, param_index: usize, _value: &dyn any::Any) {
let param = &self.config.params[param_index];
self.check_param(param);
}
fn visit_nested_config(&mut self, config_index: usize, config: &dyn VisitConfig) {
let config_meta = &self.config.nested_configs[config_index];
let checkpoint = self.push_config(config_meta);
config.visit_config(self);
self.pop_config(checkpoint);
}
fn visit_nested_opt_config(&mut self, config_index: usize, config: Option<&dyn VisitConfig>) {
if let Some(config) = config {
self.visit_nested_config(config_index, config);
} else if matches!(self.mode, CompletenessCheckerMode::Complete) {
let config_meta = &self.config.nested_configs[config_index];
let checkpoint = self.push_config(config_meta);
self.collect_all_params();
self.pop_config(checkpoint);
}
}
}
#[derive(Debug)]
struct TesterData {
de_options: DeserializerOptions,
schema: ConfigSchema,
env_guard: MockEnvGuard,
}
#[derive(Debug)]
enum TesterDataGoat<'a> {
Owned(TesterData),
Borrowed(&'a mut TesterData),
}
impl AsRef<TesterData> for TesterDataGoat<'_> {
fn as_ref(&self) -> &TesterData {
match self {
Self::Owned(data) => data,
Self::Borrowed(data) => data,
}
}
}
impl AsMut<TesterData> for TesterDataGoat<'_> {
fn as_mut(&mut self) -> &mut TesterData {
match self {
Self::Owned(data) => data,
Self::Borrowed(data) => data,
}
}
}
#[derive(Debug)]
pub struct Tester<'a, C> {
data: TesterDataGoat<'a>,
_config: PhantomData<C>,
}
impl<C: DeserializeConfig + VisitConfig> Default for Tester<'static, C> {
fn default() -> Self {
Self {
data: TesterDataGoat::Owned(TesterData {
de_options: DeserializerOptions::default(),
schema: ConfigSchema::new(&C::DESCRIPTION, ""),
env_guard: MockEnvGuard::default(),
}),
_config: PhantomData,
}
}
}
impl Default for Tester<'static, ()> {
fn default() -> Self {
Self {
data: TesterDataGoat::Owned(TesterData {
de_options: DeserializerOptions::default(),
schema: ConfigSchema::default(),
env_guard: MockEnvGuard::default(),
}),
_config: PhantomData,
}
}
}
impl Tester<'_, ()> {
pub fn new(schema: ConfigSchema) -> Self {
Self {
data: TesterDataGoat::Owned(TesterData {
de_options: DeserializerOptions::default(),
schema,
env_guard: MockEnvGuard::default(),
}),
_config: PhantomData,
}
}
pub fn for_config<C: DeserializeConfig + VisitConfig>(&mut self) -> Tester<'_, C> {
self.data.as_ref().schema.single(&C::DESCRIPTION).unwrap();
Tester {
data: TesterDataGoat::Borrowed(self.data.as_mut()),
_config: PhantomData,
}
}
pub fn insert<C: DeserializeConfig + VisitConfig>(
&mut self,
prefix: &'static str,
) -> Tester<'_, C> {
self.data
.as_mut()
.schema
.insert(&C::DESCRIPTION, prefix)
.expect("failed inserting config into schema");
Tester {
data: TesterDataGoat::Borrowed(self.data.as_mut()),
_config: PhantomData,
}
}
}
impl<C> Tester<'_, C> {
pub fn coerce_variant_names(&mut self) -> &mut Self {
self.data.as_mut().de_options.coerce_variant_names = true;
self
}
pub fn coerce_serde_enums(&mut self) -> &mut Self {
self.data.as_mut().schema.coerce_serde_enums(true);
self
}
pub fn set_env(&mut self, var_name: impl Into<String>, value: impl Into<String>) -> &mut Self {
self.data
.as_mut()
.env_guard
.set_env(var_name.into(), value.into());
self
}
pub fn new_repository(&self) -> ConfigRepository<'_> {
let data = self.data.as_ref();
let mut repo = ConfigRepository::new(&data.schema);
*repo.deserializer_options() = data.de_options.clone();
repo
}
}
impl<C: DeserializeConfig + VisitConfig> Tester<'_, C> {
#[allow(clippy::missing_panics_doc)] pub fn test(&self, sample: impl ConfigSource) -> Result<C, ParseErrors> {
let repo = self.new_repository();
repo.with(sample).single::<C>().unwrap().parse()
}
#[track_caller]
pub fn test_complete(&self, sample: impl ConfigSource) -> Result<C, ParseErrors> {
let repo = self.new_repository().with(sample);
let (missing_params, config) =
Self::test_with_checker(&repo, CompletenessCheckerMode::Complete)?;
assert!(
missing_params.is_empty(),
"The provided sample is incomplete; missing params: {missing_params:?}"
);
Ok(config)
}
fn test_with_checker(
repo: &ConfigRepository<'_>,
mode: CompletenessCheckerMode,
) -> Result<(HashMap<String, RustType>, C), ParseErrors> {
let config_ref = repo.single::<C>().unwrap();
let config_prefix = config_ref.config().prefix();
let config = config_ref.parse()?;
let mut visitor =
CompletenessChecker::new(mode, repo.merged(), &C::DESCRIPTION, config_prefix);
config.visit_config(&mut visitor);
let CompletenessChecker { found_params, .. } = visitor;
Ok((found_params, config))
}
#[track_caller]
pub fn test_minimal(&self, sample: impl ConfigSource) -> Result<C, ParseErrors> {
let repo = self.new_repository().with(sample);
let (redundant_params, config) =
Self::test_with_checker(&repo, CompletenessCheckerMode::Minimal)?;
assert!(
redundant_params.is_empty(),
"The provided sample is not minimal; params with default values: {redundant_params:?}"
);
Ok(config)
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use smart_config_derive::DescribeConfig;
use super::*;
use crate::{
config,
testonly::{CompoundConfig, DefaultingConfig, EnumConfig, NestedConfig, SimpleEnum},
Environment, Json,
};
#[test]
fn testing_config() {
let config = test::<DefaultingConfig>(Json::empty("test.json")).unwrap();
assert_eq!(config, DefaultingConfig::default());
let config = test_minimal::<DefaultingConfig>(Json::empty("test.json")).unwrap();
assert_eq!(config, DefaultingConfig::default());
let json = config!("float": 4.2, "url": ());
let config = test::<DefaultingConfig>(json).unwrap();
assert_eq!(
config,
DefaultingConfig {
float: Some(4.2),
url: None,
..DefaultingConfig::default()
}
);
}
#[should_panic(expected = "missing params")]
#[test]
fn panicking_on_incomplete_sample() {
let json = config!("renamed": "first", "nested.renamed": "second");
test_complete::<CompoundConfig>(json).unwrap();
}
#[should_panic(expected = "sample is not minimal")]
#[test]
fn panicking_on_redundant_sample() {
let json = config!("renamed": "first", "other_int": 23);
test_minimal::<NestedConfig>(json).unwrap();
}
#[test]
fn minimal_testing() {
let json = config!("renamed": "first", "nested.renamed": "second");
let config: CompoundConfig = test_minimal(json).unwrap();
assert_eq!(config.flat.simple_enum, SimpleEnum::First);
assert_eq!(config.nested.simple_enum, SimpleEnum::Second);
assert!(config.nested_opt.is_none());
assert_eq!(config.nested_default, NestedConfig::default_nested());
}
#[test]
fn complete_testing() {
let json = config!(
"other_int": 123,
"renamed": "first",
"map": HashMap::from([("test", 3)]),
"nested.other_int": 42,
"nested.renamed": "second",
"nested.map": HashMap::from([("test", 2)]),
"nested_opt.other_int": 777,
"nested_opt.renamed": "first",
"nested_opt.map": HashMap::<&str, u32>::new(),
"default.other_int": 11,
"default.renamed": "second",
"default.map": HashMap::from([("test", 1)]),
);
let config = test_complete::<CompoundConfig>(json).unwrap();
assert_eq!(config.flat.other_int, 123);
assert_eq!(config.nested.other_int, 42);
assert_eq!(config.nested_default.other_int, 11);
let opt = config.nested_opt.unwrap();
assert_eq!(opt.other_int, 777);
assert_eq!(opt.simple_enum, SimpleEnum::First);
assert_eq!(opt.map, HashMap::new());
}
#[test]
fn complete_testing_for_env_vars() {
let env = Environment::from_dotenv(
"test.env",
r#"
APP_INT=123
APP_FLOAT=8.4
APP_URL="https://example.com/"
APP_SET="first,second"
"#,
)
.unwrap()
.strip_prefix("APP_");
let config = test_complete::<DefaultingConfig>(env).unwrap();
assert_eq!(config.int, 123);
assert_eq!(config.float, Some(8.4));
assert_eq!(config.url.unwrap(), "https://example.com/");
assert_eq!(
config.set,
HashSet::from([SimpleEnum::First, SimpleEnum::Second])
);
}
#[test]
fn complete_testing_for_enum_configs() {
let json = config!("type": "first");
let config = test_complete::<EnumConfig>(json).unwrap();
assert_eq!(config, EnumConfig::First);
let json = config!("type": "Fields", "string": "!", "flag": false, "set": [1, 2]);
let config = test_complete::<EnumConfig>(json).unwrap();
assert_eq!(
config,
EnumConfig::WithFields {
string: Some("!".to_owned()),
flag: false,
set: HashSet::from([1, 2]),
}
);
}
#[should_panic(expected = "missing params")]
#[test]
fn incomplete_enum_config() {
let json = config!("type": "Fields");
test_complete::<EnumConfig>(json).unwrap();
}
#[should_panic(expected = "opt.nested_opt.other_int")]
#[test]
fn panicking_on_incomplete_sample_with_optional_nested_config() {
#[derive(Debug, DescribeConfig, DeserializeConfig)]
#[config(crate = crate)]
struct TestConfig {
required: u32,
#[config(nest)]
opt: Option<CompoundConfig>,
}
let json = config!("required": 42);
test_complete::<TestConfig>(json).ok();
}
}