use crate::static_regex;
use crate::utils::{TomlExt, fs, log_backtrace};
use anyhow::{Context, Error, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::env;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use toml::Value;
use toml::value::Table;
use tracing::{debug, trace};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive]
pub struct Config {
pub book: BookConfig,
#[serde(skip_serializing_if = "is_default")]
pub build: BuildConfig,
#[serde(skip_serializing_if = "is_default")]
pub rust: RustConfig,
#[serde(skip_serializing_if = "toml_is_empty")]
output: Value,
#[serde(skip_serializing_if = "toml_is_empty")]
preprocessor: Value,
}
fn is_default<T: Default + PartialEq>(t: &T) -> bool {
t == &T::default()
}
fn toml_is_empty(table: &Value) -> bool {
table.as_table().unwrap().is_empty()
}
impl FromStr for Config {
type Err = Error;
fn from_str(src: &str) -> Result<Self> {
toml::from_str(src).with_context(|| "Invalid configuration file")
}
}
impl Default for Config {
fn default() -> Config {
Config {
book: BookConfig::default(),
build: BuildConfig::default(),
rust: RustConfig::default(),
output: Value::Table(Table::default()),
preprocessor: Value::Table(Table::default()),
}
}
}
impl Config {
pub fn from_disk<P: AsRef<Path>>(config_file: P) -> Result<Config> {
let cfg = fs::read_to_string(config_file)?;
Config::from_str(&cfg)
}
pub fn update_from_env(&mut self) -> Result<()> {
debug!("Updating the config from environment variables");
static_regex!(
VALID_KEY,
r"^(:?book|build|rust|output|preprocessor)(:?$|\.)"
);
let overrides =
env::vars().filter_map(|(key, value)| parse_env(&key).map(|index| (index, value)));
for (key, value) in overrides {
trace!("{} => {}", key, value);
if !VALID_KEY.is_match(&key) {
continue;
}
let parsed_value = serde_json::from_str(&value)
.unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
self.set(key, parsed_value)?;
}
Ok(())
}
pub fn get<'de, T: Deserialize<'de>>(&self, name: &str) -> Result<Option<T>> {
let (key, table) = if let Some(key) = name.strip_prefix("output.") {
(key, &self.output)
} else if let Some(key) = name.strip_prefix("preprocessor.") {
(key, &self.preprocessor)
} else {
bail!(
"unable to get `{name}`, only `output` and `preprocessor` table entries are allowed"
);
};
table
.read(key)
.map(|value| {
value
.clone()
.try_into()
.with_context(|| format!("Failed to deserialize `{name}`"))
})
.transpose()
}
pub fn contains_key(&self, name: &str) -> bool {
if let Some(key) = name.strip_prefix("output.") {
self.output.read(key)
} else if let Some(key) = name.strip_prefix("preprocessor.") {
self.preprocessor.read(key)
} else {
panic!("invalid key `{name}`");
}
.is_some()
}
pub fn preprocessors<'de, T: Deserialize<'de>>(&self) -> Result<BTreeMap<String, T>> {
self.preprocessor
.clone()
.try_into()
.with_context(|| "Failed to read preprocessors")
}
pub fn outputs<'de, T: Deserialize<'de>>(&self) -> Result<BTreeMap<String, T>> {
self.output
.clone()
.try_into()
.with_context(|| "Failed to read renderers")
}
#[doc(hidden)]
pub fn html_config(&self) -> Option<HtmlConfig> {
match self.get("output.html") {
Ok(Some(config)) => Some(config),
Ok(None) => None,
Err(e) => {
log_backtrace(&e);
None
}
}
}
pub fn set<S: Serialize, I: AsRef<str>>(&mut self, index: I, value: S) -> Result<()> {
let index = index.as_ref();
let value = Value::try_from(value)
.with_context(|| "Unable to represent the item as a JSON Value")?;
if index == "book" {
self.book = value.try_into()?;
} else if index == "build" {
self.build = value.try_into()?;
} else if index == "rust" {
self.rust = value.try_into()?;
} else if index == "output" {
self.output = value;
} else if index == "preprocessor" {
self.preprocessor = value;
} else if let Some(key) = index.strip_prefix("book.") {
self.book.update_value(key, value)?;
} else if let Some(key) = index.strip_prefix("build.") {
self.build.update_value(key, value)?;
} else if let Some(key) = index.strip_prefix("rust.") {
self.rust.update_value(key, value)?;
} else if let Some(key) = index.strip_prefix("output.") {
self.output.update_value(key, value)?;
} else if let Some(key) = index.strip_prefix("preprocessor.") {
self.preprocessor.update_value(key, value)?;
} else {
bail!("invalid key `{index}`");
}
Ok(())
}
}
fn parse_env(key: &str) -> Option<String> {
key.strip_prefix("MDBOOK_")
.map(|key| key.to_lowercase().replace("__", ".").replace('_', "-"))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive]
pub struct BookConfig {
pub title: Option<String>,
pub authors: Vec<String>,
pub description: Option<String>,
#[serde(skip_serializing_if = "is_default_src")]
pub src: PathBuf,
pub language: Option<String>,
pub text_direction: Option<TextDirection>,
}
fn is_default_src(src: &PathBuf) -> bool {
src == Path::new("src")
}
impl Default for BookConfig {
fn default() -> BookConfig {
BookConfig {
title: None,
authors: Vec::new(),
description: None,
src: PathBuf::from("src"),
language: Some(String::from("en")),
text_direction: None,
}
}
}
impl BookConfig {
pub fn realized_text_direction(&self) -> TextDirection {
if let Some(direction) = self.text_direction {
direction
} else {
TextDirection::from_lang_code(self.language.as_deref().unwrap_or_default())
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum TextDirection {
#[serde(rename = "ltr")]
LeftToRight,
#[serde(rename = "rtl")]
RightToLeft,
}
impl TextDirection {
pub fn from_lang_code(code: &str) -> Self {
match code {
"ar" | "ara" | "arc" | "ae" | "ave" | "egy" | "he" | "heb" | "nqo" | "pal" | "phn"
| "sam" | "syc" | "syr" | "fa" | "per" | "fas" | "ku" | "kur" | "ur" | "urd"
| "pus" | "ps" | "yi" | "yid" => TextDirection::RightToLeft,
_ => TextDirection::LeftToRight,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive]
pub struct BuildConfig {
pub build_dir: PathBuf,
pub create_missing: bool,
pub use_default_preprocessors: bool,
pub extra_watch_dirs: Vec<PathBuf>,
}
impl Default for BuildConfig {
fn default() -> BuildConfig {
BuildConfig {
build_dir: PathBuf::from("book"),
create_missing: true,
use_default_preprocessors: true,
extra_watch_dirs: Vec::new(),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive]
pub struct RustConfig {
pub edition: Option<RustEdition>,
}
#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum RustEdition {
#[serde(rename = "2024")]
E2024,
#[serde(rename = "2021")]
E2021,
#[serde(rename = "2018")]
E2018,
#[serde(rename = "2015")]
E2015,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive]
pub struct HtmlConfig {
pub theme: Option<PathBuf>,
pub default_theme: Option<String>,
pub preferred_dark_theme: Option<String>,
pub smart_punctuation: bool,
pub definition_lists: bool,
pub admonitions: bool,
pub mathjax_support: bool,
pub additional_css: Vec<PathBuf>,
pub additional_js: Vec<PathBuf>,
pub fold: Fold,
#[serde(alias = "playpen")]
pub playground: Playground,
pub code: Code,
pub print: Print,
pub no_section_label: bool,
pub search: Option<Search>,
pub git_repository_url: Option<String>,
pub git_repository_icon: Option<String>,
pub input_404: Option<String>,
pub site_url: Option<String>,
pub cname: Option<String>,
pub edit_url_template: Option<String>,
#[doc(hidden)]
pub live_reload_endpoint: Option<String>,
pub redirect: HashMap<String, String>,
pub hash_files: bool,
pub sidebar_header_nav: bool,
}
impl Default for HtmlConfig {
fn default() -> HtmlConfig {
HtmlConfig {
theme: None,
default_theme: None,
preferred_dark_theme: None,
smart_punctuation: true,
definition_lists: true,
admonitions: true,
mathjax_support: false,
additional_css: Vec::new(),
additional_js: Vec::new(),
fold: Fold::default(),
playground: Playground::default(),
code: Code::default(),
print: Print::default(),
no_section_label: false,
search: None,
git_repository_url: None,
git_repository_icon: None,
input_404: None,
site_url: None,
cname: None,
edit_url_template: None,
live_reload_endpoint: None,
redirect: HashMap::new(),
hash_files: true,
sidebar_header_nav: true,
}
}
}
impl HtmlConfig {
pub fn theme_dir(&self, root: &Path) -> PathBuf {
match self.theme {
Some(ref d) => root.join(d),
None => root.join("theme"),
}
}
pub fn get_404_output_file(&self) -> String {
self.input_404
.as_ref()
.unwrap_or(&"404.md".to_string())
.replace(".md", ".html")
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive]
pub struct Print {
pub enable: bool,
pub page_break: bool,
}
impl Default for Print {
fn default() -> Self {
Self {
enable: true,
page_break: true,
}
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive]
pub struct Fold {
pub enable: bool,
pub level: u8,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive]
pub struct Playground {
pub editable: bool,
pub copyable: bool,
pub copy_js: bool,
pub line_numbers: bool,
pub runnable: bool,
}
impl Default for Playground {
fn default() -> Playground {
Playground {
editable: false,
copyable: true,
copy_js: true,
line_numbers: false,
runnable: true,
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive]
pub struct Code {
pub hidelines: HashMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive]
pub struct Search {
pub enable: bool,
pub limit_results: u32,
pub teaser_word_count: u32,
pub use_boolean_and: bool,
pub boost_title: u8,
pub boost_hierarchy: u8,
pub boost_paragraph: u8,
pub expand: bool,
pub heading_split_level: u8,
pub copy_js: bool,
pub chapter: HashMap<String, SearchChapterSettings>,
}
impl Default for Search {
fn default() -> Search {
Search {
enable: true,
limit_results: 30,
teaser_word_count: 30,
use_boolean_and: false,
boost_title: 2,
boost_hierarchy: 1,
boost_paragraph: 1,
expand: true,
heading_split_level: 3,
copy_js: true,
chapter: HashMap::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive]
pub struct SearchChapterSettings {
pub enable: Option<bool>,
}
trait Updateable<'de>: Serialize + Deserialize<'de> {
fn update_value<S: Serialize>(&mut self, key: &str, value: S) -> Result<()> {
let mut raw = Value::try_from(&self).expect("unreachable");
let value = Value::try_from(value)?;
raw.insert(key, value);
let updated = raw.try_into()?;
*self = updated;
Ok(())
}
}
impl<'de, T> Updateable<'de> for T where T: Serialize + Deserialize<'de> {}
#[cfg(test)]
mod tests {
use super::*;
const COMPLEX_CONFIG: &str = r#"
[book]
title = "Some Book"
authors = ["Michael-F-Bryan <michaelfbryan@gmail.com>"]
description = "A completely useless book"
src = "source"
language = "ja"
[build]
build-dir = "outputs"
create-missing = false
use-default-preprocessors = true
[output.html]
theme = "./themedir"
default-theme = "rust"
smart-punctuation = true
additional-css = ["./foo/bar/baz.css"]
git-repository-url = "https://foo.com/"
git-repository-icon = "fa-code-fork"
[output.html.playground]
editable = true
[output.html.redirect]
"index.html" = "overview.html"
"nexted/page.md" = "https://rust-lang.org/"
[preprocessor.first]
[preprocessor.second]
"#;
#[test]
fn load_a_complex_config_file() {
let src = COMPLEX_CONFIG;
let book_should_be = BookConfig {
title: Some(String::from("Some Book")),
authors: vec![String::from("Michael-F-Bryan <michaelfbryan@gmail.com>")],
description: Some(String::from("A completely useless book")),
src: PathBuf::from("source"),
language: Some(String::from("ja")),
text_direction: None,
};
let build_should_be = BuildConfig {
build_dir: PathBuf::from("outputs"),
create_missing: false,
use_default_preprocessors: true,
extra_watch_dirs: Vec::new(),
};
let rust_should_be = RustConfig { edition: None };
let playground_should_be = Playground {
editable: true,
copyable: true,
copy_js: true,
line_numbers: false,
runnable: true,
};
let html_should_be = HtmlConfig {
smart_punctuation: true,
additional_css: vec![PathBuf::from("./foo/bar/baz.css")],
theme: Some(PathBuf::from("./themedir")),
default_theme: Some(String::from("rust")),
playground: playground_should_be,
git_repository_url: Some(String::from("https://foo.com/")),
git_repository_icon: Some(String::from("fa-code-fork")),
redirect: vec![
(String::from("index.html"), String::from("overview.html")),
(
String::from("nexted/page.md"),
String::from("https://rust-lang.org/"),
),
]
.into_iter()
.collect(),
..Default::default()
};
let got = Config::from_str(src).unwrap();
assert_eq!(got.book, book_should_be);
assert_eq!(got.build, build_should_be);
assert_eq!(got.rust, rust_should_be);
assert_eq!(got.html_config().unwrap(), html_should_be);
}
#[test]
fn disable_runnable() {
let src = r#"
[book]
title = "Some Book"
description = "book book book"
authors = ["Shogo Takata"]
[output.html.playground]
runnable = false
"#;
let got = Config::from_str(src).unwrap();
assert!(!got.html_config().unwrap().playground.runnable);
}
#[test]
fn edition_2015() {
let src = r#"
[book]
title = "mdBook Documentation"
description = "Create book from markdown files. Like Gitbook but implemented in Rust"
authors = ["Mathieu David"]
src = "./source"
[rust]
edition = "2015"
"#;
let book_should_be = BookConfig {
title: Some(String::from("mdBook Documentation")),
description: Some(String::from(
"Create book from markdown files. Like Gitbook but implemented in Rust",
)),
authors: vec![String::from("Mathieu David")],
src: PathBuf::from("./source"),
..Default::default()
};
let got = Config::from_str(src).unwrap();
assert_eq!(got.book, book_should_be);
let rust_should_be = RustConfig {
edition: Some(RustEdition::E2015),
};
let got = Config::from_str(src).unwrap();
assert_eq!(got.rust, rust_should_be);
}
#[test]
fn edition_2018() {
let src = r#"
[book]
title = "mdBook Documentation"
description = "Create book from markdown files. Like Gitbook but implemented in Rust"
authors = ["Mathieu David"]
src = "./source"
[rust]
edition = "2018"
"#;
let rust_should_be = RustConfig {
edition: Some(RustEdition::E2018),
};
let got = Config::from_str(src).unwrap();
assert_eq!(got.rust, rust_should_be);
}
#[test]
fn edition_2021() {
let src = r#"
[book]
title = "mdBook Documentation"
description = "Create book from markdown files. Like Gitbook but implemented in Rust"
authors = ["Mathieu David"]
src = "./source"
[rust]
edition = "2021"
"#;
let rust_should_be = RustConfig {
edition: Some(RustEdition::E2021),
};
let got = Config::from_str(src).unwrap();
assert_eq!(got.rust, rust_should_be);
}
#[test]
fn load_arbitrary_output_type() {
#[derive(Debug, Deserialize, PartialEq)]
struct RandomOutput {
foo: u32,
bar: String,
baz: Vec<bool>,
}
let src = r#"
[output.random]
foo = 5
bar = "Hello World"
baz = [true, true, false]
"#;
let should_be = RandomOutput {
foo: 5,
bar: String::from("Hello World"),
baz: vec![true, true, false],
};
let cfg = Config::from_str(src).unwrap();
let got: RandomOutput = cfg.get("output.random").unwrap().unwrap();
assert_eq!(got, should_be);
let got_baz: Vec<bool> = cfg.get("output.random.baz").unwrap().unwrap();
let baz_should_be = vec![true, true, false];
assert_eq!(got_baz, baz_should_be);
}
#[test]
fn set_special_tables() {
let mut cfg = Config::default();
assert_eq!(cfg.book.title, None);
cfg.set("book.title", "my title").unwrap();
assert_eq!(cfg.book.title, Some("my title".to_string()));
assert_eq!(&cfg.build.build_dir, Path::new("book"));
cfg.set("build.build-dir", "some-directory").unwrap();
assert_eq!(&cfg.build.build_dir, Path::new("some-directory"));
assert_eq!(cfg.rust.edition, None);
cfg.set("rust.edition", "2024").unwrap();
assert_eq!(cfg.rust.edition, Some(RustEdition::E2024));
cfg.set("output.foo.value", "123").unwrap();
let got: String = cfg.get("output.foo.value").unwrap().unwrap();
assert_eq!(got, "123");
cfg.set("preprocessor.bar.value", "456").unwrap();
let got: String = cfg.get("preprocessor.bar.value").unwrap().unwrap();
assert_eq!(got, "456");
}
#[test]
fn set_invalid_keys() {
let mut cfg = Config::default();
let err = cfg.set("foo", "test").unwrap_err();
assert!(err.to_string().contains("invalid key `foo`"));
}
#[test]
fn parse_env_vars() {
let inputs = vec![
("FOO", None),
("MDBOOK_foo", Some("foo")),
("MDBOOK_FOO__bar__baz", Some("foo.bar.baz")),
("MDBOOK_FOO_bar__baz", Some("foo-bar.baz")),
];
for (src, should_be) in inputs {
let got = parse_env(src);
let should_be = should_be.map(ToString::to_string);
assert_eq!(got, should_be);
}
}
#[test]
fn file_404_default() {
let src = r#"
[output.html]
"#;
let got = Config::from_str(src).unwrap();
let html_config = got.html_config().unwrap();
assert_eq!(html_config.input_404, None);
assert_eq!(html_config.get_404_output_file(), "404.html");
}
#[test]
fn file_404_custom() {
let src = r#"
[output.html]
input-404= "missing.md"
"#;
let got = Config::from_str(src).unwrap();
let html_config = got.html_config().unwrap();
assert_eq!(html_config.input_404, Some("missing.md".to_string()));
assert_eq!(html_config.get_404_output_file(), "missing.html");
}
#[test]
fn text_direction_ltr() {
let src = r#"
[book]
text-direction = "ltr"
"#;
let got = Config::from_str(src).unwrap();
assert_eq!(got.book.text_direction, Some(TextDirection::LeftToRight));
}
#[test]
fn text_direction_rtl() {
let src = r#"
[book]
text-direction = "rtl"
"#;
let got = Config::from_str(src).unwrap();
assert_eq!(got.book.text_direction, Some(TextDirection::RightToLeft));
}
#[test]
fn text_direction_none() {
let src = r#"
[book]
"#;
let got = Config::from_str(src).unwrap();
assert_eq!(got.book.text_direction, None);
}
#[test]
fn test_text_direction() {
let mut cfg = BookConfig::default();
cfg.language = Some("ar".into());
assert_eq!(cfg.realized_text_direction(), TextDirection::RightToLeft);
cfg.language = Some("he".into());
assert_eq!(cfg.realized_text_direction(), TextDirection::RightToLeft);
cfg.language = Some("en".into());
assert_eq!(cfg.realized_text_direction(), TextDirection::LeftToRight);
cfg.language = Some("ja".into());
assert_eq!(cfg.realized_text_direction(), TextDirection::LeftToRight);
cfg.language = Some("ar".into());
cfg.text_direction = Some(TextDirection::LeftToRight);
assert_eq!(cfg.realized_text_direction(), TextDirection::LeftToRight);
cfg.language = Some("ar".into());
cfg.text_direction = Some(TextDirection::RightToLeft);
assert_eq!(cfg.realized_text_direction(), TextDirection::RightToLeft);
cfg.language = Some("en".into());
cfg.text_direction = Some(TextDirection::LeftToRight);
assert_eq!(cfg.realized_text_direction(), TextDirection::LeftToRight);
cfg.language = Some("en".into());
cfg.text_direction = Some(TextDirection::RightToLeft);
assert_eq!(cfg.realized_text_direction(), TextDirection::RightToLeft);
}
#[test]
#[should_panic(expected = "Invalid configuration file")]
fn invalid_language_type_error() {
let src = r#"
[book]
title = "mdBook Documentation"
language = ["en", "pt-br"]
description = "Create book from markdown files. Like Gitbook but implemented in Rust"
authors = ["Mathieu David"]
src = "./source"
"#;
Config::from_str(src).unwrap();
}
#[test]
#[should_panic(expected = "Invalid configuration file")]
fn invalid_title_type() {
let src = r#"
[book]
title = 20
language = "en"
description = "Create book from markdown files. Like Gitbook but implemented in Rust"
authors = ["Mathieu David"]
src = "./source"
"#;
Config::from_str(src).unwrap();
}
#[test]
#[should_panic(expected = "Invalid configuration file")]
fn invalid_build_dir_type() {
let src = r#"
[build]
build-dir = 99
create-missing = false
"#;
Config::from_str(src).unwrap();
}
#[test]
#[should_panic(expected = "Invalid configuration file")]
fn invalid_rust_edition() {
let src = r#"
[rust]
edition = "1999"
"#;
Config::from_str(src).unwrap();
}
#[test]
#[should_panic(
expected = "unknown variant `1999`, expected one of `2024`, `2021`, `2018`, `2015`\n"
)]
fn invalid_rust_edition_expected() {
let src = r#"
[rust]
edition = "1999"
"#;
Config::from_str(src).unwrap();
}
#[test]
fn print_config() {
let src = r#"
[output.html.print]
enable = false
"#;
let got = Config::from_str(src).unwrap();
let html_config = got.html_config().unwrap();
assert!(!html_config.print.enable);
assert!(html_config.print.page_break);
let src = r#"
[output.html.print]
page-break = false
"#;
let got = Config::from_str(src).unwrap();
let html_config = got.html_config().unwrap();
assert!(html_config.print.enable);
assert!(!html_config.print.page_break);
}
#[test]
fn test_json_direction() {
use serde_json::json;
assert_eq!(json!(TextDirection::RightToLeft), json!("rtl"));
assert_eq!(json!(TextDirection::LeftToRight), json!("ltr"));
}
#[test]
fn get_deserialize_error() {
let src = r#"
[preprocessor.foo]
x = 123
"#;
let cfg = Config::from_str(src).unwrap();
let err = cfg.get::<String>("preprocessor.foo.x").unwrap_err();
assert_eq!(
err.to_string(),
"Failed to deserialize `preprocessor.foo.x`"
);
}
#[test]
fn contains_key() {
let src = r#"
[preprocessor.foo]
x = 123
[output.foo.sub]
y = 'x'
"#;
let cfg = Config::from_str(src).unwrap();
assert!(cfg.contains_key("preprocessor.foo"));
assert!(cfg.contains_key("preprocessor.foo.x"));
assert!(!cfg.contains_key("preprocessor.bar"));
assert!(!cfg.contains_key("preprocessor.foo.y"));
assert!(cfg.contains_key("output.foo"));
assert!(cfg.contains_key("output.foo.sub"));
assert!(cfg.contains_key("output.foo.sub.y"));
assert!(!cfg.contains_key("output.bar"));
assert!(!cfg.contains_key("output.foo.sub.z"));
}
}