Skip to main content

hyprcorrect_core/
config.rs

1//! Configuration: loading and saving `config.toml`, plus the hotkey,
2//! provider, behavior, and privacy settings it holds.
3//!
4//! Cross-platform: paths resolve via the `directories` crate so the
5//! file lives at the OS-conventional location (`~/.config/hyprcorrect/`
6//! on Linux, `~/Library/Application Support/io.hyprcorrect.hyprcorrect/`
7//! on macOS, `%APPDATA%\hyprcorrect\hyprcorrect\config\` on Windows).
8//!
9//! Every field has a default — a missing file or partial TOML still
10//! produces a valid [`Config`]. See the "Configuration & GUI" section
11//! of `DESIGN.md`.
12
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use directories::ProjectDirs;
17use serde::{Deserialize, Serialize};
18
19/// An error loading or saving the config.
20#[derive(Debug, thiserror::Error)]
21pub enum ConfigError {
22    /// No suitable OS config dir was found (extremely rare — happens
23    /// in restricted sandboxes with no `$HOME`).
24    #[error("no OS config directory is available")]
25    NoConfigDir,
26    /// The config file could not be read or written.
27    #[error("config I/O: {0}")]
28    Io(String),
29    /// The TOML on disk could not be parsed.
30    #[error("config TOML: {0}")]
31    Parse(String),
32    /// The config could not be serialized.
33    #[error("could not serialize config: {0}")]
34    Serialize(String),
35}
36
37/// The full hyprcorrect configuration.
38#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(default)]
40pub struct Config {
41    pub hotkeys: Hotkeys,
42    pub providers: Providers,
43    pub behavior: Behavior,
44    pub privacy: Privacy,
45}
46
47/// Hotkey settings. Each action is fully configurable — pick any
48/// combination of modifiers plus a single non-modifier key. Stored
49/// as `+`-separated accelerator strings (see [`crate::Chord`]) so
50/// the file stays human-readable. An empty string means "unbound".
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(default)]
53pub struct Hotkeys {
54    /// Accelerator for `fix-last-word`. Example: `"CTRL+SHIFT+ALT+SUPER+F"`.
55    pub fix_word: String,
56    /// Accelerator for `fix-last-sentence`. Empty = unbound.
57    pub fix_sentence: String,
58    /// Accelerator for the review popup — shows the proposed
59    /// correction in a small egui window and waits for Apply / Cancel
60    /// before emitting. Empty = unbound.
61    pub review: String,
62    /// Accelerator that, while the review popup is open, re-processes the
63    /// original sentence through the LLM and reloads the popup with its
64    /// suggestions — for escalating past a weak LanguageTool/spellbook
65    /// correction without calling the LLM on every fix. Empty = unbound.
66    pub review_llm: String,
67}
68impl Default for Hotkeys {
69    fn default() -> Self {
70        Self {
71            fix_word: "CTRL+SHIFT+ALT+SUPER+F".into(),
72            fix_sentence: "CTRL+SHIFT+ALT+SUPER+S".into(),
73            review: "CTRL+SHIFT+ALT+SUPER+R".into(),
74            review_llm: "CTRL+SHIFT+ALT+SUPER+L".into(),
75        }
76    }
77}
78
79/// Most LLM provider tabs the prefs UI lets you configure. Each is a
80/// distinct hosted backend (Anthropic, OpenAI, …) with its own model and
81/// keychain entry. The prefs UI enforces this cap and one-tab-per-backend
82/// uniqueness when adding providers.
83pub const MAX_LLM_PROVIDERS: usize = 5;
84
85/// Provider routing settings.
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
87#[serde(default)]
88pub struct Providers {
89    /// Provider used for `fix-last-word` (instant, ideally local).
90    pub default: ProviderId,
91    /// Provider used for `fix-last-sentence` and the review popup.
92    pub smart: ProviderId,
93    /// Configured LLM providers, one per hosted backend (max
94    /// [`MAX_LLM_PROVIDERS`]). When [`ProviderId::Llm`] is selected the
95    /// daemon uses the first entry whose backend is wired and keyed. The
96    /// field-level `#[serde(default)]` makes an absent `llms` deserialize
97    /// to an empty list, so the prefs UI can persist "no providers".
98    #[serde(default)]
99    pub llms: Vec<LlmConfig>,
100    pub languagetool: LanguageToolConfig,
101}
102impl Default for Providers {
103    fn default() -> Self {
104        Self {
105            default: ProviderId::Spellbook,
106            smart: ProviderId::Llm,
107            llms: vec![LlmConfig::default()],
108            languagetool: LanguageToolConfig::default(),
109        }
110    }
111}
112
113/// The set of correction providers the UI lets the user choose between.
114#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
115#[serde(rename_all = "snake_case")]
116pub enum ProviderId {
117    /// Offline pure-Rust spell checker (Hunspell-compatible).
118    #[default]
119    Spellbook,
120    /// Network LLM (model and backend per [`LlmConfig`]).
121    Llm,
122    /// Self-hosted LanguageTool over HTTP. Serialized as
123    /// `"languagetool"` (one word) so the TOML enum value matches
124    /// the `[providers.languagetool]` section header and the
125    /// product's own one-word branding — overriding the
126    /// container-level snake_case default that would otherwise
127    /// produce `"language_tool"`.
128    #[serde(rename = "languagetool")]
129    LanguageTool,
130}
131
132/// LLM provider settings. The API key lives in the OS keychain — see
133/// [`crate::secrets`].
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135#[serde(default)]
136pub struct LlmConfig {
137    /// LLM vendor: one of `anthropic`, `openai`, `gemini`, `openrouter`,
138    /// `mistral`, `groq`, `deepseek`, `xai`, or `openai-compatible` for a
139    /// custom/local OpenAI-style endpoint (see `base_url`). See
140    /// [`crate::llm::is_backend_wired`].
141    pub backend: String,
142    /// Model name passed to the vendor API.
143    pub model: String,
144    /// Base URL for the `openai-compatible` backend — a local
145    /// Ollama / LM Studio server or any other OpenAI-style endpoint, up
146    /// to but not including `/chat/completions`
147    /// (e.g. `http://localhost:11434/v1`). The named cloud backends
148    /// ignore it and use their own built-in URLs, so it's `None` for
149    /// them and omitted from the TOML.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub base_url: Option<String>,
152}
153impl Default for LlmConfig {
154    fn default() -> Self {
155        Self {
156            backend: "anthropic".into(),
157            model: "claude-haiku-4-5".into(),
158            base_url: None,
159        }
160    }
161}
162
163/// LanguageTool HTTP settings. Off by default — the user supplies their
164/// own self-hosted URL.
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
166#[serde(default)]
167pub struct LanguageToolConfig {
168    pub enabled: bool,
169    pub url: String,
170    /// Host folder of LanguageTool's n-gram dataset (the unzipped
171    /// directory that contains an `en/` subfolder). When set, the
172    /// Install-with-Docker convenience mounts it and points the server at
173    /// it so real-word confusions (wear/where) get caught. `None` = the
174    /// server runs without n-grams.
175    pub ngram_dir: Option<String>,
176}
177impl Default for LanguageToolConfig {
178    fn default() -> Self {
179        Self {
180            enabled: false,
181            url: "http://localhost:8081".into(),
182            ngram_dir: None,
183        }
184    }
185}
186
187/// Where the review popup's per-option word definitions come from.
188#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
189#[serde(rename_all = "snake_case")]
190pub enum DefinitionSource {
191    /// Don't show definitions.
192    Off,
193    /// Bundled offline WordNet glosses — the privacy-preserving default.
194    #[default]
195    Local,
196    /// Online dictionary API (`api.dictionaryapi.dev`). Sends the
197    /// looked-up word to a third party, so it's opt-in.
198    Online,
199}
200
201/// Behavior knobs.
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
203#[serde(default)]
204pub struct Behavior {
205    /// Wait time per backspace, applied as a single pause between
206    /// the backspace burst and the replacement-text burst. Total
207    /// pause = `pause_per_backspace_ms` × backspace count.
208    ///
209    /// The only emit-side knob most users need. The reason behind
210    /// it: Wayland delivers all dispatched backspaces reliably, but
211    /// the focused app drains them through its own event loop at
212    /// its own pace — if the daemon's next `wtype` (the typing
213    /// burst) starts before the app has finished applying the
214    /// backspaces, those text events queue behind the still-
215    /// processing deletes and visually leave a prefix of the
216    /// original on screen. This pause covers that drain time.
217    /// Raise it if you still see leftover prefix characters.
218    pub pause_per_backspace_ms: u32,
219
220    /// Inter-character pause (ms) while typing the replacement text —
221    /// the companion to [`pause_per_backspace_ms`], but for the
222    /// *typing* phase rather than the *deletion* phase.
223    ///
224    /// macOS injects the replacement one character at a time (Electron
225    /// apps like Slack and VS Code silently ignore a single event that
226    /// carries a whole multi-char string), and this is the sleep
227    /// between those per-character events. Raise it if a fast app drops
228    /// or coalesces characters; lower it toward 0 for snappier typing.
229    ///
230    /// On Linux the typing burst goes through `wtype` and this becomes
231    /// its inter-key delay directly. There is no floor on either
232    /// platform — the value is used as-is so users can dial it in for
233    /// their own apps/terminals. The *default* differs by platform
234    /// (see [`DEFAULT_PAUSE_PER_CHAR_MS`]): 1 ms on macOS, 8 ms on
235    /// Linux, where `wtype` starts at a more terminal-friendly delay.
236    /// (The deletion bursts keep their own fixed terminal-safe delay,
237    /// independent of this knob.)
238    pub pause_per_char_ms: u32,
239
240    /// Which keys clear the per-window typing buffer when pressed.
241    /// Useful trade-off: a reset is the safest response to a key
242    /// we can't precisely track (so fix-word never lands at the
243    /// wrong spot), but disabling some resets lets the buffer
244    /// survive an autocomplete (Tab), a mode switch (Esc), and so
245    /// on so a subsequent fix-word can still operate on the
246    /// already-typed text.
247    pub reset_keys: ResetKeys,
248
249    /// Open the review popup straight into vim mode instead of the
250    /// word-edit (Tab) mode. `Ctrl+E` still toggles between the two — so
251    /// when this is on, `Ctrl+E` flips *to* word-edit mode.
252    pub review_starts_in_vim: bool,
253
254    /// When a fix routed to the LLM can't run — no API key, an
255    /// unsupported/unwired backend, or the network call itself fails —
256    /// try a configured LanguageTool server before dropping to the
257    /// offline Spellbook. Only has an effect when LanguageTool is
258    /// enabled with a URL; otherwise the fix falls straight through to
259    /// Spellbook either way. On by default so a configured LanguageTool
260    /// is preferred over the offline dictionary.
261    pub fallback_to_languagetool: bool,
262
263    /// Source for the per-option word definitions under the review
264    /// popup's suggestion dropdown. Defaults to the bundled offline
265    /// dictionary; can be turned off or pointed at an online API.
266    pub definitions: DefinitionSource,
267}
268
269/// Platform default for [`Behavior::pause_per_char_ms`]. macOS injects
270/// one character at a time and needs only a hair (1 ms) to keep Electron
271/// apps from dropping characters; Linux types via `wtype`, which starts
272/// at the more terminal-friendly 8 ms. Neither is a floor — users dial
273/// the value in from here.
274pub const DEFAULT_PAUSE_PER_CHAR_MS: u32 = if cfg!(target_os = "linux") { 8 } else { 1 };
275
276impl Default for Behavior {
277    fn default() -> Self {
278        Self {
279            pause_per_backspace_ms: 8,
280            pause_per_char_ms: DEFAULT_PAUSE_PER_CHAR_MS,
281            reset_keys: ResetKeys::default(),
282            review_starts_in_vim: false,
283            fallback_to_languagetool: true,
284            definitions: DefinitionSource::Local,
285        }
286    }
287}
288
289/// Per-key toggles for "this key clears the typing buffer." See
290/// [`Behavior::reset_keys`]. Defaults match what the daemon needs
291/// to stay safe — Enter, the arrow keys above/below, Page Up/Down,
292/// forward Delete, and Insert all reset; Tab and Escape do not
293/// because they typically don't change typed text and resetting
294/// drops the buffer for no gain.
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
296#[serde(default)]
297pub struct ResetKeys {
298    pub enter: bool,
299    pub tab: bool,
300    pub escape: bool,
301    pub up: bool,
302    pub down: bool,
303    pub page_up: bool,
304    pub page_down: bool,
305    pub delete: bool,
306    pub insert: bool,
307}
308
309impl Default for ResetKeys {
310    fn default() -> Self {
311        Self {
312            enter: true,
313            tab: false,
314            escape: false,
315            up: true,
316            down: true,
317            page_up: true,
318            page_down: true,
319            delete: true,
320            insert: true,
321        }
322    }
323}
324
325/// Privacy settings.
326#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
327#[serde(default)]
328pub struct Privacy {
329    /// Window classes (lowercase, exact match) for which the daemon
330    /// will not buffer keystrokes. Useful for password managers.
331    pub app_blocklist: Vec<String>,
332}
333
334impl Config {
335    /// The OS-conventional path to `config.toml`.
336    ///
337    /// # Errors
338    ///
339    /// Returns [`ConfigError::NoConfigDir`] when the platform exposes
340    /// no usable config directory (e.g. a sandbox with no `$HOME`).
341    pub fn path() -> Result<PathBuf, ConfigError> {
342        let dirs = ProjectDirs::from("io", "hyprcorrect", "hyprcorrect")
343            .ok_or(ConfigError::NoConfigDir)?;
344        Ok(dirs.config_dir().join("config.toml"))
345    }
346}
347
348/// The OS-conventional data folder where prefs downloads the LanguageTool
349/// n-gram dataset (`<data_dir>/ngrams`). `None` when no data directory is
350/// available (e.g. a sandbox with no `$HOME`).
351pub fn ngram_data_dir() -> Option<PathBuf> {
352    ProjectDirs::from("io", "hyprcorrect", "hyprcorrect").map(|dirs| dirs.data_dir().join("ngrams"))
353}
354
355impl Config {
356    /// Load from the OS-conventional path. A missing file yields a
357    /// default [`Config`] (not an error).
358    ///
359    /// # Errors
360    ///
361    /// See [`ConfigError`].
362    pub fn load() -> Result<Self, ConfigError> {
363        Self::load_from(&Self::path()?)
364    }
365
366    /// Load from a specific path. A missing file is not an error.
367    ///
368    /// # Errors
369    ///
370    /// See [`ConfigError`].
371    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
372        match fs::read_to_string(path) {
373            Ok(text) => toml::from_str(&text).map_err(|e| ConfigError::Parse(e.to_string())),
374            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
375            Err(e) => Err(ConfigError::Io(e.to_string())),
376        }
377    }
378
379    /// Save to the OS-conventional path, creating parent dirs as needed.
380    ///
381    /// # Errors
382    ///
383    /// See [`ConfigError`].
384    pub fn save(&self) -> Result<(), ConfigError> {
385        self.save_to(&Self::path()?)
386    }
387
388    /// Save to a specific path.
389    ///
390    /// # Errors
391    ///
392    /// See [`ConfigError`].
393    pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
394        if let Some(parent) = path.parent() {
395            fs::create_dir_all(parent).map_err(|e| ConfigError::Io(e.to_string()))?;
396        }
397        let text =
398            toml::to_string_pretty(self).map_err(|e| ConfigError::Serialize(e.to_string()))?;
399        fs::write(path, text).map_err(|e| ConfigError::Io(e.to_string()))
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn defaults_roundtrip_through_toml() {
409        let cfg = Config::default();
410        let text = toml::to_string_pretty(&cfg).unwrap();
411        let back: Config = toml::from_str(&text).unwrap();
412        assert_eq!(cfg, back);
413    }
414
415    #[test]
416    fn empty_file_yields_defaults() {
417        let cfg: Config = toml::from_str("").unwrap();
418        assert_eq!(cfg, Config::default());
419    }
420
421    #[test]
422    fn partial_file_fills_missing_with_defaults() {
423        let cfg: Config = toml::from_str(
424            r#"[hotkeys]
425fix_word = "CTRL+J"
426"#,
427        )
428        .unwrap();
429        assert_eq!(cfg.hotkeys.fix_word, "CTRL+J");
430        // Untouched sections still hold defaults.
431        assert_eq!(cfg.behavior.pause_per_backspace_ms, 8);
432        assert_eq!(cfg.behavior.pause_per_char_ms, DEFAULT_PAUSE_PER_CHAR_MS);
433        assert_eq!(cfg.providers.default, ProviderId::Spellbook);
434        assert!(cfg.privacy.app_blocklist.is_empty());
435    }
436
437    #[test]
438    fn save_then_load_round_trips_through_disk() {
439        let dir = unique_tempdir();
440        let path = dir.join("config.toml");
441        let mut cfg = Config::default();
442        cfg.hotkeys.fix_word = "CTRL+ALT+K".into();
443        cfg.privacy.app_blocklist = vec!["1password".into(), "keepassxc".into()];
444        cfg.save_to(&path).unwrap();
445        let loaded = Config::load_from(&path).unwrap();
446        assert_eq!(loaded, cfg);
447        let _ = fs::remove_dir_all(&dir);
448    }
449
450    #[test]
451    fn load_from_missing_path_yields_defaults() {
452        let path = unique_tempdir().join("does-not-exist.toml");
453        let cfg = Config::load_from(&path).unwrap();
454        assert_eq!(cfg, Config::default());
455    }
456
457    #[test]
458    fn llms_list_round_trips_through_toml() {
459        let mut cfg = Config::default();
460        // Active provider is first in the list (index 0).
461        cfg.providers.llms = vec![
462            LlmConfig {
463                backend: "anthropic".into(),
464                model: "claude-haiku-4-5".into(),
465                base_url: None,
466            },
467            LlmConfig {
468                backend: "openai".into(),
469                model: "gpt-4o-mini".into(),
470                base_url: None,
471            },
472            // A custom OpenAI-compatible endpoint carries its base URL.
473            LlmConfig {
474                backend: "openai-compatible".into(),
475                model: "llama3.1".into(),
476                base_url: Some("http://localhost:11434/v1".into()),
477            },
478        ];
479        let text = toml::to_string_pretty(&cfg).unwrap();
480        let back: Config = toml::from_str(&text).unwrap();
481        assert_eq!(back.providers.llms, cfg.providers.llms);
482
483        // Deleting every provider persists as an empty list (doesn't
484        // silently resurrect the default).
485        cfg.providers.llms.clear();
486        let text = toml::to_string_pretty(&cfg).unwrap();
487        let back: Config = toml::from_str(&text).unwrap();
488        assert!(back.providers.llms.is_empty());
489    }
490
491    fn unique_tempdir() -> PathBuf {
492        let nano = std::time::SystemTime::now()
493            .duration_since(std::time::UNIX_EPOCH)
494            .map(|d| d.as_nanos())
495            .unwrap_or(0);
496        let dir = std::env::temp_dir().join(format!("hyprcorrect-cfg-{nano}"));
497        fs::create_dir_all(&dir).unwrap();
498        dir
499    }
500}