use std::fs;
use serde::Deserialize;
use crate::config::{Config, GlyphSet, Theme};
use crate::domain::error::{Error, Result};
use crate::domain::format::{AngleMode, Notation};
use crate::util::paths;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
notation: Option<Notation>,
decimals: Option<usize>,
angle_mode: Option<AngleMode>,
decimal_separator: Option<String>,
thousands_separator: Option<String>,
trim_trailing_zeros: Option<bool>,
max_history: Option<usize>,
glyphs: Option<GlyphSet>,
restore_last_settings: Option<bool>,
live_feedback: Option<bool>,
history_zebra: Option<bool>,
history_spacing: Option<usize>,
history_separator: Option<bool>,
input_max_lines: Option<usize>,
theme: Option<RawTheme>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawTheme {
accent_color: Option<String>,
function_color: Option<String>,
constant_color: Option<String>,
operator_color: Option<String>,
number_color: Option<String>,
variable_color: Option<String>,
ans_color: Option<String>,
comment_color: Option<String>,
unit_color: Option<String>,
settings_bar_bg: Option<String>,
history_alt_bg: Option<String>,
history_separator_color: Option<String>,
}
pub fn load_config() -> Result<Config> {
let raw = read_raw_config()?;
let mut config = merge(raw);
apply_env(&mut config);
Ok(config)
}
fn read_raw_config() -> Result<RawConfig> {
let path = paths::config_file();
if !path.exists() {
return Ok(RawConfig::default());
}
let text = fs::read_to_string(&path)
.map_err(|e| Error::config(&path, e.to_string()))?;
toml::from_str(&text).map_err(|e| Error::config(&path, e.to_string()))
}
fn merge(raw: RawConfig) -> Config {
let defaults = Config::default();
Config {
notation: raw.notation.unwrap_or(defaults.notation),
decimals: raw.decimals.unwrap_or(defaults.decimals),
angle_mode: raw.angle_mode.unwrap_or(defaults.angle_mode),
decimal_separator: raw
.decimal_separator
.and_then(|s| parse_separator(&s))
.unwrap_or(defaults.decimal_separator),
thousands_separator: raw
.thousands_separator
.unwrap_or(defaults.thousands_separator),
trim_trailing_zeros: raw
.trim_trailing_zeros
.unwrap_or(defaults.trim_trailing_zeros),
max_history: raw.max_history.unwrap_or(defaults.max_history).max(1),
glyphs: raw.glyphs.unwrap_or(defaults.glyphs),
restore_last_settings: raw
.restore_last_settings
.unwrap_or(defaults.restore_last_settings),
live_feedback: raw.live_feedback.unwrap_or(defaults.live_feedback),
history_zebra: raw.history_zebra.unwrap_or(defaults.history_zebra),
history_spacing: raw
.history_spacing
.unwrap_or(defaults.history_spacing),
history_separator: raw
.history_separator
.unwrap_or(defaults.history_separator),
input_max_lines: raw
.input_max_lines
.unwrap_or(defaults.input_max_lines)
.max(1),
theme: merge_theme(raw.theme),
}
}
fn merge_theme(raw: Option<RawTheme>) -> Theme {
let defaults = Theme::default();
let Some(raw) = raw else {
return defaults;
};
Theme {
accent_color: raw.accent_color.unwrap_or(defaults.accent_color),
function_color: raw.function_color.unwrap_or(defaults.function_color),
constant_color: raw.constant_color.unwrap_or(defaults.constant_color),
operator_color: raw.operator_color.unwrap_or(defaults.operator_color),
number_color: raw.number_color.unwrap_or(defaults.number_color),
variable_color: raw.variable_color.unwrap_or(defaults.variable_color),
ans_color: raw.ans_color.unwrap_or(defaults.ans_color),
comment_color: raw.comment_color.unwrap_or(defaults.comment_color),
unit_color: raw.unit_color.unwrap_or(defaults.unit_color),
settings_bar_bg: raw
.settings_bar_bg
.unwrap_or(defaults.settings_bar_bg),
history_alt_bg: raw.history_alt_bg.unwrap_or(defaults.history_alt_bg),
history_separator_color: raw
.history_separator_color
.unwrap_or(defaults.history_separator_color),
}
}
fn parse_separator(value: &str) -> Option<char> {
match value {
"." => Some('.'),
"," => Some(','),
other => {
log::warn!(
"ignoring unsupported decimal_separator {other:?}; \
using the default"
);
None
}
}
}
fn apply_env(config: &mut Config) {
if let Ok(value) = std::env::var("CALCLI_DECIMAL_SEPARATOR")
&& let Some(separator) = parse_separator(&value)
{
config.decimal_separator = separator;
}
if let Ok(value) = std::env::var("CALCLI_DECIMALS")
&& let Ok(decimals) = value.parse::<usize>()
{
config.decimals = decimals;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_raw_config_yields_defaults() {
let config = merge(RawConfig::default());
assert_eq!(config, Config::default());
}
#[test]
fn partial_config_overrides_only_given_keys() {
let raw = RawConfig {
decimals: Some(6),
decimal_separator: Some(",".to_string()),
..RawConfig::default()
};
let config = merge(raw);
assert_eq!(config.decimals, 6);
assert_eq!(config.decimal_separator, ',');
assert_eq!(config.notation, Config::default().notation);
assert_eq!(config.thousands_separator, " ");
}
#[test]
fn an_unsupported_separator_falls_back_to_the_default() {
let raw = RawConfig {
decimal_separator: Some(";".to_string()),
..RawConfig::default()
};
assert_eq!(merge(raw).decimal_separator, '.');
}
#[test]
fn max_history_is_at_least_one() {
let raw = RawConfig {
max_history: Some(0),
..RawConfig::default()
};
assert_eq!(merge(raw).max_history, 1);
}
#[test]
fn live_feedback_defaults_on_and_can_be_disabled() {
assert!(merge(RawConfig::default()).live_feedback);
let raw: RawConfig = toml::from_str("live_feedback = false\n").unwrap();
assert!(!merge(raw).live_feedback);
}
#[test]
fn trim_trailing_zeros_defaults_on_and_can_be_disabled() {
assert!(merge(RawConfig::default()).trim_trailing_zeros);
let raw: RawConfig =
toml::from_str("trim_trailing_zeros = false\n").unwrap();
assert!(!merge(raw).trim_trailing_zeros);
}
#[test]
fn input_max_lines_defaults_and_is_at_least_one() {
assert_eq!(merge(RawConfig::default()).input_max_lines, 5);
let raw = RawConfig {
input_max_lines: Some(0),
..RawConfig::default()
};
assert_eq!(merge(raw).input_max_lines, 1);
}
#[test]
fn theme_colours_default_and_override_individually() {
let defaults = merge(RawConfig::default()).theme;
assert_eq!(defaults, Theme::default());
let raw: RawConfig =
toml::from_str("[theme]\nfunction_color = \"#112233\"\n").unwrap();
let theme = merge(raw).theme;
assert_eq!(theme.function_color, "#112233");
assert_eq!(theme.constant_color, Theme::default().constant_color);
assert_eq!(theme.accent_color, Theme::default().accent_color);
}
#[test]
fn parses_notation_and_angle_mode_from_toml() {
let raw: RawConfig =
toml::from_str("notation = \"scientific\"\nangle_mode = \"deg\"\n")
.unwrap();
let config = merge(raw);
assert_eq!(config.notation, Notation::Scientific);
assert_eq!(config.angle_mode, AngleMode::Deg);
}
}