use crate::error::{Error, Result};
use crate::rules::RuleDef;
use crate::theme::Theme;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RulesMode {
#[default]
Prepend,
Extend,
Replace,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Config {
pub theme: Option<String>,
pub rules_mode: RulesMode,
pub themes: Vec<Theme>,
pub rules: Vec<RuleDef>,
pub modules: Vec<String>,
}
impl Config {
pub fn default_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("zcolorizer").join("config.toml"))
}
pub fn load(path: &Path) -> Result<Config> {
let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})?;
Config::parse(&text, path)
}
pub fn parse(text: &str, path: &Path) -> Result<Config> {
toml::from_str(text).map_err(|source| Error::ConfigParse {
path: path.to_path_buf(),
source,
})
}
pub fn load_default() -> Result<Config> {
match Config::default_path() {
Some(p) if p.exists() => Config::load(&p),
_ => Ok(Config::default()),
}
}
pub fn resolve_theme(&self, name: Option<&str>) -> Result<Theme> {
let want = name
.or(self.theme.as_deref())
.unwrap_or(crate::theme::DEFAULT_THEME);
let user = self
.themes
.iter()
.find(|t| t.name.eq_ignore_ascii_case(want));
match crate::theme::builtin(want) {
Some(base) => Ok(match user {
Some(u) => base.merged_with(u),
None => base,
}),
None => user
.cloned()
.ok_or_else(|| Error::UnknownTheme(want.to_string())),
}
}
pub fn resolve_rule_defs(&self) -> Vec<RuleDef> {
let generic = crate::rules::builtin_generic();
let module_rules = crate::modules::resolve(&self.modules).unwrap_or_else(|_| {
let known: Vec<String> = self
.modules
.iter()
.filter(|n| n.eq_ignore_ascii_case("all") || crate::modules::get(n).is_some())
.cloned()
.collect();
crate::modules::resolve(&known).unwrap_or_default()
});
match self.rules_mode {
RulesMode::Replace => {
let mut v = self.rules.clone();
v.extend(module_rules);
v
}
RulesMode::Extend => {
let mut v = module_rules;
v.extend(generic);
v.extend(self.rules.iter().cloned());
v
}
RulesMode::Prepend => {
let mut v = self.rules.clone();
v.extend(module_rules);
v.extend(generic);
v
}
}
}
pub fn unknown_modules(&self) -> Vec<String> {
match crate::modules::resolve(&self.modules) {
Ok(_) => Vec::new(),
Err(unknown) => unknown,
}
}
pub fn available_theme_names(&self) -> Vec<String> {
let mut names: Vec<String> = crate::theme::builtins()
.into_iter()
.map(|t| t.name)
.collect();
for t in &self.themes {
if !names.iter().any(|n| n.eq_ignore_ascii_case(&t.name)) {
names.push(t.name.clone());
}
}
names
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_config_resolves_default_theme() {
let c = Config::default();
let t = c.resolve_theme(None).unwrap();
assert_eq!(t.name, crate::theme::DEFAULT_THEME);
}
#[test]
fn cyberpunk_alias_still_works() {
let c = Config::default();
assert_eq!(
c.resolve_theme(Some("cyberpunk")).unwrap().name,
"neon-sprawl"
);
}
#[test]
fn module_rules_sit_before_generic() {
let mut c = Config::default();
c.modules.push("syslog".into());
let defs = c.resolve_rule_defs();
let syslog_idx = defs.iter().position(|d| d.name == "syslog-line").unwrap();
let number_idx = defs.iter().position(|d| d.name == "number").unwrap();
assert!(
syslog_idx < number_idx,
"module rule must precede generic number rule"
);
}
#[test]
fn name_override_beats_config_theme() {
let c = Config {
theme: Some("cyberpunk".into()),
..Config::default()
};
let t = c.resolve_theme(Some("ccze-classic")).unwrap();
assert_eq!(t.name, "ccze-classic");
}
#[test]
fn extend_mode_appends_user_rules() {
let mut c = Config {
rules_mode: RulesMode::Extend,
..Config::default()
};
c.rules.push(RuleDef::with_token("x", "FOO", "error"));
let defs = c.resolve_rule_defs();
assert!(defs.len() > 1);
assert_eq!(defs.last().unwrap().name, "x");
}
#[test]
fn prepend_is_default_and_user_rules_lead() {
let mut c = Config::default();
c.rules.push(RuleDef::with_token("x", "FOO", "error"));
let defs = c.resolve_rule_defs();
assert_eq!(c.rules_mode, RulesMode::Prepend);
assert_eq!(defs.first().unwrap().name, "x");
}
#[test]
fn replace_mode_drops_builtins() {
let mut c = Config {
rules_mode: RulesMode::Replace,
..Config::default()
};
c.rules.push(RuleDef::with_token("x", "FOO", "error"));
let defs = c.resolve_rule_defs();
assert_eq!(defs.len(), 1);
}
#[test]
fn parse_minimal_toml() {
let c = Config::parse("theme = \"ccze-classic\"\n", Path::new("test")).unwrap();
assert_eq!(c.theme.as_deref(), Some("ccze-classic"));
}
#[test]
fn unknown_modules_reported_and_known_ones_kept() {
let c = Config {
modules: vec!["syslog".into(), "bogus".into(), "httpd".into()],
..Config::default()
};
assert_eq!(c.unknown_modules(), vec!["bogus".to_string()]);
let defs = c.resolve_rule_defs();
assert!(defs.iter().any(|d| d.name == "syslog-line"), "syslog kept");
assert!(defs.iter().any(|d| d.name == "httpd-access"), "httpd kept");
}
#[test]
fn no_modules_means_none_unknown() {
assert!(Config::default().unknown_modules().is_empty());
}
#[test]
fn available_theme_names_lists_builtins_first_and_dedups() {
let mut c = Config::default();
let builtin_count = c.available_theme_names().len();
assert_eq!(builtin_count, 32, "31 palettes + ccze-classic");
assert_eq!(
c.available_theme_names().first().unwrap(),
crate::theme::DEFAULT_THEME
);
c.themes.push(Theme {
name: "My Custom".into(),
description: String::new(),
base: Default::default(),
styles: Default::default(),
});
c.themes.push(Theme {
name: "NEON-SPRAWL".into(),
description: String::new(),
base: Default::default(),
styles: Default::default(),
});
let names = c.available_theme_names();
assert_eq!(
names.len(),
builtin_count + 1,
"only the fresh name was added"
);
assert!(names.iter().any(|n| n == "My Custom"));
}
#[test]
fn rules_mode_defaults_to_prepend() {
assert_eq!(RulesMode::default(), RulesMode::Prepend);
assert_eq!(Config::default().rules_mode, RulesMode::Prepend);
}
#[test]
fn unknown_theme_name_errors() {
let c = Config::default();
let err = c.resolve_theme(Some("does-not-exist")).unwrap_err();
assert!(matches!(err, Error::UnknownTheme(name) if name == "does-not-exist"));
}
#[test]
fn parse_rejects_malformed_toml() {
let err = Config::parse("theme = = nope", Path::new("bad.toml")).unwrap_err();
assert!(matches!(err, Error::ConfigParse { .. }));
}
#[test]
fn color_forms_deserialize_from_toml() {
use crate::color::{Color, Named, Rgb};
let toml = r##"
theme = "custom-x"
[[themes]]
name = "custom-x"
base = { fg = "red" }
[themes.styles.a]
fg = { index = 213 }
[themes.styles.b]
fg = "#ff00aa"
[themes.styles.c]
fg = { rgb = [1, 2, 3] }
"##;
let c = Config::parse(toml, Path::new("t")).unwrap();
let t = c.resolve_theme(None).unwrap();
assert_eq!(t.base.fg, Some(Color::Named(Named::Red)));
assert_eq!(t.style("a").fg, Some(Color::idx(213)));
assert_eq!(t.style("b").fg, Some(Color::hex("#ff00aa")));
assert_eq!(t.style("c").fg, Some(Color::Rgb(Rgb { r: 1, g: 2, b: 3 })));
}
#[test]
fn extend_mode_orders_modules_then_generic_then_user() {
let mut c = Config {
rules_mode: RulesMode::Extend,
..Config::default()
};
c.modules.push("syslog".into());
c.rules.push(RuleDef::with_token("user-x", "FOO", "error"));
let defs = c.resolve_rule_defs();
let syslog_i = defs.iter().position(|d| d.name == "syslog-line").unwrap();
let number_i = defs.iter().position(|d| d.name == "number").unwrap();
let user_i = defs.iter().position(|d| d.name == "user-x").unwrap();
assert!(syslog_i < number_i, "module rules precede generic");
assert!(number_i < user_i, "user rules come last in extend mode");
}
#[test]
fn load_missing_file_is_io_error() {
let err = Config::load(Path::new("/no/such/dir/zcolorizer-cfg.toml")).unwrap_err();
assert!(matches!(err, Error::Io { .. }));
}
#[test]
fn load_reads_and_parses_a_file() {
let path =
std::env::temp_dir().join(format!("zcolorizer-load-{}.toml", std::process::id()));
std::fs::write(&path, "theme = \"ccze-classic\"\n").unwrap();
let loaded = Config::load(&path);
let _ = std::fs::remove_file(&path);
assert_eq!(loaded.unwrap().theme.as_deref(), Some("ccze-classic"));
}
#[test]
fn user_theme_overrides_builtin_token() {
let toml = r##"
theme = "cyberpunk"
[[themes]]
name = "cyberpunk"
[themes.styles.error]
fg = "#123456"
"##;
let c = Config::parse(toml, Path::new("test")).unwrap();
let t = c.resolve_theme(None).unwrap();
assert_eq!(
t.style("error").fg,
Some(crate::color::Color::hex("#123456"))
);
assert!(t.style("good").fg.is_some());
}
}