logger2 0.1.5

A modern, feature-rich replacement for the classic `logger` CLI: syslog (local/remote, RFC3164 & RFC5424), truecolor hex output, emoji, JSON, and a fully configurable TOML config file.
Documentation
use crate::color::HexColor;
use crate::error::{Logger2Error, Result};
use crate::level::Level;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GeneralConfig {
    pub default_priority: String,
    pub default_tag: String,
    pub timestamp_format: String,
    pub show_pid: bool,
    pub max_message_size: usize,
}

impl Default for GeneralConfig {
    fn default() -> Self {
        GeneralConfig {
            default_priority: "user.notice".to_string(),
            default_tag: String::new(),
            timestamp_format: "%Y-%m-%d %H:%M:%S%.3f".to_string(),
            show_pid: false,
            max_message_size: 8192,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OutputConfig {
    pub stderr: bool,
    pub syslog: bool,
    pub json: bool,
    pub template: String,
}

impl Default for OutputConfig {
    fn default() -> Self {
        OutputConfig {
            stderr: true,
            syslog: true,
            json: false,
            template: "{timestamp} {emoji} {level} [{tag}] {message}".to_string(),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
    Udp,
    Tcp,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct NetworkConfig {
    pub server: String,
    pub port: u16,
    pub protocol: Protocol,
    pub socket: String,
    pub rfc5424: bool,
    pub octet_count: bool,
    pub connect_timeout_ms: u64,
}

impl Default for NetworkConfig {
    fn default() -> Self {
        NetworkConfig {
            server: String::new(),
            port: 514,
            protocol: Protocol::Udp,
            socket: default_local_socket(),
            rfc5424: false,
            octet_count: false,
            connect_timeout_ms: 2000,
        }
    }
}

#[cfg(target_os = "macos")]
fn default_local_socket() -> String {
    "/var/run/syslog".to_string()
}

#[cfg(all(unix, not(target_os = "macos")))]
fn default_local_socket() -> String {
    "/dev/log".to_string()
}

#[cfg(not(unix))]
fn default_local_socket() -> String {
    String::new()
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ColorsConfig {
    pub enabled: bool,
    pub levels: HashMap<String, HexColor>,
    pub tag: HexColor,
    pub timestamp: HexColor,
    pub message: HexColor,
}

impl Default for ColorsConfig {
    fn default() -> Self {
        let mut levels = HashMap::new();
        levels.insert("trace".into(), HexColor::new(0x80, 0x80, 0x80));
        levels.insert("debug".into(), HexColor::new(0x00, 0xAF, 0xFF));
        levels.insert("info".into(), HexColor::new(0x00, 0xFF, 0x00));
        levels.insert("notice".into(), HexColor::new(0x00, 0xFF, 0xFF));
        levels.insert("warning".into(), HexColor::new(0xFF, 0xFF, 0x00));
        levels.insert("error".into(), HexColor::new(0xFF, 0x55, 0x55));
        levels.insert("critical".into(), HexColor::new(0xFF, 0x00, 0xFF));
        levels.insert("alert".into(), HexColor::new(0xFF, 0x00, 0x00));
        levels.insert("emergency".into(), HexColor::new(0xFF, 0xFF, 0xFF));
        ColorsConfig {
            enabled: true,
            levels,
            tag: HexColor::new(0x87, 0xAF, 0xFF),
            timestamp: HexColor::new(0x80, 0x80, 0x80),
            message: HexColor::new(0xFF, 0xFF, 0xFF),
        }
    }
}

impl ColorsConfig {
    pub fn for_level(&self, level: Level) -> HexColor {
        self.levels
            .get(level.as_str())
            .copied()
            .unwrap_or(HexColor::new(0xFF, 0xFF, 0xFF))
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EmojiConfig {
    pub enabled: bool,
    pub levels: HashMap<String, String>,
}

impl Default for EmojiConfig {
    fn default() -> Self {
        let mut levels = HashMap::new();
        levels.insert("trace".into(), "🔍".into());
        levels.insert("debug".into(), "🐛".into());
        levels.insert("info".into(), "ℹ️".into());
        levels.insert("notice".into(), "📝".into());
        levels.insert("warning".into(), "⚠️".into());
        levels.insert("error".into(), "".into());
        levels.insert("critical".into(), "🔥".into());
        levels.insert("alert".into(), "🚨".into());
        levels.insert("emergency".into(), "💀".into());
        EmojiConfig {
            enabled: true,
            levels,
        }
    }
}

impl EmojiConfig {
    pub fn for_level(&self, level: Level) -> &str {
        self.levels
            .get(level.as_str())
            .map(String::as_str)
            .unwrap_or("")
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
    pub general: GeneralConfig,
    pub output: OutputConfig,
    pub network: NetworkConfig,
    pub colors: ColorsConfig,
    pub emoji: EmojiConfig,
}

impl Config {
    /// Returns the ordered list of candidate config paths, most specific first.
    pub fn candidate_paths(explicit: Option<&Path>) -> Vec<PathBuf> {
        let mut paths = Vec::new();
        if let Some(p) = explicit {
            paths.push(p.to_path_buf());
            return paths;
        }
        if let Ok(env_path) = std::env::var("LOGGER2_CONFIG") {
            paths.push(PathBuf::from(env_path));
        }
        if let Some(config_dir) = dirs::config_dir() {
            paths.push(config_dir.join("logger2").join("config.toml"));
        }
        if let Some(home) = dirs::home_dir() {
            paths.push(home.join(".logger2.toml"));
        }
        #[cfg(unix)]
        paths.push(PathBuf::from("/etc/logger2/config.toml"));
        paths
    }

    /// Loads config from the first candidate path that exists, falling back
    /// to defaults if none is found. Returns the config plus the path it was
    /// loaded from, if any.
    pub fn load(explicit: Option<&Path>) -> Result<(Config, Option<PathBuf>)> {
        for path in Self::candidate_paths(explicit) {
            if path.is_file() {
                let text = std::fs::read_to_string(&path)?;
                let cfg: Config = toml::from_str(&text)
                    .map_err(|e| Logger2Error::Config(format!("{}: {}", path.display(), e)))?;
                return Ok((cfg, Some(path)));
            }
        }
        if explicit.is_some() {
            return Err(Logger2Error::Config(format!(
                "config file not found: {}",
                explicit.unwrap().display()
            )));
        }
        Ok((Config::default(), None))
    }

    pub fn to_toml_string(&self) -> Result<String> {
        Ok(toml::to_string_pretty(self)?)
    }

    /// Writes the default config (with helpful comments) to `path`.
    pub fn init_default(path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let cfg = Config::default();
        let toml_body = cfg.to_toml_string()?;
        let banner = "# logger2 configuration file\n\
# See https://github.com/licface/logger2 for full documentation.\n\
# Command-line flags always override values from this file.\n\n";
        std::fs::write(path, format!("{banner}{toml_body}"))?;
        Ok(())
    }

    pub fn default_write_path() -> Result<PathBuf> {
        let dir = dirs::config_dir()
            .ok_or_else(|| Logger2Error::Config("could not determine config directory".into()))?
            .join("logger2");
        Ok(dir.join("config.toml"))
    }
}