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}