zcolorizer 0.2.1

Real-time log file colorizer (ccze/pygments port) — fully customizable regex→token rules, swappable themes, cyberpunk by default
Documentation
//! Configuration: the on-disk TOML that makes rules and themes fully customizable.
//!
//! A config can:
//!   * pick the active theme by name (`theme = "cyberpunk"`),
//!   * define or override themes (`[[themes]]`),
//!   * replace or extend the rule list (`[[rules]]`),
//!   * tweak behavior (`rules_mode`, etc.).
//!
//! Anything omitted falls back to the builtins, so a one-line config that only
//! sets `theme = "..."` is valid. The default search path is
//! `$XDG_CONFIG_HOME/zcolorizer/config.toml` (a.k.a. `~/.config/...`).

use crate::error::{Error, Result};
use crate::rules::RuleDef;
use crate::theme::Theme;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

/// How the config's `[[rules]]` combine with the builtin generic ruleset.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RulesMode {
    /// Config rules first, then builtins (config rules win on overlap). Default —
    /// a user adding a specific rule expects it to beat the generic builtins.
    #[default]
    Prepend,
    /// Builtin rules first, then the config's rules (builtins win on overlap).
    Extend,
    /// Only the config's rules; builtins are dropped entirely.
    Replace,
}

/// The parsed config document.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Config {
    /// Name of the active theme. Defaults to `cyberpunk`.
    pub theme: Option<String>,
    /// How `rules` combine with the builtins.
    pub rules_mode: RulesMode,
    /// User-defined / override themes. Merged onto same-named builtins.
    pub themes: Vec<Theme>,
    /// User rules (combined with builtins per `rules_mode`).
    pub rules: Vec<RuleDef>,
    /// Format modules to enable (ccze plugin ports: `httpd`, `squid`, …, or `all`).
    /// Their rules sit between user rules and the generic builtins.
    pub modules: Vec<String>,
}

impl Config {
    /// The default per-user config path, if a config dir can be determined.
    pub fn default_path() -> Option<PathBuf> {
        dirs::config_dir().map(|d| d.join("zcolorizer").join("config.toml"))
    }

    /// Load and parse a config from `path`.
    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)
    }

    /// Parse config text (the `path` is only used for error messages).
    pub fn parse(text: &str, path: &Path) -> Result<Config> {
        toml::from_str(text).map_err(|source| Error::ConfigParse {
            path: path.to_path_buf(),
            source,
        })
    }

    /// Load the default-path config if it exists; otherwise an empty config.
    pub fn load_default() -> Result<Config> {
        match Config::default_path() {
            Some(p) if p.exists() => Config::load(&p),
            _ => Ok(Config::default()),
        }
    }

    /// Resolve the effective theme: a builtin (possibly overridden by a same-named
    /// `[[themes]]` entry) or a wholly config-defined theme. `name` overrides
    /// `self.theme`; both default to `cyberpunk`.
    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())),
        }
    }

    /// The effective ordered rule-definition list. Composition: the enabled format
    /// modules' rules always sit between the user rules and the generic builtins
    /// (so a format module beats the generic word-colorizer but a user rule still
    /// wins). `rules_mode` controls where the user rules go relative to generics.
    /// Unknown module names are ignored here — validate with [`Config::unknown_modules`].
    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(|_| {
            // Drop only the unknown names; keep the valid ones.
            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
            }
        }
    }

    /// Any requested module names that aren't known (for CLI validation).
    pub fn unknown_modules(&self) -> Vec<String> {
        match crate::modules::resolve(&self.modules) {
            Ok(_) => Vec::new(),
            Err(unknown) => unknown,
        }
    }

    /// Every theme name available to this config (builtins + user-defined), deduped,
    /// with builtins first. This is what a theme picker enumerates.
    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 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")));
        // a non-overridden token still has its builtin value
        assert!(t.style("good").fg.is_some());
    }
}