diff options
Diffstat (limited to 'vendor/mdbook/src/config.rs')
-rw-r--r-- | vendor/mdbook/src/config.rs | 1190 |
1 files changed, 1190 insertions, 0 deletions
diff --git a/vendor/mdbook/src/config.rs b/vendor/mdbook/src/config.rs new file mode 100644 index 000000000..b7d03d1a2 --- /dev/null +++ b/vendor/mdbook/src/config.rs @@ -0,0 +1,1190 @@ +//! Mdbook's configuration system. +//! +//! The main entrypoint of the `config` module is the `Config` struct. This acts +//! essentially as a bag of configuration information, with a couple +//! pre-determined tables ([`BookConfig`] and [`BuildConfig`]) as well as support +//! for arbitrary data which is exposed to plugins and alternative backends. +//! +//! +//! # Examples +//! +//! ```rust +//! # use mdbook::errors::*; +//! use std::path::PathBuf; +//! use std::str::FromStr; +//! use mdbook::Config; +//! use toml::Value; +//! +//! # fn run() -> Result<()> { +//! let src = r#" +//! [book] +//! title = "My Book" +//! authors = ["Michael-F-Bryan"] +//! +//! [build] +//! src = "out" +//! +//! [other-table.foo] +//! bar = 123 +//! "#; +//! +//! // load the `Config` from a toml string +//! let mut cfg = Config::from_str(src)?; +//! +//! // retrieve a nested value +//! let bar = cfg.get("other-table.foo.bar").cloned(); +//! assert_eq!(bar, Some(Value::Integer(123))); +//! +//! // Set the `output.html.theme` directory +//! assert!(cfg.get("output.html").is_none()); +//! cfg.set("output.html.theme", "./themes"); +//! +//! // then load it again, automatically deserializing to a `PathBuf`. +//! let got: Option<PathBuf> = cfg.get_deserialized_opt("output.html.theme")?; +//! assert_eq!(got, Some(PathBuf::from("./themes"))); +//! # Ok(()) +//! # } +//! # run().unwrap() +//! ``` + +#![deny(missing_docs)] + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::collections::HashMap; +use std::env; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use toml::value::Table; +use toml::{self, Value}; + +use crate::errors::*; +use crate::utils::{self, toml_ext::TomlExt}; + +/// The overall configuration object for MDBook, essentially an in-memory +/// representation of `book.toml`. +#[derive(Debug, Clone, PartialEq)] +pub struct Config { + /// Metadata about the book. + pub book: BookConfig, + /// Information about the build environment. + pub build: BuildConfig, + /// Information about Rust language support. + pub rust: RustConfig, + rest: Value, +} + +impl FromStr for Config { + type Err = Error; + + /// Load a `Config` from some string. + fn from_str(src: &str) -> Result<Self> { + toml::from_str(src).with_context(|| "Invalid configuration file") + } +} + +impl Config { + /// Load the configuration file from disk. + pub fn from_disk<P: AsRef<Path>>(config_file: P) -> Result<Config> { + let mut buffer = String::new(); + File::open(config_file) + .with_context(|| "Unable to open the configuration file")? + .read_to_string(&mut buffer) + .with_context(|| "Couldn't read the file")?; + + Config::from_str(&buffer) + } + + /// Updates the `Config` from the available environment variables. + /// + /// Variables starting with `MDBOOK_` are used for configuration. The key is + /// created by removing the `MDBOOK_` prefix and turning the resulting + /// string into `kebab-case`. Double underscores (`__`) separate nested + /// keys, while a single underscore (`_`) is replaced with a dash (`-`). + /// + /// For example: + /// + /// - `MDBOOK_foo` -> `foo` + /// - `MDBOOK_FOO` -> `foo` + /// - `MDBOOK_FOO__BAR` -> `foo.bar` + /// - `MDBOOK_FOO_BAR` -> `foo-bar` + /// - `MDBOOK_FOO_bar__baz` -> `foo-bar.baz` + /// + /// So by setting the `MDBOOK_BOOK__TITLE` environment variable you can + /// override the book's title without needing to touch your `book.toml`. + /// + /// > **Note:** To facilitate setting more complex config items, the value + /// > of an environment variable is first parsed as JSON, falling back to a + /// > string if the parse fails. + /// > + /// > This means, if you so desired, you could override all book metadata + /// > when building the book with something like + /// > + /// > ```text + /// > $ export MDBOOK_BOOK='{"title": "My Awesome Book", "authors": ["Michael-F-Bryan"]}' + /// > $ mdbook build + /// > ``` + /// + /// The latter case may be useful in situations where `mdbook` is invoked + /// from a script or CI, where it sometimes isn't possible to update the + /// `book.toml` before building. + pub fn update_from_env(&mut self) { + debug!("Updating the config from environment variables"); + + let overrides = + env::vars().filter_map(|(key, value)| parse_env(&key).map(|index| (index, value))); + + for (key, value) in overrides { + trace!("{} => {}", key, value); + let parsed_value = serde_json::from_str(&value) + .unwrap_or_else(|_| serde_json::Value::String(value.to_string())); + + if key == "book" || key == "build" { + if let serde_json::Value::Object(ref map) = parsed_value { + // To `set` each `key`, we wrap them as `prefix.key` + for (k, v) in map { + let full_key = format!("{}.{}", key, k); + self.set(&full_key, v).expect("unreachable"); + } + return; + } + } + + self.set(key, parsed_value).expect("unreachable"); + } + } + + /// Fetch an arbitrary item from the `Config` as a `toml::Value`. + /// + /// You can use dotted indices to access nested items (e.g. + /// `output.html.playground` will fetch the "playground" out of the html output + /// table). + pub fn get(&self, key: &str) -> Option<&Value> { + self.rest.read(key) + } + + /// Fetch a value from the `Config` so you can mutate it. + pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> { + self.rest.read_mut(key) + } + + /// Convenience method for getting the html renderer's configuration. + /// + /// # Note + /// + /// This is for compatibility only. It will be removed completely once the + /// HTML renderer is refactored to be less coupled to `mdbook` internals. + #[doc(hidden)] + pub fn html_config(&self) -> Option<HtmlConfig> { + match self + .get_deserialized_opt("output.html") + .with_context(|| "Parsing configuration [output.html]") + { + Ok(Some(config)) => Some(config), + Ok(None) => None, + Err(e) => { + utils::log_backtrace(&e); + None + } + } + } + + /// Deprecated, use get_deserialized_opt instead. + #[deprecated = "use get_deserialized_opt instead"] + pub fn get_deserialized<'de, T: Deserialize<'de>, S: AsRef<str>>(&self, name: S) -> Result<T> { + let name = name.as_ref(); + match self.get_deserialized_opt(name)? { + Some(value) => Ok(value), + None => bail!("Key not found, {:?}", name), + } + } + + /// Convenience function to fetch a value from the config and deserialize it + /// into some arbitrary type. + pub fn get_deserialized_opt<'de, T: Deserialize<'de>, S: AsRef<str>>( + &self, + name: S, + ) -> Result<Option<T>> { + let name = name.as_ref(); + self.get(name) + .map(|value| { + value + .clone() + .try_into() + .with_context(|| "Couldn't deserialize the value") + }) + .transpose() + } + + /// Set a config key, clobbering any existing values along the way. + /// + /// The only way this can fail is if we can't serialize `value` into a + /// `toml::Value`. + 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 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 { + self.rest.insert(index, value); + } + + Ok(()) + } + + /// Get the table associated with a particular renderer. + pub fn get_renderer<I: AsRef<str>>(&self, index: I) -> Option<&Table> { + let key = format!("output.{}", index.as_ref()); + self.get(&key).and_then(Value::as_table) + } + + /// Get the table associated with a particular preprocessor. + pub fn get_preprocessor<I: AsRef<str>>(&self, index: I) -> Option<&Table> { + let key = format!("preprocessor.{}", index.as_ref()); + self.get(&key).and_then(Value::as_table) + } + + fn from_legacy(mut table: Value) -> Config { + let mut cfg = Config::default(); + + // we use a macro here instead of a normal loop because the $out + // variable can be different types. This way we can make type inference + // figure out what try_into() deserializes to. + macro_rules! get_and_insert { + ($table:expr, $key:expr => $out:expr) => { + let got = $table + .as_table_mut() + .and_then(|t| t.remove($key)) + .and_then(|v| v.try_into().ok()); + if let Some(value) = got { + $out = value; + } + }; + } + + get_and_insert!(table, "title" => cfg.book.title); + get_and_insert!(table, "authors" => cfg.book.authors); + get_and_insert!(table, "source" => cfg.book.src); + get_and_insert!(table, "description" => cfg.book.description); + + if let Some(dest) = table.delete("output.html.destination") { + if let Ok(destination) = dest.try_into() { + cfg.build.build_dir = destination; + } + } + + cfg.rest = table; + cfg + } +} + +impl Default for Config { + fn default() -> Config { + Config { + book: BookConfig::default(), + build: BuildConfig::default(), + rust: RustConfig::default(), + rest: Value::Table(Table::default()), + } + } +} + +impl<'de> Deserialize<'de> for Config { + fn deserialize<D: Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> { + let raw = Value::deserialize(de)?; + + if is_legacy_format(&raw) { + warn!("It looks like you are using the legacy book.toml format."); + warn!("We'll parse it for now, but you should probably convert to the new format."); + warn!("See the mdbook documentation for more details, although as a rule of thumb"); + warn!("just move all top level configuration entries like `title`, `author` and"); + warn!("`description` under a table called `[book]`, move the `destination` entry"); + warn!("from `[output.html]`, renamed to `build-dir`, under a table called"); + warn!("`[build]`, and it should all work."); + warn!("Documentation: http://rust-lang.github.io/mdBook/format/config.html"); + return Ok(Config::from_legacy(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> { + key.strip_prefix("MDBOOK_") + .map(|key| key.to_lowercase().replace("__", ".").replace('_', "-")) +} + +fn is_legacy_format(table: &Value) -> bool { + let legacy_items = [ + "title", + "authors", + "source", + "description", + "output.html.destination", + ]; + + for item in &legacy_items { + if table.read(item).is_some() { + return true; + } + } + + false +} + +/// Configuration options which are specific to the book and required for +/// loading it from disk. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct BookConfig { + /// The book's title. + pub title: Option<String>, + /// The book's authors. + pub authors: Vec<String>, + /// An optional description for the book. + pub description: Option<String>, + /// Location of the book source relative to the book's root directory. + pub src: PathBuf, + /// Does this book support more than one language? + pub multilingual: bool, + /// The main language of the book. + pub language: Option<String>, +} + +impl Default for BookConfig { + fn default() -> BookConfig { + BookConfig { + title: None, + authors: Vec::new(), + description: None, + src: PathBuf::from("src"), + multilingual: false, + language: Some(String::from("en")), + } + } +} + +/// Configuration for the build procedure. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct BuildConfig { + /// Where to put built artefacts relative to the book's root directory. + pub build_dir: PathBuf, + /// Should non-existent markdown files specified in `SUMMARY.md` be created + /// if they don't exist? + pub create_missing: bool, + /// Should the default preprocessors always be used when they are + /// compatible with the renderer? + pub use_default_preprocessors: bool, +} + +impl Default for BuildConfig { + fn default() -> BuildConfig { + BuildConfig { + build_dir: PathBuf::from("book"), + create_missing: true, + use_default_preprocessors: true, + } + } +} + +/// Configuration for the Rust compiler(e.g., for playground) +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct RustConfig { + /// Rust edition used in playground + pub edition: Option<RustEdition>, +} + +#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] +/// Rust edition to use for the code. +pub enum RustEdition { + /// The 2021 edition of Rust + #[serde(rename = "2021")] + E2021, + /// The 2018 edition of Rust + #[serde(rename = "2018")] + E2018, + /// The 2015 edition of Rust + #[serde(rename = "2015")] + E2015, +} + +/// Configuration for the HTML renderer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct HtmlConfig { + /// The theme directory, if specified. + pub theme: Option<PathBuf>, + /// The default theme to use, defaults to 'light' + pub default_theme: Option<String>, + /// The theme to use if the browser requests the dark version of the site. + /// Defaults to 'navy'. + pub preferred_dark_theme: Option<String>, + /// Use "smart quotes" instead of the usual `"` character. + pub curly_quotes: bool, + /// Should mathjax be enabled? + pub mathjax_support: bool, + /// Whether to fonts.css and respective font files to the output directory. + pub copy_fonts: bool, + /// An optional google analytics code. + pub google_analytics: Option<String>, + /// Additional CSS stylesheets to include in the rendered page's `<head>`. + pub additional_css: Vec<PathBuf>, + /// Additional JS scripts to include at the bottom of the rendered page's + /// `<body>`. + pub additional_js: Vec<PathBuf>, + /// Fold settings. + pub fold: Fold, + /// Playground settings. + #[serde(alias = "playpen")] + pub playground: Playground, + /// Print settings. + pub print: Print, + /// Don't render section labels. + pub no_section_label: bool, + /// Search settings. If `None`, the default will be used. + pub search: Option<Search>, + /// Git repository url. If `None`, the git button will not be shown. + pub git_repository_url: Option<String>, + /// FontAwesome icon class to use for the Git repository link. + /// Defaults to `fa-github` if `None`. + pub git_repository_icon: Option<String>, + /// Input path for the 404 file, defaults to 404.md, set to "" to disable 404 file output + pub input_404: Option<String>, + /// Absolute url to site, used to emit correct paths for the 404 page, which might be accessed in a deeply nested directory + pub site_url: Option<String>, + /// The DNS subdomain or apex domain at which your book will be hosted. This + /// string will be written to a file named CNAME in the root of your site, + /// as required by GitHub Pages (see [*Managing a custom domain for your + /// GitHub Pages site*][custom domain]). + /// + /// [custom domain]: https://docs.github.com/en/github/working-with-github-pages/managing-a-custom-domain-for-your-github-pages-site + pub cname: Option<String>, + /// Edit url template, when set shows a "Suggest an edit" button for + /// directly jumping to editing the currently viewed page. + /// Contains {path} that is replaced with chapter source file path + pub edit_url_template: Option<String>, + /// Endpoint of websocket, for livereload usage. Value loaded from .toml file + /// is ignored, because our code overrides this field with the value [`LIVE_RELOAD_ENDPOINT`] + /// + /// [`LIVE_RELOAD_ENDPOINT`]: cmd::serve::LIVE_RELOAD_ENDPOINT + /// + /// This config item *should not be edited* by the end user. + #[doc(hidden)] + pub live_reload_endpoint: Option<String>, + /// The mapping from old pages to new pages/URLs to use when generating + /// redirects. + pub redirect: HashMap<String, String>, +} + +impl Default for HtmlConfig { + fn default() -> HtmlConfig { + HtmlConfig { + theme: None, + default_theme: None, + preferred_dark_theme: None, + curly_quotes: false, + mathjax_support: false, + copy_fonts: true, + google_analytics: None, + additional_css: Vec::new(), + additional_js: Vec::new(), + fold: Fold::default(), + playground: Playground::default(), + print: Print::default(), + no_section_label: false, + search: None, + git_repository_url: None, + git_repository_icon: None, + edit_url_template: None, + input_404: None, + site_url: None, + cname: None, + live_reload_endpoint: None, + redirect: HashMap::new(), + } + } +} + +impl HtmlConfig { + /// Returns the directory of theme from the provided root directory. If the + /// directory is not present it will append the default directory of "theme" + pub fn theme_dir(&self, root: &Path) -> PathBuf { + match self.theme { + Some(ref d) => root.join(d), + None => root.join("theme"), + } + } +} + +/// Configuration for how to render the print icon, print.html, and print.css. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Print { + /// Whether print support is enabled. + pub enable: bool, + /// Insert page breaks between chapters. Default: `true`. + pub page_break: bool, +} + +impl Default for Print { + fn default() -> Self { + Self { + enable: true, + page_break: true, + } + } +} + +/// Configuration for how to fold chapters of sidebar. +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Fold { + /// When off, all folds are open. Default: `false`. + pub enable: bool, + /// The higher the more folded regions are open. When level is 0, all folds + /// are closed. + /// Default: `0`. + pub level: u8, +} + +/// Configuration for tweaking how the the HTML renderer handles the playground. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Playground { + /// Should playground snippets be editable? Default: `false`. + pub editable: bool, + /// Display the copy button. Default: `true`. + pub copyable: bool, + /// Copy JavaScript files for the editor to the output directory? + /// Default: `true`. + pub copy_js: bool, + /// Display line numbers on playground snippets. Default: `false`. + pub line_numbers: bool, + /// Display the run button. Default: `true` + pub runnable: bool, +} + +impl Default for Playground { + fn default() -> Playground { + Playground { + editable: false, + copyable: true, + copy_js: true, + line_numbers: false, + runnable: true, + } + } +} + +/// Configuration of the search functionality of the HTML renderer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Search { + /// Enable the search feature. Default: `true`. + pub enable: bool, + /// Maximum number of visible results. Default: `30`. + pub limit_results: u32, + /// The number of words used for a search result teaser. Default: `30`. + pub teaser_word_count: u32, + /// Define the logical link between multiple search words. + /// If true, all search words must appear in each result. Default: `false`. + pub use_boolean_and: bool, + /// Boost factor for the search result score if a search word appears in the header. + /// Default: `2`. + pub boost_title: u8, + /// Boost factor for the search result score if a search word appears in the hierarchy. + /// The hierarchy contains all titles of the parent documents and all parent headings. + /// Default: `1`. + pub boost_hierarchy: u8, + /// Boost factor for the search result score if a search word appears in the text. + /// Default: `1`. + pub boost_paragraph: u8, + /// True if the searchword `micro` should match `microwave`. Default: `true`. + pub expand: bool, + /// Documents are split into smaller parts, separated by headings. This defines, until which + /// level of heading documents should be split. Default: `3`. (`### This is a level 3 heading`) + pub heading_split_level: u8, + /// Copy JavaScript files for the search functionality to the output directory? + /// Default: `true`. + pub copy_js: bool, +} + +impl Default for Search { + fn default() -> Search { + // Please update the documentation of `Search` when changing values! + 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, + } + } +} + +/// Allows you to "update" any arbitrary field in a struct by round-tripping via +/// a `toml::Value`. +/// +/// This is definitely not the most performant way to do things, which means you +/// should probably keep it away from tight loops... +trait Updateable<'de>: Serialize + Deserialize<'de> { + fn update_value<S: Serialize>(&mut self, key: &str, value: S) { + let mut raw = Value::try_from(&self).expect("unreachable"); + + if let Ok(value) = Value::try_from(value) { + let _ = raw.insert(key, value); + } else { + return; + } + + if let Ok(updated) = raw.try_into() { + *self = updated; + } + } +} + +impl<'de, T> Updateable<'de> for T where T: Serialize + Deserialize<'de> {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::fs::get_404_output_file; + + const COMPLEX_CONFIG: &str = r#" + [book] + title = "Some Book" + authors = ["Michael-F-Bryan <michaelfbryan@gmail.com>"] + description = "A completely useless book" + multilingual = true + src = "source" + language = "ja" + + [build] + build-dir = "outputs" + create-missing = false + use-default-preprocessors = true + + [output.html] + theme = "./themedir" + default-theme = "rust" + curly-quotes = true + google-analytics = "123456" + additional-css = ["./foo/bar/baz.css"] + git-repository-url = "https://foo.com/" + git-repository-icon = "fa-code-fork" + + [output.html.playground] + editable = true + editor = "ace" + + [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")), + multilingual: true, + src: PathBuf::from("source"), + language: Some(String::from("ja")), + }; + let build_should_be = BuildConfig { + build_dir: PathBuf::from("outputs"), + create_missing: false, + use_default_preprocessors: true, + }; + 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 { + curly_quotes: true, + google_analytics: Some(String::from("123456")), + 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_deserialized_opt("output.random").unwrap().unwrap(); + + assert_eq!(got, should_be); + + let got_baz: Vec<bool> = cfg + .get_deserialized_opt("output.random.baz") + .unwrap() + .unwrap(); + let baz_should_be = vec![true, true, false]; + + assert_eq!(got_baz, baz_should_be); + } + + #[test] + fn mutate_some_stuff() { + // really this is just a sanity check to make sure the borrow checker + // is happy... + let src = COMPLEX_CONFIG; + let mut config = Config::from_str(src).unwrap(); + let key = "output.html.playground.editable"; + + assert_eq!(config.get(key).unwrap(), &Value::Boolean(true)); + *config.get_mut(key).unwrap() = Value::Boolean(false); + assert_eq!(config.get(key).unwrap(), &Value::Boolean(false)); + } + + /// The config file format has slightly changed (metadata stuff is now under + /// the `book` table instead of being at the top level) so we're adding a + /// **temporary** compatibility check. You should be able to still load the + /// old format, emitting a warning. + #[test] + fn can_still_load_the_previous_format() { + let src = r#" + title = "mdBook Documentation" + description = "Create book from markdown files. Like Gitbook but implemented in Rust" + authors = ["Mathieu David"] + source = "./source" + + [output.html] + destination = "my-book" # the output files will be generated in `root/my-book` instead of `root/book` + theme = "my-theme" + curly-quotes = true + google-analytics = "123456" + additional-css = ["custom.css", "custom2.css"] + additional-js = ["custom.js"] + "#; + + 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 build_should_be = BuildConfig { + build_dir: PathBuf::from("my-book"), + create_missing: true, + use_default_preprocessors: true, + }; + + let html_should_be = HtmlConfig { + theme: Some(PathBuf::from("my-theme")), + curly_quotes: true, + google_analytics: Some(String::from("123456")), + additional_css: vec![PathBuf::from("custom.css"), PathBuf::from("custom2.css")], + additional_js: vec![PathBuf::from("custom.js")], + ..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.html_config().unwrap(), html_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(key).is_none()); + cfg.set(key, value).unwrap(); + + let got: String = cfg.get_deserialized_opt(key).unwrap().unwrap(); + assert_eq!(got, value); + } + + #[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); + } + } + + 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(key).is_none()); + + let encoded_key = encode_env_var(key); + env::set_var(encoded_key, value); + + cfg.update_from_env(); + + assert_eq!( + cfg.get_deserialized_opt::<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(key).is_none()); + + let encoded_key = encode_env_var(key); + env::set_var(encoded_key, value_str); + + cfg.update_from_env(); + + assert_eq!( + cfg.get_deserialized_opt::<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())); + + env::set_var("MDBOOK_BOOK__TITLE", &should_be); + cfg.update_from_env(); + + assert_eq!(cfg.book.title, Some(should_be)); + } + + #[test] + fn file_404_default() { + let src = r#" + [output.html] + destination = "my-book" + "#; + + let got = Config::from_str(src).unwrap(); + let html_config = got.html_config().unwrap(); + assert_eq!(html_config.input_404, None); + assert_eq!(&get_404_output_file(&html_config.input_404), "404.html"); + } + + #[test] + fn file_404_custom() { + let src = r#" + [output.html] + input-404= "missing.md" + output-404= "missing.html" + "#; + + 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!(&get_404_output_file(&html_config.input_404), "missing.html"); + } + + #[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] + 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); + } +} |