Deny all unknown config fields

This changes it so that it is an error if there is ever an unknown
configuration field. This is intended to help avoid things like typos,
or using an outdated version of mdbook. Although it is possible that new
fields could potentially safely be ignored, setting up a warning system
is a bit more of a hassle. I don't think mdbook needs to have the same
kind of multi-version support as something like cargo does. However, if
this ends up being too much of a pain point, we can try to add a warning
system instead.

There are a variety of changes here:

- The top-level config namespace is now closed so that it only accepts
  the keys defined in `Config`.
- All config tables now reject unknown fields.
- Added `Config::outputs` and `Config::preprocessors` for convenience
  to access the entire `output` and `preprocessor` tables.
- Moved the unit-tests that were setting environment variables to the
  testsuite where it launches a process instead.

Closes https://github.com/rust-lang/mdBook/issues/1595
This commit is contained in:
Eric Huss 2025-08-12 15:00:53 -07:00
parent e284eb1c30
commit d29072783f
11 changed files with 287 additions and 227 deletions

View file

@ -13,7 +13,6 @@
//! use std::path::PathBuf; //! use std::path::PathBuf;
//! use std::str::FromStr; //! use std::str::FromStr;
//! use mdbook_core::config::Config; //! use mdbook_core::config::Config;
//! use toml::Value;
//! //!
//! # fn run() -> Result<()> { //! # fn run() -> Result<()> {
//! let src = r#" //! let src = r#"
@ -21,9 +20,6 @@
//! title = "My Book" //! title = "My Book"
//! authors = ["Michael-F-Bryan"] //! authors = ["Michael-F-Bryan"]
//! //!
//! [build]
//! src = "out"
//!
//! [preprocessor.my-preprocessor] //! [preprocessor.my-preprocessor]
//! bar = 123 //! bar = 123
//! "#; //! "#;
@ -36,7 +32,7 @@
//! assert_eq!(bar, Some(123)); //! assert_eq!(bar, Some(123));
//! //!
//! // Set the `output.html.theme` directory //! // Set the `output.html.theme` directory
//! assert!(cfg.get::<Value>("output.html")?.is_none()); //! assert!(cfg.get::<toml::Value>("output.html")?.is_none());
//! cfg.set("output.html.theme", "./themes"); //! cfg.set("output.html.theme", "./themes");
//! //!
//! // then load it again, automatically deserializing to a `PathBuf`. //! // then load it again, automatically deserializing to a `PathBuf`.
@ -49,9 +45,9 @@
use crate::utils::TomlExt; use crate::utils::TomlExt;
use crate::utils::log_backtrace; use crate::utils::log_backtrace;
use anyhow::{Context, Error, Result}; use anyhow::{Context, Error, Result, bail};
use log::{debug, trace, warn}; use log::{debug, trace};
use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::env; use std::env;
use std::fs::File; use std::fs::File;
@ -63,16 +59,34 @@ use toml::value::Table;
/// The overall configuration object for MDBook, essentially an in-memory /// The overall configuration object for MDBook, essentially an in-memory
/// representation of `book.toml`. /// representation of `book.toml`.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive] #[non_exhaustive]
pub struct Config { pub struct Config {
/// Metadata about the book. /// Metadata about the book.
pub book: BookConfig, pub book: BookConfig,
/// Information about the build environment. /// Information about the build environment.
#[serde(skip_serializing_if = "is_default")]
pub build: BuildConfig, pub build: BuildConfig,
/// Information about Rust language support. /// Information about Rust language support.
#[serde(skip_serializing_if = "is_default")]
pub rust: RustConfig, pub rust: RustConfig,
rest: Value, /// The renderer configurations.
#[serde(skip_serializing_if = "toml_is_empty")]
output: Value,
/// The preprocessor configurations.
#[serde(skip_serializing_if = "toml_is_empty")]
preprocessor: Value,
}
/// Helper for serde serialization.
fn is_default<T: Default + PartialEq>(t: &T) -> bool {
t == &T::default()
}
/// Helper for serde serialization.
fn toml_is_empty(table: &Value) -> bool {
table.as_table().unwrap().is_empty()
} }
impl FromStr for Config { impl FromStr for Config {
@ -84,6 +98,18 @@ impl FromStr for Config {
} }
} }
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 { impl Config {
/// Load the configuration file from disk. /// Load the configuration file from disk.
pub fn from_disk<P: AsRef<Path>>(config_file: P) -> Result<Config> { pub fn from_disk<P: AsRef<Path>>(config_file: P) -> Result<Config> {
@ -161,24 +187,48 @@ impl Config {
/// dotted indices to access nested items (e.g. `output.html.playground` /// dotted indices to access nested items (e.g. `output.html.playground`
/// will fetch the "playground" out of the html output table). /// will fetch the "playground" out of the html output table).
/// ///
/// This does not have access to the [`Config::book`], [`Config::build`], /// This can only access the `output` and `preprocessor` tables.
/// or [`Config::rust`] fields.
/// ///
/// Returns `Ok(None)` if the field is not set. /// Returns `Ok(None)` if the field is not set.
/// ///
/// Returns `Err` if it fails to deserialize. /// Returns `Err` if it fails to deserialize.
pub fn get<'de, T: Deserialize<'de>>(&self, name: &str) -> Result<Option<T>> { pub fn get<'de, T: Deserialize<'de>>(&self, name: &str) -> Result<Option<T>> {
self.rest let (key, table) = if let Some(key) = name.strip_prefix("output.") {
.read(name) (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| { .map(|value| {
value value
.clone() .clone()
.try_into() .try_into()
.with_context(|| "Couldn't deserialize the value") .with_context(|| "Failed to deserialize `{name}`")
}) })
.transpose() .transpose()
} }
/// Returns the configuration for all preprocessors.
pub fn preprocessors<'de, T: Deserialize<'de>>(&self) -> Result<HashMap<String, T>> {
self.preprocessor
.clone()
.try_into()
.with_context(|| "Failed to read preprocessors")
}
/// Returns the configuration for all renderers.
pub fn outputs<'de, T: Deserialize<'de>>(&self) -> Result<HashMap<String, T>> {
self.output
.clone()
.try_into()
.with_context(|| "Failed to read renderers")
}
/// Convenience method for getting the html renderer's configuration. /// Convenience method for getting the html renderer's configuration.
/// ///
/// # Note /// # Note
@ -187,10 +237,7 @@ impl Config {
/// HTML renderer is refactored to be less coupled to `mdbook` internals. /// HTML renderer is refactored to be less coupled to `mdbook` internals.
#[doc(hidden)] #[doc(hidden)]
pub fn html_config(&self) -> Option<HtmlConfig> { pub fn html_config(&self) -> Option<HtmlConfig> {
match self match self.get("output.html") {
.get("output.html")
.with_context(|| "Parsing configuration [output.html]")
{
Ok(Some(config)) => Some(config), Ok(Some(config)) => Some(config),
Ok(None) => None, Ok(None) => None,
Err(e) => { Err(e) => {
@ -220,110 +267,27 @@ impl Config {
self.build.update_value(key, value); self.build.update_value(key, value);
} else if let Some(key) = index.strip_prefix("rust.") { } else if let Some(key) = index.strip_prefix("rust.") {
self.rust.update_value(key, value); 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 { } else {
self.rest.insert(index, value); bail!("invalid key `{index}`");
} }
Ok(()) Ok(())
} }
} }
impl Default for Config {
fn default() -> Config {
Config {
book: BookConfig::default(),
build: BuildConfig::default(),
rust: RustConfig::default(),
rest: Value::Table(Table::default()),
}
}
}
impl<'de> serde::Deserialize<'de> for Config {
fn deserialize<D: Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
let raw = Value::deserialize(de)?;
warn_on_invalid_fields(&raw);
use serde::de::Error;
let mut table = match raw {
Value::Table(t) => t,
_ => {
return Err(D::Error::custom(
"A config file should always be a toml table",
));
}
};
let book: BookConfig = table
.remove("book")
.map(|book| book.try_into().map_err(D::Error::custom))
.transpose()?
.unwrap_or_default();
let build: BuildConfig = table
.remove("build")
.map(|build| build.try_into().map_err(D::Error::custom))
.transpose()?
.unwrap_or_default();
let rust: RustConfig = table
.remove("rust")
.map(|rust| rust.try_into().map_err(D::Error::custom))
.transpose()?
.unwrap_or_default();
Ok(Config {
book,
build,
rust,
rest: Value::Table(table),
})
}
}
impl Serialize for Config {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
// TODO: This should probably be removed and use a derive instead.
let mut table = self.rest.clone();
let book_config = Value::try_from(&self.book).expect("should always be serializable");
table.insert("book", book_config);
if self.build != BuildConfig::default() {
let build_config = Value::try_from(&self.build).expect("should always be serializable");
table.insert("build", build_config);
}
if self.rust != RustConfig::default() {
let rust_config = Value::try_from(&self.rust).expect("should always be serializable");
table.insert("rust", rust_config);
}
table.serialize(s)
}
}
fn parse_env(key: &str) -> Option<String> { fn parse_env(key: &str) -> Option<String> {
key.strip_prefix("MDBOOK_") key.strip_prefix("MDBOOK_")
.map(|key| key.to_lowercase().replace("__", ".").replace('_', "-")) .map(|key| key.to_lowercase().replace("__", ".").replace('_', "-"))
} }
fn warn_on_invalid_fields(table: &Value) {
let valid_items = ["book", "build", "rust", "output", "preprocessor"];
let table = table.as_table().expect("root must be a table");
for item in table.keys() {
if !valid_items.contains(&item.as_str()) {
warn!("Invalid field {:?} in book.toml", &item);
}
}
}
/// Configuration options which are specific to the book and required for /// Configuration options which are specific to the book and required for
/// loading it from disk. /// loading it from disk.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive] #[non_exhaustive]
pub struct BookConfig { pub struct BookConfig {
/// The book's title. /// The book's title.
@ -393,7 +357,7 @@ impl TextDirection {
/// Configuration for the build procedure. /// Configuration for the build procedure.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive] #[non_exhaustive]
pub struct BuildConfig { pub struct BuildConfig {
/// Where to put built artefacts relative to the book's root directory. /// Where to put built artefacts relative to the book's root directory.
@ -421,7 +385,7 @@ impl Default for BuildConfig {
/// Configuration for the Rust compiler(e.g., for playground) /// Configuration for the Rust compiler(e.g., for playground)
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive] #[non_exhaustive]
pub struct RustConfig { pub struct RustConfig {
/// Rust edition used in playground /// Rust edition used in playground
@ -448,7 +412,7 @@ pub enum RustEdition {
/// Configuration for the HTML renderer. /// Configuration for the HTML renderer.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive] #[non_exhaustive]
pub struct HtmlConfig { pub struct HtmlConfig {
/// The theme directory, if specified. /// The theme directory, if specified.
@ -568,7 +532,7 @@ impl HtmlConfig {
/// Configuration for how to render the print icon, print.html, and print.css. /// Configuration for how to render the print icon, print.html, and print.css.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive] #[non_exhaustive]
pub struct Print { pub struct Print {
/// Whether print support is enabled. /// Whether print support is enabled.
@ -588,7 +552,7 @@ impl Default for Print {
/// Configuration for how to fold chapters of sidebar. /// Configuration for how to fold chapters of sidebar.
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive] #[non_exhaustive]
pub struct Fold { pub struct Fold {
/// When off, all folds are open. Default: `false`. /// When off, all folds are open. Default: `false`.
@ -601,7 +565,7 @@ pub struct Fold {
/// Configuration for tweaking how the HTML renderer handles the playground. /// Configuration for tweaking how the HTML renderer handles the playground.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive] #[non_exhaustive]
pub struct Playground { pub struct Playground {
/// Should playground snippets be editable? Default: `false`. /// Should playground snippets be editable? Default: `false`.
@ -631,7 +595,7 @@ impl Default for Playground {
/// Configuration for tweaking how the HTML renderer handles code blocks. /// Configuration for tweaking how the HTML renderer handles code blocks.
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive] #[non_exhaustive]
pub struct Code { pub struct Code {
/// A prefix string to hide lines per language (one or more chars). /// A prefix string to hide lines per language (one or more chars).
@ -640,7 +604,7 @@ pub struct Code {
/// Configuration of the search functionality of the HTML renderer. /// Configuration of the search functionality of the HTML renderer.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive] #[non_exhaustive]
pub struct Search { pub struct Search {
/// Enable the search feature. Default: `true`. /// Enable the search feature. Default: `true`.
@ -698,7 +662,7 @@ impl Default for Search {
/// Search options for chapters (or paths). /// Search options for chapters (or paths).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive] #[non_exhaustive]
pub struct SearchChapterSettings { pub struct SearchChapterSettings {
/// Whether or not indexing is enabled, default `true`. /// Whether or not indexing is enabled, default `true`.
@ -732,7 +696,6 @@ impl<'de, T> Updateable<'de> for T where T: Serialize + Deserialize<'de> {}
mod tests { mod tests {
use super::*; use super::*;
use crate::utils::fs::get_404_output_file; use crate::utils::fs::get_404_output_file;
use serde_json::json;
const COMPLEX_CONFIG: &str = r#" const COMPLEX_CONFIG: &str = r#"
[book] [book]
@ -757,7 +720,6 @@ mod tests {
[output.html.playground] [output.html.playground]
editable = true editable = true
editor = "ace"
[output.html.redirect] [output.html.redirect]
"index.html" = "overview.html" "index.html" = "overview.html"
@ -943,19 +905,6 @@ mod tests {
assert_eq!(got_baz, baz_should_be); assert_eq!(got_baz, baz_should_be);
} }
#[test]
fn set_a_config_item() {
let mut cfg = Config::default();
let key = "foo.bar.baz";
let value = "Something Interesting";
assert!(cfg.get::<i32>(key).unwrap().is_none());
cfg.set(key, value).unwrap();
let got: String = cfg.get(key).unwrap().unwrap();
assert_eq!(got, value);
}
#[test] #[test]
fn set_special_tables() { fn set_special_tables() {
let mut cfg = Config::default(); let mut cfg = Config::default();
@ -970,6 +919,21 @@ mod tests {
assert_eq!(cfg.rust.edition, None); assert_eq!(cfg.rust.edition, None);
cfg.set("rust.edition", "2024").unwrap(); cfg.set("rust.edition", "2024").unwrap();
assert_eq!(cfg.rust.edition, Some(RustEdition::E2024)); 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] #[test]
@ -989,67 +953,10 @@ mod tests {
} }
} }
fn encode_env_var(key: &str) -> String {
format!(
"MDBOOK_{}",
key.to_uppercase().replace('.', "__").replace('-', "_")
)
}
#[test]
fn update_config_using_env_var() {
let mut cfg = Config::default();
let key = "foo.bar";
let value = "baz";
assert!(cfg.get::<String>(key).unwrap().is_none());
let encoded_key = encode_env_var(key);
// TODO: This is unsafe, and should be rewritten to use a process.
unsafe { env::set_var(encoded_key, value) };
cfg.update_from_env();
assert_eq!(cfg.get::<String>(key).unwrap().unwrap(), value);
}
#[test]
fn update_config_using_env_var_and_complex_value() {
let mut cfg = Config::default();
let key = "foo-bar.baz";
let value = json!({"array": [1, 2, 3], "number": 13.37});
let value_str = serde_json::to_string(&value).unwrap();
assert!(cfg.get::<serde_json::Value>(key).unwrap().is_none());
let encoded_key = encode_env_var(key);
// TODO: This is unsafe, and should be rewritten to use a process.
unsafe { env::set_var(encoded_key, value_str) };
cfg.update_from_env();
assert_eq!(cfg.get::<serde_json::Value>(key).unwrap().unwrap(), value);
}
#[test]
fn update_book_title_via_env() {
let mut cfg = Config::default();
let should_be = "Something else".to_string();
assert_ne!(cfg.book.title, Some(should_be.clone()));
// TODO: This is unsafe, and should be rewritten to use a process.
unsafe { env::set_var("MDBOOK_BOOK__TITLE", &should_be) };
cfg.update_from_env();
assert_eq!(cfg.book.title, Some(should_be));
}
#[test] #[test]
fn file_404_default() { fn file_404_default() {
let src = r#" let src = r#"
[output.html] [output.html]
destination = "my-book"
"#; "#;
let got = Config::from_str(src).unwrap(); let got = Config::from_str(src).unwrap();
@ -1063,7 +970,6 @@ mod tests {
let src = r#" let src = r#"
[output.html] [output.html]
input-404= "missing.md" input-404= "missing.md"
output-404= "missing.html"
"#; "#;
let got = Config::from_str(src).unwrap(); let got = Config::from_str(src).unwrap();

View file

@ -14,7 +14,6 @@ use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
use mdbook_renderer::{RenderContext, Renderer}; use mdbook_renderer::{RenderContext, Renderer};
use mdbook_summary::Summary; use mdbook_summary::Summary;
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap;
use std::ffi::OsString; use std::ffi::OsString;
use std::io::{IsTerminal, Write}; use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -423,9 +422,8 @@ struct OutputConfig {
fn determine_renderers(config: &Config) -> Result<Vec<Box<dyn Renderer>>> { fn determine_renderers(config: &Config) -> Result<Vec<Box<dyn Renderer>>> {
let mut renderers = Vec::new(); let mut renderers = Vec::new();
match config.get::<HashMap<String, OutputConfig>>("output") { let outputs = config.outputs::<OutputConfig>()?;
Ok(Some(output_table)) => { renderers.extend(outputs.into_iter().map(|(key, table)| {
renderers.extend(output_table.into_iter().map(|(key, table)| {
if key == "html" { if key == "html" {
Box::new(HtmlHandlebars::new()) as Box<dyn Renderer> Box::new(HtmlHandlebars::new()) as Box<dyn Renderer>
} else if key == "markdown" { } else if key == "markdown" {
@ -435,10 +433,6 @@ fn determine_renderers(config: &Config) -> Result<Vec<Box<dyn Renderer>>> {
Box::new(CmdRenderer::new(key, command)) Box::new(CmdRenderer::new(key, command))
} }
})); }));
}
Ok(None) => {}
Err(e) => bail!("failed to get output table config: {e}"),
}
// if we couldn't find anything, add the HTML renderer as a default // if we couldn't find anything, add the HTML renderer as a default
if renderers.is_empty() { if renderers.is_empty() {
@ -477,12 +471,7 @@ fn determine_preprocessors(config: &Config) -> Result<Vec<Box<dyn Preprocessor>>
} }
} }
let preprocessor_table = match config.get::<HashMap<String, PreprocessorConfig>>("preprocessor") let preprocessor_table = config.preprocessors::<PreprocessorConfig>()?;
{
Ok(Some(preprocessor_table)) => preprocessor_table,
Ok(None) => HashMap::new(),
Err(e) => bail!("failed to get preprocessor table config: {e}"),
};
for (name, table) in preprocessor_table.iter() { for (name, table) in preprocessor_table.iter() {
preprocessor_names.insert(name.to_string()); preprocessor_names.insert(name.to_string());

View file

@ -7,7 +7,7 @@ fn config_defaults_to_html_renderer_if_empty() {
let cfg = Config::default(); let cfg = Config::default();
// make sure we haven't got anything in the `output` table // make sure we haven't got anything in the `output` table
assert!(cfg.get::<Value>("output").unwrap().is_none()); assert!(cfg.outputs::<toml::Value>().unwrap().is_empty());
let got = determine_renderers(&cfg).unwrap(); let got = determine_renderers(&cfg).unwrap();
@ -45,7 +45,7 @@ fn config_defaults_to_link_and_index_preprocessor_if_not_set() {
let cfg = Config::default(); let cfg = Config::default();
// make sure we haven't got anything in the `preprocessor` table // make sure we haven't got anything in the `preprocessor` table
assert!(cfg.get::<Value>("preprocessor").unwrap().is_none()); assert!(cfg.preprocessors::<toml::Value>().unwrap().is_empty());
let got = determine_preprocessors(&cfg); let got = determine_preprocessors(&cfg);

View file

@ -2,7 +2,7 @@ use super::command_prelude::*;
#[cfg(feature = "watch")] #[cfg(feature = "watch")]
use super::watch; use super::watch;
use crate::{get_book_dir, open}; use crate::{get_book_dir, open};
use anyhow::{Result, bail}; use anyhow::Result;
use axum::Router; use axum::Router;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::routing::get; use axum::routing::get;
@ -76,10 +76,8 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
.next() .next()
.ok_or_else(|| anyhow::anyhow!("no address found for {}", address))?; .ok_or_else(|| anyhow::anyhow!("no address found for {}", address))?;
let build_dir = book.build_dir_for("html"); let build_dir = book.build_dir_for("html");
let input_404 = match book.config.get::<String>("output.html.input-404") { let html_config = book.config.html_config();
Ok(v) => v, let input_404 = html_config.and_then(|c| c.input_404);
Err(e) => bail!("expected string for output.html.input-404: {e}"),
};
let file_404 = get_404_output_file(&input_404); let file_404 = get_404_output_file(&input_404);
// A channel used to broadcast to any websockets to reload when a file changes. // A channel used to broadcast to any websockets to reload when a file changes.

159
tests/testsuite/config.rs Normal file
View file

@ -0,0 +1,159 @@
//! Tests for book configuration loading.
use crate::prelude::*;
// Test that config can load from environment variable.
#[test]
fn config_from_env() {
BookTest::from_dir("config/empty")
.run("build", |cmd| {
cmd.env("MDBOOK_BOOK__TITLE", "Custom env title");
})
.check_file_contains(
"book/index.html",
"<title>Chapter 1 - Custom env title</title>",
);
// json for some subtable
//
}
// Test environment config with JSON.
#[test]
fn config_json_from_env() {
// build table
BookTest::from_dir("config/empty")
.run("build", |cmd| {
cmd.env(
"MDBOOK_BOOK",
r#"{"title": "My Awesome Book", "authors": ["Michael-F-Bryan"]}"#,
);
})
.check_file_contains(
"book/index.html",
"<title>Chapter 1 - My Awesome Book</title>",
);
// book table
BookTest::from_dir("config/empty")
.run("build", |cmd| {
cmd.env("MDBOOK_BUILD", r#"{"build-dir": "alt"}"#);
})
.check_file_contains("alt/index.html", "<title>Chapter 1</title>");
}
// Test that a preprocessor receives config set in the environment.
#[test]
fn preprocessor_cfg_from_env() {
let mut test = BookTest::from_dir("config/empty");
test.rust_program(
"cat-to-file",
r#"
fn main() {
use std::io::Read;
let mut s = String::new();
std::io::stdin().read_to_string(&mut s).unwrap();
std::fs::write("out.txt", s).unwrap();
println!("{{\"sections\": []}}");
}
"#,
)
.run("build", |cmd| {
cmd.env(
"MDBOOK_PREPROCESSOR__CAT_TO_FILE",
r#"{"command":"./cat-to-file", "array": [1,2,3], "number": 123}"#,
);
});
let out = read_to_string(test.dir.join("out.txt"));
let (ctx, _book) = mdbook_preprocessor::parse_input(out.as_bytes()).unwrap();
let cfg: serde_json::Value = ctx.config.get("preprocessor.cat-to-file").unwrap().unwrap();
assert_eq!(
cfg,
serde_json::json!({
"command": "./cat-to-file",
"array": [1,2,3],
"number": 123,
})
);
}
// Test that a renderer receives config set in the environment.
#[test]
fn output_cfg_from_env() {
let mut test = BookTest::from_dir("config/empty");
test.rust_program(
"cat-to-file",
r#"
fn main() {
use std::io::Read;
let mut s = String::new();
std::io::stdin().read_to_string(&mut s).unwrap();
std::fs::write("out.txt", s).unwrap();
}
"#,
)
.run("build", |cmd| {
cmd.env(
"MDBOOK_OUTPUT__CAT_TO_FILE",
r#"{"command":"./cat-to-file", "array": [1,2,3], "number": 123}"#,
);
});
let out = read_to_string(test.dir.join("book/out.txt"));
let ctx = mdbook_renderer::RenderContext::from_json(out.as_bytes()).unwrap();
let cfg: serde_json::Value = ctx.config.get("output.cat-to-file").unwrap().unwrap();
assert_eq!(
cfg,
serde_json::json!({
"command": "./cat-to-file",
"array": [1,2,3],
"number": 123,
})
);
}
// An invalid key at the top level.
#[test]
fn bad_config_top_level() {
BookTest::init(|_| {})
.change_file("book.toml", "foo = 123")
.run("build", |cmd| {
cmd.expect_failure()
.expect_stdout(str![[""]])
.expect_stderr(str![[r#"
[TIMESTAMP] [ERROR] (mdbook_core::utils): Error: Invalid configuration file
[TIMESTAMP] [ERROR] (mdbook_core::utils): [TAB]Caused By: TOML parse error at line 1, column 1
|
1 | foo = 123
| ^^^
unknown field `foo`, expected one of `book`, `build`, `rust`, `output`, `preprocessor`
"#]]);
});
}
// An invalid key in the main book table.
#[test]
fn bad_config_in_book_table() {
BookTest::init(|_| {})
.change_file(
"book.toml",
"[book]\n\
title = \"bad-config\"\n\
foo = 123"
)
.run("build", |cmd| {
cmd.expect_failure()
.expect_stdout(str![[""]])
.expect_stderr(str![[r#"
[TIMESTAMP] [ERROR] (mdbook_core::utils): Error: Invalid configuration file
[TIMESTAMP] [ERROR] (mdbook_core::utils): [TAB]Caused By: TOML parse error at line 3, column 1
|
3 | foo = 123
| ^^^
unknown field `foo`, expected one of `title`, `authors`, `description`, `src`, `language`, `text-direction`
"#]]);
});
}

View file

View file

@ -0,0 +1,3 @@
# Summary
- [Chapter 1](./chapter_1.md)

View file

@ -0,0 +1 @@
# Chapter 1

View file

@ -28,8 +28,8 @@ All done, no errors...
str![[r#" str![[r#"
[book] [book]
authors = [] authors = []
language = "en"
src = "src" src = "src"
language = "en"
"#]], "#]],
) )
@ -94,8 +94,8 @@ All done, no errors...
str![[r#" str![[r#"
[book] [book]
authors = [] authors = []
language = "en"
src = "src" src = "src"
language = "en"
"#]], "#]],
); );
@ -126,10 +126,10 @@ All done, no errors...
"book.toml", "book.toml",
str![[r#" str![[r#"
[book] [book]
authors = []
language = "en"
src = "src"
title = "Example title" title = "Example title"
authors = []
src = "src"
language = "en"
"#]], "#]],
); );
@ -179,14 +179,14 @@ fn init_with_custom_book_and_src_locations() {
str![[r#" str![[r#"
[book] [book]
authors = [] authors = []
language = "en"
src = "in" src = "in"
language = "en"
[build] [build]
build-dir = "out" build-dir = "out"
create-missing = true create-missing = true
extra-watch-dirs = []
use-default-preprocessors = true use-default-preprocessors = true
extra-watch-dirs = []
"#]], "#]],
) )

View file

@ -7,6 +7,7 @@
mod book_test; mod book_test;
mod build; mod build;
mod cli; mod cli;
mod config;
mod includes; mod includes;
mod index; mod index;
mod init; mod init;

View file

@ -186,8 +186,11 @@ fn backends_receive_render_context_via_stdin() {
"config": { "config": {
"book": { "book": {
"authors": [], "authors": [],
"description": null,
"language": "en", "language": "en",
"src": "src" "src": "src",
"text-direction": null,
"title": null
}, },
"output": { "output": {
"cat-to-file": { "cat-to-file": {