mdbook_termlink/config/
mod.rs1mod 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#[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#[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 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 #[must_use]
125 pub fn glossary_path(&self) -> &Path {
126 &self.glossary_path
127 }
128
129 #[must_use]
131 pub const fn link_first_only(&self) -> bool {
132 self.link_first_only
133 }
134
135 #[must_use]
137 pub fn css_class(&self) -> &str {
138 &self.css_class
139 }
140
141 #[must_use]
143 pub const fn case_sensitive(&self) -> bool {
144 self.case_sensitive
145 }
146
147 #[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 #[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 #[must_use]
162 pub fn aliases(&self, term_name: &str) -> Option<&Vec<String>> {
163 self.aliases.get(term_name)
164 }
165
166 #[must_use]
168 pub fn split_pattern(&self) -> Option<&str> {
169 self.split_pattern.as_deref()
170 }
171
172 #[must_use]
174 pub const fn display_mode(&self) -> DisplayMode {
175 self.display_mode
176 }
177
178 #[must_use]
185 pub const fn process_glossary(&self) -> bool {
186 self.process_glossary
187 }
188
189 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}