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    /// Which keys clear the per-window typing buffer when pressed.
221    /// Useful trade-off: a reset is the safest response to a key
222    /// we can't precisely track (so fix-word never lands at the
223    /// wrong spot), but disabling some resets lets the buffer
224    /// survive an autocomplete (Tab), a mode switch (Esc), and so
225    /// on so a subsequent fix-word can still operate on the
226    /// already-typed text.
227    pub reset_keys: ResetKeys,
228
229    /// Open the review popup straight into vim mode instead of the
230    /// word-edit (Tab) mode. `Ctrl+E` still toggles between the two — so
231    /// when this is on, `Ctrl+E` flips *to* word-edit mode.
232    pub review_starts_in_vim: bool,
233
234    /// When a fix routed to the LLM can't run — no API key, an
235    /// unsupported/unwired backend, or the network call itself fails —
236    /// try a configured LanguageTool server before dropping to the
237    /// offline Spellbook. Only has an effect when LanguageTool is
238    /// enabled with a URL; otherwise the fix falls straight through to
239    /// Spellbook either way. On by default so a configured LanguageTool
240    /// is preferred over the offline dictionary.
241    pub fallback_to_languagetool: bool,
242
243    /// Source for the per-option word definitions under the review
244    /// popup's suggestion dropdown. Defaults to the bundled offline
245    /// dictionary; can be turned off or pointed at an online API.
246    pub definitions: DefinitionSource,
247}
248impl Default for Behavior {
249    fn default() -> Self {
250        Self {
251            pause_per_backspace_ms: 8,
252            reset_keys: ResetKeys::default(),
253            review_starts_in_vim: false,
254            fallback_to_languagetool: true,
255            definitions: DefinitionSource::Local,
256        }
257    }
258}
259
260/// Per-key toggles for "this key clears the typing buffer." See
261/// [`Behavior::reset_keys`]. Defaults match what the daemon needs
262/// to stay safe — Enter, the arrow keys above/below, Page Up/Down,
263/// forward Delete, and Insert all reset; Tab and Escape do not
264/// because they typically don't change typed text and resetting
265/// drops the buffer for no gain.
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
267#[serde(default)]
268pub struct ResetKeys {
269    pub enter: bool,
270    pub tab: bool,
271    pub escape: bool,
272    pub up: bool,
273    pub down: bool,
274    pub page_up: bool,
275    pub page_down: bool,
276    pub delete: bool,
277    pub insert: bool,
278}
279
280impl Default for ResetKeys {
281    fn default() -> Self {
282        Self {
283            enter: true,
284            tab: false,
285            escape: false,
286            up: true,
287            down: true,
288            page_up: true,
289            page_down: true,
290            delete: true,
291            insert: true,
292        }
293    }
294}
295
296/// Privacy settings.
297#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
298#[serde(default)]
299pub struct Privacy {
300    /// Window classes (lowercase, exact match) for which the daemon
301    /// will not buffer keystrokes. Useful for password managers.
302    pub app_blocklist: Vec<String>,
303}
304
305impl Config {
306    /// The OS-conventional path to `config.toml`.
307    ///
308    /// # Errors
309    ///
310    /// Returns [`ConfigError::NoConfigDir`] when the platform exposes
311    /// no usable config directory (e.g. a sandbox with no `$HOME`).
312    pub fn path() -> Result<PathBuf, ConfigError> {
313        let dirs = ProjectDirs::from("io", "hyprcorrect", "hyprcorrect")
314            .ok_or(ConfigError::NoConfigDir)?;
315        Ok(dirs.config_dir().join("config.toml"))
316    }
317}
318
319/// The OS-conventional data folder where prefs downloads the LanguageTool
320/// n-gram dataset (`<data_dir>/ngrams`). `None` when no data directory is
321/// available (e.g. a sandbox with no `$HOME`).
322pub fn ngram_data_dir() -> Option<PathBuf> {
323    ProjectDirs::from("io", "hyprcorrect", "hyprcorrect").map(|dirs| dirs.data_dir().join("ngrams"))
324}
325
326impl Config {
327    /// Load from the OS-conventional path. A missing file yields a
328    /// default [`Config`] (not an error).
329    ///
330    /// # Errors
331    ///
332    /// See [`ConfigError`].
333    pub fn load() -> Result<Self, ConfigError> {
334        Self::load_from(&Self::path()?)
335    }
336
337    /// Load from a specific path. A missing file is not an error.
338    ///
339    /// # Errors
340    ///
341    /// See [`ConfigError`].
342    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
343        match fs::read_to_string(path) {
344            Ok(text) => toml::from_str(&text).map_err(|e| ConfigError::Parse(e.to_string())),
345            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
346            Err(e) => Err(ConfigError::Io(e.to_string())),
347        }
348    }
349
350    /// Save to the OS-conventional path, creating parent dirs as needed.
351    ///
352    /// # Errors
353    ///
354    /// See [`ConfigError`].
355    pub fn save(&self) -> Result<(), ConfigError> {
356        self.save_to(&Self::path()?)
357    }
358
359    /// Save to a specific path.
360    ///
361    /// # Errors
362    ///
363    /// See [`ConfigError`].
364    pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
365        if let Some(parent) = path.parent() {
366            fs::create_dir_all(parent).map_err(|e| ConfigError::Io(e.to_string()))?;
367        }
368        let text =
369            toml::to_string_pretty(self).map_err(|e| ConfigError::Serialize(e.to_string()))?;
370        fs::write(path, text).map_err(|e| ConfigError::Io(e.to_string()))
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn defaults_roundtrip_through_toml() {
380        let cfg = Config::default();
381        let text = toml::to_string_pretty(&cfg).unwrap();
382        let back: Config = toml::from_str(&text).unwrap();
383        assert_eq!(cfg, back);
384    }
385
386    #[test]
387    fn empty_file_yields_defaults() {
388        let cfg: Config = toml::from_str("").unwrap();
389        assert_eq!(cfg, Config::default());
390    }
391
392    #[test]
393    fn partial_file_fills_missing_with_defaults() {
394        let cfg: Config = toml::from_str(
395            r#"[hotkeys]
396fix_word = "CTRL+J"
397"#,
398        )
399        .unwrap();
400        assert_eq!(cfg.hotkeys.fix_word, "CTRL+J");
401        // Untouched sections still hold defaults.
402        assert_eq!(cfg.behavior.pause_per_backspace_ms, 8);
403        assert_eq!(cfg.providers.default, ProviderId::Spellbook);
404        assert!(cfg.privacy.app_blocklist.is_empty());
405    }
406
407    #[test]
408    fn save_then_load_round_trips_through_disk() {
409        let dir = unique_tempdir();
410        let path = dir.join("config.toml");
411        let mut cfg = Config::default();
412        cfg.hotkeys.fix_word = "CTRL+ALT+K".into();
413        cfg.privacy.app_blocklist = vec!["1password".into(), "keepassxc".into()];
414        cfg.save_to(&path).unwrap();
415        let loaded = Config::load_from(&path).unwrap();
416        assert_eq!(loaded, cfg);
417        let _ = fs::remove_dir_all(&dir);
418    }
419
420    #[test]
421    fn load_from_missing_path_yields_defaults() {
422        let path = unique_tempdir().join("does-not-exist.toml");
423        let cfg = Config::load_from(&path).unwrap();
424        assert_eq!(cfg, Config::default());
425    }
426
427    #[test]
428    fn llms_list_round_trips_through_toml() {
429        let mut cfg = Config::default();
430        // Active provider is first in the list (index 0).
431        cfg.providers.llms = vec![
432            LlmConfig {
433                backend: "anthropic".into(),
434                model: "claude-haiku-4-5".into(),
435                base_url: None,
436            },
437            LlmConfig {
438                backend: "openai".into(),
439                model: "gpt-4o-mini".into(),
440                base_url: None,
441            },
442            // A custom OpenAI-compatible endpoint carries its base URL.
443            LlmConfig {
444                backend: "openai-compatible".into(),
445                model: "llama3.1".into(),
446                base_url: Some("http://localhost:11434/v1".into()),
447            },
448        ];
449        let text = toml::to_string_pretty(&cfg).unwrap();
450        let back: Config = toml::from_str(&text).unwrap();
451        assert_eq!(back.providers.llms, cfg.providers.llms);
452
453        // Deleting every provider persists as an empty list (doesn't
454        // silently resurrect the default).
455        cfg.providers.llms.clear();
456        let text = toml::to_string_pretty(&cfg).unwrap();
457        let back: Config = toml::from_str(&text).unwrap();
458        assert!(back.providers.llms.is_empty());
459    }
460
461    fn unique_tempdir() -> PathBuf {
462        let nano = std::time::SystemTime::now()
463            .duration_since(std::time::UNIX_EPOCH)
464            .map(|d| d.as_nanos())
465            .unwrap_or(0);
466        let dir = std::env::temp_dir().join(format!("hyprcorrect-cfg-{nano}"));
467        fs::create_dir_all(&dir).unwrap();
468        dir
469    }
470}