Skip to main content

lang_check/
config.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::Path;
5use tracing::warn;
6
7#[derive(Debug, Serialize, Deserialize, Clone)]
8pub struct Config {
9    #[serde(default)]
10    pub engines: EngineConfig,
11    #[serde(default)]
12    pub rules: HashMap<String, RuleConfig>,
13    #[serde(default = "default_exclude")]
14    pub exclude: Vec<String>,
15    #[serde(default)]
16    pub auto_fix: Vec<AutoFixRule>,
17    #[serde(default)]
18    pub performance: PerformanceConfig,
19    #[serde(default)]
20    pub dictionaries: DictionaryConfig,
21    #[serde(default)]
22    pub languages: LanguageConfig,
23    #[serde(default)]
24    pub workspace: WorkspaceConfig,
25    #[serde(default)]
26    pub names: NameConfig,
27}
28
29/// Opt-in suppression of spelling diagnostics on human names.
30///
31/// Off by default: the failure mode is silently hiding a real misspelling, which is
32/// much harder to notice than a stray squiggle on a surname.
33///
34/// ```yaml
35/// names:
36///   enabled: true
37///   aggressiveness: balanced   # conservative | balanced | aggressive
38/// ```
39#[derive(Debug, Serialize, Deserialize, Clone, Default)]
40pub struct NameConfig {
41    /// Whether to drop spelling diagnostics on tokens detected as human names.
42    #[serde(default)]
43    pub enabled: bool,
44    /// How much corroborating evidence a name needs before its diagnostic is dropped.
45    /// Default: `balanced`.
46    #[serde(default)]
47    pub aggressiveness: crate::names::Aggressiveness,
48}
49
50/// Language extension aliasing configuration.
51///
52/// Maps canonical language IDs to additional file extensions.
53/// Built-in extensions (e.g. `.md` → markdown, `.htm` → html) are always
54/// included; entries here add to them.
55///
56/// ```yaml
57/// languages:
58///   extensions:
59///     markdown: [mdx, Rmd]
60///     latex: [sty]
61/// ```
62#[derive(Debug, Serialize, Deserialize, Clone, Default)]
63pub struct LanguageConfig {
64    /// Additional file extensions per language ID (without leading dots).
65    #[serde(default)]
66    pub extensions: HashMap<String, Vec<String>>,
67    /// LaTeX-specific settings.
68    #[serde(default)]
69    pub latex: LaTeXConfig,
70}
71
72/// LaTeX-specific configuration.
73///
74/// ```yaml
75/// languages:
76///   latex:
77///     skip_environments:
78///       - prooftree
79///       - mycustomenv
80/// ```
81#[derive(Debug, Serialize, Deserialize, Clone, Default)]
82pub struct LaTeXConfig {
83    /// Extra environment names to skip during prose extraction.
84    /// These are checked in addition to the built-in skip list.
85    #[serde(default)]
86    pub skip_environments: Vec<String>,
87    /// Extra command names whose arguments should be skipped during prose
88    /// extraction. These are checked in addition to the built-in skip list
89    /// (which includes `texttt`, `verb`, `url`, etc.).
90    #[serde(default)]
91    pub skip_commands: Vec<String>,
92}
93
94/// Workspace-level settings.
95///
96/// ```yaml
97/// workspace:
98///   index_on_open: true
99/// ```
100#[derive(Debug, Serialize, Deserialize, Clone, Default)]
101pub struct WorkspaceConfig {
102    /// Whether to run a full workspace index when the project is opened.
103    /// Default: false (only check documents on open/change).
104    #[serde(default)]
105    pub index_on_open: bool,
106    /// Custom path for the workspace database file. When empty (default),
107    /// databases are stored in the user data directory.
108    #[serde(default)]
109    pub db_path: Option<String>,
110}
111
112/// Performance tuning options. High Performance Mode (HPM) disables
113/// expensive engines and external providers, using only harper-core.
114#[derive(Debug, Serialize, Deserialize, Clone)]
115pub struct PerformanceConfig {
116    /// Enable High Performance Mode (only harper, no LT/externals).
117    #[serde(default)]
118    pub high_performance_mode: bool,
119    /// Debounce delay in milliseconds for LSP on-type checking.
120    #[serde(default = "default_debounce_ms")]
121    pub debounce_ms: u64,
122    /// Maximum file size in bytes to check (0 = unlimited).
123    #[serde(default)]
124    pub max_file_size: usize,
125}
126
127impl Default for PerformanceConfig {
128    fn default() -> Self {
129        Self {
130            high_performance_mode: false,
131            debounce_ms: 300,
132            max_file_size: 0,
133        }
134    }
135}
136
137const fn default_debounce_ms() -> u64 {
138    300
139}
140
141/// Configuration for bundled and additional wordlist dictionaries.
142#[derive(Debug, Serialize, Deserialize, Clone)]
143pub struct DictionaryConfig {
144    /// Whether to load the bundled domain-specific dictionaries (software terms,
145    /// TypeScript, companies, jargon). Default: true.
146    #[serde(default = "default_true")]
147    pub bundled: bool,
148    /// Paths to additional wordlist files (one word per line, `#` comments).
149    /// Relative paths are resolved from the workspace root.
150    #[serde(default)]
151    pub paths: Vec<String>,
152}
153
154impl Default for DictionaryConfig {
155    fn default() -> Self {
156        Self {
157            bundled: true,
158            paths: Vec::new(),
159        }
160    }
161}
162
163/// A user-defined find->replace auto-fix rule.
164#[derive(Debug, Serialize, Deserialize, Clone)]
165pub struct AutoFixRule {
166    /// Pattern to find (plain text, case-sensitive).
167    pub find: String,
168    /// Replacement text.
169    pub replace: String,
170    /// Optional context filter: only apply when surrounding text matches.
171    #[serde(default)]
172    pub context: Option<String>,
173    /// Optional description for the rule.
174    #[serde(default)]
175    pub description: Option<String>,
176}
177
178#[derive(Debug, Serialize, Deserialize, Clone)]
179pub struct EngineConfig {
180    #[serde(
181        default = "default_harper_config",
182        deserialize_with = "deser_engine_or_bool"
183    )]
184    pub harper: HarperConfig,
185    #[serde(default, deserialize_with = "deser_engine_or_bool")]
186    pub languagetool: LanguageToolConfig,
187    #[serde(default, deserialize_with = "deser_engine_or_bool")]
188    pub vale: ValeConfig,
189    #[serde(default, deserialize_with = "deser_engine_or_bool")]
190    pub proselint: ProselintConfig,
191    /// External checker providers registered via config.
192    #[serde(default)]
193    pub external: Vec<ExternalProvider>,
194    /// WASM checker plugins loaded via Extism.
195    #[serde(default)]
196    pub wasm_plugins: Vec<WasmPlugin>,
197    /// BCP-47 natural language tag for spell/grammar checking (e.g. "en-US", "de-DE").
198    #[serde(default = "default_spell_language")]
199    pub spell_language: String,
200}
201
202/// Deserialize an engine config from either a bool shorthand or the full struct.
203/// `harper: true` → `HarperConfig { enabled: true, ..default }`.
204fn deser_engine_or_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
205where
206    D: serde::Deserializer<'de>,
207    T: Deserialize<'de> + EngineToggle + Default,
208{
209    #[derive(Deserialize)]
210    #[serde(untagged)]
211    enum BoolOrStruct<T> {
212        Bool(bool),
213        Struct(T),
214    }
215
216    match BoolOrStruct::deserialize(deserializer)? {
217        BoolOrStruct::Bool(b) => {
218            let mut cfg = T::default();
219            cfg.set_enabled(b);
220            Ok(cfg)
221        }
222        BoolOrStruct::Struct(s) => Ok(s),
223    }
224}
225
226/// Trait for engine configs that can be toggled with a bool shorthand.
227pub trait EngineToggle {
228    fn enabled(&self) -> bool;
229    fn set_enabled(&mut self, v: bool);
230}
231
232/// Harper engine configuration.
233#[derive(Debug, Serialize, Deserialize, Clone)]
234pub struct HarperConfig {
235    #[serde(default = "default_true")]
236    pub enabled: bool,
237    /// Harper dialect: `American`, `British`, `Canadian`, `Australian`, `Indian`.
238    #[serde(default = "default_dialect")]
239    pub dialect: String,
240    /// Per-rule toggles. Key is the rule name (e.g. `LongSentences`), value
241    /// is `true`/`false`. Omitted rules use the curated default.
242    #[serde(default)]
243    pub linters: HashMap<String, bool>,
244}
245
246impl Default for HarperConfig {
247    fn default() -> Self {
248        Self {
249            enabled: true,
250            dialect: "American".to_string(),
251            linters: HashMap::new(),
252        }
253    }
254}
255
256fn default_harper_config() -> HarperConfig {
257    HarperConfig::default()
258}
259
260fn default_dialect() -> String {
261    "American".to_string()
262}
263
264impl EngineToggle for HarperConfig {
265    fn enabled(&self) -> bool {
266        self.enabled
267    }
268    fn set_enabled(&mut self, v: bool) {
269        self.enabled = v;
270    }
271}
272
273/// `LanguageTool` engine configuration.
274#[derive(Debug, Serialize, Deserialize, Clone)]
275pub struct LanguageToolConfig {
276    #[serde(default)]
277    pub enabled: bool,
278    /// `LanguageTool` server URL.
279    #[serde(default = "default_lt_url")]
280    pub url: String,
281    /// Checking level: `default` or `picky` (enables stricter rules).
282    #[serde(default = "default_lt_level")]
283    pub level: String,
284    /// User's native language for false-friends detection (BCP-47 tag).
285    #[serde(default)]
286    pub mother_tongue: Option<String>,
287    /// Rule IDs to disable (e.g. `["WHITESPACE_RULE"]`).
288    #[serde(default)]
289    pub disabled_rules: Vec<String>,
290    /// Rule IDs to enable beyond defaults.
291    #[serde(default)]
292    pub enabled_rules: Vec<String>,
293    /// Category IDs to disable.
294    #[serde(default)]
295    pub disabled_categories: Vec<String>,
296    /// Category IDs to enable.
297    #[serde(default)]
298    pub enabled_categories: Vec<String>,
299}
300
301impl Default for LanguageToolConfig {
302    fn default() -> Self {
303        Self {
304            enabled: false,
305            url: default_lt_url(),
306            level: "default".to_string(),
307            mother_tongue: None,
308            disabled_rules: Vec::new(),
309            enabled_rules: Vec::new(),
310            disabled_categories: Vec::new(),
311            enabled_categories: Vec::new(),
312        }
313    }
314}
315
316fn default_lt_level() -> String {
317    "default".to_string()
318}
319
320impl EngineToggle for LanguageToolConfig {
321    fn enabled(&self) -> bool {
322        self.enabled
323    }
324    fn set_enabled(&mut self, v: bool) {
325        self.enabled = v;
326    }
327}
328
329/// Vale engine configuration.
330#[derive(Debug, Default, Serialize, Deserialize, Clone)]
331pub struct ValeConfig {
332    #[serde(default)]
333    pub enabled: bool,
334    /// Path to `.vale.ini`. When empty, Vale uses its own search logic.
335    #[serde(default)]
336    pub config: Option<String>,
337}
338
339impl EngineToggle for ValeConfig {
340    fn enabled(&self) -> bool {
341        self.enabled
342    }
343    fn set_enabled(&mut self, v: bool) {
344        self.enabled = v;
345    }
346}
347
348/// Proselint engine configuration.
349#[derive(Debug, Default, Serialize, Deserialize, Clone)]
350pub struct ProselintConfig {
351    #[serde(default)]
352    pub enabled: bool,
353    /// Path to `proselint.json` config. When empty, proselint uses its own search logic.
354    #[serde(default)]
355    pub config: Option<String>,
356}
357
358impl EngineToggle for ProselintConfig {
359    fn enabled(&self) -> bool {
360        self.enabled
361    }
362    fn set_enabled(&mut self, v: bool) {
363        self.enabled = v;
364    }
365}
366
367/// An external checker binary that communicates via stdin/stdout JSON.
368///
369/// The binary receives `{"text": "...", "language_id": "..."}` on stdin
370/// and returns `[{"start_byte": N, "end_byte": N, "message": "...", ...}]` on stdout.
371#[derive(Debug, Serialize, Deserialize, Clone)]
372pub struct ExternalProvider {
373    /// Display name for this provider.
374    pub name: String,
375    /// Path to the executable.
376    pub command: String,
377    /// Optional arguments to pass to the command.
378    #[serde(default)]
379    pub args: Vec<String>,
380    /// Optional file extensions this provider supports (empty = all).
381    #[serde(default)]
382    pub extensions: Vec<String>,
383}
384
385/// A WASM plugin loaded via Extism.
386///
387/// Plugins must export a `check` function that receives a JSON string
388/// `{"text": "...", "language_id": "..."}` and returns a JSON array of diagnostics.
389#[derive(Debug, Serialize, Deserialize, Clone)]
390pub struct WasmPlugin {
391    /// Display name for this plugin.
392    pub name: String,
393    /// Path to the `.wasm` file (relative to workspace root or absolute).
394    pub path: String,
395    /// Optional file extensions this plugin supports (empty = all).
396    #[serde(default)]
397    pub extensions: Vec<String>,
398}
399
400impl Default for EngineConfig {
401    fn default() -> Self {
402        Self {
403            harper: HarperConfig::default(),
404            languagetool: LanguageToolConfig::default(),
405            vale: ValeConfig::default(),
406            proselint: ProselintConfig::default(),
407            external: Vec::new(),
408            wasm_plugins: Vec::new(),
409            spell_language: default_spell_language(),
410        }
411    }
412}
413
414#[derive(Debug, Serialize, Deserialize, Clone)]
415pub struct RuleConfig {
416    pub severity: Option<String>, // "error", "warning", "info", "hint", "off"
417}
418
419const fn default_true() -> bool {
420    true
421}
422fn default_lt_url() -> String {
423    "http://localhost:8010".to_string()
424}
425fn default_spell_language() -> String {
426    "en-US".to_string()
427}
428fn default_exclude() -> Vec<String> {
429    vec![
430        "node_modules/**".to_string(),
431        ".git/**".to_string(),
432        "target/**".to_string(),
433        "dist/**".to_string(),
434        "build/**".to_string(),
435        ".next/**".to_string(),
436        ".nuxt/**".to_string(),
437        "vendor/**".to_string(),
438        "__pycache__/**".to_string(),
439        ".venv/**".to_string(),
440        "venv/**".to_string(),
441        ".tox/**".to_string(),
442        ".mypy_cache/**".to_string(),
443        "*.min.js".to_string(),
444        "*.min.css".to_string(),
445        "*.bundle.js".to_string(),
446        "package-lock.json".to_string(),
447        "yarn.lock".to_string(),
448        "pnpm-lock.yaml".to_string(),
449    ]
450}
451
452impl Config {
453    pub fn load(workspace_root: &Path) -> Result<Self> {
454        // Prefer YAML, fall back to JSON for backward compatibility
455        let yaml_path = workspace_root.join(".languagecheck.yaml");
456        let yml_path = workspace_root.join(".languagecheck.yml");
457        let json_path = workspace_root.join(".languagecheck.json");
458
459        if yaml_path.exists() {
460            let content = std::fs::read_to_string(yaml_path)?;
461            warn_duplicate_rule_keys(&content);
462            let config: Self = serde_yaml::from_str(&content)?;
463            Ok(config)
464        } else if yml_path.exists() {
465            let content = std::fs::read_to_string(yml_path)?;
466            warn_duplicate_rule_keys(&content);
467            let config: Self = serde_yaml::from_str(&content)?;
468            Ok(config)
469        } else if json_path.exists() {
470            let content = std::fs::read_to_string(json_path)?;
471            let config: Self = serde_json::from_str(&content)?;
472            Ok(config)
473        } else {
474            Ok(Self::default())
475        }
476    }
477
478    /// Apply user-defined auto-fix rules to the given text, returning the modified text
479    /// and the number of replacements made.
480    #[must_use]
481    pub fn apply_auto_fixes(&self, text: &str) -> (String, usize) {
482        let mut result = text.to_string();
483        let mut total = 0;
484
485        for rule in &self.auto_fix {
486            if let Some(ctx) = &rule.context
487                && !result.contains(ctx.as_str())
488            {
489                continue;
490            }
491            let count = result.matches(&rule.find).count();
492            if count > 0 {
493                result = result.replace(&rule.find, &rule.replace);
494                total += count;
495            }
496        }
497
498        (result, total)
499    }
500}
501
502/// Collect rule keys that appear more than once under the top-level `rules:`
503/// mapping of a raw YAML config, in first-seen order.
504///
505/// `serde_yaml` silently keeps only the last value for a duplicated mapping
506/// key, so duplicates vanish after parsing; this scans the raw text so they can
507/// be surfaced. Recognizes block-style child keys (`  some.rule:` on its own
508/// line) at the mapping's first child indentation.
509fn duplicate_rule_keys(content: &str) -> Vec<String> {
510    let mut in_rules = false;
511    let mut child_indent: Option<usize> = None;
512    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
513    let mut duplicates: Vec<String> = Vec::new();
514
515    for line in content.lines() {
516        if line.trim().is_empty() {
517            continue;
518        }
519        let indent = line.len() - line.trim_start().len();
520
521        if !in_rules {
522            if indent == 0 && line.trim() == "rules:" {
523                in_rules = true;
524            }
525            continue;
526        }
527
528        // A new top-level key ends the rules block.
529        if indent == 0 {
530            break;
531        }
532
533        let child = *child_indent.get_or_insert(indent);
534        if indent != child {
535            continue; // deeper line (e.g. `severity: ...`), not a rule key
536        }
537        if let Some(key) = line.trim().strip_suffix(':') {
538            let key = key.trim().to_string();
539            if !key.is_empty() && !seen.insert(key.clone()) && !duplicates.contains(&key) {
540                duplicates.push(key);
541            }
542        }
543    }
544
545    duplicates
546}
547
548/// Log a warning if a raw YAML config contains duplicate rule keys.
549fn warn_duplicate_rule_keys(content: &str) {
550    let duplicates = duplicate_rule_keys(content);
551    if !duplicates.is_empty() {
552        warn!(
553            duplicates = ?duplicates,
554            "Duplicate rule keys in .languagecheck.yaml; only the last entry for each takes \
555             effect. Remove the extra copies to keep the ignore list clean."
556        );
557    }
558}
559
560impl Default for Config {
561    fn default() -> Self {
562        Self {
563            engines: EngineConfig::default(),
564            rules: HashMap::new(),
565            exclude: default_exclude(),
566            auto_fix: Vec::new(),
567            performance: PerformanceConfig::default(),
568            dictionaries: DictionaryConfig::default(),
569            languages: LanguageConfig::default(),
570            workspace: WorkspaceConfig::default(),
571            names: NameConfig::default(),
572        }
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn duplicate_rule_keys_detects_repeats() {
582        let yaml = "rules:\n  languagetool.ARROWS:\n    severity: \"off\"\n  \
583                    languagetool.UPPERCASE_SENTENCE_START:\n    severity: \"off\"\n  \
584                    languagetool.ARROWS:\n    severity: \"off\"\n  \
585                    languagetool.UPPERCASE_SENTENCE_START:\n    severity: \"off\"\n  \
586                    languagetool.THE_SUPERLATIVE:\n    severity: \"off\"\n";
587        let dups = duplicate_rule_keys(yaml);
588        assert_eq!(
589            dups,
590            vec![
591                "languagetool.ARROWS".to_string(),
592                "languagetool.UPPERCASE_SENTENCE_START".to_string()
593            ]
594        );
595    }
596
597    #[test]
598    fn duplicate_rule_keys_clean_list_is_empty() {
599        let yaml = "rules:\n  a.B:\n    severity: \"off\"\n  c.D:\n    severity: \"off\"\n";
600        assert!(duplicate_rule_keys(yaml).is_empty());
601    }
602
603    #[test]
604    fn duplicate_rule_keys_stops_at_next_section() {
605        // A repeat under a *different* top-level section must not count.
606        let yaml = "rules:\n  a.B:\n    severity: \"off\"\nengines:\n  harper: false\n";
607        assert!(duplicate_rule_keys(yaml).is_empty());
608    }
609
610    #[test]
611    fn default_config_has_harper_enabled_lt_disabled() {
612        let config = Config::default();
613        assert!(config.engines.harper.enabled);
614        assert!(!config.engines.languagetool.enabled);
615    }
616
617    #[test]
618    fn default_config_has_standard_excludes() {
619        let config = Config::default();
620        assert!(config.exclude.contains(&"node_modules/**".to_string()));
621        assert!(config.exclude.contains(&".git/**".to_string()));
622        assert!(config.exclude.contains(&"target/**".to_string()));
623        assert!(config.exclude.contains(&"dist/**".to_string()));
624        assert!(config.exclude.contains(&"vendor/**".to_string()));
625    }
626
627    #[test]
628    fn default_lt_url() {
629        let config = Config::default();
630        assert_eq!(config.engines.languagetool.url, "http://localhost:8010");
631    }
632
633    #[test]
634    fn load_from_json_string() {
635        let json = r#"{
636            "engines": { "harper": true, "languagetool": false },
637            "rules": { "spelling.typo": { "severity": "warning" } }
638        }"#;
639        let config: Config = serde_json::from_str(json).unwrap();
640        assert!(config.engines.harper.enabled);
641        assert!(!config.engines.languagetool.enabled);
642        assert!(config.rules.contains_key("spelling.typo"));
643        assert_eq!(
644            config.rules["spelling.typo"].severity.as_deref(),
645            Some("warning")
646        );
647    }
648
649    #[test]
650    fn load_partial_json_uses_defaults() {
651        let json = r#"{}"#;
652        let config: Config = serde_json::from_str(json).unwrap();
653        assert!(config.engines.harper.enabled);
654        assert!(!config.engines.languagetool.enabled);
655        assert!(config.rules.is_empty());
656    }
657
658    #[test]
659    fn load_from_json_file() {
660        let dir = std::env::temp_dir().join("lang_check_test_config_json");
661        let _ = std::fs::remove_dir_all(&dir);
662        std::fs::create_dir_all(&dir).unwrap();
663
664        let config_path = dir.join(".languagecheck.json");
665        std::fs::write(
666            &config_path,
667            r#"{"engines": {"harper": false, "languagetool": true}}"#,
668        )
669        .unwrap();
670
671        let config = Config::load(&dir).unwrap();
672        assert!(!config.engines.harper.enabled);
673        assert!(config.engines.languagetool.enabled);
674
675        let _ = std::fs::remove_dir_all(&dir);
676    }
677
678    #[test]
679    fn load_from_yaml_file() {
680        let dir = std::env::temp_dir().join("lang_check_test_config_yaml");
681        let _ = std::fs::remove_dir_all(&dir);
682        std::fs::create_dir_all(&dir).unwrap();
683
684        let config_path = dir.join(".languagecheck.yaml");
685        std::fs::write(
686            &config_path,
687            "engines:\n  harper: false\n  languagetool: true\n",
688        )
689        .unwrap();
690
691        let config = Config::load(&dir).unwrap();
692        assert!(!config.engines.harper.enabled);
693        assert!(config.engines.languagetool.enabled);
694
695        let _ = std::fs::remove_dir_all(&dir);
696    }
697
698    #[test]
699    fn yaml_takes_precedence_over_json() {
700        let dir = std::env::temp_dir().join("lang_check_test_config_precedence");
701        let _ = std::fs::remove_dir_all(&dir);
702        std::fs::create_dir_all(&dir).unwrap();
703
704        // Write both files with different values
705        std::fs::write(
706            dir.join(".languagecheck.yaml"),
707            "engines:\n  harper: false\n",
708        )
709        .unwrap();
710        std::fs::write(
711            dir.join(".languagecheck.json"),
712            r#"{"engines": {"harper": true}}"#,
713        )
714        .unwrap();
715
716        let config = Config::load(&dir).unwrap();
717        // YAML should win
718        assert!(!config.engines.harper.enabled);
719
720        let _ = std::fs::remove_dir_all(&dir);
721    }
722
723    #[test]
724    fn load_missing_file_returns_default() {
725        let dir = std::env::temp_dir().join("lang_check_test_config_missing");
726        let _ = std::fs::remove_dir_all(&dir);
727        std::fs::create_dir_all(&dir).unwrap();
728
729        let config = Config::load(&dir).unwrap();
730        assert!(config.engines.harper.enabled);
731
732        let _ = std::fs::remove_dir_all(&dir);
733    }
734
735    #[test]
736    fn auto_fix_simple_replacement() {
737        let config = Config {
738            auto_fix: vec![AutoFixRule {
739                find: "teh".to_string(),
740                replace: "the".to_string(),
741                context: None,
742                description: None,
743            }],
744            ..Config::default()
745        };
746        let (result, count) = config.apply_auto_fixes("Fix teh typo in teh text.");
747        assert_eq!(result, "Fix the typo in the text.");
748        assert_eq!(count, 2);
749    }
750
751    #[test]
752    fn auto_fix_with_context_filter() {
753        let config = Config {
754            auto_fix: vec![AutoFixRule {
755                find: "colour".to_string(),
756                replace: "color".to_string(),
757                context: Some("American".to_string()),
758                description: Some("Use American spelling".to_string()),
759            }],
760            ..Config::default()
761        };
762        // Context matches — replacement should happen
763        let (result, count) = config.apply_auto_fixes("American English: the colour is red.");
764        assert_eq!(result, "American English: the color is red.");
765        assert_eq!(count, 1);
766
767        // Context does not match — no replacement
768        let (result, count) = config.apply_auto_fixes("British English: the colour is red.");
769        assert_eq!(result, "British English: the colour is red.");
770        assert_eq!(count, 0);
771    }
772
773    #[test]
774    fn auto_fix_no_match() {
775        let config = Config {
776            auto_fix: vec![AutoFixRule {
777                find: "foo".to_string(),
778                replace: "bar".to_string(),
779                context: None,
780                description: None,
781            }],
782            ..Config::default()
783        };
784        let (result, count) = config.apply_auto_fixes("No matches here.");
785        assert_eq!(result, "No matches here.");
786        assert_eq!(count, 0);
787    }
788
789    #[test]
790    fn auto_fix_multiple_rules() {
791        let config = Config {
792            auto_fix: vec![
793                AutoFixRule {
794                    find: "recieve".to_string(),
795                    replace: "receive".to_string(),
796                    context: None,
797                    description: None,
798                },
799                AutoFixRule {
800                    find: "seperate".to_string(),
801                    replace: "separate".to_string(),
802                    context: None,
803                    description: None,
804                },
805            ],
806            ..Config::default()
807        };
808        let (result, count) = config.apply_auto_fixes("Please recieve the seperate package.");
809        assert_eq!(result, "Please receive the separate package.");
810        assert_eq!(count, 2);
811    }
812
813    #[test]
814    fn auto_fix_loads_from_yaml() {
815        let yaml = r#"
816auto_fix:
817  - find: "teh"
818    replace: "the"
819    description: "Fix common typo"
820  - find: "colour"
821    replace: "color"
822    context: "American"
823"#;
824        let config: Config = serde_yaml::from_str(yaml).unwrap();
825        assert_eq!(config.auto_fix.len(), 2);
826        assert_eq!(config.auto_fix[0].find, "teh");
827        assert_eq!(config.auto_fix[0].replace, "the");
828        assert_eq!(
829            config.auto_fix[0].description.as_deref(),
830            Some("Fix common typo")
831        );
832        assert_eq!(config.auto_fix[1].context.as_deref(), Some("American"));
833    }
834
835    #[test]
836    fn default_config_has_empty_auto_fix() {
837        let config = Config::default();
838        assert!(config.auto_fix.is_empty());
839    }
840
841    #[test]
842    fn external_providers_from_yaml() {
843        let yaml = r#"
844engines:
845  harper: true
846  languagetool: false
847  external:
848    - name: vale
849      command: /usr/bin/vale
850      args: ["--output", "JSON"]
851      extensions: [md, rst]
852    - name: custom-checker
853      command: ./my-checker
854"#;
855        let config: Config = serde_yaml::from_str(yaml).unwrap();
856        assert_eq!(config.engines.external.len(), 2);
857        assert_eq!(config.engines.external[0].name, "vale");
858        assert_eq!(config.engines.external[0].command, "/usr/bin/vale");
859        assert_eq!(config.engines.external[0].args, vec!["--output", "JSON"]);
860        assert_eq!(config.engines.external[0].extensions, vec!["md", "rst"]);
861        assert_eq!(config.engines.external[1].name, "custom-checker");
862        assert!(config.engines.external[1].args.is_empty());
863    }
864
865    #[test]
866    fn default_config_has_no_external_providers() {
867        let config = Config::default();
868        assert!(config.engines.external.is_empty());
869    }
870
871    #[test]
872    fn wasm_plugins_from_yaml() {
873        let yaml = r#"
874engines:
875  harper: true
876  wasm_plugins:
877    - name: custom-checker
878      path: .languagecheck/plugins/checker.wasm
879      extensions: [md, html]
880    - name: style-linter
881      path: /opt/plugins/style.wasm
882"#;
883        let config: Config = serde_yaml::from_str(yaml).unwrap();
884        assert_eq!(config.engines.wasm_plugins.len(), 2);
885        assert_eq!(config.engines.wasm_plugins[0].name, "custom-checker");
886        assert_eq!(
887            config.engines.wasm_plugins[0].path,
888            ".languagecheck/plugins/checker.wasm"
889        );
890        assert_eq!(
891            config.engines.wasm_plugins[0].extensions,
892            vec!["md", "html"]
893        );
894        assert_eq!(config.engines.wasm_plugins[1].name, "style-linter");
895        assert!(config.engines.wasm_plugins[1].extensions.is_empty());
896    }
897
898    #[test]
899    fn default_config_has_no_wasm_plugins() {
900        let config = Config::default();
901        assert!(config.engines.wasm_plugins.is_empty());
902    }
903
904    #[test]
905    fn performance_config_defaults() {
906        let config = Config::default();
907        assert!(!config.performance.high_performance_mode);
908        assert_eq!(config.performance.debounce_ms, 300);
909        assert_eq!(config.performance.max_file_size, 0);
910    }
911
912    #[test]
913    fn performance_config_from_yaml() {
914        let yaml = r#"
915performance:
916  high_performance_mode: true
917  debounce_ms: 500
918  max_file_size: 1048576
919"#;
920        let config: Config = serde_yaml::from_str(yaml).unwrap();
921        assert!(config.performance.high_performance_mode);
922        assert_eq!(config.performance.debounce_ms, 500);
923        assert_eq!(config.performance.max_file_size, 1_048_576);
924    }
925
926    #[test]
927    fn latex_skip_environments_from_yaml() {
928        let yaml = r#"
929languages:
930  latex:
931    skip_environments:
932      - prooftree
933      - mycustomenv
934"#;
935        let config: Config = serde_yaml::from_str(yaml).unwrap();
936        assert_eq!(
937            config.languages.latex.skip_environments,
938            vec!["prooftree", "mycustomenv"]
939        );
940    }
941
942    #[test]
943    fn default_config_has_empty_latex_skip_environments() {
944        let config = Config::default();
945        assert!(config.languages.latex.skip_environments.is_empty());
946    }
947
948    #[test]
949    fn latex_skip_commands_from_yaml() {
950        let yaml = r#"
951languages:
952  latex:
953    skip_commands:
954      - codefont
955      - myverb
956"#;
957        let config: Config = serde_yaml::from_str(yaml).unwrap();
958        assert_eq!(
959            config.languages.latex.skip_commands,
960            vec!["codefont", "myverb"]
961        );
962    }
963
964    #[test]
965    fn default_spell_language_is_en_us() {
966        let config = Config::default();
967        assert_eq!(config.engines.spell_language, "en-US");
968    }
969
970    #[test]
971    fn spell_language_from_yaml() {
972        let yaml = r#"
973engines:
974  spell_language: de-DE
975"#;
976        let config: Config = serde_yaml::from_str(yaml).unwrap();
977        assert_eq!(config.engines.spell_language, "de-DE");
978    }
979
980    #[test]
981    fn default_config_has_empty_latex_skip_commands() {
982        let config = Config::default();
983        assert!(config.languages.latex.skip_commands.is_empty());
984    }
985
986    #[test]
987    fn default_vale_is_disabled() {
988        let config = Config::default();
989        assert!(!config.engines.vale.enabled);
990        assert!(config.engines.vale.config.is_none());
991    }
992
993    #[test]
994    fn vale_bool_shorthand_from_yaml() {
995        let yaml = r#"
996engines:
997  vale: true
998"#;
999        let config: Config = serde_yaml::from_str(yaml).unwrap();
1000        assert!(config.engines.vale.enabled);
1001    }
1002
1003    #[test]
1004    fn vale_nested_config_from_yaml() {
1005        let yaml = r#"
1006engines:
1007  vale:
1008    enabled: true
1009    config: ".vale.ini"
1010"#;
1011        let config: Config = serde_yaml::from_str(yaml).unwrap();
1012        assert!(config.engines.vale.enabled);
1013        assert_eq!(config.engines.vale.config.as_deref(), Some(".vale.ini"));
1014    }
1015
1016    #[test]
1017    fn harper_nested_config_from_yaml() {
1018        let yaml = r#"
1019engines:
1020  harper:
1021    enabled: true
1022    dialect: "British"
1023    linters:
1024      LongSentences: false
1025"#;
1026        let config: Config = serde_yaml::from_str(yaml).unwrap();
1027        assert!(config.engines.harper.enabled);
1028        assert_eq!(config.engines.harper.dialect, "British");
1029        assert_eq!(
1030            config.engines.harper.linters.get("LongSentences"),
1031            Some(&false)
1032        );
1033    }
1034
1035    #[test]
1036    fn languagetool_nested_config_from_yaml() {
1037        let yaml = r#"
1038engines:
1039  languagetool:
1040    enabled: true
1041    url: "http://localhost:9090"
1042    level: "picky"
1043    disabled_rules:
1044      - WHITESPACE_RULE
1045"#;
1046        let config: Config = serde_yaml::from_str(yaml).unwrap();
1047        assert!(config.engines.languagetool.enabled);
1048        assert_eq!(config.engines.languagetool.url, "http://localhost:9090");
1049        assert_eq!(config.engines.languagetool.level, "picky");
1050        assert_eq!(
1051            config.engines.languagetool.disabled_rules,
1052            vec!["WHITESPACE_RULE"]
1053        );
1054    }
1055
1056    #[test]
1057    fn default_proselint_is_disabled() {
1058        let config = Config::default();
1059        assert!(!config.engines.proselint.enabled);
1060        assert!(config.engines.proselint.config.is_none());
1061    }
1062
1063    #[test]
1064    fn proselint_bool_shorthand_from_yaml() {
1065        let yaml = r#"
1066engines:
1067  proselint: true
1068"#;
1069        let config: Config = serde_yaml::from_str(yaml).unwrap();
1070        assert!(config.engines.proselint.enabled);
1071    }
1072
1073    #[test]
1074    fn proselint_nested_config_from_yaml() {
1075        let yaml = r#"
1076engines:
1077  proselint:
1078    enabled: true
1079    config: "proselint.json"
1080"#;
1081        let config: Config = serde_yaml::from_str(yaml).unwrap();
1082        assert!(config.engines.proselint.enabled);
1083        assert_eq!(
1084            config.engines.proselint.config.as_deref(),
1085            Some("proselint.json")
1086        );
1087    }
1088}