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,
pub review_llm: String,
}
impl Default for Hotkeys {
fn default() -> Self {
Self {
fix_word: "CTRL+SHIFT+ALT+SUPER+F".into(),
fix_sentence: "CTRL+SHIFT+ALT+SUPER+S".into(),
review: "CTRL+SHIFT+ALT+SUPER+R".into(),
review_llm: "CTRL+SHIFT+ALT+SUPER+L".into(),
}
}
}
pub const MAX_LLM_PROVIDERS: usize = 5;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Providers {
pub default: ProviderId,
pub smart: ProviderId,
#[serde(default)]
pub llms: Vec<LlmConfig>,
pub languagetool: LanguageToolConfig,
}
impl Default for Providers {
fn default() -> Self {
Self {
default: ProviderId::Spellbook,
smart: ProviderId::Llm,
llms: vec![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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
}
impl Default for LlmConfig {
fn default() -> Self {
Self {
backend: "anthropic".into(),
model: "claude-haiku-4-5".into(),
base_url: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct LanguageToolConfig {
pub enabled: bool,
pub url: String,
pub ngram_dir: Option<String>,
}
impl Default for LanguageToolConfig {
fn default() -> Self {
Self {
enabled: false,
url: "http://localhost:8081".into(),
ngram_dir: None,
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DefinitionSource {
Off,
#[default]
Local,
Online,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Behavior {
pub pause_per_backspace_ms: u32,
pub reset_keys: ResetKeys,
pub review_starts_in_vim: bool,
pub fallback_to_languagetool: bool,
pub definitions: DefinitionSource,
}
impl Default for Behavior {
fn default() -> Self {
Self {
pause_per_backspace_ms: 8,
reset_keys: ResetKeys::default(),
review_starts_in_vim: false,
fallback_to_languagetool: true,
definitions: DefinitionSource::Local,
}
}
}
#[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 ngram_data_dir() -> Option<PathBuf> {
ProjectDirs::from("io", "hyprcorrect", "hyprcorrect").map(|dirs| dirs.data_dir().join("ngrams"))
}
impl Config {
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());
}
#[test]
fn llms_list_round_trips_through_toml() {
let mut cfg = Config::default();
cfg.providers.llms = vec![
LlmConfig {
backend: "anthropic".into(),
model: "claude-haiku-4-5".into(),
base_url: None,
},
LlmConfig {
backend: "openai".into(),
model: "gpt-4o-mini".into(),
base_url: None,
},
LlmConfig {
backend: "openai-compatible".into(),
model: "llama3.1".into(),
base_url: Some("http://localhost:11434/v1".into()),
},
];
let text = toml::to_string_pretty(&cfg).unwrap();
let back: Config = toml::from_str(&text).unwrap();
assert_eq!(back.providers.llms, cfg.providers.llms);
cfg.providers.llms.clear();
let text = toml::to_string_pretty(&cfg).unwrap();
let back: Config = toml::from_str(&text).unwrap();
assert!(back.providers.llms.is_empty());
}
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
}
}