fstdout_logger/
lib.rs

1//! # FStdout Logger
2//!
3//! A flexible logger implementation for Rust that logs to both stdout and a file,
4//! with support for colored console output and customizable formatting.
5//!
6//! ## Key Features
7//!
8//! - Log to both stdout and a file simultaneously
9//! - Colored terminal output (configurable)
10//! - Minimal stdout formatting (timestamp without date by default)
11//! - Full file logging with timestamps and source location
12//! - Multiple configuration options and presets
13//!
14//! ## Basic Usage
15//!
16//! ```rust
17//! use fstdout_logger::init_logger;
18//! use log::info;
19//!
20//! // Initialize with defaults (Info level, colors enabled, file info shown)
21//! init_logger(Some("application.log")).expect("Failed to initialize logger");
22//!
23//! info!("Application started");
24//! ```
25//!
26//! ## Configuration Options
27//!
28//! The logger can be customized using the `LoggerConfig` struct:
29//!
30//! ```rust
31//! use fstdout_logger::{init_logger_with_config, LoggerConfig};
32//! use log::LevelFilter;
33//!
34//! // Create a custom configuration
35//! let config = LoggerConfig::builder()
36//!     .level(LevelFilter::Debug)
37//!     .show_file_info(false)      // Don't show file paths in stdout
38//!     .show_date_in_stdout(false) // Show only time, not date in stdout
39//!     .use_colors(true)           // Use colored output in terminal
40//!     .build();
41//!
42//! init_logger_with_config(Some("debug.log"), config).expect("Failed to initialize logger");
43//! ```
44//!
45//! ## Presets
46//!
47//! The library provides convenient presets for common scenarios:
48//!
49//! ```rust
50//! // For development (Debug level, file info shown)
51//! fstdout_logger::init_development_logger(Some("dev.log")).expect("Failed to initialize logger");
52//!
53//! // For production (Info level, no file info)
54//! fstdout_logger::init_production_logger(Some("app.log")).expect("Failed to initialize logger");
55//! ```
56
57use flate2::Compression;
58use log::{LevelFilter, Log, Metadata, Record};
59use std::fs::{File, OpenOptions, create_dir_all};
60use std::io::{self, Write};
61use std::path::Path;
62use std::sync::Mutex;
63use thiserror::Error;
64
65mod config;
66pub mod examples;
67pub mod formatter;
68
69pub use config::{LoggerConfig, LoggerConfigBuilder, ModuleFilters};
70pub use formatter::LogFormatter;
71
72/// Errors that can occur when using the logger.
73#[derive(Error, Debug)]
74pub enum LogError {
75    /// I/O errors when opening or writing to log files.
76    #[error("IO error: {0}")]
77    Io(#[from] io::Error),
78
79    /// Errors when setting up the global logger.
80    #[error("Failed to set logger")]
81    Logger,
82}
83
84/// The main logger implementation that outputs to stdout and optionally to a file.
85///
86/// This struct implements the [`Log`] trait from the standard `log` crate,
87/// handling log messages by:
88///
89/// 1. Writing to stdout with optional colors and formatting
90/// 2. Writing to a file (if configured) with full details
91///
92/// # Example
93///
94/// ```rust
95/// use fstdout_logger::{FStdoutLogger, LoggerConfig};
96/// use log::LevelFilter;
97///
98/// // Creating a logger directly (usually done via helper functions)
99/// let logger = FStdoutLogger::with_config(
100///     Some("app.log"),
101///     LoggerConfig::default()
102/// ).expect("Failed to create logger");
103///
104/// // Initialize as the global logger
105/// logger.init_with_level(LevelFilter::Info).expect("Failed to initialize logger");
106/// ```
107pub struct FStdoutLogger {
108    /// Optional file to log to
109    log_file: Option<Mutex<File>>,
110
111    /// Formatter for log messages
112    formatter: LogFormatter,
113}
114
115impl FStdoutLogger {
116    /// Create a new logger with default configuration.
117    ///
118    /// This is a convenience method that uses [`LoggerConfig::default()`].
119    ///
120    /// # Arguments
121    ///
122    /// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
123    ///
124    /// # Returns
125    ///
126    /// A new logger instance or an error if the log file couldn't be opened.
127    pub fn new<P: AsRef<Path>>(file_path: Option<P>) -> Result<Self, LogError> {
128        Self::with_config(file_path, LoggerConfig::default())
129    }
130
131    /// Create a new logger with custom configuration.
132    ///
133    /// # Arguments
134    ///
135    /// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
136    /// * `config` - Configuration options for the logger.
137    ///
138    /// # Returns
139    ///
140    /// A new logger instance or an error if the log file couldn't be opened.
141    pub fn with_config<P: AsRef<Path>>(
142        file_path: Option<P>,
143        config: LoggerConfig,
144    ) -> Result<Self, LogError> {
145        let log_file = match file_path {
146            Some(path) => {
147                let file = Path::new(path.as_ref()).to_path_buf();
148                if let Some(parent) = file.parent() {
149                    create_dir_all(parent)?;
150                };
151                if file.exists() {
152                    use flate2::write::GzEncoder;
153                    use tar::Builder;
154
155                    let file_basename = format!("{}", chrono::Local::now().format("%d%m%Y_%H%M%S"));
156                    let archive_ref = format!("{}.tar.xz", file_basename);
157                    let mut archive_path = Path::new(&archive_ref).to_path_buf();
158                    if let Some(parent) = file.parent() {
159                        archive_path = parent.join(archive_path.file_name().unwrap());
160                    };
161                    let archive_file = File::create(archive_path)?;
162
163                    let encoder = GzEncoder::new(archive_file, Compression::default());
164                    let mut archive = Builder::new(encoder);
165
166                    archive.append_file(
167                        Path::new(&format!("{}.log", file_basename)),
168                        &mut File::open(file)?,
169                    )?;
170
171                    archive.into_inner().unwrap();
172                }
173                let file = OpenOptions::new()
174                    .create(true)
175                    .truncate(true)
176                    .write(true)
177                    .open(path)?;
178                Some(Mutex::new(file))
179            }
180            None => None,
181        };
182
183        Ok(Self {
184            log_file,
185            formatter: LogFormatter::new(config),
186        })
187    }
188
189    /// Initialize the logger with the default configuration.
190    ///
191    /// This sets the maximum log level to `Trace` to enable all logs,
192    /// but actual filtering will happen according to the `level` setting
193    /// in the logger's configuration.
194    ///
195    /// # Returns
196    ///
197    /// `Ok(())` if initialization succeeded, or an error if it failed.
198    pub fn init(self) -> Result<(), LogError> {
199        if log::set_logger(Box::leak(Box::new(self))).is_err() {
200            return Err(LogError::Logger);
201        }
202        log::set_max_level(LevelFilter::Trace);
203        Ok(())
204    }
205
206    /// Initialize the logger with a specific log level.
207    ///
208    /// This sets the global maximum log level, overriding the level
209    /// in the logger's configuration.
210    ///
211    /// # Arguments
212    ///
213    /// * `level` - The minimum log level to display.
214    ///
215    /// # Returns
216    ///
217    /// `Ok(())` if initialization succeeded, or an error if it failed.
218    pub fn init_with_level(self, level: LevelFilter) -> Result<(), LogError> {
219        if log::set_logger(Box::leak(Box::new(self))).is_err() {
220            return Err(LogError::Logger);
221        }
222        log::set_max_level(level);
223        Ok(())
224    }
225}
226
227/// Implementation of the `Log` trait for `FStdoutLogger`.
228///
229/// This handles:
230/// - Checking if a log message should be processed (with module-level filtering)
231/// - Formatting messages differently for stdout and file
232/// - Writing to both destinations
233/// - Flushing output streams
234impl Log for FStdoutLogger {
235    fn enabled(&self, metadata: &Metadata) -> bool {
236        // First check global max level
237        if metadata.level() > log::max_level() {
238            return false;
239        }
240
241        // Then check module-specific filters
242        let target = metadata.target();
243        let module_level = self.formatter.config().module_filters.level_for(target);
244
245        metadata.level() <= module_level
246    }
247
248    fn log(&self, record: &Record) {
249        if !self.enabled(record.metadata()) {
250            return;
251        }
252
253        // Format for stdout (with or without colors)
254        let stdout_formatted = format!("{}\n", self.formatter.format_stdout(record));
255
256        // Log to stdout
257        print!("{stdout_formatted}");
258
259        // Log to file if configured
260        if let Some(file) = &self.log_file
261            && let Ok(mut file) = file.lock()
262        {
263            // Format for file (always without colors)
264            let file_formatted = self.formatter.format_file(record);
265
266            // Ignore errors when writing to file as we don't want to crash the application
267            let _ = file.write_all(file_formatted.as_bytes());
268        }
269    }
270
271    fn flush(&self) {
272        // Flush stdout
273        let _ = io::stdout().flush();
274
275        // Flush file if configured
276        if let Some(file) = &self.log_file
277            && let Ok(mut file) = file.lock()
278        {
279            let _ = file.flush();
280        }
281    }
282}
283
284//
285// Helper functions for easily initializing the logger
286//
287
288/// Initialize a logger with default configuration.
289///
290/// This automatically reads from environment variables:
291/// - `RUST_LOG` for module-level filtering
292/// - `LOG_LEVEL` for numeric log level (0-5)
293///
294/// If neither is set, defaults to Info level with standard settings.
295///
296/// # Arguments
297///
298/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
299///
300/// # Returns
301///
302/// `Ok(())` if initialization succeeded, or an error if it failed.
303///
304/// # Example
305///
306/// ```rust
307/// use fstdout_logger::init_logger;
308/// use log::info;
309///
310/// init_logger(Some("app.log")).expect("Failed to initialize logger");
311/// info!("Logger initialized with environment variable support");
312/// ```
313pub fn init_logger<P: AsRef<Path>>(file_path: Option<P>) -> Result<(), LogError> {
314    // Check if environment variables are set
315    if std::env::var("RUST_LOG").is_ok() || std::env::var("LOG_LEVEL").is_ok() {
316        // Use from_env if any environment variable is set
317        init_logger_from_env(file_path)
318    } else {
319        // Use default configuration
320        FStdoutLogger::new(file_path)?.init()
321    }
322}
323
324/// Initialize a logger with a specific log level.
325///
326/// This uses the default configuration but overrides the log level.
327///
328/// # Arguments
329///
330/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
331/// * `level` - The minimum log level to display.
332///
333/// # Returns
334///
335/// `Ok(())` if initialization succeeded, or an error if it failed.
336///
337/// # Example
338///
339/// ```rust
340/// use fstdout_logger::init_logger_with_level;
341/// use log::LevelFilter;
342///
343/// init_logger_with_level(Some("debug.log"), LevelFilter::Debug)
344///     .expect("Failed to initialize logger");
345/// ```
346pub fn init_logger_with_level<P: AsRef<Path>>(
347    file_path: Option<P>,
348    level: LevelFilter,
349) -> Result<(), LogError> {
350    FStdoutLogger::new(file_path)?.init_with_level(level)
351}
352
353/// Initialize a logger with custom configuration.
354///
355/// This gives full control over all configuration options.
356///
357/// # Arguments
358///
359/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
360/// * `config` - Configuration options for the logger.
361///
362/// # Returns
363///
364/// `Ok(())` if initialization succeeded, or an error if it failed.
365///
366/// # Example
367///
368/// ```rust
369/// use fstdout_logger::{init_logger_with_config, LoggerConfig};
370/// use log::LevelFilter;
371///
372/// // Create a custom configuration
373/// let config = LoggerConfig::builder()
374///     .level(LevelFilter::Debug)
375///     .show_file_info(false)
376///     .use_colors(true)
377///     .build();
378///
379/// init_logger_with_config(Some("app.log"), config)
380///     .expect("Failed to initialize logger");
381/// ```
382pub fn init_logger_with_config<P: AsRef<Path>>(
383    file_path: Option<P>,
384    config: LoggerConfig,
385) -> Result<(), LogError> {
386    let level = config.level;
387    FStdoutLogger::with_config(file_path, config)?.init_with_level(level)
388}
389
390/// Initialize a production-ready logger (no file info, concise format).
391///
392/// This uses [`LoggerConfig::production()`] which is optimized for
393/// clean, minimal output in production environments:
394/// - `Info` as the minimum log level (no debug messages)
395/// - No file information shown in logs
396/// - No date in stdout output (only time)
397/// - Colors enabled for better readability
398///
399/// # Arguments
400///
401/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
402///
403/// # Returns
404///
405/// `Ok(())` if initialization succeeded, or an error if it failed.
406///
407/// # Example
408///
409/// ```rust
410/// use fstdout_logger::init_production_logger;
411///
412/// init_production_logger(Some("app.log"))
413///     .expect("Failed to initialize production logger");
414/// ```
415pub fn init_production_logger<P: AsRef<Path>>(file_path: Option<P>) -> Result<(), LogError> {
416    init_logger_with_config(file_path, LoggerConfig::production())
417}
418
419/// Initialize a development logger (with file info, colored output).
420///
421/// This uses [`LoggerConfig::development()`] which is optimized for
422/// detailed output during development:
423/// - `Debug` as the minimum log level (shows debug messages)
424/// - File information shown in logs (helps with debugging)
425/// - No date in stdout output (only time)
426/// - Colors enabled for better readability
427///
428/// # Arguments
429///
430/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
431///
432/// # Returns
433///
434/// `Ok(())` if initialization succeeded, or an error if it failed.
435///
436/// # Example
437///
438/// ```rust
439/// use fstdout_logger::init_development_logger;
440///
441/// init_development_logger(Some("debug.log"))
442///     .expect("Failed to initialize development logger");
443/// ```
444pub fn init_development_logger<P: AsRef<Path>>(file_path: Option<P>) -> Result<(), LogError> {
445    init_logger_with_config(file_path, LoggerConfig::development())
446}
447
448/// Initialize a logger that only writes to stdout (not to a file).
449///
450/// # Arguments
451///
452/// * `config` - Configuration options for the logger.
453///
454/// # Returns
455///
456/// `Ok(())` if initialization succeeded, or an error if it failed.
457///
458/// # Example
459///
460/// ```rust
461/// use fstdout_logger::{init_stdout_logger, LoggerConfig};
462///
463/// init_stdout_logger(LoggerConfig::default())
464///     .expect("Failed to initialize stdout logger");
465/// ```
466pub fn init_stdout_logger(config: LoggerConfig) -> Result<(), LogError> {
467    init_logger_with_config(None::<String>, config)
468}
469
470/// Initialize a minimal stdout-only logger with just the specified level.
471///
472/// This is the simplest way to get a stdout-only logger with a specific level.
473///
474/// # Arguments
475///
476/// * `level` - The minimum log level to display.
477///
478/// # Returns
479///
480/// `Ok(())` if initialization succeeded, or an error if it failed.
481///
482/// # Example
483///
484/// ```rust
485/// use fstdout_logger::init_simple_stdout_logger;
486/// use log::LevelFilter;
487///
488/// init_simple_stdout_logger(LevelFilter::Info)
489///     .expect("Failed to initialize simple logger");
490/// ```
491pub fn init_simple_stdout_logger(level: LevelFilter) -> Result<(), LogError> {
492    // Create a minimal config with the specified level
493    let config = LoggerConfig {
494        level,
495        ..LoggerConfig::default()
496    };
497
498    // Initialize with the config
499    FStdoutLogger::with_config(None::<String>, config)?.init_with_level(level)
500}
501
502/// Initialize a logger from the RUST_LOG environment variable.
503///
504/// This reads the RUST_LOG environment variable to configure log levels
505/// and module-specific filters. If RUST_LOG is not set, defaults to Info level.
506///
507/// # Supported RUST_LOG formats
508///
509/// - `debug` - Set default level to debug
510/// - `my_crate=debug` - Set specific module to debug
511/// - `my_crate::module=trace` - Set submodule to trace
512/// - `my_crate=debug,other_crate::module=trace` - Multiple module filters
513/// - `warn` - Set default level to warn
514///
515/// # Arguments
516///
517/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
518///
519/// # Returns
520///
521/// `Ok(())` if initialization succeeded, or an error if it failed.
522///
523/// # Example
524///
525/// ```rust
526/// use fstdout_logger::init_logger_from_env;
527///
528/// // Set RUST_LOG=debug or RUST_LOG=my_crate=trace,other=warn
529/// init_logger_from_env(Some("app.log"))
530///     .expect("Failed to initialize logger");
531/// ```
532pub fn init_logger_from_env<P: AsRef<Path>>(file_path: Option<P>) -> Result<(), LogError> {
533    let config = LoggerConfig::from_env();
534    // Always use Trace as max level to allow module filters to work correctly
535    FStdoutLogger::with_config(file_path, config)?.init()
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use log::{debug, error, info, trace, warn};
542    use std::fs;
543    use std::io::Read;
544
545    #[test]
546    fn test_stdout_logger() {
547        // This test only checks that initialization doesn't fail
548        let config = LoggerConfig::builder()
549            .level(LevelFilter::Debug)
550            .show_file_info(false)
551            .build();
552
553        let result = init_stdout_logger(config);
554        assert!(result.is_ok());
555    }
556
557    #[test]
558    fn test_file_logger() {
559        let test_file = "test_log.txt";
560        // Clean up any existing test file
561        let _ = fs::remove_file(test_file);
562
563        // Initialize logger
564        let config = LoggerConfig::builder()
565            .level(LevelFilter::Debug)
566            .show_file_info(true)
567            .use_colors(false)
568            .build();
569
570        let result = init_logger_with_config(Some(test_file), config);
571        assert!(result.is_ok());
572
573        // Log some messages
574        trace!("This is a trace message");
575        debug!("This is a debug message");
576        info!("This is an info message");
577        warn!("This is a warning message");
578        error!("This is an error message");
579
580        // Verify file contains logs
581        let mut file = File::open(test_file).expect("Failed to open log file");
582        let mut contents = String::new();
583        file.read_to_string(&mut contents)
584            .expect("Failed to read log file");
585
586        // Debug and higher should be logged
587        assert!(!contents.contains("trace message"));
588        assert!(contents.contains("debug message"));
589        assert!(contents.contains("info message"));
590        assert!(contents.contains("warning message"));
591        assert!(contents.contains("error message"));
592
593        // Clean up
594        let _ = fs::remove_file(test_file);
595    }
596}