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 log::{LevelFilter, Log, Metadata, Record};
58use std::fs::{File, OpenOptions};
59use std::io::{self, Write};
60use std::path::Path;
61use std::sync::Mutex;
62use thiserror::Error;
63
64mod config;
65pub mod examples;
66pub mod formatter;
67
68pub use config::{LoggerConfig, LoggerConfigBuilder};
69pub use formatter::LogFormatter;
70
71/// Errors that can occur when using the logger.
72#[derive(Error, Debug)]
73pub enum LogError {
74    /// I/O errors when opening or writing to log files.
75    #[error("IO error: {0}")]
76    Io(#[from] io::Error),
77
78    /// Errors when setting up the global logger.
79    #[error("Failed to set logger")]
80    Logger,
81}
82
83/// The main logger implementation that outputs to stdout and optionally to a file.
84///
85/// This struct implements the [`Log`] trait from the standard `log` crate,
86/// handling log messages by:
87///
88/// 1. Writing to stdout with optional colors and formatting
89/// 2. Writing to a file (if configured) with full details
90///
91/// # Example
92///
93/// ```rust
94/// use fstdout_logger::{FStdoutLogger, LoggerConfig};
95/// use log::LevelFilter;
96///
97/// // Creating a logger directly (usually done via helper functions)
98/// let logger = FStdoutLogger::with_config(
99///     Some("app.log"),
100///     LoggerConfig::default()
101/// ).expect("Failed to create logger");
102///
103/// // Initialize as the global logger
104/// logger.init_with_level(LevelFilter::Info).expect("Failed to initialize logger");
105/// ```
106pub struct FStdoutLogger {
107    /// Optional file to log to
108    log_file: Option<Mutex<File>>,
109
110    /// Formatter for log messages
111    formatter: LogFormatter,
112}
113
114impl FStdoutLogger {
115    /// Create a new logger with default configuration.
116    ///
117    /// This is a convenience method that uses [`LoggerConfig::default()`].
118    ///
119    /// # Arguments
120    ///
121    /// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
122    ///
123    /// # Returns
124    ///
125    /// A new logger instance or an error if the log file couldn't be opened.
126    pub fn new<P: AsRef<Path>>(file_path: Option<P>) -> Result<Self, LogError> {
127        Self::with_config(file_path, LoggerConfig::default())
128    }
129
130    /// Create a new logger with custom configuration.
131    ///
132    /// # Arguments
133    ///
134    /// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
135    /// * `config` - Configuration options for the logger.
136    ///
137    /// # Returns
138    ///
139    /// A new logger instance or an error if the log file couldn't be opened.
140    pub fn with_config<P: AsRef<Path>>(
141        file_path: Option<P>,
142        config: LoggerConfig,
143    ) -> Result<Self, LogError> {
144        let log_file = match file_path {
145            Some(path) => {
146                let file = OpenOptions::new().create(true).append(true).open(path)?;
147                Some(Mutex::new(file))
148            }
149            None => None,
150        };
151
152        Ok(Self {
153            log_file,
154            formatter: LogFormatter::new(config),
155        })
156    }
157
158    /// Initialize the logger with the default configuration.
159    ///
160    /// This sets the maximum log level to `Trace` to enable all logs,
161    /// but actual filtering will happen according to the `level` setting
162    /// in the logger's configuration.
163    ///
164    /// # Returns
165    ///
166    /// `Ok(())` if initialization succeeded, or an error if it failed.
167    pub fn init(self) -> Result<(), LogError> {
168        if log::set_logger(Box::leak(Box::new(self))).is_err() {
169            return Err(LogError::Logger);
170        }
171        log::set_max_level(LevelFilter::Trace);
172        Ok(())
173    }
174
175    /// Initialize the logger with a specific log level.
176    ///
177    /// This sets the global maximum log level, overriding the level
178    /// in the logger's configuration.
179    ///
180    /// # Arguments
181    ///
182    /// * `level` - The minimum log level to display.
183    ///
184    /// # Returns
185    ///
186    /// `Ok(())` if initialization succeeded, or an error if it failed.
187    pub fn init_with_level(self, level: LevelFilter) -> Result<(), LogError> {
188        if log::set_logger(Box::leak(Box::new(self))).is_err() {
189            return Err(LogError::Logger);
190        }
191        log::set_max_level(level);
192        Ok(())
193    }
194}
195
196/// Implementation of the `Log` trait for `FStdoutLogger`.
197///
198/// This handles:
199/// - Checking if a log message should be processed
200/// - Formatting messages differently for stdout and file
201/// - Writing to both destinations
202/// - Flushing output streams
203impl Log for FStdoutLogger {
204    fn enabled(&self, metadata: &Metadata) -> bool {
205        metadata.level() <= log::max_level()
206    }
207
208    fn log(&self, record: &Record) {
209        if !self.enabled(record.metadata()) {
210            return;
211        }
212
213        // Format for stdout (with or without colors)
214        let stdout_formatted = format!("{}\n", self.formatter.format_stdout(record));
215
216        // Log to stdout
217        print!("{stdout_formatted}");
218
219        // Log to file if configured
220        if let Some(file) = &self.log_file {
221            if let Ok(mut file) = file.lock() {
222                // Format for file (always without colors)
223                let file_formatted = self.formatter.format_file(record);
224
225                // Ignore errors when writing to file as we don't want to crash the application
226                let _ = file.write_all(file_formatted.as_bytes());
227            }
228        }
229    }
230
231    fn flush(&self) {
232        // Flush stdout
233        let _ = io::stdout().flush();
234
235        // Flush file if configured
236        if let Some(file) = &self.log_file {
237            if let Ok(mut file) = file.lock() {
238                let _ = file.flush();
239            }
240        }
241    }
242}
243
244//
245// Helper functions for easily initializing the logger
246//
247
248/// Initialize a logger with default configuration.
249///
250/// This uses [`LoggerConfig::default()`] which sets:
251/// - `Info` as the minimum log level
252/// - File information shown in logs
253/// - No date in stdout output (only time)
254/// - Colors enabled for terminal output
255///
256/// # Arguments
257///
258/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
259///
260/// # Returns
261///
262/// `Ok(())` if initialization succeeded, or an error if it failed.
263///
264/// # Example
265///
266/// ```rust
267/// use fstdout_logger::init_logger;
268/// use log::info;
269///
270/// init_logger(Some("app.log")).expect("Failed to initialize logger");
271/// info!("Logger initialized with default settings");
272///
273/// ```
274pub fn init_logger<P: AsRef<Path>>(file_path: Option<P>) -> Result<(), LogError> {
275    FStdoutLogger::new(file_path)?.init()
276}
277
278/// Initialize a logger with a specific log level.
279///
280/// This uses the default configuration but overrides the log level.
281///
282/// # Arguments
283///
284/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
285/// * `level` - The minimum log level to display.
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_with_level;
295/// use log::LevelFilter;
296///
297/// init_logger_with_level(Some("debug.log"), LevelFilter::Debug)
298///     .expect("Failed to initialize logger");
299/// ```
300pub fn init_logger_with_level<P: AsRef<Path>>(
301    file_path: Option<P>,
302    level: LevelFilter,
303) -> Result<(), LogError> {
304    FStdoutLogger::new(file_path)?.init_with_level(level)
305}
306
307/// Initialize a logger with custom configuration.
308///
309/// This gives full control over all configuration options.
310///
311/// # Arguments
312///
313/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
314/// * `config` - Configuration options for the logger.
315///
316/// # Returns
317///
318/// `Ok(())` if initialization succeeded, or an error if it failed.
319///
320/// # Example
321///
322/// ```rust
323/// use fstdout_logger::{init_logger_with_config, LoggerConfig};
324/// use log::LevelFilter;
325///
326/// // Create a custom configuration
327/// let config = LoggerConfig::builder()
328///     .level(LevelFilter::Debug)
329///     .show_file_info(false)
330///     .use_colors(true)
331///     .build();
332///
333/// init_logger_with_config(Some("app.log"), config)
334///     .expect("Failed to initialize logger");
335/// ```
336pub fn init_logger_with_config<P: AsRef<Path>>(
337    file_path: Option<P>,
338    config: LoggerConfig,
339) -> Result<(), LogError> {
340    let level = config.level;
341    FStdoutLogger::with_config(file_path, config)?.init_with_level(level)
342}
343
344/// Initialize a production-ready logger (no file info, concise format).
345///
346/// This uses [`LoggerConfig::production()`] which is optimized for
347/// clean, minimal output in production environments:
348/// - `Info` as the minimum log level (no debug messages)
349/// - No file information shown in logs
350/// - No date in stdout output (only time)
351/// - Colors enabled for better readability
352///
353/// # Arguments
354///
355/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
356///
357/// # Returns
358///
359/// `Ok(())` if initialization succeeded, or an error if it failed.
360///
361/// # Example
362///
363/// ```rust
364/// use fstdout_logger::init_production_logger;
365///
366/// init_production_logger(Some("app.log"))
367///     .expect("Failed to initialize production logger");
368/// ```
369pub fn init_production_logger<P: AsRef<Path>>(file_path: Option<P>) -> Result<(), LogError> {
370    init_logger_with_config(file_path, LoggerConfig::production())
371}
372
373/// Initialize a development logger (with file info, colored output).
374///
375/// This uses [`LoggerConfig::development()`] which is optimized for
376/// detailed output during development:
377/// - `Debug` as the minimum log level (shows debug messages)
378/// - File information shown in logs (helps with debugging)
379/// - No date in stdout output (only time)
380/// - Colors enabled for better readability
381///
382/// # Arguments
383///
384/// * `file_path` - Optional path to a log file. If `None`, logs will only go to stdout.
385///
386/// # Returns
387///
388/// `Ok(())` if initialization succeeded, or an error if it failed.
389///
390/// # Example
391///
392/// ```rust
393/// use fstdout_logger::init_development_logger;
394///
395/// init_development_logger(Some("debug.log"))
396///     .expect("Failed to initialize development logger");
397/// ```
398pub fn init_development_logger<P: AsRef<Path>>(file_path: Option<P>) -> Result<(), LogError> {
399    init_logger_with_config(file_path, LoggerConfig::development())
400}
401
402/// Initialize a logger that only writes to stdout (not to a file).
403///
404/// # Arguments
405///
406/// * `config` - Configuration options for the logger.
407///
408/// # Returns
409///
410/// `Ok(())` if initialization succeeded, or an error if it failed.
411///
412/// # Example
413///
414/// ```rust
415/// use fstdout_logger::{init_stdout_logger, LoggerConfig};
416///
417/// init_stdout_logger(LoggerConfig::default())
418///     .expect("Failed to initialize stdout logger");
419/// ```
420pub fn init_stdout_logger(config: LoggerConfig) -> Result<(), LogError> {
421    init_logger_with_config(None::<String>, config)
422}
423
424/// Initialize a minimal stdout-only logger with just the specified level.
425///
426/// This is the simplest way to get a stdout-only logger with a specific level.
427///
428/// # Arguments
429///
430/// * `level` - The minimum log level to display.
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_simple_stdout_logger;
440/// use log::LevelFilter;
441///
442/// init_simple_stdout_logger(LevelFilter::Info)
443///     .expect("Failed to initialize simple logger");
444/// ```
445pub fn init_simple_stdout_logger(level: LevelFilter) -> Result<(), LogError> {
446    // Create a minimal config with the specified level
447    let config = LoggerConfig {
448        level,
449        ..LoggerConfig::default()
450    };
451
452    // Initialize with the config
453    FStdoutLogger::with_config(None::<String>, config)?.init_with_level(level)
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use log::{debug, error, info, trace, warn};
460    use std::fs;
461    use std::io::Read;
462
463    #[test]
464    fn test_stdout_logger() {
465        // This test only checks that initialization doesn't fail
466        let config = LoggerConfig::builder()
467            .level(LevelFilter::Debug)
468            .show_file_info(false)
469            .build();
470
471        let result = init_stdout_logger(config);
472        assert!(result.is_ok());
473    }
474
475    #[test]
476    fn test_file_logger() {
477        let test_file = "test_log.txt";
478        // Clean up any existing test file
479        let _ = fs::remove_file(test_file);
480
481        // Initialize logger
482        let config = LoggerConfig::builder()
483            .level(LevelFilter::Debug)
484            .show_file_info(true)
485            .use_colors(false)
486            .build();
487
488        let result = init_logger_with_config(Some(test_file), config);
489        assert!(result.is_ok());
490
491        // Log some messages
492        trace!("This is a trace message");
493        debug!("This is a debug message");
494        info!("This is an info message");
495        warn!("This is a warning message");
496        error!("This is an error message");
497
498        // Verify file contains logs
499        let mut file = File::open(test_file).expect("Failed to open log file");
500        let mut contents = String::new();
501        file.read_to_string(&mut contents)
502            .expect("Failed to read log file");
503
504        // Debug and higher should be logged
505        assert!(!contents.contains("trace message"));
506        assert!(contents.contains("debug message"));
507        assert!(contents.contains("info message"));
508        assert!(contents.contains("warning message"));
509        assert!(contents.contains("error message"));
510
511        // Clean up
512        let _ = fs::remove_file(test_file);
513    }
514}