with-watch 0.1.5

Watch command inputs and rerun commands when they change
Documentation
use tracing_subscriber::EnvFilter;

const WW_LOG_ENV: &str = "WW_LOG";
const WITH_WATCH_LOG_COLOR_ENV: &str = "WITH_WATCH_LOG_COLOR";
const NO_COLOR_ENV: &str = "NO_COLOR";
const DEFAULT_LOG_FILTER: &str = "with_watch=off";

pub fn init_logging() {
    let env_filter = resolve_env_filter_from_environment();
    let _ = tracing_subscriber::fmt()
        .with_env_filter(env_filter)
        .with_ansi(log_color_enabled())
        .with_target(false)
        .with_level(true)
        .without_time()
        .try_init();
}

fn resolve_env_filter_from_environment() -> EnvFilter {
    resolve_env_filter(std::env::var(WW_LOG_ENV).ok().as_deref())
}

fn resolve_env_filter(ww_log: Option<&str>) -> EnvFilter {
    let filter = ww_log
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or(DEFAULT_LOG_FILTER);

    EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER))
}

fn log_color_enabled() -> bool {
    resolve_log_color_enabled(
        std::env::var(WITH_WATCH_LOG_COLOR_ENV).ok().as_deref(),
        std::env::var(NO_COLOR_ENV).ok().as_deref(),
    )
}

fn resolve_log_color_enabled(with_watch_log_color: Option<&str>, no_color: Option<&str>) -> bool {
    match with_watch_log_color.and_then(parse_log_color_mode) {
        Some(LogColorMode::Always) => return true,
        Some(LogColorMode::Never) => return false,
        Some(LogColorMode::Auto) | None => {}
    }

    if no_color.is_some() {
        return false;
    }

    true
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LogColorMode {
    Auto,
    Always,
    Never,
}

fn parse_log_color_mode(raw: &str) -> Option<LogColorMode> {
    match raw.trim().to_ascii_lowercase().as_str() {
        "always" | "on" | "true" | "1" | "yes" => Some(LogColorMode::Always),
        "never" | "off" | "false" | "0" | "no" => Some(LogColorMode::Never),
        "auto" => Some(LogColorMode::Auto),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use std::{
        io::{self, Write},
        sync::{Arc, Mutex, OnceLock},
    };

    use tracing::{debug, info};

    use super::{
        resolve_env_filter, resolve_env_filter_from_environment, resolve_log_color_enabled,
        EnvFilter, DEFAULT_LOG_FILTER, WW_LOG_ENV,
    };

    const RUST_LOG_ENV: &str = "RUST_LOG";

    #[test]
    fn default_enables_colored_logs() {
        assert!(resolve_log_color_enabled(None, None));
    }

    #[test]
    fn override_can_disable_logs() {
        assert!(!resolve_log_color_enabled(Some("never"), None));
    }

    #[test]
    fn no_color_disables_logs_without_override() {
        assert!(!resolve_log_color_enabled(None, Some("1")));
    }

    #[test]
    fn default_filter_disables_info_logs() {
        let output = capture_logs(resolve_env_filter(None), || {
            info!("hidden");
        });

        assert!(output.is_empty());
    }

    #[test]
    fn blank_ww_log_uses_default_filter() {
        let output = capture_logs(resolve_env_filter(Some("   ")), || {
            info!("hidden");
        });

        assert!(
            output.is_empty(),
            "blank WW_LOG should fall back to {DEFAULT_LOG_FILTER}"
        );
    }

    #[test]
    fn ww_log_can_enable_info_logs() {
        let output = capture_logs(resolve_env_filter(Some("with_watch=info")), || {
            info!("visible");
            debug!("still hidden");
        });

        assert!(output.contains("visible"));
        assert!(!output.contains("still hidden"));
    }

    #[test]
    fn ww_log_can_enable_debug_logs() {
        let output = capture_logs(resolve_env_filter(Some("with_watch=debug")), || {
            debug!("visible");
        });

        assert!(output.contains("visible"));
    }

    #[test]
    fn invalid_ww_log_falls_back_to_default_filter() {
        let output = capture_logs(resolve_env_filter(Some("not a valid filter[")), || {
            info!("hidden");
        });

        assert!(output.is_empty());
    }

    #[test]
    fn rust_log_is_ignored_when_resolving_environment_filter() {
        with_logging_environment(None, Some("with_watch=debug"), || {
            let output = capture_logs(resolve_env_filter_from_environment(), || {
                info!("hidden");
            });

            assert!(output.is_empty());
        });
    }

    #[test]
    fn ww_log_from_environment_overrides_default_off() {
        with_logging_environment(Some("with_watch=info"), Some("with_watch=off"), || {
            let output = capture_logs(resolve_env_filter_from_environment(), || {
                info!("visible");
            });

            assert!(output.contains("visible"));
        });
    }

    fn capture_logs(env_filter: EnvFilter, callback: impl FnOnce()) -> String {
        let buffer = Arc::new(Mutex::new(Vec::new()));
        let writer = SharedWriter(buffer.clone());
        let subscriber = tracing_subscriber::fmt()
            .with_env_filter(env_filter)
            .with_ansi(false)
            .with_target(false)
            .with_level(false)
            .without_time()
            .with_writer(move || writer.clone())
            .finish();

        tracing::subscriber::with_default(subscriber, callback);

        let output = buffer.lock().expect("lock log buffer").clone();
        String::from_utf8(output).expect("utf8 log output")
    }

    fn with_logging_environment<T>(
        ww_log: Option<&str>,
        rust_log: Option<&str>,
        callback: impl FnOnce() -> T,
    ) -> T {
        let _guard = environment_lock().lock().expect("lock logging env");

        let original_ww_log = std::env::var_os(WW_LOG_ENV);
        let original_rust_log = std::env::var_os(RUST_LOG_ENV);

        set_optional_env(WW_LOG_ENV, ww_log);
        set_optional_env(RUST_LOG_ENV, rust_log);

        let result = callback();

        restore_optional_env(WW_LOG_ENV, original_ww_log);
        restore_optional_env(RUST_LOG_ENV, original_rust_log);

        result
    }

    fn environment_lock() -> &'static Mutex<()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
    }

    fn set_optional_env(key: &str, value: Option<&str>) {
        match value {
            Some(value) => std::env::set_var(key, value),
            None => std::env::remove_var(key),
        }
    }

    fn restore_optional_env(key: &str, value: Option<std::ffi::OsString>) {
        match value {
            Some(value) => std::env::set_var(key, value),
            None => std::env::remove_var(key),
        }
    }

    #[derive(Clone)]
    struct SharedWriter(Arc<Mutex<Vec<u8>>>);

    impl Write for SharedWriter {
        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
            self.0
                .lock()
                .expect("lock log buffer")
                .extend_from_slice(buf);
            Ok(buf.len())
        }

        fn flush(&mut self) -> io::Result<()> {
            Ok(())
        }
    }
}