use std::path::PathBuf;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
fn default_target_wpm() -> u32 {
35
}
fn default_fragment_length() -> usize {
100
}
fn default_natural_words() -> bool {
true
}
fn default_daily_goal_minutes() -> u32 {
30
}
fn default_alphabet_size() -> f64 {
0.0
}
fn de_alphabet_size<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = f64::deserialize(deserializer)?;
if value.is_finite() {
Ok(value.clamp(0.0, 1.0))
} else {
Ok(default_alphabet_size())
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ErrorModeSerde {
#[default]
ForgiveMistakes,
StopOnError,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default = "default_target_wpm")]
pub target_wpm: u32,
#[serde(default)]
pub error_mode: ErrorModeSerde,
#[serde(default = "default_fragment_length")]
pub fragment_length: usize,
#[serde(default = "default_natural_words")]
pub natural_words: bool,
#[serde(default = "default_daily_goal_minutes")]
pub daily_goal_minutes: u32,
#[serde(
default = "default_alphabet_size",
deserialize_with = "de_alphabet_size"
)]
pub alphabet_size: f64,
}
impl Default for Config {
fn default() -> Self {
Self {
target_wpm: default_target_wpm(),
error_mode: ErrorModeSerde::default(),
fragment_length: default_fragment_length(),
natural_words: default_natural_words(),
daily_goal_minutes: default_daily_goal_minutes(),
alphabet_size: default_alphabet_size(),
}
}
}
impl Config {
pub fn path() -> Option<PathBuf> {
ProjectDirs::from("", "", "keybr-tui").map(|dirs| dirs.config_dir().join("config.toml"))
}
pub fn load() -> Self {
let path = match Self::path() {
Some(p) => p,
None => return Self::default(),
};
if !path.exists() {
return Self::default();
}
match std::fs::read_to_string(&path) {
Ok(contents) => match toml::from_str::<Config>(&contents) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!(
"Warning: malformed config at {}: {}. Using defaults.",
path.display(),
e
);
Self::default()
}
},
Err(e) => {
eprintln!(
"Warning: could not read config at {}: {}. Using defaults.",
path.display(),
e
);
Self::default()
}
}
}
pub fn save(&self) -> color_eyre::Result<()> {
let path = match Self::path() {
Some(p) => p,
None => return Ok(()),
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(self)?;
std::fs::write(&path, contents)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_serialization_roundtrip() {
let cfg = Config {
target_wpm: 50,
error_mode: ErrorModeSerde::StopOnError,
fragment_length: 120,
natural_words: false,
daily_goal_minutes: 45,
alphabet_size: 0.35,
};
let serialized = toml::to_string_pretty(&cfg).unwrap();
let deserialized: Config = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.target_wpm, 50);
assert_eq!(deserialized.error_mode, ErrorModeSerde::StopOnError);
assert_eq!(deserialized.fragment_length, 120);
assert!(!deserialized.natural_words);
assert_eq!(deserialized.daily_goal_minutes, 45);
assert_eq!(deserialized.alphabet_size, 0.35);
}
#[test]
fn config_defaults_on_empty_toml() {
let cfg: Config = toml::from_str("").unwrap();
assert_eq!(cfg.target_wpm, 35);
assert_eq!(cfg.error_mode, ErrorModeSerde::ForgiveMistakes);
assert_eq!(cfg.fragment_length, 100);
assert!(cfg.natural_words);
assert_eq!(cfg.daily_goal_minutes, 30);
assert_eq!(cfg.alphabet_size, 0.0);
}
#[test]
fn config_clamps_alphabet_size() {
let cfg: Config = toml::from_str("alphabet_size = 1.5").unwrap();
assert_eq!(cfg.alphabet_size, 1.0);
let cfg: Config = toml::from_str("alphabet_size = -0.2").unwrap();
assert_eq!(cfg.alphabet_size, 0.0);
let cfg: Config = toml::from_str("alphabet_size = 0.5").unwrap();
assert_eq!(cfg.alphabet_size, 0.5);
}
#[test]
fn config_ignores_unknown_keys() {
let toml_str = r#"
target_wpm = 40
some_future_key = "hello"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.target_wpm, 40);
}
#[test]
fn config_path_is_some() {
assert!(Config::path().is_some());
}
}