batata_client/logging/
mod.rs

1//! Logging configuration module
2//!
3//! Provides configurable file-based logging with rotation support.
4//!
5//! # Example
6//!
7//! ```rust,no_run
8//! use batata_client::logging::{LogConfig, LogRotation};
9//!
10//! // Create log config with daily rotation
11//! let log_config = LogConfig::new("/var/log/batata")
12//!     .with_level("info")
13//!     .with_rotation(LogRotation::Daily)
14//!     .with_max_files(7);
15//!
16//! // Initialize logging (returns a guard that must be held)
17//! let _guard = log_config.init().expect("Failed to initialize logging");
18//! ```
19
20use std::path::PathBuf;
21
22use tracing_appender::non_blocking::WorkerGuard;
23use tracing_appender::rolling::{RollingFileAppender, Rotation};
24use tracing_subscriber::layer::SubscriberExt;
25use tracing_subscriber::util::SubscriberInitExt;
26use tracing_subscriber::EnvFilter;
27
28/// Log rotation interval
29#[derive(Clone, Debug, Default)]
30pub enum LogRotation {
31    /// Rotate logs daily
32    #[default]
33    Daily,
34    /// Rotate logs hourly
35    Hourly,
36    /// Rotate logs every minute (for testing)
37    Minutely,
38    /// Never rotate logs
39    Never,
40}
41
42impl LogRotation {
43    fn to_rotation(&self) -> Rotation {
44        match self {
45            LogRotation::Daily => Rotation::DAILY,
46            LogRotation::Hourly => Rotation::HOURLY,
47            LogRotation::Minutely => Rotation::MINUTELY,
48            LogRotation::Never => Rotation::NEVER,
49        }
50    }
51}
52
53/// Logging configuration
54///
55/// Provides configuration for file-based logging with rotation support.
56#[derive(Clone, Debug)]
57pub struct LogConfig {
58    /// Log directory path
59    pub log_dir: PathBuf,
60    /// Log file name prefix
61    pub log_file_prefix: String,
62    /// Log rotation interval
63    pub rotation: LogRotation,
64    /// Maximum number of log files to keep (None = unlimited)
65    pub max_files: Option<usize>,
66    /// Log level (trace, debug, info, warn, error)
67    pub level: String,
68    /// Whether to also log to stdout
69    pub stdout: bool,
70}
71
72impl Default for LogConfig {
73    fn default() -> Self {
74        Self {
75            log_dir: PathBuf::from("logs"),
76            log_file_prefix: "batata-client".to_string(),
77            rotation: LogRotation::Daily,
78            max_files: Some(7),
79            level: "info".to_string(),
80            stdout: true,
81        }
82    }
83}
84
85impl LogConfig {
86    /// Create a new log config with the specified log directory
87    pub fn new(log_dir: impl Into<PathBuf>) -> Self {
88        Self {
89            log_dir: log_dir.into(),
90            ..Default::default()
91        }
92    }
93
94    /// Set log level (trace, debug, info, warn, error)
95    pub fn with_level(mut self, level: &str) -> Self {
96        self.level = level.to_string();
97        self
98    }
99
100    /// Set log rotation interval
101    pub fn with_rotation(mut self, rotation: LogRotation) -> Self {
102        self.rotation = rotation;
103        self
104    }
105
106    /// Set maximum number of log files to keep
107    pub fn with_max_files(mut self, max_files: usize) -> Self {
108        self.max_files = Some(max_files);
109        self
110    }
111
112    /// Set log file name prefix
113    pub fn with_prefix(mut self, prefix: &str) -> Self {
114        self.log_file_prefix = prefix.to_string();
115        self
116    }
117
118    /// Enable or disable stdout logging
119    pub fn with_stdout(mut self, enabled: bool) -> Self {
120        self.stdout = enabled;
121        self
122    }
123
124    /// Initialize the logging system
125    ///
126    /// Returns a guard that must be held for the duration of the program.
127    /// When the guard is dropped, any remaining logs will be flushed.
128    ///
129    /// # Example
130    ///
131    /// ```rust,no_run
132    /// use batata_client::logging::LogConfig;
133    ///
134    /// let _guard = LogConfig::new("/var/log/batata")
135    ///     .with_level("info")
136    ///     .init()
137    ///     .expect("Failed to init logging");
138    ///
139    /// // Logging is now active
140    /// tracing::info!("Hello, world!");
141    /// ```
142    pub fn init(&self) -> Result<LogGuard, Box<dyn std::error::Error + Send + Sync>> {
143        // Create log directory if it doesn't exist
144        std::fs::create_dir_all(&self.log_dir)?;
145
146        // Build the file appender
147        let mut builder = RollingFileAppender::builder()
148            .rotation(self.rotation.to_rotation())
149            .filename_prefix(&self.log_file_prefix)
150            .filename_suffix("log");
151
152        if let Some(max_files) = self.max_files {
153            builder = builder.max_log_files(max_files);
154        }
155
156        let file_appender = builder.build(&self.log_dir)?;
157
158        // Create non-blocking writer
159        let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
160
161        // Build the filter
162        let filter = EnvFilter::try_from_default_env()
163            .unwrap_or_else(|_| EnvFilter::new(&self.level));
164
165        // Build the file layer
166        let file_layer = tracing_subscriber::fmt::layer()
167            .with_writer(non_blocking)
168            .with_ansi(false)
169            .with_target(true)
170            .with_thread_ids(true);
171
172        // Build the subscriber
173        let subscriber = tracing_subscriber::registry()
174            .with(filter)
175            .with(file_layer);
176
177        if self.stdout {
178            let stdout_layer = tracing_subscriber::fmt::layer()
179                .with_target(true)
180                .with_thread_ids(false);
181
182            subscriber.with(stdout_layer).init();
183        } else {
184            subscriber.init();
185        }
186
187        Ok(LogGuard { _guard: guard })
188    }
189
190    /// Initialize logging with only file output (no stdout)
191    pub fn init_file_only(&self) -> Result<LogGuard, Box<dyn std::error::Error + Send + Sync>> {
192        let mut config = self.clone();
193        config.stdout = false;
194        config.init()
195    }
196}
197
198/// Guard that keeps the logging system active
199///
200/// This guard must be held for the duration of the program.
201/// When dropped, any remaining logs will be flushed to disk.
202pub struct LogGuard {
203    _guard: WorkerGuard,
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_log_config_defaults() {
212        let config = LogConfig::default();
213        assert_eq!(config.log_dir, PathBuf::from("logs"));
214        assert_eq!(config.log_file_prefix, "batata-client");
215        assert_eq!(config.level, "info");
216        assert!(config.stdout);
217        assert_eq!(config.max_files, Some(7));
218    }
219
220    #[test]
221    fn test_log_config_builder() {
222        let config = LogConfig::new("/var/log/test")
223            .with_level("debug")
224            .with_rotation(LogRotation::Hourly)
225            .with_max_files(10)
226            .with_prefix("my-app")
227            .with_stdout(false);
228
229        assert_eq!(config.log_dir, PathBuf::from("/var/log/test"));
230        assert_eq!(config.level, "debug");
231        assert_eq!(config.log_file_prefix, "my-app");
232        assert!(!config.stdout);
233        assert_eq!(config.max_files, Some(10));
234    }
235
236    #[test]
237    fn test_log_rotation_conversion() {
238        assert!(matches!(LogRotation::Daily.to_rotation(), Rotation::DAILY));
239        assert!(matches!(LogRotation::Hourly.to_rotation(), Rotation::HOURLY));
240        assert!(matches!(
241            LogRotation::Minutely.to_rotation(),
242            Rotation::MINUTELY
243        ));
244        assert!(matches!(LogRotation::Never.to_rotation(), Rotation::NEVER));
245    }
246}