use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use tracing::warn;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
#[serde(default)]
pub engines: EngineConfig,
#[serde(default)]
pub rules: HashMap<String, RuleConfig>,
#[serde(default = "default_exclude")]
pub exclude: Vec<String>,
#[serde(default)]
pub auto_fix: Vec<AutoFixRule>,
#[serde(default)]
pub performance: PerformanceConfig,
#[serde(default)]
pub dictionaries: DictionaryConfig,
#[serde(default)]
pub languages: LanguageConfig,
#[serde(default)]
pub workspace: WorkspaceConfig,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct LanguageConfig {
#[serde(default)]
pub extensions: HashMap<String, Vec<String>>,
#[serde(default)]
pub latex: LaTeXConfig,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct LaTeXConfig {
#[serde(default)]
pub skip_environments: Vec<String>,
#[serde(default)]
pub skip_commands: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct WorkspaceConfig {
#[serde(default)]
pub index_on_open: bool,
#[serde(default)]
pub db_path: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PerformanceConfig {
#[serde(default)]
pub high_performance_mode: bool,
#[serde(default = "default_debounce_ms")]
pub debounce_ms: u64,
#[serde(default)]
pub max_file_size: usize,
}
impl Default for PerformanceConfig {
fn default() -> Self {
Self {
high_performance_mode: false,
debounce_ms: 300,
max_file_size: 0,
}
}
}
const fn default_debounce_ms() -> u64 {
300
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DictionaryConfig {
#[serde(default = "default_true")]
pub bundled: bool,
#[serde(default)]
pub paths: Vec<String>,
}
impl Default for DictionaryConfig {
fn default() -> Self {
Self {
bundled: true,
paths: Vec::new(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AutoFixRule {
pub find: String,
pub replace: String,
#[serde(default)]
pub context: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EngineConfig {
#[serde(
default = "default_harper_config",
deserialize_with = "deser_engine_or_bool"
)]
pub harper: HarperConfig,
#[serde(default, deserialize_with = "deser_engine_or_bool")]
pub languagetool: LanguageToolConfig,
#[serde(default, deserialize_with = "deser_engine_or_bool")]
pub vale: ValeConfig,
#[serde(default, deserialize_with = "deser_engine_or_bool")]
pub proselint: ProselintConfig,
#[serde(default)]
pub external: Vec<ExternalProvider>,
#[serde(default)]
pub wasm_plugins: Vec<WasmPlugin>,
#[serde(default = "default_spell_language")]
pub spell_language: String,
}
fn deser_engine_or_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de> + EngineToggle + Default,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum BoolOrStruct<T> {
Bool(bool),
Struct(T),
}
match BoolOrStruct::deserialize(deserializer)? {
BoolOrStruct::Bool(b) => {
let mut cfg = T::default();
cfg.set_enabled(b);
Ok(cfg)
}
BoolOrStruct::Struct(s) => Ok(s),
}
}
pub trait EngineToggle {
fn enabled(&self) -> bool;
fn set_enabled(&mut self, v: bool);
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct HarperConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_dialect")]
pub dialect: String,
#[serde(default)]
pub linters: HashMap<String, bool>,
}
impl Default for HarperConfig {
fn default() -> Self {
Self {
enabled: true,
dialect: "American".to_string(),
linters: HashMap::new(),
}
}
}
fn default_harper_config() -> HarperConfig {
HarperConfig::default()
}
fn default_dialect() -> String {
"American".to_string()
}
impl EngineToggle for HarperConfig {
fn enabled(&self) -> bool {
self.enabled
}
fn set_enabled(&mut self, v: bool) {
self.enabled = v;
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LanguageToolConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_lt_url")]
pub url: String,
#[serde(default = "default_lt_level")]
pub level: String,
#[serde(default)]
pub mother_tongue: Option<String>,
#[serde(default)]
pub disabled_rules: Vec<String>,
#[serde(default)]
pub enabled_rules: Vec<String>,
#[serde(default)]
pub disabled_categories: Vec<String>,
#[serde(default)]
pub enabled_categories: Vec<String>,
}
impl Default for LanguageToolConfig {
fn default() -> Self {
Self {
enabled: false,
url: default_lt_url(),
level: "default".to_string(),
mother_tongue: None,
disabled_rules: Vec::new(),
enabled_rules: Vec::new(),
disabled_categories: Vec::new(),
enabled_categories: Vec::new(),
}
}
}
fn default_lt_level() -> String {
"default".to_string()
}
impl EngineToggle for LanguageToolConfig {
fn enabled(&self) -> bool {
self.enabled
}
fn set_enabled(&mut self, v: bool) {
self.enabled = v;
}
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct ValeConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub config: Option<String>,
}
impl EngineToggle for ValeConfig {
fn enabled(&self) -> bool {
self.enabled
}
fn set_enabled(&mut self, v: bool) {
self.enabled = v;
}
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct ProselintConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub config: Option<String>,
}
impl EngineToggle for ProselintConfig {
fn enabled(&self) -> bool {
self.enabled
}
fn set_enabled(&mut self, v: bool) {
self.enabled = v;
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ExternalProvider {
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub extensions: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct WasmPlugin {
pub name: String,
pub path: String,
#[serde(default)]
pub extensions: Vec<String>,
}
impl Default for EngineConfig {
fn default() -> Self {
Self {
harper: HarperConfig::default(),
languagetool: LanguageToolConfig::default(),
vale: ValeConfig::default(),
proselint: ProselintConfig::default(),
external: Vec::new(),
wasm_plugins: Vec::new(),
spell_language: default_spell_language(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RuleConfig {
pub severity: Option<String>, }
const fn default_true() -> bool {
true
}
fn default_lt_url() -> String {
"http://localhost:8010".to_string()
}
fn default_spell_language() -> String {
"en-US".to_string()
}
fn default_exclude() -> Vec<String> {
vec![
"node_modules/**".to_string(),
".git/**".to_string(),
"target/**".to_string(),
"dist/**".to_string(),
"build/**".to_string(),
".next/**".to_string(),
".nuxt/**".to_string(),
"vendor/**".to_string(),
"__pycache__/**".to_string(),
".venv/**".to_string(),
"venv/**".to_string(),
".tox/**".to_string(),
".mypy_cache/**".to_string(),
"*.min.js".to_string(),
"*.min.css".to_string(),
"*.bundle.js".to_string(),
"package-lock.json".to_string(),
"yarn.lock".to_string(),
"pnpm-lock.yaml".to_string(),
]
}
impl Config {
pub fn load(workspace_root: &Path) -> Result<Self> {
let yaml_path = workspace_root.join(".languagecheck.yaml");
let yml_path = workspace_root.join(".languagecheck.yml");
let json_path = workspace_root.join(".languagecheck.json");
if yaml_path.exists() {
let content = std::fs::read_to_string(yaml_path)?;
warn_duplicate_rule_keys(&content);
let config: Self = serde_yaml::from_str(&content)?;
Ok(config)
} else if yml_path.exists() {
let content = std::fs::read_to_string(yml_path)?;
warn_duplicate_rule_keys(&content);
let config: Self = serde_yaml::from_str(&content)?;
Ok(config)
} else if json_path.exists() {
let content = std::fs::read_to_string(json_path)?;
let config: Self = serde_json::from_str(&content)?;
Ok(config)
} else {
Ok(Self::default())
}
}
#[must_use]
pub fn apply_auto_fixes(&self, text: &str) -> (String, usize) {
let mut result = text.to_string();
let mut total = 0;
for rule in &self.auto_fix {
if let Some(ctx) = &rule.context
&& !result.contains(ctx.as_str())
{
continue;
}
let count = result.matches(&rule.find).count();
if count > 0 {
result = result.replace(&rule.find, &rule.replace);
total += count;
}
}
(result, total)
}
}
fn duplicate_rule_keys(content: &str) -> Vec<String> {
let mut in_rules = false;
let mut child_indent: Option<usize> = None;
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut duplicates: Vec<String> = Vec::new();
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
let indent = line.len() - line.trim_start().len();
if !in_rules {
if indent == 0 && line.trim() == "rules:" {
in_rules = true;
}
continue;
}
if indent == 0 {
break;
}
let child = *child_indent.get_or_insert(indent);
if indent != child {
continue; }
if let Some(key) = line.trim().strip_suffix(':') {
let key = key.trim().to_string();
if !key.is_empty() && !seen.insert(key.clone()) && !duplicates.contains(&key) {
duplicates.push(key);
}
}
}
duplicates
}
fn warn_duplicate_rule_keys(content: &str) {
let duplicates = duplicate_rule_keys(content);
if !duplicates.is_empty() {
warn!(
duplicates = ?duplicates,
"Duplicate rule keys in .languagecheck.yaml; only the last entry for each takes \
effect. Remove the extra copies to keep the ignore list clean."
);
}
}
impl Default for Config {
fn default() -> Self {
Self {
engines: EngineConfig::default(),
rules: HashMap::new(),
exclude: default_exclude(),
auto_fix: Vec::new(),
performance: PerformanceConfig::default(),
dictionaries: DictionaryConfig::default(),
languages: LanguageConfig::default(),
workspace: WorkspaceConfig::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duplicate_rule_keys_detects_repeats() {
let yaml = "rules:\n languagetool.ARROWS:\n severity: \"off\"\n \
languagetool.UPPERCASE_SENTENCE_START:\n severity: \"off\"\n \
languagetool.ARROWS:\n severity: \"off\"\n \
languagetool.UPPERCASE_SENTENCE_START:\n severity: \"off\"\n \
languagetool.THE_SUPERLATIVE:\n severity: \"off\"\n";
let dups = duplicate_rule_keys(yaml);
assert_eq!(
dups,
vec![
"languagetool.ARROWS".to_string(),
"languagetool.UPPERCASE_SENTENCE_START".to_string()
]
);
}
#[test]
fn duplicate_rule_keys_clean_list_is_empty() {
let yaml = "rules:\n a.B:\n severity: \"off\"\n c.D:\n severity: \"off\"\n";
assert!(duplicate_rule_keys(yaml).is_empty());
}
#[test]
fn duplicate_rule_keys_stops_at_next_section() {
let yaml = "rules:\n a.B:\n severity: \"off\"\nengines:\n harper: false\n";
assert!(duplicate_rule_keys(yaml).is_empty());
}
#[test]
fn default_config_has_harper_enabled_lt_disabled() {
let config = Config::default();
assert!(config.engines.harper.enabled);
assert!(!config.engines.languagetool.enabled);
}
#[test]
fn default_config_has_standard_excludes() {
let config = Config::default();
assert!(config.exclude.contains(&"node_modules/**".to_string()));
assert!(config.exclude.contains(&".git/**".to_string()));
assert!(config.exclude.contains(&"target/**".to_string()));
assert!(config.exclude.contains(&"dist/**".to_string()));
assert!(config.exclude.contains(&"vendor/**".to_string()));
}
#[test]
fn default_lt_url() {
let config = Config::default();
assert_eq!(config.engines.languagetool.url, "http://localhost:8010");
}
#[test]
fn load_from_json_string() {
let json = r#"{
"engines": { "harper": true, "languagetool": false },
"rules": { "spelling.typo": { "severity": "warning" } }
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert!(config.engines.harper.enabled);
assert!(!config.engines.languagetool.enabled);
assert!(config.rules.contains_key("spelling.typo"));
assert_eq!(
config.rules["spelling.typo"].severity.as_deref(),
Some("warning")
);
}
#[test]
fn load_partial_json_uses_defaults() {
let json = r#"{}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert!(config.engines.harper.enabled);
assert!(!config.engines.languagetool.enabled);
assert!(config.rules.is_empty());
}
#[test]
fn load_from_json_file() {
let dir = std::env::temp_dir().join("lang_check_test_config_json");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let config_path = dir.join(".languagecheck.json");
std::fs::write(
&config_path,
r#"{"engines": {"harper": false, "languagetool": true}}"#,
)
.unwrap();
let config = Config::load(&dir).unwrap();
assert!(!config.engines.harper.enabled);
assert!(config.engines.languagetool.enabled);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_from_yaml_file() {
let dir = std::env::temp_dir().join("lang_check_test_config_yaml");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let config_path = dir.join(".languagecheck.yaml");
std::fs::write(
&config_path,
"engines:\n harper: false\n languagetool: true\n",
)
.unwrap();
let config = Config::load(&dir).unwrap();
assert!(!config.engines.harper.enabled);
assert!(config.engines.languagetool.enabled);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn yaml_takes_precedence_over_json() {
let dir = std::env::temp_dir().join("lang_check_test_config_precedence");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".languagecheck.yaml"),
"engines:\n harper: false\n",
)
.unwrap();
std::fs::write(
dir.join(".languagecheck.json"),
r#"{"engines": {"harper": true}}"#,
)
.unwrap();
let config = Config::load(&dir).unwrap();
assert!(!config.engines.harper.enabled);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_missing_file_returns_default() {
let dir = std::env::temp_dir().join("lang_check_test_config_missing");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let config = Config::load(&dir).unwrap();
assert!(config.engines.harper.enabled);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn auto_fix_simple_replacement() {
let config = Config {
auto_fix: vec![AutoFixRule {
find: "teh".to_string(),
replace: "the".to_string(),
context: None,
description: None,
}],
..Config::default()
};
let (result, count) = config.apply_auto_fixes("Fix teh typo in teh text.");
assert_eq!(result, "Fix the typo in the text.");
assert_eq!(count, 2);
}
#[test]
fn auto_fix_with_context_filter() {
let config = Config {
auto_fix: vec![AutoFixRule {
find: "colour".to_string(),
replace: "color".to_string(),
context: Some("American".to_string()),
description: Some("Use American spelling".to_string()),
}],
..Config::default()
};
let (result, count) = config.apply_auto_fixes("American English: the colour is red.");
assert_eq!(result, "American English: the color is red.");
assert_eq!(count, 1);
let (result, count) = config.apply_auto_fixes("British English: the colour is red.");
assert_eq!(result, "British English: the colour is red.");
assert_eq!(count, 0);
}
#[test]
fn auto_fix_no_match() {
let config = Config {
auto_fix: vec![AutoFixRule {
find: "foo".to_string(),
replace: "bar".to_string(),
context: None,
description: None,
}],
..Config::default()
};
let (result, count) = config.apply_auto_fixes("No matches here.");
assert_eq!(result, "No matches here.");
assert_eq!(count, 0);
}
#[test]
fn auto_fix_multiple_rules() {
let config = Config {
auto_fix: vec![
AutoFixRule {
find: "recieve".to_string(),
replace: "receive".to_string(),
context: None,
description: None,
},
AutoFixRule {
find: "seperate".to_string(),
replace: "separate".to_string(),
context: None,
description: None,
},
],
..Config::default()
};
let (result, count) = config.apply_auto_fixes("Please recieve the seperate package.");
assert_eq!(result, "Please receive the separate package.");
assert_eq!(count, 2);
}
#[test]
fn auto_fix_loads_from_yaml() {
let yaml = r#"
auto_fix:
- find: "teh"
replace: "the"
description: "Fix common typo"
- find: "colour"
replace: "color"
context: "American"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.auto_fix.len(), 2);
assert_eq!(config.auto_fix[0].find, "teh");
assert_eq!(config.auto_fix[0].replace, "the");
assert_eq!(
config.auto_fix[0].description.as_deref(),
Some("Fix common typo")
);
assert_eq!(config.auto_fix[1].context.as_deref(), Some("American"));
}
#[test]
fn default_config_has_empty_auto_fix() {
let config = Config::default();
assert!(config.auto_fix.is_empty());
}
#[test]
fn external_providers_from_yaml() {
let yaml = r#"
engines:
harper: true
languagetool: false
external:
- name: vale
command: /usr/bin/vale
args: ["--output", "JSON"]
extensions: [md, rst]
- name: custom-checker
command: ./my-checker
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.engines.external.len(), 2);
assert_eq!(config.engines.external[0].name, "vale");
assert_eq!(config.engines.external[0].command, "/usr/bin/vale");
assert_eq!(config.engines.external[0].args, vec!["--output", "JSON"]);
assert_eq!(config.engines.external[0].extensions, vec!["md", "rst"]);
assert_eq!(config.engines.external[1].name, "custom-checker");
assert!(config.engines.external[1].args.is_empty());
}
#[test]
fn default_config_has_no_external_providers() {
let config = Config::default();
assert!(config.engines.external.is_empty());
}
#[test]
fn wasm_plugins_from_yaml() {
let yaml = r#"
engines:
harper: true
wasm_plugins:
- name: custom-checker
path: .languagecheck/plugins/checker.wasm
extensions: [md, html]
- name: style-linter
path: /opt/plugins/style.wasm
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.engines.wasm_plugins.len(), 2);
assert_eq!(config.engines.wasm_plugins[0].name, "custom-checker");
assert_eq!(
config.engines.wasm_plugins[0].path,
".languagecheck/plugins/checker.wasm"
);
assert_eq!(
config.engines.wasm_plugins[0].extensions,
vec!["md", "html"]
);
assert_eq!(config.engines.wasm_plugins[1].name, "style-linter");
assert!(config.engines.wasm_plugins[1].extensions.is_empty());
}
#[test]
fn default_config_has_no_wasm_plugins() {
let config = Config::default();
assert!(config.engines.wasm_plugins.is_empty());
}
#[test]
fn performance_config_defaults() {
let config = Config::default();
assert!(!config.performance.high_performance_mode);
assert_eq!(config.performance.debounce_ms, 300);
assert_eq!(config.performance.max_file_size, 0);
}
#[test]
fn performance_config_from_yaml() {
let yaml = r#"
performance:
high_performance_mode: true
debounce_ms: 500
max_file_size: 1048576
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.performance.high_performance_mode);
assert_eq!(config.performance.debounce_ms, 500);
assert_eq!(config.performance.max_file_size, 1_048_576);
}
#[test]
fn latex_skip_environments_from_yaml() {
let yaml = r#"
languages:
latex:
skip_environments:
- prooftree
- mycustomenv
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
config.languages.latex.skip_environments,
vec!["prooftree", "mycustomenv"]
);
}
#[test]
fn default_config_has_empty_latex_skip_environments() {
let config = Config::default();
assert!(config.languages.latex.skip_environments.is_empty());
}
#[test]
fn latex_skip_commands_from_yaml() {
let yaml = r#"
languages:
latex:
skip_commands:
- codefont
- myverb
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
config.languages.latex.skip_commands,
vec!["codefont", "myverb"]
);
}
#[test]
fn default_spell_language_is_en_us() {
let config = Config::default();
assert_eq!(config.engines.spell_language, "en-US");
}
#[test]
fn spell_language_from_yaml() {
let yaml = r#"
engines:
spell_language: de-DE
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.engines.spell_language, "de-DE");
}
#[test]
fn default_config_has_empty_latex_skip_commands() {
let config = Config::default();
assert!(config.languages.latex.skip_commands.is_empty());
}
#[test]
fn default_vale_is_disabled() {
let config = Config::default();
assert!(!config.engines.vale.enabled);
assert!(config.engines.vale.config.is_none());
}
#[test]
fn vale_bool_shorthand_from_yaml() {
let yaml = r#"
engines:
vale: true
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.engines.vale.enabled);
}
#[test]
fn vale_nested_config_from_yaml() {
let yaml = r#"
engines:
vale:
enabled: true
config: ".vale.ini"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.engines.vale.enabled);
assert_eq!(config.engines.vale.config.as_deref(), Some(".vale.ini"));
}
#[test]
fn harper_nested_config_from_yaml() {
let yaml = r#"
engines:
harper:
enabled: true
dialect: "British"
linters:
LongSentences: false
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.engines.harper.enabled);
assert_eq!(config.engines.harper.dialect, "British");
assert_eq!(
config.engines.harper.linters.get("LongSentences"),
Some(&false)
);
}
#[test]
fn languagetool_nested_config_from_yaml() {
let yaml = r#"
engines:
languagetool:
enabled: true
url: "http://localhost:9090"
level: "picky"
disabled_rules:
- WHITESPACE_RULE
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.engines.languagetool.enabled);
assert_eq!(config.engines.languagetool.url, "http://localhost:9090");
assert_eq!(config.engines.languagetool.level, "picky");
assert_eq!(
config.engines.languagetool.disabled_rules,
vec!["WHITESPACE_RULE"]
);
}
#[test]
fn default_proselint_is_disabled() {
let config = Config::default();
assert!(!config.engines.proselint.enabled);
assert!(config.engines.proselint.config.is_none());
}
#[test]
fn proselint_bool_shorthand_from_yaml() {
let yaml = r#"
engines:
proselint: true
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.engines.proselint.enabled);
}
#[test]
fn proselint_nested_config_from_yaml() {
let yaml = r#"
engines:
proselint:
enabled: true
config: "proselint.json"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.engines.proselint.enabled);
assert_eq!(
config.engines.proselint.config.as_deref(),
Some("proselint.json")
);
}
}