batata-client 0.0.2

Rust client for Batata/Nacos service discovery and configuration management
Documentation
//! Logging configuration module
//!
//! Provides configurable file-based logging with rotation support.
//!
//! # Example
//!
//! ```rust,no_run
//! use batata_client::logging::{LogConfig, LogRotation};
//!
//! // Create log config with daily rotation
//! let log_config = LogConfig::new("/var/log/batata")
//!     .with_level("info")
//!     .with_rotation(LogRotation::Daily)
//!     .with_max_files(7);
//!
//! // Initialize logging (returns a guard that must be held)
//! let _guard = log_config.init().expect("Failed to initialize logging");
//! ```

use std::path::PathBuf;

use tracing_appender::non_blocking::WorkerGuard;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;

/// Log rotation interval
#[derive(Clone, Debug, Default)]
pub enum LogRotation {
    /// Rotate logs daily
    #[default]
    Daily,
    /// Rotate logs hourly
    Hourly,
    /// Rotate logs every minute (for testing)
    Minutely,
    /// Never rotate logs
    Never,
}

impl LogRotation {
    fn to_rotation(&self) -> Rotation {
        match self {
            LogRotation::Daily => Rotation::DAILY,
            LogRotation::Hourly => Rotation::HOURLY,
            LogRotation::Minutely => Rotation::MINUTELY,
            LogRotation::Never => Rotation::NEVER,
        }
    }
}

/// Logging configuration
///
/// Provides configuration for file-based logging with rotation support.
#[derive(Clone, Debug)]
pub struct LogConfig {
    /// Log directory path
    pub log_dir: PathBuf,
    /// Log file name prefix
    pub log_file_prefix: String,
    /// Log rotation interval
    pub rotation: LogRotation,
    /// Maximum number of log files to keep (None = unlimited)
    pub max_files: Option<usize>,
    /// Log level (trace, debug, info, warn, error)
    pub level: String,
    /// Whether to also log to stdout
    pub stdout: bool,
}

impl Default for LogConfig {
    fn default() -> Self {
        Self {
            log_dir: PathBuf::from("logs"),
            log_file_prefix: "batata-client".to_string(),
            rotation: LogRotation::Daily,
            max_files: Some(7),
            level: "info".to_string(),
            stdout: true,
        }
    }
}

impl LogConfig {
    /// Create a new log config with the specified log directory
    pub fn new(log_dir: impl Into<PathBuf>) -> Self {
        Self {
            log_dir: log_dir.into(),
            ..Default::default()
        }
    }

    /// Set log level (trace, debug, info, warn, error)
    pub fn with_level(mut self, level: &str) -> Self {
        self.level = level.to_string();
        self
    }

    /// Set log rotation interval
    pub fn with_rotation(mut self, rotation: LogRotation) -> Self {
        self.rotation = rotation;
        self
    }

    /// Set maximum number of log files to keep
    pub fn with_max_files(mut self, max_files: usize) -> Self {
        self.max_files = Some(max_files);
        self
    }

    /// Set log file name prefix
    pub fn with_prefix(mut self, prefix: &str) -> Self {
        self.log_file_prefix = prefix.to_string();
        self
    }

    /// Enable or disable stdout logging
    pub fn with_stdout(mut self, enabled: bool) -> Self {
        self.stdout = enabled;
        self
    }

    /// Initialize the logging system
    ///
    /// Returns a guard that must be held for the duration of the program.
    /// When the guard is dropped, any remaining logs will be flushed.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// use batata_client::logging::LogConfig;
    ///
    /// let _guard = LogConfig::new("/var/log/batata")
    ///     .with_level("info")
    ///     .init()
    ///     .expect("Failed to init logging");
    ///
    /// // Logging is now active
    /// tracing::info!("Hello, world!");
    /// ```
    pub fn init(&self) -> Result<LogGuard, Box<dyn std::error::Error + Send + Sync>> {
        // Create log directory if it doesn't exist
        std::fs::create_dir_all(&self.log_dir)?;

        // Build the file appender
        let mut builder = RollingFileAppender::builder()
            .rotation(self.rotation.to_rotation())
            .filename_prefix(&self.log_file_prefix)
            .filename_suffix("log");

        if let Some(max_files) = self.max_files {
            builder = builder.max_log_files(max_files);
        }

        let file_appender = builder.build(&self.log_dir)?;

        // Create non-blocking writer
        let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);

        // Build the filter
        let filter = EnvFilter::try_from_default_env()
            .unwrap_or_else(|_| EnvFilter::new(&self.level));

        // Build the file layer
        let file_layer = tracing_subscriber::fmt::layer()
            .with_writer(non_blocking)
            .with_ansi(false)
            .with_target(true)
            .with_thread_ids(true);

        // Build the subscriber
        let subscriber = tracing_subscriber::registry()
            .with(filter)
            .with(file_layer);

        if self.stdout {
            let stdout_layer = tracing_subscriber::fmt::layer()
                .with_target(true)
                .with_thread_ids(false);

            subscriber.with(stdout_layer).init();
        } else {
            subscriber.init();
        }

        Ok(LogGuard { _guard: guard })
    }

    /// Initialize logging with only file output (no stdout)
    pub fn init_file_only(&self) -> Result<LogGuard, Box<dyn std::error::Error + Send + Sync>> {
        let mut config = self.clone();
        config.stdout = false;
        config.init()
    }
}

/// Guard that keeps the logging system active
///
/// This guard must be held for the duration of the program.
/// When dropped, any remaining logs will be flushed to disk.
pub struct LogGuard {
    _guard: WorkerGuard,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_log_config_defaults() {
        let config = LogConfig::default();
        assert_eq!(config.log_dir, PathBuf::from("logs"));
        assert_eq!(config.log_file_prefix, "batata-client");
        assert_eq!(config.level, "info");
        assert!(config.stdout);
        assert_eq!(config.max_files, Some(7));
    }

    #[test]
    fn test_log_config_builder() {
        let config = LogConfig::new("/var/log/test")
            .with_level("debug")
            .with_rotation(LogRotation::Hourly)
            .with_max_files(10)
            .with_prefix("my-app")
            .with_stdout(false);

        assert_eq!(config.log_dir, PathBuf::from("/var/log/test"));
        assert_eq!(config.level, "debug");
        assert_eq!(config.log_file_prefix, "my-app");
        assert!(!config.stdout);
        assert_eq!(config.max_files, Some(10));
    }

    #[test]
    fn test_log_rotation_conversion() {
        assert!(matches!(LogRotation::Daily.to_rotation(), Rotation::DAILY));
        assert!(matches!(LogRotation::Hourly.to_rotation(), Rotation::HOURLY));
        assert!(matches!(
            LogRotation::Minutely.to_rotation(),
            Rotation::MINUTELY
        ));
        assert!(matches!(LogRotation::Never.to_rotation(), Rotation::NEVER));
    }
}