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};
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 archive_file = File::create(&archive_ref)?;
158
159                    let encoder = GzEncoder::new(archive_file, Compression::default());
160                    let mut archive = Builder::new(encoder);
161
162                    archive.append_file(
163                        Path::new(&format!("{}.log", file_basename)),
164                        &mut File::open(file)?,
165                    )?;
166
167                    archive.into_inner().unwrap();
168                }
169                let file = OpenOptions::new()
170                    .create(true)
171                    .truncate(true)
172                    .write(true)
173                    .open(path)?;
174                Some(Mutex::new(file))
175            }
176            None => None,
177        };
178
179        Ok(Self {
180            log_file,
181            formatter: LogFormatter::new(config),
182        })
183    }
184
185    /// Initialize the logger with the default configuration.
186    ///
187    /// This sets the maximum log level to `Trace` to enable all logs,
188    /// but actual filtering will happen according to the `level` setting
189    /// in the logger's configuration.
190    ///
191    /// # Returns
192    ///
193    /// `Ok(())` if initialization succeeded, or an error if it failed.
194    pub fn init(self) -> Result<(), LogError> {
195        if log::set_logger(Box::leak(Box::new(self))).is_err() {
196            return Err(LogError::Logger);
197        }
198        log::set_max_level(LevelFilter::Trace);
199        Ok(())
200    }
201
202    /// Initialize the logger with a specific log level.
203    ///
204    /// This sets the global maximum log level, overriding the level
205    /// in the logger's configuration.
206    ///
207    /// # Arguments
208    ///
209    /// * `level` - The minimum log level to display.
210    ///
211    /// # Returns
212    ///
213    /// `Ok(())` if initialization succeeded, or an error if it failed.
214    pub fn init_with_level(self, level: LevelFilter) -> Result<(), LogError> {
215        if log::set_logger(Box::leak(Box::new(self))).is_err() {
216            return Err(LogError::Logger);
217        }
218        log::set_max_level(level);
219        Ok(())
220    }
221}
222
223/// Implementation of the `Log` trait for `FStdoutLogger`.
224///
225/// This handles:
226/// - Checking if a log message should be processed
227/// - Formatting messages differently for stdout and file
228/// - Writing to both destinations
229/// - Flushing output streams
230impl Log for FStdoutLogger {
231    fn enabled(&self, metadata: &Metadata) -> bool {
232        metadata.level() <= log::max_level()
233    }
234
235    fn log(&self, record: &Record) {
236        if !self.enabled(record.metadata()) {
237            return;
238        }
239
240        // Format for stdout (with or without colors)
241        let stdout_formatted = format!("{}\n", self.formatter.format_stdout(record));
242
243        // Log to stdout
244        print!("{stdout_formatted}");
245
246        // Log to file if configured
247        if let Some(file) = &self.log_file {
248            if let Ok(mut file) = file.lock() {
249                // Format for file (always without colors)
250                let file_formatted = self.formatter.format_file(record);
251
252                // Ignore errors when writing to file as we don't want to crash the application
253                let _ = file.write_all(file_formatted.as_bytes());
254            }
255        }
256    }
257
258    fn flush(&self) {
259        // Flush stdout
260        let _ = io::stdout().flush();
261
262        // Flush file if configured
263        if let Some(file) = &self.log_file {
264            if let Ok(mut file) = file.lock() {
265                let _ = file.flush();
266            }
267        }
268    }
269}
270
271//
272// Helper functions for easily initializing the logger
273//
274
275/// Initialize a logger with default configuration.
276///
277/// This uses [`LoggerConfig::default()`] which sets:
278/// - `Info` as the minimum log level
279/// - File information shown in logs
280/// - No date in stdout output (only time)
281/// - Colors enabled for terminal output
282///
283/// # Arguments
284///
285/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
286///
287/// # Returns
288///
289/// `Ok(())` if initialization succeeded, or an error if it failed.
290///
291/// # Example
292///
293/// ```rust
294/// use fstdout_logger::init_logger;
295/// use log::info;
296///
297/// init_logger(Some("app.log")).expect("Failed to initialize logger");
298/// info!("Logger initialized with default settings");
299///
300/// ```
301pub fn init_logger<P: AsRef<Path>>(file_path: Option<P>) -> Result<(), LogError> {
302    FStdoutLogger::new(file_path)?.init()
303}
304
305/// Initialize a logger with a specific log level.
306///
307/// This uses the default configuration but overrides the log level.
308///
309/// # Arguments
310///
311/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
312/// * `level` - The minimum log level to display.
313///
314/// # Returns
315///
316/// `Ok(())` if initialization succeeded, or an error if it failed.
317///
318/// # Example
319///
320/// ```rust
321/// use fstdout_logger::init_logger_with_level;
322/// use log::LevelFilter;
323///
324/// init_logger_with_level(Some("debug.log"), LevelFilter::Debug)
325///     .expect("Failed to initialize logger");
326/// ```
327pub fn init_logger_with_level<P: AsRef<Path>>(
328    file_path: Option<P>,
329    level: LevelFilter,
330) -> Result<(), LogError> {
331    FStdoutLogger::new(file_path)?.init_with_level(level)
332}
333
334/// Initialize a logger with custom configuration.
335///
336/// This gives full control over all configuration options.
337///
338/// # Arguments
339///
340/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
341/// * `config` - Configuration options for the logger.
342///
343/// # Returns
344///
345/// `Ok(())` if initialization succeeded, or an error if it failed.
346///
347/// # Example
348///
349/// ```rust
350/// use fstdout_logger::{init_logger_with_config, LoggerConfig};
351/// use log::LevelFilter;
352///
353/// // Create a custom configuration
354/// let config = LoggerConfig::builder()
355///     .level(LevelFilter::Debug)
356///     .show_file_info(false)
357///     .use_colors(true)
358///     .build();
359///
360/// init_logger_with_config(Some("app.log"), config)
361///     .expect("Failed to initialize logger");
362/// ```
363pub fn init_logger_with_config<P: AsRef<Path>>(
364    file_path: Option<P>,
365    config: LoggerConfig,
366) -> Result<(), LogError> {
367    let level = config.level;
368    FStdoutLogger::with_config(file_path, config)?.init_with_level(level)
369}
370
371/// Initialize a production-ready logger (no file info, concise format).
372///
373/// This uses [`LoggerConfig::production()`] which is optimized for
374/// clean, minimal output in production environments:
375/// - `Info` as the minimum log level (no debug messages)
376/// - No file information shown in logs
377/// - No date in stdout output (only time)
378/// - Colors enabled for better readability
379///
380/// # Arguments
381///
382/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
383///
384/// # Returns
385///
386/// `Ok(())` if initialization succeeded, or an error if it failed.
387///
388/// # Example
389///
390/// ```rust
391/// use fstdout_logger::init_production_logger;
392///
393/// init_production_logger(Some("app.log"))
394///     .expect("Failed to initialize production logger");
395/// ```
396pub fn init_production_logger<P: AsRef<Path>>(file_path: Option<P>) -> Result<(), LogError> {
397    init_logger_with_config(file_path, LoggerConfig::production())
398}
399
400/// Initialize a development logger (with file info, colored output).
401///
402/// This uses [`LoggerConfig::development()`] which is optimized for
403/// detailed output during development:
404/// - `Debug` as the minimum log level (shows debug messages)
405/// - File information shown in logs (helps with debugging)
406/// - No date in stdout output (only time)
407/// - Colors enabled for better readability
408///
409/// # Arguments
410///
411/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
412///
413/// # Returns
414///
415/// `Ok(())` if initialization succeeded, or an error if it failed.
416///
417/// # Example
418///
419/// ```rust
420/// use fstdout_logger::init_development_logger;
421///
422/// init_development_logger(Some("debug.log"))
423///     .expect("Failed to initialize development logger");
424/// ```
425pub fn init_development_logger<P: AsRef<Path>>(file_path: Option<P>) -> Result<(), LogError> {
426    init_logger_with_config(file_path, LoggerConfig::development())
427}
428
429/// Initialize a logger that only writes to stdout (not to a file).
430///
431/// # Arguments
432///
433/// * `config` - Configuration options for the logger.
434///
435/// # Returns
436///
437/// `Ok(())` if initialization succeeded, or an error if it failed.
438///
439/// # Example
440///
441/// ```rust
442/// use fstdout_logger::{init_stdout_logger, LoggerConfig};
443///
444/// init_stdout_logger(LoggerConfig::default())
445///     .expect("Failed to initialize stdout logger");
446/// ```
447pub fn init_stdout_logger(config: LoggerConfig) -> Result<(), LogError> {
448    init_logger_with_config(None::<String>, config)
449}
450
451/// Initialize a minimal stdout-only logger with just the specified level.
452///
453/// This is the simplest way to get a stdout-only logger with a specific level.
454///
455/// # Arguments
456///
457/// * `level` - The minimum log level to display.
458///
459/// # Returns
460///
461/// `Ok(())` if initialization succeeded, or an error if it failed.
462///
463/// # Example
464///
465/// ```rust
466/// use fstdout_logger::init_simple_stdout_logger;
467/// use log::LevelFilter;
468///
469/// init_simple_stdout_logger(LevelFilter::Info)
470///     .expect("Failed to initialize simple logger");
471/// ```
472pub fn init_simple_stdout_logger(level: LevelFilter) -> Result<(), LogError> {
473    // Create a minimal config with the specified level
474    let config = LoggerConfig {
475        level,
476        ..LoggerConfig::default()
477    };
478
479    // Initialize with the config
480    FStdoutLogger::with_config(None::<String>, config)?.init_with_level(level)
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use log::{debug, error, info, trace, warn};
487    use std::fs;
488    use std::io::Read;
489
490    #[test]
491    fn test_stdout_logger() {
492        // This test only checks that initialization doesn't fail
493        let config = LoggerConfig::builder()
494            .level(LevelFilter::Debug)
495            .show_file_info(false)
496            .build();
497
498        let result = init_stdout_logger(config);
499        assert!(result.is_ok());
500    }
501
502    #[test]
503    fn test_file_logger() {
504        let test_file = "test_log.txt";
505        // Clean up any existing test file
506        let _ = fs::remove_file(test_file);
507
508        // Initialize logger
509        let config = LoggerConfig::builder()
510            .level(LevelFilter::Debug)
511            .show_file_info(true)
512            .use_colors(false)
513            .build();
514
515        let result = init_logger_with_config(Some(test_file), config);
516        assert!(result.is_ok());
517
518        // Log some messages
519        trace!("This is a trace message");
520        debug!("This is a debug message");
521        info!("This is an info message");
522        warn!("This is a warning message");
523        error!("This is an error message");
524
525        // Verify file contains logs
526        let mut file = File::open(test_file).expect("Failed to open log file");
527        let mut contents = String::new();
528        file.read_to_string(&mut contents)
529            .expect("Failed to read log file");
530
531        // Debug and higher should be logged
532        assert!(!contents.contains("trace message"));
533        assert!(contents.contains("debug message"));
534        assert!(contents.contains("info message"));
535        assert!(contents.contains("warning message"));
536        assert!(contents.contains("error message"));
537
538        // Clean up
539        let _ = fs::remove_file(test_file);
540    }
541}