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