]>
Commit | Line | Data |
---|---|---|
2c00a5a8 | 1 | //! Mdbook's configuration system. |
83c7162d | 2 | //! |
2c00a5a8 XL |
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 | |
83c7162d | 5 | //! pre-determined tables (`BookConfig` and `BuildConfig`) as well as support |
2c00a5a8 | 6 | //! for arbitrary data which is exposed to plugins and alternate backends. |
83c7162d XL |
7 | //! |
8 | //! | |
2c00a5a8 | 9 | //! # Examples |
83c7162d | 10 | //! |
2c00a5a8 XL |
11 | //! ```rust |
12 | //! # extern crate mdbook; | |
13 | //! # use mdbook::errors::*; | |
14 | //! # extern crate toml; | |
15 | //! use std::path::PathBuf; | |
16 | //! use mdbook::Config; | |
17 | //! use toml::Value; | |
83c7162d | 18 | //! |
2c00a5a8 XL |
19 | //! # fn run() -> Result<()> { |
20 | //! let src = r#" | |
21 | //! [book] | |
22 | //! title = "My Book" | |
23 | //! authors = ["Michael-F-Bryan"] | |
83c7162d | 24 | //! |
2c00a5a8 XL |
25 | //! [build] |
26 | //! src = "out" | |
83c7162d | 27 | //! |
2c00a5a8 XL |
28 | //! [other-table.foo] |
29 | //! bar = 123 | |
30 | //! "#; | |
83c7162d | 31 | //! |
2c00a5a8 XL |
32 | //! // load the `Config` from a toml string |
33 | //! let mut cfg = Config::from_str(src)?; | |
83c7162d | 34 | //! |
2c00a5a8 XL |
35 | //! // retrieve a nested value |
36 | //! let bar = cfg.get("other-table.foo.bar").cloned(); | |
37 | //! assert_eq!(bar, Some(Value::Integer(123))); | |
83c7162d | 38 | //! |
2c00a5a8 XL |
39 | //! // Set the `output.html.theme` directory |
40 | //! assert!(cfg.get("output.html").is_none()); | |
41 | //! cfg.set("output.html.theme", "./themes"); | |
83c7162d | 42 | //! |
2c00a5a8 XL |
43 | //! // then load it again, automatically deserializing to a `PathBuf`. |
44 | //! let got: PathBuf = cfg.get_deserialized("output.html.theme")?; | |
45 | //! assert_eq!(got, PathBuf::from("./themes")); | |
46 | //! # Ok(()) | |
47 | //! # } | |
48 | //! # fn main() { run().unwrap() } | |
49 | //! ``` | |
50 | ||
51 | #![deny(missing_docs)] | |
52 | ||
53 | use std::path::{Path, PathBuf}; | |
54 | use std::fs::File; | |
55 | use std::io::Read; | |
56 | use std::env; | |
57 | use toml::{self, Value}; | |
58 | use toml::value::Table; | |
59 | use toml_query::read::TomlValueReadExt; | |
60 | use toml_query::insert::TomlValueInsertExt; | |
61 | use toml_query::delete::TomlValueDeleteExt; | |
62 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; | |
63 | use serde_json; | |
64 | ||
65 | use errors::*; | |
66 | ||
67 | /// The overall configuration object for MDBook, essentially an in-memory | |
68 | /// representation of `book.toml`. | |
69 | #[derive(Debug, Clone, PartialEq)] | |
70 | pub struct Config { | |
71 | /// Metadata about the book. | |
72 | pub book: BookConfig, | |
73 | /// Information about the build environment. | |
74 | pub build: BuildConfig, | |
75 | rest: Value, | |
76 | } | |
77 | ||
78 | impl Config { | |
79 | /// Load a `Config` from some string. | |
80 | pub fn from_str(src: &str) -> Result<Config> { | |
81 | toml::from_str(src).chain_err(|| Error::from("Invalid configuration file")) | |
82 | } | |
83 | ||
84 | /// Load the configuration file from disk. | |
85 | pub fn from_disk<P: AsRef<Path>>(config_file: P) -> Result<Config> { | |
86 | let mut buffer = String::new(); | |
87 | File::open(config_file) | |
88 | .chain_err(|| "Unable to open the configuration file")? | |
89 | .read_to_string(&mut buffer) | |
90 | .chain_err(|| "Couldn't read the file")?; | |
91 | ||
92 | Config::from_str(&buffer) | |
93 | } | |
94 | ||
95 | /// Updates the `Config` from the available environment variables. | |
96 | /// | |
97 | /// Variables starting with `MDBOOK_` are used for configuration. The key is | |
98 | /// created by removing the `MDBOOK_` prefix and turning the resulting | |
99 | /// string into `kebab-case`. Double underscores (`__`) separate nested | |
100 | /// keys, while a single underscore (`_`) is replaced with a dash (`-`). | |
101 | /// | |
102 | /// For example: | |
103 | /// | |
104 | /// - `MDBOOK_foo` -> `foo` | |
105 | /// - `MDBOOK_FOO` -> `foo` | |
106 | /// - `MDBOOK_FOO__BAR` -> `foo.bar` | |
107 | /// - `MDBOOK_FOO_BAR` -> `foo-bar` | |
108 | /// - `MDBOOK_FOO_bar__baz` -> `foo-bar.baz` | |
109 | /// | |
110 | /// So by setting the `MDBOOK_BOOK__TITLE` environment variable you can | |
111 | /// override the book's title without needing to touch your `book.toml`. | |
112 | /// | |
113 | /// > **Note:** To facilitate setting more complex config items, the value | |
114 | /// > of an environment variable is first parsed as JSON, falling back to a | |
115 | /// > string if the parse fails. | |
116 | /// > | |
117 | /// > This means, if you so desired, you could override all book metadata | |
118 | /// > when building the book with something like | |
119 | /// > | |
120 | /// > ```text | |
121 | /// > $ export MDBOOK_BOOK="{'title': 'My Awesome Book', authors: ['Michael-F-Bryan']}" | |
122 | /// > $ mdbook build | |
123 | /// > ``` | |
124 | /// | |
125 | /// The latter case may be useful in situations where `mdbook` is invoked | |
126 | /// from a script or CI, where it sometimes isn't possible to update the | |
127 | /// `book.toml` before building. | |
128 | pub fn update_from_env(&mut self) { | |
129 | debug!("Updating the config from environment variables"); | |
130 | ||
131 | let overrides = env::vars().filter_map(|(key, value)| match parse_env(&key) { | |
132 | Some(index) => Some((index, value)), | |
133 | None => None, | |
134 | }); | |
135 | ||
136 | for (key, value) in overrides { | |
137 | trace!("{} => {}", key, value); | |
138 | let parsed_value = serde_json::from_str(&value) | |
139 | .unwrap_or_else(|_| serde_json::Value::String(value.to_string())); | |
140 | ||
141 | self.set(key, parsed_value).expect("unreachable"); | |
142 | } | |
143 | } | |
144 | ||
145 | /// Fetch an arbitrary item from the `Config` as a `toml::Value`. | |
146 | /// | |
147 | /// You can use dotted indices to access nested items (e.g. | |
148 | /// `output.html.playpen` will fetch the "playpen" out of the html output | |
149 | /// table). | |
150 | pub fn get(&self, key: &str) -> Option<&Value> { | |
151 | match self.rest.read(key) { | |
152 | Ok(inner) => inner, | |
153 | Err(_) => None, | |
154 | } | |
155 | } | |
156 | ||
157 | /// Fetch a value from the `Config` so you can mutate it. | |
158 | pub fn get_mut<'a>(&'a mut self, key: &str) -> Option<&'a mut Value> { | |
159 | match self.rest.read_mut(key) { | |
160 | Ok(inner) => inner, | |
161 | Err(_) => None, | |
162 | } | |
163 | } | |
164 | ||
165 | /// Convenience method for getting the html renderer's configuration. | |
166 | /// | |
167 | /// # Note | |
168 | /// | |
169 | /// This is for compatibility only. It will be removed completely once the | |
170 | /// HTML renderer is refactored to be less coupled to `mdbook` internals. | |
171 | #[doc(hidden)] | |
172 | pub fn html_config(&self) -> Option<HtmlConfig> { | |
173 | self.get_deserialized("output.html").ok() | |
174 | } | |
175 | ||
176 | /// Convenience function to fetch a value from the config and deserialize it | |
177 | /// into some arbitrary type. | |
178 | pub fn get_deserialized<'de, T: Deserialize<'de>, S: AsRef<str>>(&self, name: S) -> Result<T> { | |
179 | let name = name.as_ref(); | |
180 | ||
181 | if let Some(value) = self.get(name) { | |
182 | value | |
183 | .clone() | |
184 | .try_into() | |
185 | .chain_err(|| "Couldn't deserialize the value") | |
186 | } else { | |
187 | bail!("Key not found, {:?}", name) | |
188 | } | |
189 | } | |
190 | ||
191 | /// Set a config key, clobbering any existing values along the way. | |
192 | /// | |
193 | /// The only way this can fail is if we can't serialize `value` into a | |
194 | /// `toml::Value`. | |
195 | pub fn set<S: Serialize, I: AsRef<str>>(&mut self, index: I, value: S) -> Result<()> { | |
196 | let index = index.as_ref(); | |
197 | ||
198 | let value = | |
199 | Value::try_from(value).chain_err(|| "Unable to represent the item as a JSON Value")?; | |
200 | ||
201 | if index.starts_with("book.") { | |
202 | self.book.update_value(&index[5..], value); | |
203 | } else if index.starts_with("build.") { | |
204 | self.build.update_value(&index[6..], value); | |
205 | } else { | |
206 | self.rest.insert(index, value)?; | |
207 | } | |
208 | ||
209 | Ok(()) | |
210 | } | |
211 | ||
212 | fn from_legacy(mut table: Value) -> Config { | |
213 | let mut cfg = Config::default(); | |
214 | ||
215 | // we use a macro here instead of a normal loop because the $out | |
216 | // variable can be different types. This way we can make type inference | |
217 | // figure out what try_into() deserializes to. | |
218 | macro_rules! get_and_insert { | |
219 | ($table:expr, $key:expr => $out:expr) => { | |
220 | let got = $table.as_table_mut() | |
221 | .and_then(|t| t.remove($key)) | |
222 | .and_then(|v| v.try_into().ok()); | |
223 | if let Some(value) = got { | |
224 | $out = value; | |
225 | } | |
226 | }; | |
227 | } | |
228 | ||
229 | get_and_insert!(table, "title" => cfg.book.title); | |
230 | get_and_insert!(table, "authors" => cfg.book.authors); | |
231 | get_and_insert!(table, "source" => cfg.book.src); | |
232 | get_and_insert!(table, "description" => cfg.book.description); | |
233 | ||
234 | if let Ok(Some(dest)) = table.delete("output.html.destination") { | |
235 | if let Ok(destination) = dest.try_into() { | |
236 | cfg.build.build_dir = destination; | |
237 | } | |
238 | } | |
239 | ||
240 | cfg.rest = table; | |
241 | cfg | |
242 | } | |
243 | } | |
244 | ||
245 | impl Default for Config { | |
246 | fn default() -> Config { | |
247 | Config { | |
248 | book: BookConfig::default(), | |
249 | build: BuildConfig::default(), | |
250 | rest: Value::Table(Table::default()), | |
251 | } | |
252 | } | |
253 | } | |
254 | impl<'de> Deserialize<'de> for Config { | |
255 | fn deserialize<D: Deserializer<'de>>(de: D) -> ::std::result::Result<Self, D::Error> { | |
256 | let raw = Value::deserialize(de)?; | |
257 | ||
258 | if is_legacy_format(&raw) { | |
259 | warn!("It looks like you are using the legacy book.toml format."); | |
260 | warn!("We'll parse it for now, but you should probably convert to the new format."); | |
261 | warn!("See the mdbook documentation for more details, although as a rule of thumb"); | |
262 | warn!("just move all top level configuration entries like `title`, `author` and"); | |
263 | warn!("`description` under a table called `[book]`, move the `destination` entry"); | |
264 | warn!("from `[output.html]`, renamed to `build-dir`, under a table called"); | |
265 | warn!("`[build]`, and it should all work."); | |
266 | warn!("Documentation: http://rust-lang-nursery.github.io/mdBook/format/config.html"); | |
267 | return Ok(Config::from_legacy(raw)); | |
268 | } | |
269 | ||
270 | let mut table = match raw { | |
271 | Value::Table(t) => t, | |
272 | _ => { | |
273 | use serde::de::Error; | |
274 | return Err(D::Error::custom( | |
275 | "A config file should always be a toml table", | |
276 | )); | |
277 | } | |
278 | }; | |
279 | ||
280 | let book: BookConfig = table | |
281 | .remove("book") | |
282 | .and_then(|value| value.try_into().ok()) | |
283 | .unwrap_or_default(); | |
284 | ||
285 | let build: BuildConfig = table | |
286 | .remove("build") | |
287 | .and_then(|value| value.try_into().ok()) | |
288 | .unwrap_or_default(); | |
289 | ||
290 | Ok(Config { | |
291 | book: book, | |
292 | build: build, | |
293 | rest: Value::Table(table), | |
294 | }) | |
295 | } | |
296 | } | |
297 | ||
298 | impl Serialize for Config { | |
299 | fn serialize<S: Serializer>(&self, s: S) -> ::std::result::Result<S::Ok, S::Error> { | |
300 | use serde::ser::Error; | |
301 | ||
302 | let mut table = self.rest.clone(); | |
303 | ||
304 | let book_config = match Value::try_from(self.book.clone()) { | |
305 | Ok(cfg) => cfg, | |
306 | Err(_) => { | |
307 | return Err(S::Error::custom("Unable to serialize the BookConfig")); | |
308 | } | |
309 | }; | |
310 | ||
311 | table.insert("book", book_config).expect("unreachable"); | |
312 | table.serialize(s) | |
313 | } | |
314 | } | |
315 | ||
316 | fn parse_env(key: &str) -> Option<String> { | |
317 | const PREFIX: &str = "MDBOOK_"; | |
318 | ||
319 | if key.starts_with(PREFIX) { | |
320 | let key = &key[PREFIX.len()..]; | |
321 | ||
322 | Some(key.to_lowercase().replace("__", ".").replace("_", "-")) | |
323 | } else { | |
324 | None | |
325 | } | |
326 | } | |
327 | ||
328 | fn is_legacy_format(table: &Value) -> bool { | |
329 | let legacy_items = [ | |
330 | "title", | |
331 | "authors", | |
332 | "source", | |
333 | "description", | |
334 | "output.html.destination", | |
335 | ]; | |
336 | ||
337 | for item in &legacy_items { | |
338 | if let Ok(Some(_)) = table.read(item) { | |
339 | return true; | |
340 | } | |
341 | } | |
342 | ||
343 | false | |
344 | } | |
345 | ||
346 | /// Configuration options which are specific to the book and required for | |
347 | /// loading it from disk. | |
348 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | |
349 | #[serde(default, rename_all = "kebab-case")] | |
350 | pub struct BookConfig { | |
351 | /// The book's title. | |
352 | pub title: Option<String>, | |
353 | /// The book's authors. | |
354 | pub authors: Vec<String>, | |
355 | /// An optional description for the book. | |
356 | pub description: Option<String>, | |
357 | /// Location of the book source relative to the book's root directory. | |
358 | pub src: PathBuf, | |
359 | /// Does this book support more than one language? | |
360 | pub multilingual: bool, | |
361 | } | |
362 | ||
363 | impl Default for BookConfig { | |
364 | fn default() -> BookConfig { | |
365 | BookConfig { | |
366 | title: None, | |
367 | authors: Vec::new(), | |
368 | description: None, | |
369 | src: PathBuf::from("src"), | |
370 | multilingual: false, | |
371 | } | |
372 | } | |
373 | } | |
374 | ||
375 | /// Configuration for the build procedure. | |
376 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | |
377 | #[serde(default, rename_all = "kebab-case")] | |
378 | pub struct BuildConfig { | |
379 | /// Where to put built artefacts relative to the book's root directory. | |
380 | pub build_dir: PathBuf, | |
381 | /// Should non-existent markdown files specified in `SETTINGS.md` be created | |
382 | /// if they don't exist? | |
383 | pub create_missing: bool, | |
384 | /// Which preprocessors should be applied | |
385 | pub preprocess: Option<Vec<String>>, | |
2c00a5a8 XL |
386 | } |
387 | ||
388 | impl Default for BuildConfig { | |
389 | fn default() -> BuildConfig { | |
390 | BuildConfig { | |
391 | build_dir: PathBuf::from("book"), | |
392 | create_missing: true, | |
393 | preprocess: None, | |
394 | } | |
395 | } | |
396 | } | |
397 | ||
398 | /// Configuration for the HTML renderer. | |
399 | #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] | |
400 | #[serde(default, rename_all = "kebab-case")] | |
401 | pub struct HtmlConfig { | |
402 | /// The theme directory, if specified. | |
403 | pub theme: Option<PathBuf>, | |
404 | /// Use "smart quotes" instead of the usual `"` character. | |
405 | pub curly_quotes: bool, | |
406 | /// Should mathjax be enabled? | |
407 | pub mathjax_support: bool, | |
408 | /// An optional google analytics code. | |
409 | pub google_analytics: Option<String>, | |
410 | /// Additional CSS stylesheets to include in the rendered page's `<head>`. | |
411 | pub additional_css: Vec<PathBuf>, | |
83c7162d | 412 | /// Additional JS scripts to include at the bottom of the rendered page's |
2c00a5a8 XL |
413 | /// `<body>`. |
414 | pub additional_js: Vec<PathBuf>, | |
415 | /// Playpen settings. | |
416 | pub playpen: Playpen, | |
417 | /// This is used as a bit of a workaround for the `mdbook serve` command. | |
418 | /// Basically, because you set the websocket port from the command line, the | |
419 | /// `mdbook serve` command needs a way to let the HTML renderer know where | |
420 | /// to point livereloading at, if it has been enabled. | |
421 | /// | |
422 | /// This config item *should not be edited* by the end user. | |
423 | #[doc(hidden)] | |
424 | pub livereload_url: Option<String>, | |
425 | /// Should section labels be rendered? | |
426 | pub no_section_label: bool, | |
83c7162d XL |
427 | /// Search settings. If `None`, the default will be used. |
428 | pub search: Option<Search>, | |
429 | } | |
430 | ||
431 | impl HtmlConfig { | |
432 | /// Returns the directory of theme from the provided root directory. If the | |
433 | /// directory is not present it will append the default directory of "theme" | |
434 | pub fn theme_dir(&self, root: &PathBuf) -> PathBuf { | |
435 | match self.theme { | |
436 | Some(ref d) => root.join(d), | |
437 | None => root.join("theme"), | |
438 | } | |
439 | } | |
2c00a5a8 XL |
440 | } |
441 | ||
442 | /// Configuration for tweaking how the the HTML renderer handles the playpen. | |
443 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | |
444 | #[serde(default, rename_all = "kebab-case")] | |
445 | pub struct Playpen { | |
83c7162d | 446 | /// Should playpen snippets be editable? Default: `false`. |
2c00a5a8 | 447 | pub editable: bool, |
83c7162d XL |
448 | /// Copy JavaScript files for the editor to the output directory? |
449 | /// Default: `true`. | |
450 | pub copy_js: bool, | |
2c00a5a8 XL |
451 | } |
452 | ||
453 | impl Default for Playpen { | |
454 | fn default() -> Playpen { | |
455 | Playpen { | |
2c00a5a8 | 456 | editable: false, |
83c7162d XL |
457 | copy_js: true, |
458 | } | |
459 | } | |
460 | } | |
461 | ||
462 | /// Configuration of the search functionality of the HTML renderer. | |
463 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | |
464 | #[serde(default, rename_all = "kebab-case")] | |
465 | pub struct Search { | |
466 | /// Maximum number of visible results. Default: `30`. | |
467 | pub limit_results: u32, | |
468 | /// The number of words used for a search result teaser. Default: `30`, | |
469 | pub teaser_word_count: u32, | |
470 | /// Define the logical link between multiple search words. | |
471 | /// If true, all search words must appear in each result. Default: `true`. | |
472 | pub use_boolean_and: bool, | |
473 | /// Boost factor for the search result score if a search word appears in the header. | |
474 | /// Default: `2`. | |
475 | pub boost_title: u8, | |
476 | /// Boost factor for the search result score if a search word appears in the hierarchy. | |
477 | /// The hierarchy contains all titles of the parent documents and all parent headings. | |
478 | /// Default: `1`. | |
479 | pub boost_hierarchy: u8, | |
480 | /// Boost factor for the search result score if a search word appears in the text. | |
481 | /// Default: `1`. | |
482 | pub boost_paragraph: u8, | |
483 | /// True if the searchword `micro` should match `microwave`. Default: `true`. | |
484 | pub expand: bool, | |
485 | /// Documents are split into smaller parts, seperated by headings. This defines, until which | |
486 | /// level of heading documents should be split. Default: `3`. (`### This is a level 3 heading`) | |
487 | pub heading_split_level: u8, | |
488 | /// Copy JavaScript files for the search functionality to the output directory? | |
489 | /// Default: `true`. | |
490 | pub copy_js: bool, | |
491 | } | |
492 | ||
493 | impl Default for Search { | |
494 | fn default() -> Search { | |
495 | // Please update the documentation of `Search` when changing values! | |
496 | Search { | |
497 | limit_results: 30, | |
498 | teaser_word_count: 30, | |
499 | use_boolean_and: false, | |
500 | boost_title: 2, | |
501 | boost_hierarchy: 1, | |
502 | boost_paragraph: 1, | |
503 | expand: true, | |
504 | heading_split_level: 3, | |
505 | copy_js: true, | |
2c00a5a8 XL |
506 | } |
507 | } | |
508 | } | |
509 | ||
510 | /// Allows you to "update" any arbitrary field in a struct by round-tripping via | |
511 | /// a `toml::Value`. | |
512 | /// | |
513 | /// This is definitely not the most performant way to do things, which means you | |
514 | /// should probably keep it away from tight loops... | |
515 | trait Updateable<'de>: Serialize + Deserialize<'de> { | |
516 | fn update_value<S: Serialize>(&mut self, key: &str, value: S) { | |
517 | let mut raw = Value::try_from(&self).expect("unreachable"); | |
518 | ||
519 | { | |
520 | if let Ok(value) = Value::try_from(value) { | |
521 | let _ = raw.insert(key, value); | |
522 | } else { | |
523 | return; | |
524 | } | |
525 | } | |
526 | ||
527 | if let Ok(updated) = raw.try_into() { | |
528 | *self = updated; | |
529 | } | |
530 | } | |
531 | } | |
532 | ||
533 | impl<'de, T> Updateable<'de> for T | |
534 | where | |
535 | T: Serialize + Deserialize<'de>, | |
536 | { | |
537 | } | |
538 | ||
539 | #[cfg(test)] | |
540 | mod tests { | |
541 | use super::*; | |
542 | ||
543 | const COMPLEX_CONFIG: &'static str = r#" | |
544 | [book] | |
545 | title = "Some Book" | |
546 | authors = ["Michael-F-Bryan <michaelfbryan@gmail.com>"] | |
547 | description = "A completely useless book" | |
548 | multilingual = true | |
549 | src = "source" | |
550 | ||
551 | [build] | |
552 | build-dir = "outputs" | |
553 | create-missing = false | |
554 | preprocess = ["first_preprocessor", "second_preprocessor"] | |
555 | ||
556 | [output.html] | |
557 | theme = "./themedir" | |
558 | curly-quotes = true | |
559 | google-analytics = "123456" | |
560 | additional-css = ["./foo/bar/baz.css"] | |
561 | ||
562 | [output.html.playpen] | |
563 | editable = true | |
564 | editor = "ace" | |
565 | "#; | |
566 | ||
567 | #[test] | |
568 | fn load_a_complex_config_file() { | |
569 | let src = COMPLEX_CONFIG; | |
570 | ||
571 | let book_should_be = BookConfig { | |
572 | title: Some(String::from("Some Book")), | |
573 | authors: vec![String::from("Michael-F-Bryan <michaelfbryan@gmail.com>")], | |
574 | description: Some(String::from("A completely useless book")), | |
575 | multilingual: true, | |
576 | src: PathBuf::from("source"), | |
577 | ..Default::default() | |
578 | }; | |
579 | let build_should_be = BuildConfig { | |
580 | build_dir: PathBuf::from("outputs"), | |
581 | create_missing: false, | |
83c7162d XL |
582 | preprocess: Some(vec![ |
583 | "first_preprocessor".to_string(), | |
584 | "second_preprocessor".to_string(), | |
585 | ]), | |
2c00a5a8 XL |
586 | }; |
587 | let playpen_should_be = Playpen { | |
588 | editable: true, | |
83c7162d | 589 | copy_js: true, |
2c00a5a8 XL |
590 | }; |
591 | let html_should_be = HtmlConfig { | |
592 | curly_quotes: true, | |
593 | google_analytics: Some(String::from("123456")), | |
594 | additional_css: vec![PathBuf::from("./foo/bar/baz.css")], | |
595 | theme: Some(PathBuf::from("./themedir")), | |
596 | playpen: playpen_should_be, | |
597 | ..Default::default() | |
598 | }; | |
599 | ||
600 | let got = Config::from_str(src).unwrap(); | |
601 | ||
602 | assert_eq!(got.book, book_should_be); | |
603 | assert_eq!(got.build, build_should_be); | |
604 | assert_eq!(got.html_config().unwrap(), html_should_be); | |
605 | } | |
606 | ||
607 | #[test] | |
608 | fn load_arbitrary_output_type() { | |
609 | #[derive(Debug, Deserialize, PartialEq)] | |
610 | struct RandomOutput { | |
611 | foo: u32, | |
612 | bar: String, | |
613 | baz: Vec<bool>, | |
614 | } | |
615 | ||
616 | let src = r#" | |
617 | [output.random] | |
618 | foo = 5 | |
619 | bar = "Hello World" | |
620 | baz = [true, true, false] | |
621 | "#; | |
622 | ||
623 | let should_be = RandomOutput { | |
624 | foo: 5, | |
625 | bar: String::from("Hello World"), | |
626 | baz: vec![true, true, false], | |
627 | }; | |
628 | ||
629 | let cfg = Config::from_str(src).unwrap(); | |
630 | let got: RandomOutput = cfg.get_deserialized("output.random").unwrap(); | |
631 | ||
632 | assert_eq!(got, should_be); | |
633 | ||
634 | let baz: Vec<bool> = cfg.get_deserialized("output.random.baz").unwrap(); | |
635 | let baz_should_be = vec![true, true, false]; | |
636 | ||
637 | assert_eq!(baz, baz_should_be); | |
638 | } | |
639 | ||
640 | #[test] | |
641 | fn mutate_some_stuff() { | |
642 | // really this is just a sanity check to make sure the borrow checker | |
643 | // is happy... | |
644 | let src = COMPLEX_CONFIG; | |
645 | let mut config = Config::from_str(src).unwrap(); | |
646 | let key = "output.html.playpen.editable"; | |
647 | ||
648 | assert_eq!(config.get(key).unwrap(), &Value::Boolean(true)); | |
649 | *config.get_mut(key).unwrap() = Value::Boolean(false); | |
650 | assert_eq!(config.get(key).unwrap(), &Value::Boolean(false)); | |
651 | } | |
652 | ||
653 | /// The config file format has slightly changed (metadata stuff is now under | |
654 | /// the `book` table instead of being at the top level) so we're adding a | |
655 | /// **temporary** compatibility check. You should be able to still load the | |
656 | /// old format, emitting a warning. | |
657 | #[test] | |
658 | fn can_still_load_the_previous_format() { | |
659 | let src = r#" | |
660 | title = "mdBook Documentation" | |
661 | description = "Create book from markdown files. Like Gitbook but implemented in Rust" | |
662 | authors = ["Mathieu David"] | |
663 | source = "./source" | |
664 | ||
665 | [output.html] | |
666 | destination = "my-book" # the output files will be generated in `root/my-book` instead of `root/book` | |
667 | theme = "my-theme" | |
668 | curly-quotes = true | |
669 | google-analytics = "123456" | |
670 | additional-css = ["custom.css", "custom2.css"] | |
671 | additional-js = ["custom.js"] | |
672 | "#; | |
673 | ||
674 | let book_should_be = BookConfig { | |
675 | title: Some(String::from("mdBook Documentation")), | |
676 | description: Some(String::from( | |
677 | "Create book from markdown files. Like Gitbook but implemented in Rust", | |
678 | )), | |
679 | authors: vec![String::from("Mathieu David")], | |
680 | src: PathBuf::from("./source"), | |
681 | ..Default::default() | |
682 | }; | |
683 | ||
684 | let build_should_be = BuildConfig { | |
685 | build_dir: PathBuf::from("my-book"), | |
686 | create_missing: true, | |
687 | preprocess: None, | |
688 | }; | |
689 | ||
690 | let html_should_be = HtmlConfig { | |
691 | theme: Some(PathBuf::from("my-theme")), | |
692 | curly_quotes: true, | |
693 | google_analytics: Some(String::from("123456")), | |
694 | additional_css: vec![PathBuf::from("custom.css"), PathBuf::from("custom2.css")], | |
695 | additional_js: vec![PathBuf::from("custom.js")], | |
696 | ..Default::default() | |
697 | }; | |
698 | ||
699 | let got = Config::from_str(src).unwrap(); | |
700 | assert_eq!(got.book, book_should_be); | |
701 | assert_eq!(got.build, build_should_be); | |
702 | assert_eq!(got.html_config().unwrap(), html_should_be); | |
703 | } | |
704 | ||
705 | #[test] | |
706 | fn set_a_config_item() { | |
707 | let mut cfg = Config::default(); | |
708 | let key = "foo.bar.baz"; | |
709 | let value = "Something Interesting"; | |
710 | ||
711 | assert!(cfg.get(key).is_none()); | |
712 | cfg.set(key, value).unwrap(); | |
713 | ||
714 | let got: String = cfg.get_deserialized(key).unwrap(); | |
715 | assert_eq!(got, value); | |
716 | } | |
717 | ||
718 | #[test] | |
719 | fn parse_env_vars() { | |
720 | let inputs = vec![ | |
721 | ("FOO", None), | |
722 | ("MDBOOK_foo", Some("foo")), | |
723 | ("MDBOOK_FOO__bar__baz", Some("foo.bar.baz")), | |
724 | ("MDBOOK_FOO_bar__baz", Some("foo-bar.baz")), | |
725 | ]; | |
726 | ||
727 | for (src, should_be) in inputs { | |
728 | let got = parse_env(src); | |
729 | let should_be = should_be.map(|s| s.to_string()); | |
730 | ||
731 | assert_eq!(got, should_be); | |
732 | } | |
733 | } | |
734 | ||
735 | fn encode_env_var(key: &str) -> String { | |
736 | format!( | |
737 | "MDBOOK_{}", | |
738 | key.to_uppercase().replace('.', "__").replace("-", "_") | |
739 | ) | |
740 | } | |
741 | ||
742 | #[test] | |
743 | fn update_config_using_env_var() { | |
744 | let mut cfg = Config::default(); | |
745 | let key = "foo.bar"; | |
746 | let value = "baz"; | |
747 | ||
748 | assert!(cfg.get(key).is_none()); | |
749 | ||
750 | let encoded_key = encode_env_var(key); | |
751 | env::set_var(encoded_key, value); | |
752 | ||
753 | cfg.update_from_env(); | |
754 | ||
755 | assert_eq!(cfg.get_deserialized::<String, _>(key).unwrap(), value); | |
756 | } | |
757 | ||
758 | #[test] | |
759 | fn update_config_using_env_var_and_complex_value() { | |
760 | let mut cfg = Config::default(); | |
761 | let key = "foo-bar.baz"; | |
762 | let value = json!({"array": [1, 2, 3], "number": 3.14}); | |
763 | let value_str = serde_json::to_string(&value).unwrap(); | |
764 | ||
765 | assert!(cfg.get(key).is_none()); | |
766 | ||
767 | let encoded_key = encode_env_var(key); | |
768 | env::set_var(encoded_key, value_str); | |
769 | ||
770 | cfg.update_from_env(); | |
771 | ||
772 | assert_eq!( | |
773 | cfg.get_deserialized::<serde_json::Value, _>(key).unwrap(), | |
774 | value | |
775 | ); | |
776 | } | |
777 | ||
778 | #[test] | |
779 | fn update_book_title_via_env() { | |
780 | let mut cfg = Config::default(); | |
781 | let should_be = "Something else".to_string(); | |
782 | ||
783 | assert_ne!(cfg.book.title, Some(should_be.clone())); | |
784 | ||
785 | env::set_var("MDBOOK_BOOK__TITLE", &should_be); | |
786 | cfg.update_from_env(); | |
787 | ||
788 | assert_eq!(cfg.book.title, Some(should_be)); | |
789 | } | |
790 | } |