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