Skip to main content

polyfont_config/
lib.rs

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