fstdout-logger 0.2.2

An implementation of the log crate that logs to stdout and to an optional log file with configurable options.
Documentation
//! Configuration options for the logger.
//!
//! This module provides the [`LoggerConfig`] struct and [`LoggerConfigBuilder`]
//! for configuring the behavior of the logger.

use log::LevelFilter;
use std::collections::HashMap;

/// Module-level log filters.
///
/// This struct manages log level filters on a per-module basis,
/// allowing fine-grained control over which modules should log at which levels.
///
/// Filters are checked from most specific to least specific:
/// 1. Exact module path match (e.g., `my_crate::module::submodule`)
/// 2. Prefix match (e.g., `my_crate::module` matches `my_crate::module::*`)
/// 3. Global default level
#[derive(Debug, Clone)]
pub struct ModuleFilters {
    /// Map of module paths to their log level filters
    filters: HashMap<String, LevelFilter>,
    /// Default log level for modules without specific filters
    default_level: LevelFilter,
}

impl Default for ModuleFilters {
    fn default() -> Self {
        Self {
            filters: HashMap::new(),
            default_level: LevelFilter::Info,
        }
    }
}

impl ModuleFilters {
    /// Create a new ModuleFilters with a default level.
    pub fn new(default_level: LevelFilter) -> Self {
        Self {
            filters: HashMap::new(),
            default_level,
        }
    }

    /// Parse RUST_LOG environment variable format.
    ///
    /// Supports formats like:
    /// - `debug` - Set default level to debug
    /// - `my_crate=debug` - Set specific module to debug
    /// - `my_crate::module=trace,warn` - Multiple filters separated by comma
    /// - `my_crate=debug,other_crate::module=trace` - Multiple module filters
    pub fn from_env() -> Self {
        let rust_log = std::env::var("RUST_LOG").unwrap_or_default();
        Self::parse(&rust_log)
    }

    /// Parse a RUST_LOG-style filter string.
    pub fn parse(s: &str) -> Self {
        let mut filters = HashMap::new();
        let mut default_level = LevelFilter::Info;

        if s.is_empty() {
            return Self {
                filters,
                default_level,
            };
        }

        // Split by comma to get individual filters
        for part in s.split(',') {
            let part = part.trim();
            if part.is_empty() {
                continue;
            }

            // Check if this is a module-specific filter (contains '=')
            if let Some((module, level_str)) = part.split_once('=') {
                let module = module.trim();
                let level_str = level_str.trim();

                if let Some(level) = parse_level_filter(level_str) {
                    filters.insert(module.to_string(), level);
                }
            } else {
                // No '=' means it's a global level setting
                if let Some(level) = parse_level_filter(part) {
                    default_level = level;
                }
            }
        }

        Self {
            filters,
            default_level,
        }
    }

    /// Add a filter for a specific module.
    pub fn add_filter(&mut self, module: impl Into<String>, level: LevelFilter) {
        self.filters.insert(module.into(), level);
    }

    /// Get the log level for a specific module path.
    ///
    /// This checks in order:
    /// 1. Exact match for the module path
    /// 2. Prefix matches (from most specific to least specific)
    /// 3. Default level
    pub fn level_for(&self, module_path: &str) -> LevelFilter {
        // Check for exact match
        if let Some(&level) = self.filters.get(module_path) {
            return level;
        }

        // Check for prefix matches
        let mut matching_filters: Vec<_> = self
            .filters
            .iter()
            .filter(|(path, _)| module_path.starts_with(path.as_str()))
            .collect();

        matching_filters.sort_by_key(|(path, _)| std::cmp::Reverse(path.len()));

        if let Some(&(_, &level)) = matching_filters.first() {
            return level;
        }

        // Return default level
        self.default_level
    }

    /// Get the default level.
    pub fn default_level(&self) -> LevelFilter {
        self.default_level
    }

    /// Set the default level.
    pub fn set_default_level(&mut self, level: LevelFilter) {
        self.default_level = level;
    }
}

/// Parse a log level string to a LevelFilter.
fn parse_level_filter(s: &str) -> Option<LevelFilter> {
    match s.to_lowercase().as_str() {
        "off" => Some(LevelFilter::Off),
        "error" => Some(LevelFilter::Error),
        "warn" => Some(LevelFilter::Warn),
        "info" => Some(LevelFilter::Info),
        "debug" => Some(LevelFilter::Debug),
        "trace" => Some(LevelFilter::Trace),
        _ => None,
    }
}

