Skip to main content

piquel_log/
config.rs

1use tracing_subscriber::{Registry, filter::LevelFilter, prelude::*, util::SubscriberInitExt};
2
3use crate::{
4    LogLevel,
5    error::{BuildError, InitError},
6    layer::BackendLayer,
7    sink::FormatterConfig,
8    sinks::console::ConsoleSink,
9};
10
11#[cfg(feature = "file")]
12use crate::sinks::file::{FileSink, validate_file_config};
13
14/// Builder for constructing and installing the crate's tracing backend.
15#[allow(clippy::struct_excessive_bools)]
16#[derive(Debug, Clone)]
17pub struct Logger {
18    max_level: LogLevel,
19    ansi: bool,
20    target: bool,
21    timestamp: bool,
22    #[cfg(feature = "file")]
23    file: Option<FileConfig>,
24    #[cfg(feature = "log")]
25    log_bridge: bool,
26}
27
28impl Default for Logger {
29    fn default() -> Self {
30        Self {
31            max_level: LogLevel::Info,
32            ansi: true,
33            target: true,
34            timestamp: true,
35            #[cfg(feature = "file")]
36            file: None,
37            #[cfg(feature = "log")]
38            log_bridge: false,
39        }
40    }
41}
42
43impl Logger {
44    /// Create a logger with sensible defaults.
45    ///
46    /// Defaults:
47    /// - max level: `INFO`
48    /// - ANSI colors: enabled
49    /// - timestamps: enabled
50    /// - targets: enabled
51    /// - file output: disabled
52    /// - `log` bridge: disabled
53    #[must_use]
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Set the global maximum level applied during [`Self::init`].
59    #[must_use]
60    pub fn with_max_level(mut self, level: LogLevel) -> Self {
61        self.max_level = level;
62        self
63    }
64
65    /// Enable or disable ANSI coloring for console output.
66    #[must_use]
67    pub fn with_ansi(mut self, enabled: bool) -> Self {
68        self.ansi = enabled;
69        self
70    }
71
72    /// Enable or disable including the event target in rendered output.
73    #[must_use]
74    pub fn with_target(mut self, enabled: bool) -> Self {
75        self.target = enabled;
76        self
77    }
78
79    /// Enable or disable timestamps in rendered output.
80    #[must_use]
81    pub fn with_timestamp(mut self, enabled: bool) -> Self {
82        self.timestamp = enabled;
83        self
84    }
85
86    /// Enable file output using the provided configuration.
87    #[cfg(feature = "file")]
88    #[cfg_attr(docsrs, doc(cfg(feature = "file")))]
89    #[must_use]
90    pub fn with_file(mut self, config: FileConfig) -> Self {
91        self.file = Some(config);
92        self
93    }
94
95    /// Enable or disable forwarding `log` records as `tracing` events during
96    /// [`Self::init`].
97    #[cfg(feature = "log")]
98    #[cfg_attr(docsrs, doc(cfg(feature = "log")))]
99    #[must_use]
100    pub fn with_log_bridge(mut self, enabled: bool) -> Self {
101        self.log_bridge = enabled;
102        self
103    }
104
105    /// Build a composable [`BackendLayer`] without installing it globally.
106    ///
107    /// Use this when the application already assembles its own subscriber
108    /// stack and only wants the backend output layer.
109    ///
110    /// Note that global max-level filtering is only installed by [`Self::init`].
111    /// When using `build`, apply filtering in your own subscriber stack.
112    ///
113    /// # Errors
114    ///
115    /// Returns [`BuildError`] when an optional sink cannot be constructed.
116    pub fn build(self) -> Result<BackendLayer, BuildError> {
117        let formatter = FormatterConfig {
118            ansi: self.ansi,
119            target: self.target,
120            timestamp: self.timestamp,
121        };
122
123        #[allow(unused_mut)]
124        let mut sinks = vec![Box::new(ConsoleSink) as _];
125
126        #[cfg(feature = "file")]
127        if let Some(file) = self.file {
128            validate_file_config(&file)?;
129            sinks.push(Box::new(FileSink::new(&file)?) as _);
130        }
131
132        Ok(BackendLayer::new(formatter, sinks))
133    }
134
135    /// Build and install the backend as the global tracing subscriber.
136    ///
137    /// This method also installs the optional `log` bridge if enabled.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`InitError::AlreadyInitialized`] if a global subscriber is
142    /// already set, [`InitError::LogBridgeAlreadyInitialized`] if the `log`
143    /// logger was already installed, or wraps a [`BuildError`] otherwise.
144    pub fn init(self) -> Result<(), InitError> {
145        let max_level = self.max_level;
146        #[cfg(feature = "log")]
147        let log_bridge = self.log_bridge;
148
149        let layer = self.build()?;
150
151        #[cfg(feature = "log")]
152        if log_bridge {
153            tracing_log::LogTracer::init().map_err(|_| InitError::LogBridgeAlreadyInitialized)?;
154        }
155
156        Registry::default()
157            .with(LevelFilter::from(max_level))
158            .with(layer)
159            .try_init()
160            .map_err(|_| InitError::AlreadyInitialized)
161    }
162}
163
164/// File output configuration.
165#[cfg(feature = "file")]
166#[cfg_attr(docsrs, doc(cfg(feature = "file")))]
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct FileConfig {
169    pub(crate) directory: std::path::PathBuf,
170    pub(crate) latest_file_name: String,
171    pub(crate) session_file_prefix: Option<String>,
172}
173
174#[cfg(feature = "file")]
175impl FileConfig {
176    /// Create a file configuration rooted in `directory`.
177    #[must_use]
178    pub fn new(directory: impl Into<std::path::PathBuf>) -> Self {
179        Self {
180            directory: directory.into(),
181            latest_file_name: String::from("latest.log"),
182            session_file_prefix: None,
183        }
184    }
185
186    /// Change the path used for the rolling "latest" session file.
187    #[must_use]
188    pub fn with_latest_file_name(mut self, file_name: impl Into<String>) -> Self {
189        self.latest_file_name = file_name.into();
190        self
191    }
192
193    /// Prefix the generated session file name.
194    ///
195    /// With prefix `app`, session files look like `app-2026-05-25_14-22-10.log`.
196    #[must_use]
197    pub fn with_session_file_prefix(mut self, prefix: impl Into<String>) -> Self {
198        self.session_file_prefix = Some(prefix.into());
199        self
200    }
201
202    /// Return the configured output directory.
203    #[must_use]
204    pub fn directory(&self) -> &std::path::Path {
205        &self.directory
206    }
207
208    /// Return the configured latest file name.
209    #[must_use]
210    pub fn latest_file_name(&self) -> &str {
211        &self.latest_file_name
212    }
213
214    /// Return the configured session prefix, if any.
215    #[must_use]
216    pub fn session_file_prefix(&self) -> Option<&str> {
217        self.session_file_prefix.as_deref()
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use tracing_subscriber::filter::LevelFilter;
224
225    use super::Logger;
226
227    #[test]
228    fn defaults_match_public_contract() {
229        let logger = Logger::new();
230
231        assert_eq!(logger.max_level, LevelFilter::INFO);
232        assert!(logger.ansi);
233        assert!(logger.target);
234        assert!(logger.timestamp);
235
236        #[cfg(feature = "log")]
237        assert!(!logger.log_bridge);
238
239        #[cfg(feature = "file")]
240        assert!(logger.file.is_none());
241    }
242
243    #[test]
244    fn max_level_is_stored() {
245        let logger = Logger::new().with_max_level(LevelFilter::DEBUG);
246        assert_eq!(logger.max_level, LevelFilter::DEBUG);
247    }
248}