Skip to main content

polyfont_config/
lib.rs

1use std::path::{Path, PathBuf};
2
3use polyfont_core::{FontRule, FontSpec, FontStyle, FontWeight};
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6use tracing::{debug, info, warn};
7use walkdir::WalkDir;
8
9const CONFIG_FILENAME: &str = ".polyfont.toml";
10const CURRENT_CONFIG_VERSION: u32 = 1;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct PolyfontConfig {
14    pub version: u32,
15    #[serde(default)]
16    pub default: Option<DefaultFontConfig>,
17    #[serde(default)]
18    pub rules: Vec<RuleConfig>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct DefaultFontConfig {
23    pub family: String,
24    #[serde(default)]
25    pub fallbacks: Vec<String>,
26    #[serde(default = "FontWeight::default")]
27    pub weight: FontWeight,
28    #[serde(default = "FontStyle::default")]
29    pub style: FontStyle,
30    #[serde(default)]
31    pub size: Option<f32>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct RuleConfig {
36    pub scope: String,
37    pub font: FontConfig,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct FontConfig {
42    pub family: String,
43    #[serde(default)]
44    pub fallbacks: Vec<String>,
45    #[serde(default = "FontWeight::default")]
46    pub weight: FontWeight,
47    #[serde(default = "FontStyle::default")]
48    pub style: FontStyle,
49    #[serde(default)]
50    pub size: Option<f32>,
51}
52
53impl PolyfontConfig {
54    #[allow(clippy::missing_errors_doc)]
55    pub fn validate(&self) -> Result<(), ConfigError> {
56        if self.version != CURRENT_CONFIG_VERSION {
57            return Err(ConfigError::UnsupportedVersion {
58                found: self.version,
59                expected: CURRENT_CONFIG_VERSION,
60            });
61        }
62
63        if let Some(ref default) = self.default
64            && default.family.trim().is_empty()
65        {
66            return Err(ConfigError::Validation(
67                "default font family must not be empty".into(),
68            ));
69        }
70
71        for (i, rule) in self.rules.iter().enumerate() {
72            if rule.scope.trim().is_empty() {
73                return Err(ConfigError::Validation(format!(
74                    "rule at index {i} has an empty scope"
75                )));
76            }
77            if rule.font.family.trim().is_empty() {
78                return Err(ConfigError::Validation(format!(
79                    "rule at index {i} (scope '{}') has an empty font family",
80                    rule.scope
81                )));
82            }
83        }
84
85        Ok(())
86    }
87
88    #[must_use]
89    pub fn to_rules(&self) -> Vec<FontRule> {
90        let mut rules: Vec<FontRule> = self
91            .rules
92            .iter()
93            .map(|r| FontRule {
94                scope: r.scope.clone(),
95                font: FontSpec {
96                    family: r.font.family.clone(),
97                    fallbacks: r.font.fallbacks.clone(),
98                    weight: r.font.weight,
99                    style: r.font.style,
100                    size: r.font.size,
101                },
102            })
103            .collect();
104
105        if let Some(ref default) = self.default {
106            rules.push(FontRule {
107                scope: "*".to_string(),
108                font: FontSpec {
109                    family: default.family.clone(),
110                    fallbacks: default.fallbacks.clone(),
111                    weight: default.weight,
112                    style: default.style,
113                    size: default.size,
114                },
115            });
116        }
117
118        rules
119    }
120
121    #[must_use]
122    pub fn merge(base: Self, overlay: Self) -> Self {
123        let rules = overlay.rules;
124        let default = overlay.default.or(base.default);
125        Self {
126            version: base.version,
127            default,
128            rules,
129        }
130    }
131}
132
133#[derive(Debug, Error)]
134pub enum ConfigError {
135    #[error("unsupported config version: found {found}, expected {expected}")]
136    UnsupportedVersion { found: u32, expected: u32 },
137
138    #[error("config validation failed: {0}")]
139    Validation(String),
140
141    #[error("failed to read config file: {0}")]
142    Io(#[from] std::io::Error),
143
144    #[error("failed to parse config file: {0}")]
145    Parse(#[from] toml::de::Error),
146
147    #[error("no config file found searching from {0}")]
148    NotFound(PathBuf),
149}
150
151pub struct ConfigLoader;
152
153impl ConfigLoader {
154    #[allow(clippy::missing_errors_doc)]
155    pub fn load_from_path(path: &Path) -> Result<PolyfontConfig, ConfigError> {
156        info!("loading config from {}", path.display());
157        let content = std::fs::read_to_string(path)?;
158        let config: PolyfontConfig = toml::from_str(&content)?;
159        config.validate()?;
160        Ok(config)
161    }
162
163    #[allow(clippy::missing_errors_doc)]
164    pub fn load_from_dir(start_dir: &Path) -> Result<PolyfontConfig, ConfigError> {
165        let config_path = Self::find_config(start_dir)
166            .ok_or_else(|| ConfigError::NotFound(start_dir.to_path_buf()))?;
167        Self::load_from_path(&config_path)
168    }
169
170    pub fn find_config(start_dir: &Path) -> Option<PathBuf> {
171        let canonical = start_dir.canonicalize().ok()?;
172        let mut current = canonical.as_path();
173
174        loop {
175            let candidate = current.join(CONFIG_FILENAME);
176            debug!("checking for config at {}", candidate.display());
177            if candidate.is_file() {
178                info!("found config at {}", candidate.display());
179                return Some(candidate);
180            }
181
182            if let Some(parent) = current.parent() {
183                current = parent;
184            } else {
185                warn!(
186                    "no {} found searching up from {}",
187                    CONFIG_FILENAME,
188                    start_dir.display()
189                );
190                return None;
191            }
192        }
193    }
194
195    #[allow(clippy::missing_errors_doc)]
196    #[allow(clippy::missing_panics_doc)]
197    pub fn load_merged(start_dir: &Path) -> Result<PolyfontConfig, ConfigError> {
198        let configs = Self::find_all_configs(start_dir);
199        if configs.is_empty() {
200            return Err(ConfigError::NotFound(start_dir.to_path_buf()));
201        }
202
203        let mut iter = configs.into_iter();
204        let first = Self::load_from_path(&iter.next().unwrap())?;
205        let merged = iter.fold(first, |acc, path| match Self::load_from_path(&path) {
206            Ok(overlay) => PolyfontConfig::merge(acc, overlay),
207            Err(e) => {
208                warn!("skipping config at {}: {e}", path.display());
209                acc
210            }
211        });
212
213        Ok(merged)
214    }
215
216    fn find_all_configs(start_dir: &Path) -> Vec<PathBuf> {
217        let Ok(canonical) = start_dir.canonicalize() else {
218            return Vec::new();
219        };
220
221        let mut configs: Vec<PathBuf> = WalkDir::new(&canonical)
222            .into_iter()
223            .filter_map(std::result::Result::ok)
224            .filter(|e| e.file_type().is_file())
225            .filter(|e| e.file_name() == CONFIG_FILENAME)
226            .map(walkdir::DirEntry::into_path)
227            .collect();
228
229        configs.sort_by_key(|p| p.components().count());
230
231        let mut ancestor_configs: Vec<PathBuf> = Vec::new();
232        let mut current = canonical.as_path();
233        while let Some(parent) = current.parent() {
234            let candidate = parent.join(CONFIG_FILENAME);
235            if candidate.is_file() {
236                ancestor_configs.push(candidate);
237            }
238            current = parent;
239        }
240
241        ancestor_configs.reverse();
242        ancestor_configs.extend(configs);
243        ancestor_configs
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn minimal_toml() -> &'static str {
252        r#"
253version = 1
254
255[default]
256family = "Fira Code"
257
258[[rules]]
259scope = "keyword"
260[rules.font]
261family = "Maple Mono"
262weight = "bold"
263"#
264    }
265
266    fn full_toml() -> &'static str {
267        r#"
268version = 1
269
270[default]
271family = "Fira Code"
272fallbacks = ["JetBrains Mono", "monospace"]
273weight = "regular"
274style = "normal"
275
276[[rules]]
277scope = "keyword"
278[rules.font]
279family = "Maple Mono"
280weight = "bold"
281
282[[rules]]
283scope = "comment"
284[rules.font]
285family = "IBM Plex Mono"
286style = "italic"
287
288[[rules]]
289scope = "string"
290[rules.font]
291family = "Source Code Pro"
292weight = "light"
293
294[[rules]]
295scope = "entity.name.function"
296[rules.font]
297family = "Fira Code"
298weight = "semi-bold"
299
300[[rules]]
301scope = "variable"
302[rules.font]
303family = "JetBrains Mono"
304
305[[rules]]
306scope = "constant"
307[rules.font]
308family = "Monaspace Neon"
309weight = "bold"
310
311[[rules]]
312scope = "support.function"
313[rules.font]
314family = "Monaspace Argon"
315"#
316    }
317
318    #[test]
319    fn parse_minimal_config() {
320        let config: PolyfontConfig = toml::from_str(minimal_toml()).unwrap();
321        assert_eq!(config.version, 1);
322        assert!(config.default.is_some());
323        assert_eq!(config.rules.len(), 1);
324    }
325
326    #[test]
327    fn parse_full_config() {
328        let config: PolyfontConfig = toml::from_str(full_toml()).unwrap();
329        assert_eq!(config.version, 1);
330        assert!(config.default.is_some());
331        assert_eq!(config.rules.len(), 7);
332
333        let default = config.default.as_ref().unwrap();
334        assert_eq!(default.family, "Fira Code");
335        assert_eq!(default.fallbacks, vec!["JetBrains Mono", "monospace"]);
336
337        assert_eq!(config.rules[0].scope, "keyword");
338        assert_eq!(config.rules[0].font.family, "Maple Mono");
339        assert_eq!(config.rules[0].font.weight, FontWeight::Bold);
340
341        assert_eq!(config.rules[1].scope, "comment");
342        assert_eq!(config.rules[1].font.style, FontStyle::Italic);
343
344        assert_eq!(config.rules[2].scope, "string");
345        assert_eq!(config.rules[2].font.weight, FontWeight::Light);
346
347        assert_eq!(config.rules[3].scope, "entity.name.function");
348        assert_eq!(config.rules[3].font.weight, FontWeight::SemiBold);
349    }
350
351    #[test]
352    fn validate_rejects_bad_version() {
353        let config = PolyfontConfig {
354            version: 99,
355            default: None,
356            rules: vec![],
357        };
358        assert!(config.validate().is_err());
359    }
360
361    #[test]
362    fn validate_rejects_empty_scope() {
363        let config = PolyfontConfig {
364            version: 1,
365            default: None,
366            rules: vec![RuleConfig {
367                scope: "  ".to_string(),
368                font: FontConfig {
369                    family: "Test".to_string(),
370                    fallbacks: vec![],
371                    weight: FontWeight::default(),
372                    style: FontStyle::default(),
373                    size: None,
374                },
375            }],
376        };
377        let err = config.validate().unwrap_err();
378        assert!(matches!(err, ConfigError::Validation(_)));
379    }
380
381    #[test]
382    fn validate_rejects_empty_family() {
383        let config = PolyfontConfig {
384            version: 1,
385            default: None,
386            rules: vec![RuleConfig {
387                scope: "keyword".to_string(),
388                font: FontConfig {
389                    family: "  ".to_string(),
390                    fallbacks: vec![],
391                    weight: FontWeight::default(),
392                    style: FontStyle::default(),
393                    size: None,
394                },
395            }],
396        };
397        let err = config.validate().unwrap_err();
398        assert!(matches!(err, ConfigError::Validation(_)));
399    }
400
401    #[test]
402    fn validate_rejects_empty_default_family() {
403        let config = PolyfontConfig {
404            version: 1,
405            default: Some(DefaultFontConfig {
406                family: "  ".to_string(),
407                fallbacks: vec![],
408                weight: FontWeight::default(),
409                style: FontStyle::default(),
410                size: None,
411            }),
412            rules: vec![],
413        };
414        let err = config.validate().unwrap_err();
415        assert!(matches!(err, ConfigError::Validation(_)));
416    }
417
418    #[test]
419    fn to_rules_includes_default_as_catchall() {
420        let config: PolyfontConfig = toml::from_str(minimal_toml()).unwrap();
421        let rules = config.to_rules();
422        assert_eq!(rules.len(), 2);
423        let catchall = rules.last().unwrap();
424        assert_eq!(catchall.scope, "*");
425        assert_eq!(catchall.font.family, "Fira Code");
426    }
427
428    #[test]
429    fn merge_overlay_takes_precedence() {
430        let base: PolyfontConfig = toml::from_str(minimal_toml()).unwrap();
431        let overlay = PolyfontConfig {
432            version: 1,
433            default: None,
434            rules: vec![RuleConfig {
435                scope: "comment".to_string(),
436                font: FontConfig {
437                    family: "Override".to_string(),
438                    fallbacks: vec![],
439                    weight: FontWeight::default(),
440                    style: FontStyle::Italic,
441                    size: None,
442                },
443            }],
444        };
445        let merged = PolyfontConfig::merge(base, overlay);
446        assert_eq!(merged.rules.len(), 1);
447        assert_eq!(merged.rules[0].scope, "comment");
448        assert!(merged.default.is_some());
449    }
450}