/// Parse LOG_LEVEL environment variable (numeric format).
///
/// Supports numeric levels:
/// - 0 = Off
/// - 1 = Error
/// - 2 = Warn
/// - 3 = Info
/// - 4 = Debug
/// - 5+ = Trace
fn parse_log_level_env() -> Option<LevelFilter> {
    let level_str = std::env::var("LOG_LEVEL").ok()?;
    let level = level_str.parse::<u8>().ok()?;
    Some(match level {
        0 => LevelFilter::Off,
        1 => LevelFilter::Error,
        2 => LevelFilter::Warn,
        3 => LevelFilter::Info,
        4 => LevelFilter::Debug,
        _ => LevelFilter::Trace,
    })
}

/// Configuration for the logger.
#[derive(Debug, Clone)]
pub struct LoggerConfig {
    /// Whether to show file and line information in log messages
    pub show_file_info: bool,

    /// Whether to show date in stdout logs (always shown in file logs)
    pub show_date_in_stdout: bool,

    /// Whether to use colors in stdout logs
    pub use_colors: bool,

    /// Minimum log level to display
    pub level: LevelFilter,

    /// Module-level log filters
    pub module_filters: ModuleFilters,
}

impl Default for LoggerConfig {
    /// Creates a default configuration with fixed values.
    ///
    /// To read from environment variables (RUST_LOG, LOG_LEVEL), use `LoggerConfig::from_env()` instead.
    ///
    /// Default values:
    /// - `show_file_info`: `true`
    /// - `show_date_in_stdout`: `false`
    /// - `use_colors`: `true`
    /// - `level`: `Info`
    /// - `module_filters`: Empty (all modules at Info level)
    fn default() -> Self {
        Self {
            show_file_info: true,
            show_date_in_stdout: false,
            use_colors: true,
            level: LevelFilter::Info,
            module_filters: ModuleFilters::new(LevelFilter::Info),
        }
    }
}

impl LoggerConfig {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn builder() -> LoggerConfigBuilder {
        LoggerConfigBuilder::default()
    }

    /// Create a configuration from environment variables.
    ///
    /// Reads configuration from:
    /// - `RUST_LOG` for module-level filtering (e.g., "debug", "my_crate=trace")
    /// - `LOG_LEVEL` for numeric log level (0=Off, 1=Error, 2=Warn, 3=Info, 4=Debug, 5+=Trace)
    ///
    /// If both are set, RUST_LOG takes precedence for the default level.
    pub fn from_env() -> Self {
        let module_filters = ModuleFilters::from_env();

        // Check LOG_LEVEL first, then use RUST_LOG default, then fallback to Info
        let level = if let Some(log_level) = parse_log_level_env() {
            log_level
        } else {
            module_filters.default_level()
        };

        Self {
            show_file_info: true,
            show_date_in_stdout: false,
            use_colors: true,
            level,
            module_filters,
        }
    }

    pub fn production() -> Self {
        Self {
            show_file_info: false,
            show_date_in_stdout: false,
            use_colors: true,
            level: LevelFilter::Info,
            module_filters: ModuleFilters::new(LevelFilter::Info),
        }
    }

    pub fn development() -> Self {
        Self {
            show_file_info: true,
            show_date_in_stdout: false,
            use_colors: true,
            level: LevelFilter::Debug,
            module_filters: ModuleFilters::new(LevelFilter::Debug),
        }
    }
}

#[derive(Debug, Default)]
pub struct LoggerConfigBuilder {
    config: LoggerConfig,
}

impl LoggerConfigBuilder {
    pub fn show_file_info(mut self, show: bool) -> Self {
        self.config.show_file_info = show;
        self
    }

    pub fn show_date_in_stdout(mut self, show: bool) -> Self {
        self.config.show_date_in_stdout = show;
        self
    }

    pub fn use_colors(mut self, use_colors: bool) -> Self {
        self.config.use_colors = use_colors;
        self
    }

    pub fn level(mut self, level: LevelFilter) -> Self {
        self.config.level = level;
        // Also update the default level in module_filters
        self.config.module_filters.set_default_level(level);
        self
    }

    pub fn module_filters(mut self, filters: ModuleFilters) -> Self {
        self.config.module_filters = filters;
        self
    }

    /// Add a filter for a specific module.
    ///
    /// This is a convenience method to add module-level filters without
    /// creating a ModuleFilters struct manually.
    ///
    /// # Example
    ///
    /// ```
    /// use fstdout_logger::LoggerConfigBuilder;
    /// use log::LevelFilter;
    ///
    /// let config = LoggerConfigBuilder::default()
    ///     .filter_module("rustls", LevelFilter::Error)
    ///     .filter_module("tokio_rustls", LevelFilter::Error)
    ///     .build();
    /// ```
    pub fn filter_module(mut self, module: impl Into<String>, level: LevelFilter) -> Self {
        self.config.module_filters.add_filter(module, level);
        self
    }

    pub fn build(self) -> LoggerConfig {
        self.config
    }
}