nishikaze 0.3.2

Zephyr build system companion.
Documentation
//! Logging helpers for user-facing messages.

use std::io::Write;
use std::sync::atomic::{AtomicU8, Ordering};

use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};

/// Current verbosity level for user-facing logs.
static VERBOSITY: AtomicU8 = AtomicU8::new(2);

/// Sets the global verbosity level for logging.
pub fn set_verbosity(level: u8) {
    VERBOSITY.store(level, Ordering::Relaxed);
}

/// Returns true when logs should be emitted for the current verbosity level.
#[must_use]
pub const fn is_enabled_for(level: u8) -> bool {
    level >= 2
}

/// Returns true when logs should be emitted.
#[must_use]
pub fn enabled() -> bool {
    if !is_enabled_for(VERBOSITY.load(Ordering::Relaxed)) {
        return false;
    }
    if std::env::var_os("KAZE_TESTING").is_some() && std::env::var_os("KAZE_LOGS").is_none() {
        return false;
    }
    true
}

/// Logs a user-facing message with a kaze prefix.
pub fn info(msg: impl AsRef<str>) {
    if !enabled() {
        return;
    }
    let mut stream = StandardStream::stdout(ColorChoice::Auto);
    if write_info(&mut stream, msg.as_ref()).is_err()
        && writeln!(stream, "kaze: {}", msg.as_ref()).is_err()
    {
        drop(stream);
    }
}

/// Logs a user-facing error message in red.
pub fn error(msg: impl AsRef<str>) {
    let mut stream = StandardStream::stderr(ColorChoice::Auto);
    if write_error(&mut stream, msg.as_ref()).is_err()
        && writeln!(stream, "{}", msg.as_ref()).is_err()
    {
        drop(stream);
    }
}

/// Writes a green log line to the provided colored output stream.
fn write_info(out: &mut dyn WriteColor, msg: &str) -> std::io::Result<()> {
    let mut spec = ColorSpec::new();
    spec.set_fg(Some(Color::Green));
    out.set_color(&spec)?;
    writeln!(out, "kaze: {msg}")?;
    out.reset()?;
    Ok(())
}

/// Writes a red error line to the provided colored output stream.
fn write_error(out: &mut dyn WriteColor, msg: &str) -> std::io::Result<()> {
    let mut spec = ColorSpec::new();
    spec.set_fg(Some(Color::Red));
    out.set_color(&spec)?;
    writeln!(out, "{msg}")?;
    out.reset()?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use std::ffi::OsString;
    use std::sync::{Mutex, OnceLock};

    use super::*;

    fn test_lock() -> std::sync::MutexGuard<'static, ()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
            .lock()
            .expect("lock log tests")
    }

    fn restore_env(key: &str, value: Option<OsString>) {
        match value {
            Some(val) => {
                // SAFETY: Test controls process environment; no concurrent access here.
                unsafe {
                    std::env::set_var(key, val);
                }
            }
            None => {
                // SAFETY: Test controls process environment; no concurrent access here.
                unsafe {
                    std::env::remove_var(key);
                }
            }
        }
    }

    #[test]
    fn write_info_includes_color_when_ansi_enabled() {
        let mut buf = termcolor::Buffer::ansi();
        write_info(&mut buf, "hello").expect("write info");
        let output = std::str::from_utf8(buf.as_slice()).expect("utf8");
        assert!(output.contains("kaze: hello"));
        assert!(output.contains("\u{1b}["));
    }

    #[test]
    fn write_info_no_color_when_disabled() {
        let mut buf = termcolor::Buffer::no_color();
        write_info(&mut buf, "hello").expect("write info");
        let output = std::str::from_utf8(buf.as_slice()).expect("utf8");
        assert_eq!(output, "kaze: hello\n");
    }

    #[test]
    fn write_error_includes_color_when_ansi_enabled() {
        let mut buf = termcolor::Buffer::ansi();
        write_error(&mut buf, "error: boom").expect("write error");
        let output = std::str::from_utf8(buf.as_slice()).expect("utf8");
        assert!(output.contains("error: boom"));
        assert!(output.contains("\u{1b}["));
    }

    #[test]
    fn write_error_no_color_when_disabled() {
        let mut buf = termcolor::Buffer::no_color();
        write_error(&mut buf, "error: boom").expect("write error");
        let output = std::str::from_utf8(buf.as_slice()).expect("utf8");
        assert_eq!(output, "error: boom\n");
    }

    #[test]
    fn info_and_error_cover_output_paths() {
        let _guard = test_lock();
        let old_testing = std::env::var_os("KAZE_TESTING");
        let old_logs = std::env::var_os("KAZE_LOGS");

        // SAFETY: Test controls process environment; no concurrent access here.
        unsafe {
            std::env::set_var("KAZE_TESTING", "1");
            std::env::set_var("KAZE_LOGS", "1");
        }
        set_verbosity(2);

        info("hello");
        error("oops");

        restore_env("KAZE_TESTING", old_testing);
        restore_env("KAZE_LOGS", old_logs);
    }

    #[test]
    fn enabled_false_when_verbosity_low() {
        let _guard = test_lock();
        set_verbosity(1);
        assert!(!enabled());
        set_verbosity(2);
    }

    #[test]
    fn enabled_respects_testing_env_vars() {
        let _guard = test_lock();
        let old_testing = std::env::var_os("KAZE_TESTING");
        let old_logs = std::env::var_os("KAZE_LOGS");

        // SAFETY: Test controls process environment; no concurrent access here.
        unsafe {
            std::env::set_var("KAZE_TESTING", "1");
            std::env::remove_var("KAZE_LOGS");
        }
        set_verbosity(2);
        assert!(!enabled());

        // SAFETY: Test controls process environment; no concurrent access here.
        unsafe {
            std::env::set_var("KAZE_LOGS", "1");
        }
        assert!(enabled());

        restore_env("KAZE_TESTING", old_testing);
        restore_env("KAZE_LOGS", old_logs);
    }
}