mod env;
mod legacy;
mod raw;
mod validate;
use std::io;
use std::path::Path;
use crate::config::appearance::Appearance;
use crate::config::highlight::HighlightColors;
use crate::config::loader::legacy::RawLegacyTheme;
use crate::config::loader::raw::{
ColorMap, RawAppearance, RawConfig, RawHighlight,
};
use crate::config::loader::validate::{report_unknown, unknown_color_names};
use crate::config::{Config, ConfigError};
use crate::theme::{
Color, GlyphVariant, ThemeColors, parse_color as parse_theme_color,
};
use crate::util::paths;
pub use crate::config::loader::env::apply_env;
pub fn load_config() -> Result<Config, ConfigError> {
load_from(&paths::config_file())
}
pub fn load_from(path: &Path) -> Result<Config, ConfigError> {
let mut config = match std::fs::read_to_string(path) {
Ok(content) => {
log::info!("loaded config from {}", path.display());
parse(&content, path)?
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
log::debug!("no config file at {}, using defaults", path.display());
Config::default()
}
Err(source) => {
return Err(ConfigError::Read {
path: path.display().to_string(),
source,
});
}
};
apply_env(&mut config);
Ok(config)
}
pub fn config_from_str(content: &str) -> Result<Config, ConfigError> {
parse(content, Path::new("<memory>"))
}
fn parse(content: &str, path: &Path) -> Result<Config, ConfigError> {
let raw: RawConfig =
toml::from_str(content).map_err(|source| ConfigError::Parse {
path: path.display().to_string(),
source,
})?;
Ok(merge(raw))
}
fn merge(raw: RawConfig) -> Config {
let defaults = Config::default();
report_unknown(&unknown_color_names(&raw));
let legacy = raw.theme.unwrap_or_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),
restore_last_settings: raw
.restore_last_settings
.unwrap_or(defaults.restore_last_settings),
live_feedback: raw.live_feedback.unwrap_or(defaults.live_feedback),
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),
confirm_delete: raw.confirm_delete.unwrap_or(defaults.confirm_delete),
confirm_quit: raw.confirm_quit.unwrap_or(defaults.confirm_quit),
appearance: merge_appearance(
raw.appearance.unwrap_or_default(),
raw.glyphs,
&legacy,
defaults.appearance,
),
highlight: merge_highlight(
raw.highlight.unwrap_or_default(),
&legacy,
defaults.highlight,
),
themes: raw
.themes
.into_iter()
.map(|(name, colors)| (name, theme_colors(&colors)))
.collect(),
keys: raw
.keys
.into_iter()
.map(|(action, binding)| (action, binding.into_keys()))
.collect(),
}
}
fn merge_appearance(
raw: RawAppearance,
legacy_glyphs: Option<GlyphVariant>,
legacy: &RawLegacyTheme,
defaults: Appearance,
) -> Appearance {
let mut colors = defaults.colors;
for (name, value) in legacy::chrome_colors(legacy) {
colors.insert(name.to_string(), value);
}
colors.extend(raw.colors);
Appearance {
theme: raw.theme.unwrap_or(defaults.theme),
colors,
glyphs: raw.glyphs.or(legacy_glyphs).unwrap_or(defaults.glyphs),
}
}
fn merge_highlight(
raw: RawHighlight,
legacy: &RawLegacyTheme,
defaults: HighlightColors,
) -> HighlightColors {
let pick = |new: Option<String>, old: &Option<String>, fallback: Color| {
new.or_else(|| old.clone())
.and_then(|value| parse_highlight_color(&value))
.unwrap_or(fallback)
};
HighlightColors {
function: pick(raw.function, &legacy.function_color, defaults.function),
constant: pick(raw.constant, &legacy.constant_color, defaults.constant),
operator: pick(raw.operator, &legacy.operator_color, defaults.operator),
number: pick(raw.number, &legacy.number_color, defaults.number),
variable: pick(raw.variable, &legacy.variable_color, defaults.variable),
ans: pick(raw.ans, &legacy.ans_color, defaults.ans),
comment: pick(raw.comment, &legacy.comment_color, defaults.comment),
unit: pick(raw.unit, &legacy.unit_color, defaults.unit),
}
}
fn parse_highlight_color(value: &str) -> Option<Color> {
let color = parse_theme_color(value);
if color.is_none() {
log::warn!("invalid highlight colour {value:?}, keeping the default");
}
color
}
fn theme_colors(raw: &ColorMap) -> ThemeColors {
ThemeColors::from_lookup(|name| {
raw.get(name)
.map(String::as_str)
.and_then(parse_theme_color)
})
}
fn parse_separator(value: &str) -> Option<char> {
match value {
"." => Some('.'),
"," => Some(','),
other => {
log::warn!(
"ignoring unsupported decimal_separator {other:?}; \
using the default"
);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::CALCLI_THEME;
use crate::domain::format::{AngleMode, Notation};
use crate::theme::Palette;
const LEGACY_CONFIG: &str =
include_str!("../../../tests/data/config-0.2.toml");
const EXAMPLE_CONFIG: &str = include_str!("../../../examples/config.toml");
#[test]
fn the_shipped_example_config_parses_and_states_the_defaults() {
let config = config_from_str(EXAMPLE_CONFIG)
.expect("examples/config.toml must parse");
let defaults = Config::default();
assert_eq!(config.notation, defaults.notation);
assert_eq!(config.decimals, defaults.decimals);
assert_eq!(config.angle_mode, defaults.angle_mode);
assert_eq!(config.max_history, defaults.max_history);
assert_eq!(config.history_spacing, defaults.history_spacing);
assert_eq!(config.history_separator, defaults.history_separator);
assert_eq!(config.input_max_lines, defaults.input_max_lines);
assert_eq!(config.confirm_delete, defaults.confirm_delete);
assert_eq!(config.confirm_quit, defaults.confirm_quit);
assert_eq!(config.highlight, defaults.highlight);
}
#[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 =
toml::from_str("decimals = 6\ndecimal_separator = \",\"\n")
.unwrap();
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 =
toml::from_str("decimal_separator = \";\"\n").unwrap();
assert_eq!(merge(raw).decimal_separator, '.');
}
#[test]
fn max_history_and_input_max_lines_are_at_least_one() {
let raw: RawConfig =
toml::from_str("max_history = 0\ninput_max_lines = 0\n").unwrap();
let config = merge(raw);
assert_eq!(config.max_history, 1);
assert_eq!(config.input_max_lines, 1);
}
#[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);
}
fn legacy_config_without_zebra() -> String {
LEGACY_CONFIG.replace("history_zebra = false\n", "")
}
#[test]
fn a_0_2_config_file_is_rejected_by_name_for_its_zebra_key() {
let error = config_from_str(LEGACY_CONFIG)
.expect_err("history_zebra is gone, so the 0.2 file is refused");
let cause = std::error::Error::source(&error)
.expect("a parse failure carries the TOML error")
.to_string();
assert!(
cause.contains("history_zebra"),
"the message must name the offending key: {cause}",
);
}
#[test]
fn a_legacy_0_2_config_file_still_loads() {
let config = config_from_str(&legacy_config_without_zebra())
.expect("the 0.2 config shape must keep loading");
assert_eq!(config.decimals, 3);
assert_eq!(config.max_history, 500);
assert_eq!(config.appearance.glyphs, GlyphVariant::Unicode);
assert!(config.live_feedback);
assert_eq!(config.palette().accent, Color::hex("#6dd0ff"));
assert_eq!(config.palette().footer, Color::hex("#252525"));
assert_eq!(config.palette().border, Color::hex("#3e3e3e"));
assert_eq!(config.highlight.function, Color::hex("#78c2b3"));
assert_eq!(config.highlight.unit, Color::hex("#ff79c6"));
}
#[test]
fn the_legacy_zebra_colour_no_longer_reaches_the_palette() {
let legacy = config_from_str(&legacy_config_without_zebra())
.expect("the 0.2 config shape must keep loading");
assert_eq!(legacy.palette().panel, Config::default().palette().panel);
}
#[test]
fn the_new_highlight_section_wins_over_the_legacy_theme_table() {
let raw = "\
[theme]
function_color = \"#111111\"
[highlight]
function = \"#222222\"
";
let config = config_from_str(raw).unwrap();
assert_eq!(config.highlight.function, Color::hex("#222222"));
}
#[test]
fn the_new_appearance_glyphs_wins_over_the_legacy_top_level_key() {
let config = config_from_str("glyphs = \"ascii\"\n").unwrap();
assert_eq!(config.appearance.glyphs, GlyphVariant::Ascii);
let config = config_from_str(
"glyphs = \"ascii\"\n[appearance]\nglyphs = \"unicode\"\n",
)
.unwrap();
assert_eq!(config.appearance.glyphs, GlyphVariant::Unicode);
}
#[test]
fn an_appearance_colour_override_wins_over_the_legacy_accent() {
let raw = "\
[theme]
accent_color = \"#111111\"
[appearance.colors]
accent = \"#222222\"
";
let config = config_from_str(raw).unwrap();
assert_eq!(config.palette().accent, Color::hex("#222222"));
}
#[test]
fn overriding_one_colour_keeps_the_default_red_cursor() {
let config =
config_from_str("[appearance.colors]\naccent = \"#222222\"\n")
.unwrap();
assert_eq!(config.palette().cursor, Color::hex("#d65c5c"));
}
#[test]
fn an_invalid_highlight_colour_keeps_the_default() {
let config =
config_from_str("[highlight]\nfunction = \"nope\"\n").unwrap();
assert_eq!(
config.highlight.function,
HighlightColors::default().function,
);
}
#[test]
fn key_bindings_accept_a_single_key_or_a_list() {
let config =
config_from_str("[keys]\nquit = \"x\"\ncopy = [\"y\", \"c\"]\n")
.unwrap();
assert_eq!(config.keys["quit"], vec!["x".to_string()]);
assert_eq!(config.keys["copy"], vec!["y".to_string(), "c".to_string()]);
}
#[test]
fn a_custom_theme_is_registered_alongside_the_calcli_theme() {
let config =
config_from_str("[themes.solar]\naccent = \"#010203\"\n").unwrap();
let registry = config.theme_registry();
assert!(registry.contains("solar"));
assert!(registry.contains(CALCLI_THEME));
assert_eq!(registry.resolve("solar").accent, Color::Rgb(1, 2, 3));
}
#[test]
fn a_theme_may_ship_its_own_focus_border() {
let config =
config_from_str("[themes.mine]\nborder_focus = \"#010203\"\n")
.unwrap();
let registry = config.theme_registry();
assert_eq!(registry.resolve("mine").border_focus, Color::Rgb(1, 2, 3));
}
#[test]
fn a_theme_that_sets_only_border_drags_its_focus_border_along() {
let config =
config_from_str("[themes.mine]\nborder = \"#4a4a4a\"\n").unwrap();
let theme = config.theme_registry().resolve("mine");
assert_eq!(theme.border, Color::hex("#4a4a4a"));
assert!(theme.border_focus.luminance() > theme.border.luminance());
}
#[test]
fn an_appearance_focus_border_override_reaches_the_palette() {
let config = config_from_str(
"[appearance.colors]\nborder_focus = \"#8a8a8a\"\n",
)
.unwrap();
assert_eq!(config.palette().border_focus, Color::hex("#8a8a8a"));
assert_eq!(config.palette().border, Color::hex("#3e3e3e"));
}
#[test]
fn overriding_only_the_border_drags_the_focus_border_along() {
let config =
config_from_str("[appearance.colors]\nborder = \"#4a4a4a\"\n")
.unwrap();
let palette = config.palette();
assert_eq!(palette.border, Color::hex("#4a4a4a"));
assert!(
palette.border_focus.luminance() > palette.border.luminance(),
"the focused frame must not sink into the new border",
);
}
fn unknown(content: &str) -> Vec<(String, String)> {
let raw: RawConfig = toml::from_str(content).expect("valid toml");
unknown_color_names(&raw)
}
#[test]
fn a_theme_table_rejects_the_palettes_derived_colours() {
let reported = unknown(
"[themes.mine]\n\
border = \"#4a4a4a\"\n\
border_focus = \"#8a8a8a\"\n\
cursor = \"#ff0000\"\n\
input_bg = \"#0000ff\"\n\
selection = \"#00ff00\"\n",
);
assert_eq!(
reported,
vec![
("themes.mine".to_string(), "cursor".to_string()),
("themes.mine".to_string(), "input_bg".to_string()),
("themes.mine".to_string(), "selection".to_string()),
],
);
}
#[test]
fn appearance_colours_may_name_any_palette_colour() {
let reported = unknown(
"[appearance.colors]\n\
cursor = \"#ff0000\"\n\
selection = \"#00ff00\"\n\
input_bg = \"#0000ff\"\n",
);
assert!(reported.is_empty(), "{reported:?}");
}
#[test]
fn a_theme_may_name_every_theme_colour() {
let table =
ThemeColors::KEYS
.iter()
.fold(String::new(), |mut table, name| {
table.push_str(name);
table.push_str(" = \"#010203\"\n");
table
});
assert!(unknown(&format!("[themes.mine]\n{table}")).is_empty());
}
#[test]
fn both_sections_report_a_typo() {
assert_eq!(
unknown("[appearance.colors]\nbordr = \"#010203\"\n"),
vec![("appearance.colors".to_string(), "bordr".to_string())],
);
assert_eq!(
unknown("[themes.mine]\nbordr = \"#010203\"\n"),
vec![("themes.mine".to_string(), "bordr".to_string())],
);
}
#[test]
fn a_clean_file_reports_nothing() {
assert!(unknown("[appearance]\ntheme = \"calcli\"\n").is_empty());
}
#[test]
fn every_theme_colour_name_is_also_a_palette_colour_name() {
for name in ThemeColors::KEYS {
assert!(
Palette::KEYS.contains(name),
"{name} is in a theme but not in the palette",
);
}
}
#[test]
fn an_unknown_top_level_key_is_rejected() {
assert!(config_from_str("nonsense = 1\n").is_err());
}
}