1use std::fs;
14use std::path::{Path, PathBuf};
15
16use directories::ProjectDirs;
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, thiserror::Error)]
21pub enum ConfigError {
22 #[error("no OS config directory is available")]
25 NoConfigDir,
26 #[error("config I/O: {0}")]
28 Io(String),
29 #[error("config TOML: {0}")]
31 Parse(String),
32 #[error("could not serialize config: {0}")]
34 Serialize(String),
35}
36
37#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(default)]
53pub struct Hotkeys {
54 pub fix_word: String,
56 pub fix_sentence: String,
58 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(default)]
76pub struct Providers {
77 pub default: ProviderId,
79 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#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
97#[serde(rename_all = "snake_case")]
98pub enum ProviderId {
99 #[default]
101 Spellbook,
102 Llm,
104 #[serde(rename = "languagetool")]
111 LanguageTool,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(default)]
118pub struct LlmConfig {
119 pub backend: String,
121 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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152#[serde(default)]
153pub struct Behavior {
154 pub pause_per_backspace_ms: u32,
168
169 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#[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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
225#[serde(default)]
226pub struct Privacy {
227 pub app_blocklist: Vec<String>,
230}
231
232impl Config {
233 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 pub fn load() -> Result<Self, ConfigError> {
252 Self::load_from(&Self::path()?)
253 }
254
255 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 pub fn save(&self) -> Result<(), ConfigError> {
274 self.save_to(&Self::path()?)
275 }
276
277 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 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}