use std::fs;
use std::path::{Path, PathBuf};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("no OS config directory is available")]
NoConfigDir,
#[error("config I/O: {0}")]
Io(String),
#[error("config TOML: {0}")]
Parse(String),
#[error("could not serialize config: {0}")]
Serialize(String),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Config {
pub hotkeys: Hotkeys,
pub providers: Providers,
pub behavior: Behavior,
pub privacy: Privacy,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Hotkeys {
pub fix_word: String,
pub fix_sentence: String,
pub review: String,
}
impl Default for Hotkeys {
fn default() -> Self {
Self {
fix_word: "CTRL+SHIFT+ALT+SUPER+F".into(),
fix_sentence: String::new(),
review: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Providers {
pub default: ProviderId,
pub smart: ProviderId,
pub llm: LlmConfig,
pub languagetool: LanguageToolConfig,
}
impl Default for Providers {
fn default() -> Self {
Self {
default: ProviderId::Spellbook,
smart: ProviderId::Llm,
llm: LlmConfig::default(),
languagetool: LanguageToolConfig::default(),
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProviderId {
#[default]
Spellbook,
Llm,
#[serde(rename = "languagetool")]
LanguageTool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct LlmConfig {
pub backend: String,
pub model: String,
}
impl Default for LlmConfig {
fn default() -> Self {
Self {
backend: "anthropic".into(),
model: "claude-haiku-4-5".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct LanguageToolConfig {
pub enabled: bool,
pub url: String,
}
impl Default for LanguageToolConfig {
fn default() -> Self {
Self {
enabled: false,
url: "http://localhost:8081".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Behavior {
pub pause_per_backspace_ms: u32,
pub reset_keys: ResetKeys,
}
impl Default for Behavior {
fn default() -> Self {
Self {
pause_per_backspace_ms: 8,
reset_keys: ResetKeys::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ResetKeys {
pub enter: bool,
pub tab: bool,
pub escape: bool,
pub up: bool,
pub down: bool,
pub page_up: bool,
pub page_down: bool,
pub delete: bool,
pub insert: bool,
}
impl Default for ResetKeys {
fn default() -> Self {
Self {
enter: true,
tab: false,
escape: false,
up: true,
down: true,
page_up: true,
page_down: true,
delete: true,
insert: true,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Privacy {
pub app_blocklist: Vec<String>,
}
impl Config {
pub fn path() -> Result<PathBuf, ConfigError> {
let dirs = ProjectDirs::from("io", "hyprcorrect", "hyprcorrect")
.ok_or(ConfigError::NoConfigDir)?;
Ok(dirs.config_dir().join("config.toml"))
}
pub fn load() -> Result<Self, ConfigError> {
Self::load_from(&Self::path()?)
}
pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
match fs::read_to_string(path) {
Ok(text) => toml::from_str(&text).map_err(|e| ConfigError::Parse(e.to_string())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(ConfigError::Io(e.to_string())),
}
}
pub fn save(&self) -> Result<(), ConfigError> {
self.save_to(&Self::path()?)
}
pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| ConfigError::Io(e.to_string()))?;
}
let text =
toml::to_string_pretty(self).map_err(|e| ConfigError::Serialize(e.to_string()))?;
fs::write(path, text).map_err(|e| ConfigError::Io(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_roundtrip_through_toml() {
let cfg = Config::default();
let text = toml::to_string_pretty(&cfg).unwrap();
let back: Config = toml::from_str(&text).unwrap();
assert_eq!(cfg, back);
}
#[test]
fn empty_file_yields_defaults() {
let cfg: Config = toml::from_str("").unwrap();
assert_eq!(cfg, Config::default());
}
#[test]
fn partial_file_fills_missing_with_defaults() {
let cfg: Config = toml::from_str(
r#"[hotkeys]
fix_word = "CTRL+J"
"#,
)
.unwrap();
assert_eq!(cfg.hotkeys.fix_word, "CTRL+J");
assert_eq!(cfg.behavior.pause_per_backspace_ms, 8);
assert_eq!(cfg.providers.default, ProviderId::Spellbook);
assert!(cfg.privacy.app_blocklist.is_empty());
}
#[test]
fn save_then_load_round_trips_through_disk() {
let dir = unique_tempdir();
let path = dir.join("config.toml");
let mut cfg = Config::default();
cfg.hotkeys.fix_word = "CTRL+ALT+K".into();
cfg.privacy.app_blocklist = vec!["1password".into(), "keepassxc".into()];
cfg.save_to(&path).unwrap();
let loaded = Config::load_from(&path).unwrap();
assert_eq!(loaded, cfg);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_from_missing_path_yields_defaults() {
let path = unique_tempdir().join("does-not-exist.toml");
let cfg = Config::load_from(&path).unwrap();
assert_eq!(cfg, Config::default());
}
fn unique_tempdir() -> PathBuf {
let nano = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!("hyprcorrect-cfg-{nano}"));
fs::create_dir_all(&dir).unwrap();
dir
}
}