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