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}
63impl Default for Hotkeys {
64    fn default() -> Self {
65        Self {
66            fix_word: "CTRL+SHIFT+ALT+SUPER+F".into(),
67            fix_sentence: String::new(),
68            review: String::new(),
69        }
70    }
71}
72
73/// Provider routing settings.
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(default)]
76pub struct Providers {
77    /// Provider used for `fix-last-word` (instant, ideally local).
78    pub default: ProviderId,
79    /// Provider used for `fix-last-sentence` and the review popup.
80    pub smart: ProviderId,
81    pub llm: LlmConfig,
82    pub languagetool: LanguageToolConfig,
83}
84impl Default for Providers {
85    fn default() -> Self {
86        Self {
87            default: ProviderId::Spellbook,
88            smart: ProviderId::Llm,
89            llm: LlmConfig::default(),
90            languagetool: LanguageToolConfig::default(),
91        }
92    }
93}
94
95/// The set of correction providers the UI lets the user choose between.
96#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
97#[serde(rename_all = "snake_case")]
98pub enum ProviderId {
99    /// Offline pure-Rust spell checker (Hunspell-compatible).
100    #[default]
101    Spellbook,
102    /// Network LLM (model and backend per [`LlmConfig`]).
103    Llm,
104    /// Self-hosted LanguageTool over HTTP. Serialized as
105    /// `"languagetool"` (one word) so the TOML enum value matches
106    /// the `[providers.languagetool]` section header and the
107    /// product's own one-word branding — overriding the
108    /// container-level snake_case default that would otherwise
109    /// produce `"language_tool"`.
110    #[serde(rename = "languagetool")]
111    LanguageTool,
112}
113
114/// LLM provider settings. The API key lives in the OS keychain — see
115/// [`crate::secrets`].
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(default)]
118pub struct LlmConfig {
119    /// LLM vendor. Today only `"anthropic"` is wired in (M4).
120    pub backend: String,
121    /// Model name passed to the vendor API.
122    pub model: String,
123}
124impl Default for LlmConfig {
125    fn default() -> Self {
126        Self {
127            backend: "anthropic".into(),
128            model: "claude-haiku-4-5".into(),
129        }
130    }
131}
132
133/// LanguageTool HTTP settings. Off by default — the user supplies their
134/// own self-hosted URL.
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
136#[serde(default)]
137pub struct LanguageToolConfig {
138    pub enabled: bool,
139    pub url: String,
140}
141impl Default for LanguageToolConfig {
142    fn default() -> Self {
143        Self {
144            enabled: false,
145            url: "http://localhost:8081".into(),
146        }
147    }
148}
149
150/// Behavior knobs.
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152#[serde(default)]
153pub struct Behavior {
154    /// Wait time per backspace, applied as a single pause between
155    /// the backspace burst and the replacement-text burst. Total
156    /// pause = `pause_per_backspace_ms` × backspace count.
157    ///
158    /// The only emit-side knob most users need. The reason behind
159    /// it: Wayland delivers all dispatched backspaces reliably, but
160    /// the focused app drains them through its own event loop at
161    /// its own pace — if the daemon's next `wtype` (the typing
162    /// burst) starts before the app has finished applying the
163    /// backspaces, those text events queue behind the still-
164    /// processing deletes and visually leave a prefix of the
165    /// original on screen. This pause covers that drain time.
166    /// Raise it if you still see leftover prefix characters.
167    pub pause_per_backspace_ms: u32,
168
169    /// Which keys clear the per-window typing buffer when pressed.
170    /// Useful trade-off: a reset is the safest response to a key
171    /// we can't precisely track (so fix-word never lands at the
172    /// wrong spot), but disabling some resets lets the buffer
173    /// survive an autocomplete (Tab), a mode switch (Esc), and so
174    /// on so a subsequent fix-word can still operate on the
175    /// already-typed text.
176    pub reset_keys: ResetKeys,
177}
178impl Default for Behavior {
179    fn default() -> Self {
180        Self {
181            pause_per_backspace_ms: 8,
182            reset_keys: ResetKeys::default(),
183        }
184    }
185}
186
187/// Per-key toggles for "this key clears the typing buffer." See
188/// [`Behavior::reset_keys`]. Defaults match what the daemon needs
189/// to stay safe — Enter, the arrow keys above/below, Page Up/Down,
190/// forward Delete, and Insert all reset; Tab and Escape do not
191/// because they typically don't change typed text and resetting
192/// drops the buffer for no gain.
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
194#[serde(default)]
195pub struct ResetKeys {
196    pub enter: bool,
197    pub tab: bool,
198    pub escape: bool,
199    pub up: bool,
200    pub down: bool,
201    pub page_up: bool,
202    pub page_down: bool,
203    pub delete: bool,
204    pub insert: bool,
205}
206
207impl Default for ResetKeys {
208    fn default() -> Self {
209        Self {
210            enter: true,
211            tab: false,
212            escape: false,
213            up: true,
214            down: true,
215            page_up: true,
216            page_down: true,
217            delete: true,
218            insert: true,
219        }
220    }
221}
222
223/// Privacy settings.
224#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
225#[serde(default)]
226pub struct Privacy {
227    /// Window classes (lowercase, exact match) for which the daemon
228    /// will not buffer keystrokes. Useful for password managers.
229    pub app_blocklist: Vec<String>,
230}
231
232impl Config {
233    /// The OS-conventional path to `config.toml`.
234    ///
235    /// # Errors
236    ///
237    /// Returns [`ConfigError::NoConfigDir`] when the platform exposes
238    /// no usable config directory (e.g. a sandbox with no `$HOME`).
239    pub fn path() -> Result<PathBuf, ConfigError> {
240        let dirs = ProjectDirs::from("io", "hyprcorrect", "hyprcorrect")
241            .ok_or(ConfigError::NoConfigDir)?;
242        Ok(dirs.config_dir().join("config.toml"))
243    }
244
245    /// Load from the OS-conventional path. A missing file yields a
246    /// default [`Config`] (not an error).
247    ///
248    /// # Errors
249    ///
250    /// See [`ConfigError`].
251    pub fn load() -> Result<Self, ConfigError> {
252        Self::load_from(&Self::path()?)
253    }
254
255    /// Load from a specific path. A missing file is not an error.
256    ///
257    /// # Errors
258    ///
259    /// See [`ConfigError`].
260    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
261        match fs::read_to_string(path) {
262            Ok(text) => toml::from_str(&text).map_err(|e| ConfigError::Parse(e.to_string())),
263            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
264            Err(e) => Err(ConfigError::Io(e.to_string())),
265        }
266    }
267
268    /// Save to the OS-conventional path, creating parent dirs as needed.
269    ///
270    /// # Errors
271    ///
272    /// See [`ConfigError`].
273    pub fn save(&self) -> Result<(), ConfigError> {
274        self.save_to(&Self::path()?)
275    }
276
277    /// Save to a specific path.
278    ///
279    /// # Errors
280    ///
281    /// See [`ConfigError`].
282    pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
283        if let Some(parent) = path.parent() {
284            fs::create_dir_all(parent).map_err(|e| ConfigError::Io(e.to_string()))?;
285        }
286        let text =
287            toml::to_string_pretty(self).map_err(|e| ConfigError::Serialize(e.to_string()))?;
288        fs::write(path, text).map_err(|e| ConfigError::Io(e.to_string()))
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn defaults_roundtrip_through_toml() {
298        let cfg = Config::default();
299        let text = toml::to_string_pretty(&cfg).unwrap();
300        let back: Config = toml::from_str(&text).unwrap();
301        assert_eq!(cfg, back);
302    }
303
304    #[test]
305    fn empty_file_yields_defaults() {
306        let cfg: Config = toml::from_str("").unwrap();
307        assert_eq!(cfg, Config::default());
308    }
309
310    #[test]
311    fn partial_file_fills_missing_with_defaults() {
312        let cfg: Config = toml::from_str(
313            r#"[hotkeys]
314fix_word = "CTRL+J"
315"#,
316        )
317        .unwrap();
318        assert_eq!(cfg.hotkeys.fix_word, "CTRL+J");
319        // Untouched sections still hold defaults.
320        assert_eq!(cfg.behavior.pause_per_backspace_ms, 8);
321        assert_eq!(cfg.providers.default, ProviderId::Spellbook);
322        assert!(cfg.privacy.app_blocklist.is_empty());
323    }
324
325    #[test]
326    fn save_then_load_round_trips_through_disk() {
327        let dir = unique_tempdir();
328        let path = dir.join("config.toml");
329        let mut cfg = Config::default();
330        cfg.hotkeys.fix_word = "CTRL+ALT+K".into();
331        cfg.privacy.app_blocklist = vec!["1password".into(), "keepassxc".into()];
332        cfg.save_to(&path).unwrap();
333        let loaded = Config::load_from(&path).unwrap();
334        assert_eq!(loaded, cfg);
335        let _ = fs::remove_dir_all(&dir);
336    }
337
338    #[test]
339    fn load_from_missing_path_yields_defaults() {
340        let path = unique_tempdir().join("does-not-exist.toml");
341        let cfg = Config::load_from(&path).unwrap();
342        assert_eq!(cfg, Config::default());
343    }
344
345    fn unique_tempdir() -> PathBuf {
346        let nano = std::time::SystemTime::now()
347            .duration_since(std::time::UNIX_EPOCH)
348            .map(|d| d.as_nanos())
349            .unwrap_or(0);
350        let dir = std::env::temp_dir().join(format!("hyprcorrect-cfg-{nano}"));
351        fs::create_dir_all(&dir).unwrap();
352        dir
353    }
354}