mdbook/
config.rs

1//! Mdbook's configuration system.
2//!
3//! The main entrypoint of the `config` module is the `Config` struct. This acts
4//! essentially as a bag of configuration information, with a couple
5//! pre-determined tables ([`BookConfig`] and [`BuildConfig`]) as well as support
6//! for arbitrary data which is exposed to plugins and alternative backends.
7//!
8//!
9//! # Examples
10//!
11//! ```rust
12//! # use mdbook::errors::*;
13//! use std::path::PathBuf;
14//! use std::str::FromStr;
15//! use mdbook::Config;
16//! use toml::Value;
17//!
18//! # fn run() -> Result<()> {
19//! let src = r#"
20//! [book]
21//! title = "My Book"
22//! authors = ["Michael-F-Bryan"]
23//!
24//! [build]
25//! src = "out"
26//!
27//! [other-table.foo]
28//! bar = 123
29//! "#;
30//!
31//! // load the `Config` from a toml string
32//! let mut cfg = Config::from_str(src)?;
33//!
34//! // retrieve a nested value
35//! let bar = cfg.get("other-table.foo.bar").cloned();
36//! assert_eq!(bar, Some(Value::Integer(123)));
37//!
38//! // Set the `output.html.theme` directory
39//! assert!(cfg.get("output.html").is_none());
40//! cfg.set("output.html.theme", "./themes");
41//!
42//! // then load it again, automatically deserializing to a `PathBuf`.
43//! let got: Option<PathBuf> = cfg.get_deserialized_opt("output.html.theme")?;
44//! assert_eq!(got, Some(PathBuf::from("./themes")));
45//! # Ok(())
46//! # }
47//! # run().unwrap()
48//! ```
49
50#![deny(missing_docs)]
51
52use log::{debug, trace, warn};
53use serde::{Deserialize, Deserializer, Serialize, Serializer};
54use std::collections::HashMap;
55use std::env;
56use std::fs::File;
57use std::io::Read;
58use std::path::{Path, PathBuf};
59use std::str::FromStr;
60use toml::value::Table;
61use toml::{self, Value};
62
63use crate::errors::*;
64use crate::utils::{self, toml_ext::TomlExt};
65
66/// The overall configuration object for MDBook, essentially an in-memory
67/// representation of `book.toml`.
68#[derive(Debug, Clone, PartialEq)]
69pub struct Config {
70    /// Metadata about the book.
71    pub book: BookConfig,
72    /// Information about the build environment.
73    pub build: BuildConfig,
74    /// Information about Rust language support.
75    pub rust: RustConfig,
76    rest: Value,
77}
78
79impl FromStr for Config {
80    type Err = Error;
81
82    /// Load a `Config` from some string.
83    fn from_str(src: &str) -> Result<Self> {
84        toml::from_str(src).with_context(|| "Invalid configuration file")
85    }
86}
87
88impl Config {
89    /// Load the configuration file from disk.
90    pub fn from_disk<P: AsRef<Path>>(config_file: P) -> Result<Config> {
91        let mut buffer = String::new();
92        File::open(config_file)
93            .with_context(|| "Unable to open the configuration file")?
94            .read_to_string(&mut buffer)
95            .with_context(|| "Couldn't read the file")?;
96
97        Config::from_str(&buffer)
98    }
99
100    /// Updates the `Config` from the available environment variables.
101    ///
102    /// Variables starting with `MDBOOK_` are used for configuration. The key is
103    /// created by removing the `MDBOOK_` prefix and turning the resulting
104    /// string into `kebab-case`. Double underscores (`__`) separate nested
105    /// keys, while a single underscore (`_`) is replaced with a dash (`-`).
106    ///
107    /// For example:
108    ///
109    /// - `MDBOOK_foo` -> `foo`
110    /// - `MDBOOK_FOO` -> `foo`
111    /// - `MDBOOK_FOO__BAR` -> `foo.bar`
112    /// - `MDBOOK_FOO_BAR` -> `foo-bar`
113    /// - `MDBOOK_FOO_bar__baz` -> `foo-bar.baz`
114    ///
115    /// So by setting the `MDBOOK_BOOK__TITLE` environment variable you can
116    /// override the book's title without needing to touch your `book.toml`.
117    ///
118    /// > **Note:** To facilitate setting more complex config items, the value
119    /// > of an environment variable is first parsed as JSON, falling back to a
120    /// > string if the parse fails.
121    /// >
122    /// > This means, if you so desired, you could override all book metadata
123    /// > when building the book with something like
124    /// >
125    /// > ```text
126    /// > $ export MDBOOK_BOOK='{"title": "My Awesome Book", "authors": ["Michael-F-Bryan"]}'
127    /// > $ mdbook build
128    /// > ```
129    ///
130    /// The latter case may be useful in situations where `mdbook` is invoked
131    /// from a script or CI, where it sometimes isn't possible to update the
132    /// `book.toml` before building.
133    pub fn update_from_env(&mut self) {
134        debug!("Updating the config from environment variables");
135
136        let overrides =
137            env::vars().filter_map(|(key, value)| parse_env(&key).map(|index| (index, value)));
138
139        for (key, value) in overrides {
140            trace!("{} => {}", key, value);
141            let parsed_value = serde_json::from_str(&value)
142                .unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
143
144            if key == "book" || key == "build" {
145                if let serde_json::Value::Object(ref map) = parsed_value {
146                    // To `set` each `key`, we wrap them as `prefix.key`
147                    for (k, v) in map {
148                        let full_key = format!("{}.{}", key, k);
149                        self.set(&full_key, v).expect("unreachable");
150                    }
151                    return;
152                }
153            }
154
155            self.set(key, parsed_value).expect("unreachable");
156        }
157    }
158
159    /// Fetch an arbitrary item from the `Config` as a `toml::Value`.
160    ///
161    /// You can use dotted indices to access nested items (e.g.
162    /// `output.html.playground` will fetch the "playground" out of the html output
163    /// table).
164    pub fn get(&self, key: &str) -> Option<&Value> {
165        self.rest.read(key)
166    }
167
168    /// Fetch a value from the `Config` so you can mutate it.
169    pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> {
170        self.rest.read_mut(key)
171    }
172
173    /// Convenience method for getting the html renderer's configuration.
174    ///
175    /// # Note
176    ///
177    /// This is for compatibility only. It will be removed completely once the
178    /// HTML renderer is refactored to be less coupled to `mdbook` internals.
179    #[doc(hidden)]
180    pub fn html_config(&self) -> Option<HtmlConfig> {
181        match self
182            .get_deserialized_opt("output.html")
183            .with_context(|| "Parsing configuration [output.html]")
184        {
185            Ok(Some(config)) => Some(config),
186            Ok(None) => None,
187            Err(e) => {
188                utils::log_backtrace(&e);
189                None
190            }
191        }
192    }
193
194    /// Deprecated, use get_deserialized_opt instead.
195    #[deprecated = "use get_deserialized_opt instead"]
196    pub fn get_deserialized<'de, T: Deserialize<'de>, S: AsRef<str>>(&self, name: S) -> Result<T> {
197        let name = name.as_ref();
198        match self.get_deserialized_opt(name)? {
199            Some(value) => Ok(value),
200            None => bail!("Key not found, {:?}", name),
201        }
202    }
203
204    /// Convenience function to fetch a value from the config and deserialize it
205    /// into some arbitrary type.
206    pub fn get_deserialized_opt<'de, T: Deserialize<'de>, S: AsRef<str>>(
207        &self,
208        name: S,
209    ) -> Result<Option<T>> {
210        let name = name.as_ref();
211        self.get(name)
212            .map(|value| {
213                value
214                    .clone()
215                    .try_into()
216                    .with_context(|| "Couldn't deserialize the value")
217            })
218            .transpose()
219    }
220
221    /// Set a config key, clobbering any existing values along the way.
222    ///
223    /// The only way this can fail is if we can't serialize `value` into a
224    /// `toml::Value`.
225    pub fn set<S: Serialize, I: AsRef<str>>(&mut self, index: I, value: S) -> Result<()> {
226        let index = index.as_ref();
227
228        let value = Value::try_from(value)
229            .with_context(|| "Unable to represent the item as a JSON Value")?;
230
231        if let Some(key) = index.strip_prefix("book.") {
232            self.book.update_value(key, value);
233        } else if let Some(key) = index.strip_prefix("build.") {
234            self.build.update_value(key, value);
235        } else {
236            self.rest.insert(index, value);
237        }
238
239        Ok(())
240    }
241
242    /// Get the table associated with a particular renderer.
243    pub fn get_renderer<I: AsRef<str>>(&self, index: I) -> Option<&Table> {
244        let key = format!("output.{}", index.as_ref());
245        self.get(&key).and_then(Value::as_table)
246    }
247
248    /// Get the table associated with a particular preprocessor.
249    pub fn get_preprocessor<I: AsRef<str>>(&self, index: I) -> Option<&Table> {
250        let key = format!("preprocessor.{}", index.as_ref());
251        self.get(&key).and_then(Value::as_table)
252    }
253
254    fn from_legacy(mut table: Value) -> Config {
255        let mut cfg = Config::default();
256
257        // we use a macro here instead of a normal loop because the $out
258        // variable can be different types. This way we can make type inference
259        // figure out what try_into() deserializes to.
260        macro_rules! get_and_insert {
261            ($table:expr, $key:expr => $out:expr) => {
262                let got = $table
263                    .as_table_mut()
264                    .and_then(|t| t.remove($key))
265                    .and_then(|v| v.try_into().ok());
266                if let Some(value) = got {
267                    $out = value;
268                }
269            };
270        }
271
272        get_and_insert!(table, "title" => cfg.book.title);
273        get_and_insert!(table, "authors" => cfg.book.authors);
274        get_and_insert!(table, "source" => cfg.book.src);
275        get_and_insert!(table, "description" => cfg.book.description);
276
277        if let Some(dest) = table.delete("output.html.destination") {
278            if let Ok(destination) = dest.try_into() {
279                cfg.build.build_dir = destination;
280            }
281        }
282
283        cfg.rest = table;
284        cfg
285    }
286}
287
288impl Default for Config {
289    fn default() -> Config {
290        Config {
291            book: BookConfig::default(),
292            build: BuildConfig::default(),
293            rust: RustConfig::default(),
294            rest: Value::Table(Table::default()),
295        }
296    }
297}
298
299impl<'de> serde::Deserialize<'de> for Config {
300    fn deserialize<D: Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
301        let raw = Value::deserialize(de)?;
302
303        if is_legacy_format(&raw) {
304            warn!("It looks like you are using the legacy book.toml format.");
305            warn!("We'll parse it for now, but you should probably convert to the new format.");
306            warn!("See the mdbook documentation for more details, although as a rule of thumb");
307            warn!("just move all top level configuration entries like `title`, `author` and");
308            warn!("`description` under a table called `[book]`, move the `destination` entry");
309            warn!("from `[output.html]`, renamed to `build-dir`, under a table called");
310            warn!("`[build]`, and it should all work.");
311            warn!("Documentation: http://rust-lang.github.io/mdBook/format/config.html");
312            return Ok(Config::from_legacy(raw));
313        }
314
315        use serde::de::Error;
316        let mut table = match raw {
317            Value::Table(t) => t,
318            _ => {
319                return Err(D::Error::custom(
320                    "A config file should always be a toml table",
321                ));
322            }
323        };
324
325        let book: BookConfig = table
326            .remove("book")
327            .map(|book| book.try_into().map_err(D::Error::custom))
328            .transpose()?
329            .unwrap_or_default();
330
331        let build: BuildConfig = table
332            .remove("build")
333            .map(|build| build.try_into().map_err(D::Error::custom))
334            .transpose()?
335            .unwrap_or_default();
336
337        let rust: RustConfig = table
338            .remove("rust")
339            .map(|rust| rust.try_into().map_err(D::Error::custom))
340            .transpose()?
341            .unwrap_or_default();
342
343        Ok(Config {
344            book,
345            build,
346            rust,
347            rest: Value::Table(table),
348        })
349    }
350}
351
352impl Serialize for Config {
353    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
354        // TODO: This should probably be removed and use a derive instead.
355        let mut table = self.rest.clone();
356
357        let book_config = Value::try_from(&self.book).expect("should always be serializable");
358        table.insert("book", book_config);
359
360        if self.build != BuildConfig::default() {
361            let build_config = Value::try_from(&self.build).expect("should always be serializable");
362            table.insert("build", build_config);
363        }
364
365        if self.rust != RustConfig::default() {
366            let rust_config = Value::try_from(&self.rust).expect("should always be serializable");
367            table.insert("rust", rust_config);
368        }
369
370        table.serialize(s)
371    }
372}
373
374fn parse_env(key: &str) -> Option<String> {
375    key.strip_prefix("MDBOOK_")
376        .map(|key| key.to_lowercase().replace("__", ".").replace('_', "-"))
377}
378
379fn is_legacy_format(table: &Value) -> bool {
380    let legacy_items = [
381        "title",
382        "authors",
383        "source",
384        "description",
385        "output.html.destination",
386    ];
387
388    for item in &legacy_items {
389        if table.read(item).is_some() {
390            return true;
391        }
392    }
393
394    false
395}
396
397/// Configuration options which are specific to the book and required for
398/// loading it from disk.
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400#[serde(default, rename_all = "kebab-case")]
401pub struct BookConfig {
402    /// The book's title.
403    pub title: Option<String>,
404    /// The book's authors.
405    pub authors: Vec<String>,
406    /// An optional description for the book.
407    pub description: Option<String>,
408    /// Location of the book source relative to the book's root directory.
409    pub src: PathBuf,
410    /// Does this book support more than one language?
411    pub multilingual: bool,
412    /// The main language of the book.
413    pub language: Option<String>,
414}
415
416impl Default for BookConfig {
417    fn default() -> BookConfig {
418        BookConfig {
419            title: None,
420            authors: Vec::new(),
421            description: None,
422            src: PathBuf::from("src"),
423            multilingual: false,
424            language: Some(String::from("en")),
425        }
426    }
427}
428
429/// Configuration for the build procedure.
430#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
431#[serde(default, rename_all = "kebab-case")]
432pub struct BuildConfig {
433    /// Where to put built artefacts relative to the book's root directory.
434    pub build_dir: PathBuf,
435    /// Should non-existent markdown files specified in `SUMMARY.md` be created
436    /// if they don't exist?
437    pub create_missing: bool,
438    /// Should the default preprocessors always be used when they are
439    /// compatible with the renderer?
440    pub use_default_preprocessors: bool,
441    /// Extra directories to trigger rebuild when watching/serving
442    pub extra_watch_dirs: Vec<PathBuf>,
443}
444
445impl Default for BuildConfig {
446    fn default() -> BuildConfig {
447        BuildConfig {
448            build_dir: PathBuf::from("book"),
449            create_missing: true,
450            use_default_preprocessors: true,
451            extra_watch_dirs: Vec::new(),
452        }
453    }
454}
455
456/// Configuration for the Rust compiler(e.g., for playground)
457#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
458#[serde(default, rename_all = "kebab-case")]
459pub struct RustConfig {
460    /// Rust edition used in playground
461    pub edition: Option<RustEdition>,
462}
463
464#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
465/// Rust edition to use for the code.
466pub enum RustEdition {
467    /// The 2021 edition of Rust
468    #[serde(rename = "2021")]
469    E2021,
470    /// The 2018 edition of Rust
471    #[serde(rename = "2018")]
472    E2018,
473    /// The 2015 edition of Rust
474    #[serde(rename = "2015")]
475    E2015,
476}
477
478/// Configuration for the HTML renderer.
479#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
480#[serde(default, rename_all = "kebab-case")]
481pub struct HtmlConfig {
482    /// The theme directory, if specified.
483    pub theme: Option<PathBuf>,
484    /// The default theme to use, defaults to 'light'
485    pub default_theme: Option<String>,
486    /// The theme to use if the browser requests the dark version of the site.
487    /// Defaults to 'navy'.
488    pub preferred_dark_theme: Option<String>,
489    /// Use "smart quotes" instead of the usual `"` character.
490    pub curly_quotes: bool,
491    /// Should mathjax be enabled?
492    pub mathjax_support: bool,
493    /// Whether to fonts.css and respective font files to the output directory.
494    pub copy_fonts: bool,
495    /// An optional google analytics code.
496    pub google_analytics: Option<String>,
497    /// Additional CSS stylesheets to include in the rendered page's `<head>`.
498    pub additional_css: Vec<PathBuf>,
499    /// Additional JS scripts to include at the bottom of the rendered page's
500    /// `<body>`.
501    pub additional_js: Vec<PathBuf>,
502    /// Fold settings.
503    pub fold: Fold,
504    /// Playground settings.
505    #[serde(alias = "playpen")]
506    pub playground: Playground,
507    /// Print settings.
508    pub print: Print,
509    /// Don't render section labels.
510    pub no_section_label: bool,
511    /// Search settings. If `None`, the default will be used.
512    pub search: Option<Search>,
513    /// Git repository url. If `None`, the git button will not be shown.
514    pub git_repository_url: Option<String>,
515    /// FontAwesome icon class to use for the Git repository link.
516    /// Defaults to `fa-github` if `None`.
517    pub git_repository_icon: Option<String>,
518    /// Input path for the 404 file, defaults to 404.md, set to "" to disable 404 file output
519    pub input_404: Option<String>,
520    /// Absolute url to site, used to emit correct paths for the 404 page, which might be accessed in a deeply nested directory
521    pub site_url: Option<String>,
522    /// The DNS subdomain or apex domain at which your book will be hosted. This
523    /// string will be written to a file named CNAME in the root of your site,
524    /// as required by GitHub Pages (see [*Managing a custom domain for your
525    /// GitHub Pages site*][custom domain]).
526    ///
527    /// [custom domain]: https://docs.github.com/en/github/working-with-github-pages/managing-a-custom-domain-for-your-github-pages-site
528    pub cname: Option<String>,
529    /// Edit url template, when set shows a "Suggest an edit" button for
530    /// directly jumping to editing the currently viewed page.
531    /// Contains {path} that is replaced with chapter source file path
532    pub edit_url_template: Option<String>,
533    /// Endpoint of websocket, for livereload usage. Value loaded from .toml
534    /// file is ignored, because our code overrides this field with an
535    /// internal value (`LIVE_RELOAD_ENDPOINT)
536    ///
537    /// This config item *should not be edited* by the end user.
538    #[doc(hidden)]
539    pub live_reload_endpoint: Option<String>,
540    /// The mapping from old pages to new pages/URLs to use when generating
541    /// redirects.
542    pub redirect: HashMap<String, String>,
543}
544
545impl Default for HtmlConfig {
546    fn default() -> HtmlConfig {
547        HtmlConfig {
548            theme: None,
549            default_theme: None,
550            preferred_dark_theme: None,
551            curly_quotes: false,
552            mathjax_support: false,
553            copy_fonts: true,
554            google_analytics: None,
555            additional_css: Vec::new(),
556            additional_js: Vec::new(),
557            fold: Fold::default(),
558            playground: Playground::default(),
559            print: Print::default(),
560            no_section_label: false,
561            search: None,
562            git_repository_url: None,
563            git_repository_icon: None,
564            edit_url_template: None,
565            input_404: None,
566            site_url: None,
567            cname: None,
568            live_reload_endpoint: None,
569            redirect: HashMap::new(),
570        }
571    }
572}
573
574impl HtmlConfig {
575    /// Returns the directory of theme from the provided root directory. If the
576    /// directory is not present it will append the default directory of "theme"
577    pub fn theme_dir(&self, root: &Path) -> PathBuf {
578        match self.theme {
579            Some(ref d) => root.join(d),
580            None => root.join("theme"),
581        }
582    }
583}
584
585/// Configuration for how to render the print icon, print.html, and print.css.
586#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
587#[serde(default, rename_all = "kebab-case")]
588pub struct Print {
589    /// Whether print support is enabled.
590    pub enable: bool,
591    /// Insert page breaks between chapters. Default: `true`.
592    pub page_break: bool,
593}
594
595impl Default for Print {
596    fn default() -> Self {
597        Self {
598            enable: true,
599            page_break: true,
600        }
601    }
602}
603
604/// Configuration for how to fold chapters of sidebar.
605#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
606#[serde(default, rename_all = "kebab-case")]
607pub struct Fold {
608    /// When off, all folds are open. Default: `false`.
609    pub enable: bool,
610    /// The higher the more folded regions are open. When level is 0, all folds
611    /// are closed.
612    /// Default: `0`.
613    pub level: u8,
614}
615
616/// Configuration for tweaking how the the HTML renderer handles the playground.
617#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
618#[serde(default, rename_all = "kebab-case")]
619pub struct Playground {
620    /// Should playground snippets be editable? Default: `false`.
621    pub editable: bool,
622    /// Display the copy button. Default: `true`.
623    pub copyable: bool,
624    /// Copy JavaScript files for the editor to the output directory?
625    /// Default: `true`.
626    pub copy_js: bool,
627    /// Display line numbers on playground snippets. Default: `false`.
628    pub line_numbers: bool,
629    /// Display the run button. Default: `true`
630    pub runnable: bool,
631    /// Configuration for adding playground support for languages other then rust.
632    /// Default: vec![].
633    /// If there is no configuration for rust specified, the default rust configuration is used.
634    pub languages: Vec<PlaygroundLanguage>,
635}
636
637impl Default for Playground {
638    fn default() -> Playground {
639        Playground {
640            editable: false,
641            copyable: true,
642            copy_js: true,
643            line_numbers: false,
644            runnable: true,
645            languages: vec![],
646        }
647    }
648}
649
650/// Configuration for adding playground support for languages other then rust.
651#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
652#[serde(default, rename_all = "kebab-case")]
653pub struct PlaygroundLanguage {
654    /// The language name. Default: "rust".
655    pub language: String,
656    /// Specifies the default hidelines character(s), used to hide lines in codeblocks.
657    /// Default: "#"
658    pub hidelines: String,
659    /// The playground endpoint, this is where the POST request with the code is made. Default: "https://play.rust-lang.org/evaluate.json"
660    pub endpoint: String,
661}
662
663impl Default for PlaygroundLanguage {
664    fn default() -> PlaygroundLanguage {
665        PlaygroundLanguage {
666            language: "rust".to_string(),
667            hidelines: "#".to_string(),
668            endpoint: "https://play.rust-lang.org/evaluate.json".to_string()
669        }
670    }
671}
672
673/// Configuration of the search functionality of the HTML renderer.
674#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
675#[serde(default, rename_all = "kebab-case")]
676pub struct Search {
677    /// Enable the search feature. Default: `true`.
678    pub enable: bool,
679    /// Maximum number of visible results. Default: `30`.
680    pub limit_results: u32,
681    /// The number of words used for a search result teaser. Default: `30`.
682    pub teaser_word_count: u32,
683    /// Define the logical link between multiple search words.
684    /// If true, all search words must appear in each result. Default: `false`.
685    pub use_boolean_and: bool,
686    /// Boost factor for the search result score if a search word appears in the header.
687    /// Default: `2`.
688    pub boost_title: u8,
689    /// Boost factor for the search result score if a search word appears in the hierarchy.
690    /// The hierarchy contains all titles of the parent documents and all parent headings.
691    /// Default: `1`.
692    pub boost_hierarchy: u8,
693    /// Boost factor for the search result score if a search word appears in the text.
694    /// Default: `1`.
695    pub boost_paragraph: u8,
696    /// True if the searchword `micro` should match `microwave`. Default: `true`.
697    pub expand: bool,
698    /// Documents are split into smaller parts, separated by headings. This defines, until which
699    /// level of heading documents should be split. Default: `3`. (`### This is a level 3 heading`)
700    pub heading_split_level: u8,
701    /// Copy JavaScript files for the search functionality to the output directory?
702    /// Default: `true`.
703    pub copy_js: bool,
704}
705
706impl Default for Search {
707    fn default() -> Search {
708        // Please update the documentation of `Search` when changing values!
709        Search {
710            enable: true,
711            limit_results: 30,
712            teaser_word_count: 30,
713            use_boolean_and: false,
714            boost_title: 2,
715            boost_hierarchy: 1,
716            boost_paragraph: 1,
717            expand: true,
718            heading_split_level: 3,
719            copy_js: true,
720        }
721    }
722}
723
724/// Allows you to "update" any arbitrary field in a struct by round-tripping via
725/// a `toml::Value`.
726///
727/// This is definitely not the most performant way to do things, which means you
728/// should probably keep it away from tight loops...
729trait Updateable<'de>: Serialize + Deserialize<'de> {
730    fn update_value<S: Serialize>(&mut self, key: &str, value: S) {
731        let mut raw = Value::try_from(&self).expect("unreachable");
732
733        if let Ok(value) = Value::try_from(value) {
734            let _ = raw.insert(key, value);
735        } else {
736            return;
737        }
738
739        if let Ok(updated) = raw.try_into() {
740            *self = updated;
741        }
742    }
743}
744
745impl<'de, T> Updateable<'de> for T where T: Serialize + Deserialize<'de> {}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use crate::utils::fs::get_404_output_file;
751    use serde_json::json;
752
753    const COMPLEX_CONFIG: &str = r##"
754        [book]
755        title = "Some Book"
756        authors = ["Michael-F-Bryan <michaelfbryan@gmail.com>"]
757        description = "A completely useless book"
758        multilingual = true
759        src = "source"
760        language = "ja"
761
762        [build]
763        build-dir = "outputs"
764        create-missing = false
765        use-default-preprocessors = true
766
767        [output.html]
768        theme = "./themedir"
769        default-theme = "rust"
770        curly-quotes = true
771        google-analytics = "123456"
772        additional-css = ["./foo/bar/baz.css"]
773        git-repository-url = "https://foo.com/"
774        git-repository-icon = "fa-code-fork"
775
776        [output.html.playground]
777        editable = true
778        editor = "ace"
779
780        [[output.html.playground.languages]]
781        language = "rust"
782        hidelines = "#"
783        endpoint = "https://play.rust-lang.org/evaluate.json"
784
785        [[output.html.playground.languages]]
786        language = "c"
787        hidelines = "$"
788        endpoint = "https://some-endpoint.com/evaluate"
789
790        [output.html.redirect]
791        "index.html" = "overview.html"
792        "nexted/page.md" = "https://rust-lang.org/"
793
794        [preprocessor.first]
795
796        [preprocessor.second]
797        "##;
798
799    #[test]
800    fn load_a_complex_config_file() {
801        let src = COMPLEX_CONFIG;
802
803        let book_should_be = BookConfig {
804            title: Some(String::from("Some Book")),
805            authors: vec![String::from("Michael-F-Bryan <michaelfbryan@gmail.com>")],
806            description: Some(String::from("A completely useless book")),
807            multilingual: true,
808            src: PathBuf::from("source"),
809            language: Some(String::from("ja")),
810        };
811        let build_should_be = BuildConfig {
812            build_dir: PathBuf::from("outputs"),
813            create_missing: false,
814            use_default_preprocessors: true,
815            extra_watch_dirs: Vec::new(),
816        };
817        let rust_should_be = RustConfig { edition: None };
818        let playground_should_be = Playground {
819            editable: true,
820            copyable: true,
821            copy_js: true,
822            line_numbers: false,
823            runnable: true,
824            languages: vec![PlaygroundLanguage::default(), PlaygroundLanguage {
825                language: String::from("c"),
826                hidelines:String::from("$"),
827                endpoint: String::from("https://some-endpoint.com/evaluate"),
828            }],
829        };
830        let html_should_be = HtmlConfig {
831            curly_quotes: true,
832            google_analytics: Some(String::from("123456")),
833            additional_css: vec![PathBuf::from("./foo/bar/baz.css")],
834            theme: Some(PathBuf::from("./themedir")),
835            default_theme: Some(String::from("rust")),
836            playground: playground_should_be,
837            git_repository_url: Some(String::from("https://foo.com/")),
838            git_repository_icon: Some(String::from("fa-code-fork")),
839            redirect: vec![
840                (String::from("index.html"), String::from("overview.html")),
841                (
842                    String::from("nexted/page.md"),
843                    String::from("https://rust-lang.org/"),
844                ),
845            ]
846            .into_iter()
847            .collect(),
848            ..Default::default()
849        };
850
851        let got = Config::from_str(src).unwrap();
852
853        assert_eq!(got.book, book_should_be);
854        assert_eq!(got.build, build_should_be);
855        assert_eq!(got.rust, rust_should_be);
856        assert_eq!(got.html_config().unwrap(), html_should_be);
857    }
858
859    #[test]
860    fn disable_runnable() {
861        let src = r#"
862        [book]
863        title = "Some Book"
864        description = "book book book"
865        authors = ["Shogo Takata"]
866
867        [output.html.playground]
868        runnable = false
869        "#;
870
871        let got = Config::from_str(src).unwrap();
872        assert!(!got.html_config().unwrap().playground.runnable);
873    }
874
875    #[test]
876    fn edition_2015() {
877        let src = r#"
878        [book]
879        title = "mdBook Documentation"
880        description = "Create book from markdown files. Like Gitbook but implemented in Rust"
881        authors = ["Mathieu David"]
882        src = "./source"
883        [rust]
884        edition = "2015"
885        "#;
886
887        let book_should_be = BookConfig {
888            title: Some(String::from("mdBook Documentation")),
889            description: Some(String::from(
890                "Create book from markdown files. Like Gitbook but implemented in Rust",
891            )),
892            authors: vec![String::from("Mathieu David")],
893            src: PathBuf::from("./source"),
894            ..Default::default()
895        };
896
897        let got = Config::from_str(src).unwrap();
898        assert_eq!(got.book, book_should_be);
899
900        let rust_should_be = RustConfig {
901            edition: Some(RustEdition::E2015),
902        };
903        let got = Config::from_str(src).unwrap();
904        assert_eq!(got.rust, rust_should_be);
905    }
906
907    #[test]
908    fn edition_2018() {
909        let src = r#"
910        [book]
911        title = "mdBook Documentation"
912        description = "Create book from markdown files. Like Gitbook but implemented in Rust"
913        authors = ["Mathieu David"]
914        src = "./source"
915        [rust]
916        edition = "2018"
917        "#;
918
919        let rust_should_be = RustConfig {
920            edition: Some(RustEdition::E2018),
921        };
922
923        let got = Config::from_str(src).unwrap();
924        assert_eq!(got.rust, rust_should_be);
925    }
926
927    #[test]
928    fn edition_2021() {
929        let src = r#"
930        [book]
931        title = "mdBook Documentation"
932        description = "Create book from markdown files. Like Gitbook but implemented in Rust"
933        authors = ["Mathieu David"]
934        src = "./source"
935        [rust]
936        edition = "2021"
937        "#;
938
939        let rust_should_be = RustConfig {
940            edition: Some(RustEdition::E2021),
941        };
942
943        let got = Config::from_str(src).unwrap();
944        assert_eq!(got.rust, rust_should_be);
945    }
946
947    #[test]
948    fn load_arbitrary_output_type() {
949        #[derive(Debug, Deserialize, PartialEq)]
950        struct RandomOutput {
951            foo: u32,
952            bar: String,
953            baz: Vec<bool>,
954        }
955
956        let src = r#"
957        [output.random]
958        foo = 5
959        bar = "Hello World"
960        baz = [true, true, false]
961        "#;
962
963        let should_be = RandomOutput {
964            foo: 5,
965            bar: String::from("Hello World"),
966            baz: vec![true, true, false],
967        };
968
969        let cfg = Config::from_str(src).unwrap();
970        let got: RandomOutput = cfg.get_deserialized_opt("output.random").unwrap().unwrap();
971
972        assert_eq!(got, should_be);
973
974        let got_baz: Vec<bool> = cfg
975            .get_deserialized_opt("output.random.baz")
976            .unwrap()
977            .unwrap();
978        let baz_should_be = vec![true, true, false];
979
980        assert_eq!(got_baz, baz_should_be);
981    }
982
983    #[test]
984    fn mutate_some_stuff() {
985        // really this is just a sanity check to make sure the borrow checker
986        // is happy...
987        let src = COMPLEX_CONFIG;
988        let mut config = Config::from_str(src).unwrap();
989        let key = "output.html.playground.editable";
990
991        assert_eq!(config.get(key).unwrap(), &Value::Boolean(true));
992        *config.get_mut(key).unwrap() = Value::Boolean(false);
993        assert_eq!(config.get(key).unwrap(), &Value::Boolean(false));
994    }
995
996    /// The config file format has slightly changed (metadata stuff is now under
997    /// the `book` table instead of being at the top level) so we're adding a
998    /// **temporary** compatibility check. You should be able to still load the
999    /// old format, emitting a warning.
1000    #[test]
1001    fn can_still_load_the_previous_format() {
1002        let src = r#"
1003        title = "mdBook Documentation"
1004        description = "Create book from markdown files. Like Gitbook but implemented in Rust"
1005        authors = ["Mathieu David"]
1006        source = "./source"
1007
1008        [output.html]
1009        destination = "my-book" # the output files will be generated in `root/my-book` instead of `root/book`
1010        theme = "my-theme"
1011        curly-quotes = true
1012        google-analytics = "123456"
1013        additional-css = ["custom.css", "custom2.css"]
1014        additional-js = ["custom.js"]
1015        "#;
1016
1017        let book_should_be = BookConfig {
1018            title: Some(String::from("mdBook Documentation")),
1019            description: Some(String::from(
1020                "Create book from markdown files. Like Gitbook but implemented in Rust",
1021            )),
1022            authors: vec![String::from("Mathieu David")],
1023            src: PathBuf::from("./source"),
1024            ..Default::default()
1025        };
1026
1027        let build_should_be = BuildConfig {
1028            build_dir: PathBuf::from("my-book"),
1029            create_missing: true,
1030            use_default_preprocessors: true,
1031            extra_watch_dirs: Vec::new(),
1032        };
1033
1034        let html_should_be = HtmlConfig {
1035            theme: Some(PathBuf::from("my-theme")),
1036            curly_quotes: true,
1037            google_analytics: Some(String::from("123456")),
1038            additional_css: vec![PathBuf::from("custom.css"), PathBuf::from("custom2.css")],
1039            additional_js: vec![PathBuf::from("custom.js")],
1040            ..Default::default()
1041        };
1042
1043        let got = Config::from_str(src).unwrap();
1044        assert_eq!(got.book, book_should_be);
1045        assert_eq!(got.build, build_should_be);
1046        assert_eq!(got.html_config().unwrap(), html_should_be);
1047    }
1048
1049    #[test]
1050    fn set_a_config_item() {
1051        let mut cfg = Config::default();
1052        let key = "foo.bar.baz";
1053        let value = "Something Interesting";
1054
1055        assert!(cfg.get(key).is_none());
1056        cfg.set(key, value).unwrap();
1057
1058        let got: String = cfg.get_deserialized_opt(key).unwrap().unwrap();
1059        assert_eq!(got, value);
1060    }
1061
1062    #[test]
1063    fn parse_env_vars() {
1064        let inputs = vec![
1065            ("FOO", None),
1066            ("MDBOOK_foo", Some("foo")),
1067            ("MDBOOK_FOO__bar__baz", Some("foo.bar.baz")),
1068            ("MDBOOK_FOO_bar__baz", Some("foo-bar.baz")),
1069        ];
1070
1071        for (src, should_be) in inputs {
1072            let got = parse_env(src);
1073            let should_be = should_be.map(ToString::to_string);
1074
1075            assert_eq!(got, should_be);
1076        }
1077    }
1078
1079    fn encode_env_var(key: &str) -> String {
1080        format!(
1081            "MDBOOK_{}",
1082            key.to_uppercase().replace('.', "__").replace('-', "_")
1083        )
1084    }
1085
1086    #[test]
1087    fn update_config_using_env_var() {
1088        let mut cfg = Config::default();
1089        let key = "foo.bar";
1090        let value = "baz";
1091
1092        assert!(cfg.get(key).is_none());
1093
1094        let encoded_key = encode_env_var(key);
1095        env::set_var(encoded_key, value);
1096
1097        cfg.update_from_env();
1098
1099        assert_eq!(
1100            cfg.get_deserialized_opt::<String, _>(key).unwrap().unwrap(),
1101            value
1102        );
1103    }
1104
1105    #[test]
1106    fn update_config_using_env_var_and_complex_value() {
1107        let mut cfg = Config::default();
1108        let key = "foo-bar.baz";
1109        let value = json!({"array": [1, 2, 3], "number": 13.37});
1110        let value_str = serde_json::to_string(&value).unwrap();
1111
1112        assert!(cfg.get(key).is_none());
1113
1114        let encoded_key = encode_env_var(key);
1115        env::set_var(encoded_key, value_str);
1116
1117        cfg.update_from_env();
1118
1119        assert_eq!(
1120            cfg.get_deserialized_opt::<serde_json::Value, _>(key)
1121                .unwrap()
1122                .unwrap(),
1123            value
1124        );
1125    }
1126
1127    #[test]
1128    fn update_book_title_via_env() {
1129        let mut cfg = Config::default();
1130        let should_be = "Something else".to_string();
1131
1132        assert_ne!(cfg.book.title, Some(should_be.clone()));
1133
1134        env::set_var("MDBOOK_BOOK__TITLE", &should_be);
1135        cfg.update_from_env();
1136
1137        assert_eq!(cfg.book.title, Some(should_be));
1138    }
1139
1140    #[test]
1141    fn file_404_default() {
1142        let src = r#"
1143        [output.html]
1144        destination = "my-book"
1145        "#;
1146
1147        let got = Config::from_str(src).unwrap();
1148        let html_config = got.html_config().unwrap();
1149        assert_eq!(html_config.input_404, None);
1150        assert_eq!(&get_404_output_file(&html_config.input_404), "404.html");
1151    }
1152
1153    #[test]
1154    fn file_404_custom() {
1155        let src = r#"
1156        [output.html]
1157        input-404= "missing.md"
1158        output-404= "missing.html"
1159        "#;
1160
1161        let got = Config::from_str(src).unwrap();
1162        let html_config = got.html_config().unwrap();
1163        assert_eq!(html_config.input_404, Some("missing.md".to_string()));
1164        assert_eq!(&get_404_output_file(&html_config.input_404), "missing.html");
1165    }
1166
1167    #[test]
1168    #[should_panic(expected = "Invalid configuration file")]
1169    fn invalid_language_type_error() {
1170        let src = r#"
1171        [book]
1172        title = "mdBook Documentation"
1173        language = ["en", "pt-br"]
1174        description = "Create book from markdown files. Like Gitbook but implemented in Rust"
1175        authors = ["Mathieu David"]
1176        src = "./source"
1177        "#;
1178
1179        Config::from_str(src).unwrap();
1180    }
1181
1182    #[test]
1183    #[should_panic(expected = "Invalid configuration file")]
1184    fn invalid_title_type() {
1185        let src = r#"
1186        [book]
1187        title = 20
1188        language = "en"
1189        description = "Create book from markdown files. Like Gitbook but implemented in Rust"
1190        authors = ["Mathieu David"]
1191        src = "./source"
1192        "#;
1193
1194        Config::from_str(src).unwrap();
1195    }
1196
1197    #[test]
1198    #[should_panic(expected = "Invalid configuration file")]
1199    fn invalid_build_dir_type() {
1200        let src = r#"
1201        [build]
1202        build-dir = 99
1203        create-missing = false
1204        "#;
1205
1206        Config::from_str(src).unwrap();
1207    }
1208
1209    #[test]
1210    #[should_panic(expected = "Invalid configuration file")]
1211    fn invalid_rust_edition() {
1212        let src = r#"
1213        [rust]
1214        edition = "1999"
1215        "#;
1216
1217        Config::from_str(src).unwrap();
1218    }
1219
1220    #[test]
1221    fn print_config() {
1222        let src = r#"
1223        [output.html.print]
1224        enable = false
1225        "#;
1226        let got = Config::from_str(src).unwrap();
1227        let html_config = got.html_config().unwrap();
1228        assert!(!html_config.print.enable);
1229        assert!(html_config.print.page_break);
1230        let src = r#"
1231        [output.html.print]
1232        page-break = false
1233        "#;
1234        let got = Config::from_str(src).unwrap();
1235        let html_config = got.html_config().unwrap();
1236        assert!(html_config.print.enable);
1237        assert!(!html_config.print.page_break);
1238    }
1239}