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