1use std::{any, cell::RefCell, collections::HashMap, marker::PhantomData, mem};
4
5use crate::{
6 ConfigRepository, ConfigSource, DeserializeConfig, ParseErrors,
7 de::DeserializerOptions,
8 metadata::{ConfigMetadata, NestedConfigMetadata, ParamMetadata, RustType},
9 schema::ConfigSchema,
10 value::{Pointer, WithOrigin},
11 visit::{ConfigVisitor, VisitConfig},
12};
13
14thread_local! {
17 pub(crate) static MOCK_ENV_VARS: RefCell<HashMap<String, String>> = RefCell::default();
18}
19
20#[derive(Debug)]
21pub(crate) struct MockEnvGuard {
22 _not_send: PhantomData<*mut ()>,
23}
24
25impl Default for MockEnvGuard {
26 fn default() -> Self {
27 MOCK_ENV_VARS.with_borrow(|vars| {
28 assert!(
29 vars.is_empty(),
30 "Cannot define mock env vars while another `Tester` is active"
31 );
32 });
33
34 Self {
35 _not_send: PhantomData,
36 }
37 }
38}
39
40impl MockEnvGuard {
41 #[allow(clippy::unused_self)] pub(crate) fn set_env(&self, name: String, value: String) {
43 MOCK_ENV_VARS.with_borrow_mut(|vars| vars.insert(name, value));
44 }
45}
46
47impl Drop for MockEnvGuard {
48 fn drop(&mut self) {
49 MOCK_ENV_VARS.take(); }
51}
52
53pub fn test<C: DeserializeConfig>(sample: impl ConfigSource) -> Result<C, ParseErrors> {
103 Tester::default().test(sample)
104}
105
106#[track_caller] pub fn test_complete<C: DeserializeConfig>(sample: impl ConfigSource) -> Result<C, ParseErrors> {
160 Tester::default().test_complete(sample)
161}
162
163#[track_caller]
217pub fn test_minimal<C: DeserializeConfig>(sample: impl ConfigSource) -> Result<C, ParseErrors> {
218 Tester::default().test_minimal(sample)
219}
220
221#[derive(Debug)]
222enum CompletenessCheckerMode {
223 Complete,
224 Minimal,
225}
226
227#[derive(Debug)]
228#[must_use = "must be put back"]
229struct Checkpoint {
230 prev_config: &'static ConfigMetadata,
231 prev_path: Option<String>,
232}
233
234#[derive(Debug)]
235struct CompletenessChecker<'a> {
236 mode: CompletenessCheckerMode,
237 current_path: String,
238 sample: &'a WithOrigin,
239 config: &'static ConfigMetadata,
240 found_params: HashMap<String, RustType>,
241}
242
243impl<'a> CompletenessChecker<'a> {
244 fn new(
245 mode: CompletenessCheckerMode,
246 sample: &'a WithOrigin,
247 config: &'static ConfigMetadata,
248 config_prefix: &str,
249 ) -> Self {
250 Self {
251 mode,
252 current_path: config_prefix.to_owned(),
253 sample,
254 config,
255 found_params: HashMap::new(),
256 }
257 }
258
259 fn check_param(&mut self, param: &ParamMetadata) {
260 let param_path = Pointer(&self.current_path).join(param.name);
261 let should_add_param = match self.mode {
262 CompletenessCheckerMode::Complete => self.sample.get(Pointer(¶m_path)).is_none(),
263 CompletenessCheckerMode::Minimal => {
264 param.default_value.is_some() && self.sample.get(Pointer(¶m_path)).is_some()
265 }
266 };
267
268 if should_add_param {
269 self.found_params.insert(param_path, param.rust_type);
270 }
271 }
272
273 fn insert_param(&mut self, param: &ParamMetadata) {
274 let param_path = Pointer(&self.current_path).join(param.name);
275 self.found_params.insert(param_path, param.rust_type);
276 }
277
278 fn push_config(&mut self, config_meta: &NestedConfigMetadata) -> Checkpoint {
279 let prev_config = mem::replace(&mut self.config, config_meta.meta);
280 let prev_path = if config_meta.name.is_empty() {
281 None
282 } else {
283 let nested_path = Pointer(&self.current_path).join(config_meta.name);
284 Some(mem::replace(&mut self.current_path, nested_path))
285 };
286 Checkpoint {
287 prev_config,
288 prev_path,
289 }
290 }
291
292 fn pop_config(&mut self, checkpoint: Checkpoint) {
293 self.config = checkpoint.prev_config;
294 if let Some(path) = checkpoint.prev_path {
295 self.current_path = path;
296 }
297 }
298
299 fn collect_all_params(&mut self) {
300 if let Some(tag) = &self.config.tag {
301 self.insert_param(tag.param);
303 return;
304 }
305
306 for param in self.config.params {
307 self.insert_param(param);
308 }
309 for config_meta in self.config.nested_configs {
310 let checkpoint = self.push_config(config_meta);
311 self.collect_all_params();
312 self.pop_config(checkpoint);
313 }
314 }
315}
316
317impl ConfigVisitor for CompletenessChecker<'_> {
318 fn visit_tag(&mut self, _variant_index: usize) {
319 let param = self.config.tag.unwrap().param;
320 self.check_param(param);
321 }
322
323 fn visit_param(&mut self, param_index: usize, _value: &dyn any::Any) {
324 let param = &self.config.params[param_index];
325 self.check_param(param);
326 }
327
328 fn visit_nested_config(&mut self, config_index: usize, config: &dyn VisitConfig) {
329 let config_meta = &self.config.nested_configs[config_index];
330 let checkpoint = self.push_config(config_meta);
331 config.visit_config(self);
332 self.pop_config(checkpoint);
333 }
334
335 fn visit_nested_opt_config(&mut self, config_index: usize, config: Option<&dyn VisitConfig>) {
336 if let Some(config) = config {
337 self.visit_nested_config(config_index, config);
338 } else if matches!(self.mode, CompletenessCheckerMode::Complete) {
339 let config_meta = &self.config.nested_configs[config_index];
340 let checkpoint = self.push_config(config_meta);
341 self.collect_all_params();
342 self.pop_config(checkpoint);
343 }
344 }
345}
346
347#[derive(Debug)]
348struct TesterData {
349 de_options: DeserializerOptions,
350 schema: ConfigSchema,
351 env_guard: MockEnvGuard,
352}
353
354#[derive(Debug)]
355enum TesterDataGoat<'a> {
356 Owned(TesterData),
357 Borrowed(&'a mut TesterData),
358}
359
360impl AsRef<TesterData> for TesterDataGoat<'_> {
361 fn as_ref(&self) -> &TesterData {
362 match self {
363 Self::Owned(data) => data,
364 Self::Borrowed(data) => data,
365 }
366 }
367}
368
369impl AsMut<TesterData> for TesterDataGoat<'_> {
370 fn as_mut(&mut self) -> &mut TesterData {
371 match self {
372 Self::Owned(data) => data,
373 Self::Borrowed(data) => data,
374 }
375 }
376}
377
378#[derive(Debug)]
423pub struct Tester<'a, C> {
424 data: TesterDataGoat<'a>,
425 _config: PhantomData<C>,
426}
427
428impl<C: DeserializeConfig + VisitConfig> Default for Tester<'static, C> {
429 fn default() -> Self {
430 Self {
431 data: TesterDataGoat::Owned(TesterData {
432 de_options: DeserializerOptions::default(),
433 schema: ConfigSchema::new(&C::DESCRIPTION, ""),
434 env_guard: MockEnvGuard::default(),
435 }),
436 _config: PhantomData,
437 }
438 }
439}
440
441impl Default for Tester<'static, ()> {
442 fn default() -> Self {
443 Self {
444 data: TesterDataGoat::Owned(TesterData {
445 de_options: DeserializerOptions::default(),
446 schema: ConfigSchema::default(),
447 env_guard: MockEnvGuard::default(),
448 }),
449 _config: PhantomData,
450 }
451 }
452}
453
454impl Tester<'_, ()> {
455 pub fn new(schema: ConfigSchema) -> Self {
457 Self {
458 data: TesterDataGoat::Owned(TesterData {
459 de_options: DeserializerOptions::default(),
460 schema,
461 env_guard: MockEnvGuard::default(),
462 }),
463 _config: PhantomData,
464 }
465 }
466
467 pub fn for_config<C: DeserializeConfig + VisitConfig>(&mut self) -> Tester<'_, C> {
473 self.data.as_ref().schema.single(&C::DESCRIPTION).unwrap();
475 Tester {
476 data: TesterDataGoat::Borrowed(self.data.as_mut()),
477 _config: PhantomData,
478 }
479 }
480
481 pub fn insert<C: DeserializeConfig + VisitConfig>(
488 &mut self,
489 prefix: &'static str,
490 ) -> Tester<'_, C> {
491 self.data
492 .as_mut()
493 .schema
494 .insert(&C::DESCRIPTION, prefix)
495 .expect("failed inserting config into schema");
496 Tester {
497 data: TesterDataGoat::Borrowed(self.data.as_mut()),
498 _config: PhantomData,
499 }
500 }
501}
502
503impl<C> Tester<'_, C> {
504 pub fn coerce_variant_names(&mut self) -> &mut Self {
506 self.data.as_mut().de_options.coerce_variant_names = true;
507 self
508 }
509
510 pub fn coerce_serde_enums(&mut self) -> &mut Self {
512 self.data.as_mut().schema.coerce_serde_enums(true);
513 self
514 }
515
516 pub fn set_env(&mut self, var_name: impl Into<String>, value: impl Into<String>) -> &mut Self {
521 self.data
522 .as_mut()
523 .env_guard
524 .set_env(var_name.into(), value.into());
525 self
526 }
527
528 pub fn new_repository(&self) -> ConfigRepository<'_> {
530 let data = self.data.as_ref();
531 let mut repo = ConfigRepository::new(&data.schema);
532 *repo.deserializer_options() = data.de_options.clone();
533 repo
534 }
535}
536
537impl<C: DeserializeConfig + VisitConfig> Tester<'_, C> {
538 #[allow(clippy::missing_panics_doc)] pub fn test(&self, sample: impl ConfigSource) -> Result<C, ParseErrors> {
550 let repo = self.new_repository();
551 repo.with(sample).single::<C>().unwrap().parse()
552 }
553
554 #[track_caller]
569 pub fn test_complete(&self, sample: impl ConfigSource) -> Result<C, ParseErrors> {
570 let repo = self.new_repository().with(sample);
571 let (missing_params, config) =
572 Self::test_with_checker(&repo, CompletenessCheckerMode::Complete)?;
573 assert!(
574 missing_params.is_empty(),
575 "The provided sample is incomplete; missing params: {missing_params:?}"
576 );
577 Ok(config)
578 }
579
580 fn test_with_checker(
581 repo: &ConfigRepository<'_>,
582 mode: CompletenessCheckerMode,
583 ) -> Result<(HashMap<String, RustType>, C), ParseErrors> {
584 let config_ref = repo.single::<C>().unwrap();
585 let config_prefix = config_ref.config().prefix();
586 let config = config_ref.parse()?;
587 let mut visitor =
588 CompletenessChecker::new(mode, repo.merged(), &C::DESCRIPTION, config_prefix);
589 config.visit_config(&mut visitor);
590 let CompletenessChecker { found_params, .. } = visitor;
591 Ok((found_params, config))
592 }
593
594 #[track_caller]
609 pub fn test_minimal(&self, sample: impl ConfigSource) -> Result<C, ParseErrors> {
610 let repo = self.new_repository().with(sample);
611 let (redundant_params, config) =
612 Self::test_with_checker(&repo, CompletenessCheckerMode::Minimal)?;
613 assert!(
614 redundant_params.is_empty(),
615 "The provided sample is not minimal; params with default values: {redundant_params:?}"
616 );
617 Ok(config)
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use std::collections::HashSet;
624
625 use smart_config_derive::DescribeConfig;
626
627 use super::*;
628 use crate::{
629 Environment, Json, config,
630 testonly::{CompoundConfig, DefaultingConfig, EnumConfig, NestedConfig, SimpleEnum},
631 };
632
633 #[test]
634 fn testing_config() {
635 let config = test::<DefaultingConfig>(Json::empty("test.json")).unwrap();
636 assert_eq!(config, DefaultingConfig::default());
637
638 let config = test_minimal::<DefaultingConfig>(Json::empty("test.json")).unwrap();
639 assert_eq!(config, DefaultingConfig::default());
640
641 let json = config!("float": 4.2, "url": ());
642 let config = test::<DefaultingConfig>(json).unwrap();
643 assert_eq!(
644 config,
645 DefaultingConfig {
646 float: Some(4.2),
647 url: None,
648 ..DefaultingConfig::default()
649 }
650 );
651 }
652
653 #[should_panic(expected = "missing params")]
654 #[test]
655 fn panicking_on_incomplete_sample() {
656 let json = config!("renamed": "first", "nested.renamed": "second");
657 test_complete::<CompoundConfig>(json).unwrap();
658 }
659
660 #[should_panic(expected = "sample is not minimal")]
661 #[test]
662 fn panicking_on_redundant_sample() {
663 let json = config!("renamed": "first", "other_int": 23);
664 test_minimal::<NestedConfig>(json).unwrap();
665 }
666
667 #[test]
668 fn minimal_testing() {
669 let json = config!("renamed": "first", "nested.renamed": "second");
670 let config: CompoundConfig = test_minimal(json).unwrap();
671 assert_eq!(config.flat.simple_enum, SimpleEnum::First);
672 assert_eq!(config.nested.simple_enum, SimpleEnum::Second);
673 assert!(config.nested_opt.is_none());
674 assert_eq!(config.nested_default, NestedConfig::default_nested());
675 }
676
677 #[test]
678 fn complete_testing() {
679 let json = config!(
680 "other_int": 123,
681 "renamed": "first",
682 "map": HashMap::from([("test", 3)]),
683 "nested.other_int": 42,
684 "nested.renamed": "second",
685 "nested.map": HashMap::from([("test", 2)]),
686 "nested_opt.other_int": 777,
687 "nested_opt.renamed": "first",
688 "nested_opt.map": HashMap::<&str, u32>::new(),
689 "default.other_int": 11,
690 "default.renamed": "second",
691 "default.map": HashMap::from([("test", 1)]),
692 );
693 let config = test_complete::<CompoundConfig>(json).unwrap();
694 assert_eq!(config.flat.other_int, 123);
695 assert_eq!(config.nested.other_int, 42);
696 assert_eq!(config.nested_default.other_int, 11);
697 let opt = config.nested_opt.unwrap();
698 assert_eq!(opt.other_int, 777);
699 assert_eq!(opt.simple_enum, SimpleEnum::First);
700 assert_eq!(opt.map, HashMap::new());
701 }
702
703 #[test]
704 fn complete_testing_for_env_vars() {
705 let env = Environment::from_dotenv(
706 "test.env",
707 r#"
708 APP_INT=123
709 APP_FLOAT=8.4
710 APP_URL="https://example.com/"
711 APP_SET="first,second"
712 "#,
713 )
714 .unwrap()
715 .strip_prefix("APP_");
716 let config = test_complete::<DefaultingConfig>(env).unwrap();
717 assert_eq!(config.int, 123);
718 assert_eq!(config.float, Some(8.4));
719 assert_eq!(config.url.unwrap(), "https://example.com/");
720 assert_eq!(
721 config.set,
722 HashSet::from([SimpleEnum::First, SimpleEnum::Second])
723 );
724 }
725
726 #[test]
727 fn complete_testing_for_enum_configs() {
728 let json = config!("type": "first");
729 let config = test_complete::<EnumConfig>(json).unwrap();
730 assert_eq!(config, EnumConfig::First);
731
732 let json = config!("type": "Fields", "string": "!", "flag": false, "set": [1, 2]);
733 let config = test_complete::<EnumConfig>(json).unwrap();
734 assert_eq!(
735 config,
736 EnumConfig::WithFields {
737 string: Some("!".to_owned()),
738 flag: false,
739 set: HashSet::from([1, 2]),
740 }
741 );
742 }
743
744 #[should_panic(expected = "missing params")]
745 #[test]
746 fn incomplete_enum_config() {
747 let json = config!("type": "Fields");
748 test_complete::<EnumConfig>(json).unwrap();
749 }
750
751 #[should_panic(expected = "opt.nested_opt.other_int")]
752 #[test]
753 fn panicking_on_incomplete_sample_with_optional_nested_config() {
754 #[derive(Debug, DescribeConfig, DeserializeConfig)]
755 #[config(crate = crate)]
756 struct TestConfig {
757 required: u32,
758 #[config(nest)]
759 opt: Option<CompoundConfig>,
760 }
761
762 let json = config!("required": 42);
763 test_complete::<TestConfig>(json).ok();
764 }
765}