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}