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
// File: src\main.rs
// Author: Hadi Cahyadi <cumulus13@gmail.com>
// Date: 2026-07-01
// Description: 
// License: MIT

use clap::Parser;
use logger2::cli::Cli;
use logger2::color::{parse_hex_color, should_use_color};
use logger2::config::Config;
use logger2::error::{Logger2Error, Result};
use logger2::level::{Level, Priority};
use logger2::output::RenderContext;
use logger2::{output, parse_kv, syslog};
use std::io::{IsTerminal, Read, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use clap_version_flag::colorful_version;

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().collect();
    if args.len() == 2 && (args[1] == "-V" || args[1] == "--version") {
        let version = colorful_version!();
        version.print_and_exit();
    }
    let cli = Cli::parse();
    match run(cli) {
        Ok(code) => code,
        Err(e) => {
            eprintln!("logger2: error: {e}");
            ExitCode::FAILURE
        }
    }
}

fn run(cli: Cli) -> Result<ExitCode> {
    // --- --init-config -------------------------------------------------
    if cli.init_config {
        let path = match &cli.config {
            Some(p) => p.clone(),
            None => Config::default_write_path()?,
        };
        if path.exists() {
            eprintln!(
                "logger2: refusing to overwrite existing config at {} (remove it first)",
                path.display()
            );
            return Ok(ExitCode::FAILURE);
        }
        Config::init_default(&path)?;
        println!("logger2: wrote default config to {}", path.display());
        return Ok(ExitCode::SUCCESS);
    }

    // --- load config -----------------------------------------------------
    let (mut config, loaded_from) = Config::load(cli.config.as_deref())?;

    if cli.verbose > 0 {
        match &loaded_from {
            Some(p) => eprintln!("logger2: loaded config from {}", p.display()),
            None => eprintln!("logger2: no config file found, using built-in defaults"),
        }
    }

    // --- --print-config --------------------------------------------------
    if cli.print_config {
        print!("{}", config.to_toml_string()?);
        return Ok(ExitCode::SUCCESS);
    }

    // --- apply CLI overrides onto config ---------------------------------
    if let Some(fmt) = &cli.timestamp_format {
        config.general.timestamp_format = fmt.clone();
    }
    if let Some(server) = &cli.server {
        config.network.server = server.clone();
    }
    if let Some(port) = cli.port {
        config.network.port = port;
    }
    if let Some(proto) = cli.protocol_override() {
        config.network.protocol = proto;
    }
    if let Some(socket) = &cli.socket {
        config.network.socket = socket.display().to_string();
    }
    if cli.rfc5424 {
        config.network.rfc5424 = true;
    }
    if cli.octet_count {
        config.network.octet_count = true;
    }
    if let Some(size) = cli.size {
        config.general.max_message_size = size;
    }
    if cli.stderr {
        config.output.stderr = true;
    }
    if cli.json {
        config.output.json = true;
    }

    // --- resolve priority / level -----------------------------------------
    let mut priority: Priority = match &cli.priority {
        Some(p) => p.parse()?,
        None => config.general.default_priority.parse()?,
    };
    if let Some(level_str) = &cli.level {
        priority.level = level_str.parse::<Level>()?;
    }
    let level = priority.level;

    // --- resolve message ---------------------------------------------------
    let message = resolve_message(&cli)?;
    if message.len() > config.general.max_message_size {
        return Err(Logger2Error::MessageTooLarge(
            message.len(),
            config.general.max_message_size,
        ));
    }

    // --- resolve tag / pid ---------------------------------------------------
    let tag = cli.tag.clone().unwrap_or_else(|| {
        if !config.general.default_tag.is_empty() {
            config.general.default_tag.clone()
        } else {
            whoami::username()
        }
    });

    let pid: Option<u32> = if cli.id.is_some() || config.general.show_pid {
        match cli.id.as_deref() {
            Some("self") | None => Some(std::process::id()),
            Some(other) => other.parse::<u32>().ok().or(Some(std::process::id())),
        }
    } else {
        None
    };

    // --- structured fields ---------------------------------------------------
    let mut fields = Vec::with_capacity(cli.kv.len());
    for kv in &cli.kv {
        fields.push(parse_kv(kv)?);
    }

    // --- color / emoji decisions ---------------------------------------------
    let color_override = match &cli.color {
        Some(c) => Some(parse_hex_color(c)?),
        None => None,
    };
    let emoji_override = cli.emoji.as_deref();

    let force = if cli.no_color {
        Some(false)
    } else if cli.force_color {
        Some(true)
    } else {
        None
    };
    let stdout_is_tty = std::io::stdout().is_terminal();
    let stderr_is_tty = std::io::stderr().is_terminal();
    let use_color_stdout = config.colors.enabled && should_use_color(force, stdout_is_tty);
    let use_color_stderr = config.colors.enabled && should_use_color(force, stderr_is_tty);
    let use_emoji = config.emoji.enabled && !cli.no_emoji;

    // --- render + emit ----------------------------------------------------
    let json_mode = config.output.json;

    if json_mode {
        let ctx = RenderContext {
            level,
            tag: &tag,
            pid,
            message: &message,
            fields: &fields,
            use_color: false,
            use_emoji,
            color_override,
            emoji_override,
        };
        let line = output::render_json(&ctx)?;
        println!("{line}");
    } else if config.output.stderr || cli.dry_run {
        let ctx = RenderContext {
            level,
            tag: &tag,
            pid,
            message: &message,
            fields: &fields,
            use_color: use_color_stderr,
            use_emoji,
            color_override,
            emoji_override,
        };
        let line = output::render_console(&ctx, &config);
        eprintln!("{line}");
        // Keep stdout coloring decision available for future template modes.
        let _ = use_color_stdout;
    }

    if cli.dry_run {
        if !cli.quiet {
            eprintln!("logger2: dry-run, nothing was sent to syslog");
        }
        return Ok(ExitCode::SUCCESS);
    }

    if config.output.syslog {
        let hostname = whoami::fallible::hostname().unwrap_or_else(|_| "localhost".to_string());
        let local_socket = PathBuf::from(&config.network.socket);
        let server = if config.network.server.is_empty() {
            None
        } else {
            Some(config.network.server.as_str())
        };

        let dest = syslog::Destination {
            server,
            port: config.network.port,
            protocol: config.network.protocol,
            local_socket: &local_socket,
            rfc5424: config.network.rfc5424,
            octet_count: config.network.octet_count,
            connect_timeout: Duration::from_millis(config.network.connect_timeout_ms),
        };
        let msg = syslog::SyslogMessage {
            priority,
            hostname: &hostname,
            tag: &tag,
            pid,
            message: &message,
            structured_data: &fields,
        };

        match syslog::deliver(&dest, &msg) {
            Ok(wire) => {
                if cli.verbose > 1 {
                    eprintln!("logger2: sent: {wire}");
                }
            }
            Err(e) => {
                eprintln!("logger2: warning: failed to deliver to syslog: {e}");
                return Ok(ExitCode::from(1));
            }
        }
    }

    Ok(ExitCode::SUCCESS)
}

/// Resolves the message body from positional args, `--file`, or stdin (in
/// that priority order).
fn resolve_message(cli: &Cli) -> Result<String> {
    if !cli.message.is_empty() {
        return Ok(cli.message.join(" "));
    }
    if let Some(path) = &cli.file {
        let content = std::fs::read_to_string(path)?;
        return Ok(content.trim_end_matches(['\n', '\r']).to_string());
    }
    if !std::io::stdin().is_terminal() {
        let mut buf = String::new();
        std::io::stdin().read_to_string(&mut buf)?;
        let trimmed = buf.trim_end_matches(['\n', '\r']).to_string();
        if !trimmed.is_empty() {
            return Ok(trimmed);
        }
    }
    let _ = std::io::stdout().flush();
    Err(Logger2Error::NoMessage)
}