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}