Skip to main content

mdbook_termlink/config/
mod.rs

1//! Configuration parsing for the termlink preprocessor.
2
3mod display_mode;
4
5pub use display_mode::DisplayMode;
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use glob::Pattern;
11use mdbook_preprocessor::PreprocessorContext;
12use serde::Deserialize;
13
14use crate::error::{Result, TermlinkError};
15
16/// Configuration for the termlink preprocessor.
17///
18/// All fields are private to allow future changes without breaking the API.
19/// Use the getter methods to access configuration values.
20#[derive(Debug, Clone)]
21pub struct Config {
22    glossary_path: PathBuf,
23    link_first_only: bool,
24    css_class: String,
25    case_sensitive: bool,
26    exclude_pages: Vec<Pattern>,
27    aliases: HashMap<String, Vec<String>>,
28    split_pattern: Option<String>,
29    display_mode: DisplayMode,
30    process_glossary: bool,
31}
32
33/// Raw configuration as deserialized from `book.toml`.
34#[derive(Debug, Clone, Deserialize, Default)]
35#[serde(rename_all = "kebab-case")]
36struct RawConfig {
37    glossary_path: Option<String>,
38    link_first_only: Option<bool>,
39    css_class: Option<String>,
40    case_sensitive: Option<bool>,
41    exclude_pages: Option<Vec<String>>,
42    aliases: Option<HashMap<String, Vec<String>>>,
43    split_pattern: Option<String>,
44    display_mode: Option<String>,
45    process_glossary: Option<bool>,
46}
47
48impl Default for Config {
49    fn default() -> Self {
50        Self {
51            glossary_path: PathBuf::from("reference/glossary.md"),
52            link_first_only: true,
53            css_class: String::from("glossary-term"),
54            case_sensitive: false,
55            exclude_pages: Vec::new(),
56            aliases: HashMap::new(),
57            split_pattern: None,
58            display_mode: DisplayMode::default(),
59            process_glossary: false,
60        }
61    }
62}
63
64impl Config {
65    /// Creates configuration from the preprocessor context.
66    ///
67    /// Unknown `display-mode` values fall back to the default with a
68    /// `log::warn!`; invalid glob patterns under `exclude-pages` are dropped
69    /// the same way. These are user-typo cases, not hard errors.
70    ///
71    /// # Errors
72    ///
73    /// Returns [`TermlinkError::BadConfig`] if `[preprocessor.termlink]` in
74    /// `book.toml` fails to deserialize.
75    pub fn from_context(ctx: &PreprocessorContext) -> Result<Self> {
76        let preprocessors: std::collections::BTreeMap<String, RawConfig> = ctx
77            .config
78            .preprocessors()
79            .map_err(|e| TermlinkError::BadConfig(e.into()))?;
80
81        let raw = preprocessors.get("termlink").cloned().unwrap_or_default();
82
83        let exclude_pages: Vec<Pattern> = raw
84            .exclude_pages
85            .unwrap_or_default()
86            .iter()
87            .filter_map(|p| match Pattern::new(p) {
88                Ok(pattern) => Some(pattern),
89                Err(e) => {
90                    log::warn!("Invalid exclude-pages glob pattern '{p}': {e}");
91                    None
92                }
93            })
94            .collect();
95
96        let display_mode = raw
97            .display_mode
98            .as_deref()
99            .map_or_else(DisplayMode::default, |v| {
100                v.parse::<DisplayMode>().unwrap_or_else(|err| {
101                    log::warn!("{err}. Falling back to 'link'.");
102                    DisplayMode::default()
103                })
104            });
105
106        Ok(Self {
107            glossary_path: raw
108                .glossary_path
109                .map_or_else(|| PathBuf::from("reference/glossary.md"), PathBuf::from),
110            link_first_only: raw.link_first_only.unwrap_or(true),
111            css_class: raw
112                .css_class
113                .unwrap_or_else(|| String::from("glossary-term")),
114            case_sensitive: raw.case_sensitive.unwrap_or(false),
115            exclude_pages,
116            aliases: raw.aliases.unwrap_or_default(),
117            split_pattern: raw.split_pattern.filter(|p| !p.is_empty()),
118            display_mode,
119            process_glossary: raw.process_glossary.unwrap_or(false),
120        })
121    }
122
123    /// Returns the path to the glossary file.
124    #[must_use]
125    pub fn glossary_path(&self) -> &Path {
126        &self.glossary_path
127    }
128
129    /// Returns true if only the first occurrence of each term should be linked.
130    #[must_use]
131    pub const fn link_first_only(&self) -> bool {
132        self.link_first_only
133    }
134
135    /// Returns the CSS class to apply to glossary term links.
136    #[must_use]
137    pub fn css_class(&self) -> &str {
138        &self.css_class
139    }
140
141    /// Returns true if term matching should be case-sensitive.
142    #[must_use]
143    pub const fn case_sensitive(&self) -> bool {
144        self.case_sensitive
145    }
146
147    /// Checks if the given path is the glossary file.
148    #[must_use]
149    pub fn is_glossary_path(&self, path: &Path) -> bool {
150        path == self.glossary_path || path.ends_with(&self.glossary_path)
151    }
152
153    /// Checks if the given path should be excluded from term linking.
154    #[must_use]
155    pub fn should_exclude(&self, path: &Path) -> bool {
156        let path_str = path.to_string_lossy();
157        self.exclude_pages.iter().any(|p| p.matches(&path_str))
158    }
159
160    /// Returns aliases for a term name (if configured).
161    #[must_use]
162    pub fn aliases(&self, term_name: &str) -> Option<&Vec<String>> {
163        self.aliases.get(term_name)
164    }
165
166    /// Returns the split delimiter for glossary definitions, if configured.
167    #[must_use]
168    pub fn split_pattern(&self) -> Option<&str> {
169        self.split_pattern.as_deref()
170    }
171
172    /// Returns how linked terms should be rendered.
173    #[must_use]
174    pub const fn display_mode(&self) -> DisplayMode {
175        self.display_mode
176    }
177
178    /// Returns true if the glossary page itself should be processed.
179    ///
180    /// When true, term usages in the glossary page's prose and inside other
181    /// terms' definitions are linkified (with same-page `#anchor` hrefs), but
182    /// the definition-list titles are left untouched so a term never
183    /// self-links.
184    #[must_use]
185    pub const fn process_glossary(&self) -> bool {
186        self.process_glossary
187    }
188
189    /// Returns an iterator over every configured alias (for conflict detection).
190    pub fn all_aliases(&self) -> impl Iterator<Item = (&String, &Vec<String>)> {
191        self.aliases.iter()
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use std::str::FromStr;
198
199    use super::*;
200    use mdbook_preprocessor::config::Config as MdBookConf;
201
202    fn config_from_toml(toml: &str) -> Result<Config> {
203        let mdb_conf = MdBookConf::from_str(toml).unwrap();
204        let ctx = PreprocessorContext::new(PathBuf::new(), mdb_conf, String::new());
205        Config::from_context(&ctx)
206    }
207
208    #[test]
209    fn default_config_has_expected_values() {
210        let config = Config::default();
211        assert_eq!(config.glossary_path(), Path::new("reference/glossary.md"));
212        assert!(config.link_first_only());
213        assert_eq!(config.css_class(), "glossary-term");
214        assert!(!config.case_sensitive());
215        assert_eq!(config.display_mode(), DisplayMode::Link);
216        assert!(!config.process_glossary());
217    }
218
219    #[test]
220    fn is_glossary_path_exact_and_suffix_match() {
221        let config = Config::default();
222        assert!(config.is_glossary_path(Path::new("reference/glossary.md")));
223        assert!(config.is_glossary_path(Path::new("src/reference/glossary.md")));
224        assert!(!config.is_glossary_path(Path::new("chapter1.md")));
225        assert!(!config.is_glossary_path(Path::new("glossary.md")));
226    }
227
228    #[test]
229    fn should_exclude_matches_exact_wildcard_and_recursive_patterns() {
230        let config = Config {
231            exclude_pages: vec![
232                Pattern::new("changelog.md").unwrap(),
233                Pattern::new("appendix/*").unwrap(),
234                Pattern::new("**/draft-*.md").unwrap(),
235            ],
236            ..Default::default()
237        };
238        assert!(config.should_exclude(Path::new("changelog.md")));
239        assert!(config.should_exclude(Path::new("appendix/a.md")));
240        assert!(config.should_exclude(Path::new("chapters/draft-x.md")));
241        assert!(!config.should_exclude(Path::new("chapter1.md")));
242    }
243
244    #[test]
245    fn aliases_getter_and_iterator() {
246        let mut aliases = HashMap::new();
247        aliases.insert("API".to_string(), vec!["apis".to_string()]);
248        aliases.insert("REST".to_string(), vec!["RESTful".to_string()]);
249        let config = Config {
250            aliases,
251            ..Default::default()
252        };
253        assert_eq!(config.aliases("API"), Some(&vec!["apis".to_string()]));
254        assert_eq!(config.aliases("none"), None);
255        assert_eq!(config.all_aliases().count(), 2);
256    }
257
258    #[test]
259    fn empty_split_pattern_disables_splitting() {
260        let conf =
261            config_from_toml("[book]\ntitle = 'T'\n[preprocessor.termlink]\nsplit-pattern = ''\n")
262                .unwrap();
263        assert_eq!(conf.split_pattern(), None);
264    }
265
266    #[test]
267    fn display_mode_parses_each_variant_from_toml() {
268        for (value, expected) in [
269            ("link", DisplayMode::Link),
270            ("tooltip", DisplayMode::Tooltip),
271            ("both", DisplayMode::Both),
272        ] {
273            let toml =
274                format!("[book]\ntitle = 'T'\n[preprocessor.termlink]\ndisplay-mode = '{value}'\n");
275            assert_eq!(config_from_toml(&toml).unwrap().display_mode(), expected);
276        }
277    }
278
279    #[test]
280    fn display_mode_invalid_value_falls_back_to_link() {
281        for value in ["nonsense", ""] {
282            let toml =
283                format!("[book]\ntitle = 'T'\n[preprocessor.termlink]\ndisplay-mode = '{value}'\n");
284            assert_eq!(
285                config_from_toml(&toml).unwrap().display_mode(),
286                DisplayMode::Link
287            );
288        }
289    }
290
291    #[test]
292    fn process_glossary_defaults_to_false_and_parses_true_from_book_toml() {
293        assert!(!Config::default().process_glossary());
294
295        let conf = config_from_toml(
296            "[book]\ntitle = 'T'\n[preprocessor.termlink]\nprocess-glossary = true\n",
297        )
298        .unwrap();
299        assert!(conf.process_glossary());
300    }
301}