Skip to main content

piquel_log/
config.rs

1use parking_lot::Mutex;
2use std::sync::Arc;
3
4use tracing_subscriber::{Registry, filter::LevelFilter, prelude::*, util::SubscriberInitExt};
5
6use crate::{
7    LogLevel,
8    error::{BuildError, InitError},
9    layer::{BackendId, BackendLayer, SinkRegistry},
10    sink::FormatterConfig,
11    sinks::console::ConsoleSink,
12};
13
14#[cfg(feature = "file")]
15use crate::sinks::file::{FileSink, validate_file_config};
16
17#[cfg(feature = "store")]
18use crate::{sinks::store::StoreSink, store::LogStore};
19
20/// If you want to add a backend to the library, add it to this enum.
21/// The compiler will tell you what you need to updated.
22#[derive(Debug, Clone)]
23enum BackendKind {
24    Console,
25    #[cfg(feature = "file")]
26    File(FileConfig),
27    #[cfg(feature = "store")]
28    Store(LogStore),
29}
30
31#[derive(Debug, Clone)]
32struct BackendSpec {
33    id: BackendId,
34    kind: BackendKind,
35}
36
37#[allow(clippy::struct_excessive_bools)]
38#[derive(Debug, Clone)]
39struct LoggerState {
40    max_level: LogLevel,
41    ansi: bool,
42    target: bool,
43    timestamp: bool,
44    backend_specs: Vec<BackendSpec>,
45    next_backend_id: BackendId,
46    #[cfg(feature = "log")]
47    log_bridge: bool,
48}
49
50impl Default for LoggerState {
51    fn default() -> Self {
52        Self {
53            max_level: LogLevel::Info,
54            ansi: true,
55            target: true,
56            timestamp: true,
57            backend_specs: vec![BackendSpec {
58                id: 0,
59                kind: BackendKind::Console,
60            }],
61            next_backend_id: 1,
62            #[cfg(feature = "log")]
63            log_bridge: false,
64        }
65    }
66}
67
68/// Builder and runtime handle for the crate's tracing backend.
69#[derive(Clone, Default)]
70pub struct Logger {
71    state: Arc<Mutex<LoggerState>>,
72    sinks: Arc<SinkRegistry>,
73}
74
75impl std::fmt::Debug for Logger {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        let state = self.lock_state();
78        f.debug_struct("Logger")
79            .field("max_level", &state.max_level)
80            .field("ansi", &state.ansi)
81            .field("target", &state.target)
82            .field("timestamp", &state.timestamp)
83            .field("backend_specs", &state.backend_specs)
84            .field("next_backend_id", &state.next_backend_id)
85            .finish_non_exhaustive()
86    }
87}
88
89impl Logger {
90    /// Create a logger with sensible defaults.
91    ///
92    /// Defaults:
93    /// - max level: `INFO`
94    /// - console output: enabled
95    /// - ANSI colors: enabled
96    /// - timestamps: enabled
97    /// - targets: enabled
98    /// - file output: disabled
99    /// - store output: disabled
100    /// - `log` bridge: disabled
101    #[must_use]
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Set the global maximum level applied during [`Self::init`].
107    #[must_use]
108    pub fn with_max_level(self, level: LogLevel) -> Self {
109        self.update_state(|state| state.max_level = level);
110        self
111    }
112
113    /// Enable or disable the console sink.
114    ///
115    /// Disabling the console sink removes it from the live backend stack.
116    #[must_use]
117    pub fn with_console(self, enabled: bool) -> Self {
118        self.set_console_enabled(enabled);
119        self
120    }
121
122    /// Enable or disable ANSI coloring for console output.
123    #[must_use]
124    pub fn with_ansi(self, enabled: bool) -> Self {
125        self.update_state(|state| state.ansi = enabled);
126        self
127    }
128
129    /// Enable or disable including the event target in rendered output.
130    #[must_use]
131    pub fn with_target(self, enabled: bool) -> Self {
132        self.update_state(|state| state.target = enabled);
133        self
134    }
135
136    /// Enable or disable timestamps in rendered output.
137    #[must_use]
138    pub fn with_timestamp(self, enabled: bool) -> Self {
139        self.update_state(|state| state.timestamp = enabled);
140        self
141    }
142
143    /// Stage a file backend so the next [`Self::build`] or [`Self::init`]
144    /// includes it in the active backend stack.
145    #[cfg(feature = "file")]
146    #[cfg_attr(docsrs, doc(cfg(feature = "file")))]
147    #[must_use]
148    pub fn with_file(self, config: FileConfig) -> Self {
149        self.stage_backend(BackendKind::File(config));
150        self
151    }
152
153    /// Add a file backend to the active backend stack.
154    ///
155    /// The backend starts receiving events immediately after this method
156    /// succeeds.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`BuildError`] when the file backend configuration is invalid
161    /// or the sink cannot be constructed.
162    #[cfg(feature = "file")]
163    #[cfg_attr(docsrs, doc(cfg(feature = "file")))]
164    pub fn add_file_backend(&self, config: FileConfig) -> Result<(), BuildError> {
165        let sink = Arc::new(Self::build_file_sink(&config)?);
166        let backend_id = self.stage_backend(BackendKind::File(config));
167        self.sinks.insert(backend_id, sink);
168        Ok(())
169    }
170
171    /// Stage an in-memory store backend so the next [`Self::build`] or
172    /// [`Self::init`] includes it in the active backend stack.
173    #[cfg(feature = "store")]
174    #[cfg_attr(docsrs, doc(cfg(feature = "store")))]
175    #[must_use]
176    pub fn with_store(self, store: LogStore) -> Self {
177        self.stage_backend(BackendKind::Store(store));
178        self
179    }
180
181    /// Add an in-memory store backend to the active backend stack.
182    ///
183    /// The backend starts receiving events immediately after this method
184    /// succeeds.
185    #[cfg(feature = "store")]
186    #[cfg_attr(docsrs, doc(cfg(feature = "store")))]
187    pub fn add_store_backend(&self, store: LogStore) {
188        let sink = Arc::new(StoreSink::new(store.clone()));
189        let backend_id = self.stage_backend(BackendKind::Store(store));
190        self.sinks.insert(backend_id, sink);
191    }
192
193    /// Enable or disable forwarding `log` records as `tracing` events during
194    /// [`Self::init`].
195    #[cfg(feature = "log")]
196    #[cfg_attr(docsrs, doc(cfg(feature = "log")))]
197    #[must_use]
198    pub fn with_log_bridge(self, enabled: bool) -> Self {
199        self.update_state(|state| state.log_bridge = enabled);
200        self
201    }
202
203    /// Build a composable [`BackendLayer`] without installing it globally.
204    ///
205    /// Use this when the application already assembles its own subscriber
206    /// stack and only wants the backend output layer.
207    ///
208    /// Note that global max-level filtering is only installed by [`Self::init`].
209    /// When using `build`, apply filtering in your own subscriber stack.
210    ///
211    /// # Errors
212    ///
213    /// Returns [`BuildError`] when an optional sink cannot be constructed.
214    pub fn build(&self) -> Result<BackendLayer, BuildError> {
215        self.ensure_realized_backends()?;
216
217        Ok(BackendLayer::new(
218            self.formatter_config(),
219            Arc::clone(&self.sinks),
220        ))
221    }
222
223    /// Build and install the backend as the global tracing subscriber.
224    ///
225    /// This method also installs the optional `log` bridge if enabled.
226    ///
227    /// # Errors
228    ///
229    /// Returns [`InitError::AlreadyInitialized`] if a global subscriber is
230    /// already set, [`InitError::LogBridgeAlreadyInitialized`] if the `log`
231    /// logger was already installed, or wraps a [`BuildError`] otherwise.
232    pub fn init(&self) -> Result<(), InitError> {
233        let max_level = self.lock_state().max_level;
234        #[cfg(feature = "log")]
235        let log_bridge = self.lock_state().log_bridge;
236
237        let layer = self.build()?;
238
239        #[cfg(feature = "log")]
240        if log_bridge {
241            tracing_log::LogTracer::init().map_err(|_| InitError::LogBridgeAlreadyInitialized)?;
242        }
243
244        Registry::default()
245            .with(LevelFilter::from(max_level))
246            .with(layer)
247            .try_init()
248            .map_err(|_| InitError::AlreadyInitialized)
249    }
250
251    fn formatter_config(&self) -> FormatterConfig {
252        let state = self.lock_state();
253        FormatterConfig {
254            ansi: state.ansi,
255            target: state.target,
256            timestamp: state.timestamp,
257        }
258    }
259
260    fn ensure_realized_backends(&self) -> Result<(), BuildError> {
261        let specs = self.lock_state().backend_specs.clone();
262
263        for spec in specs {
264            if self.sinks.contains(spec.id) {
265                continue;
266            }
267
268            let sink = Self::build_sink(&spec.kind)?;
269            self.sinks.insert(spec.id, sink);
270        }
271
272        Ok(())
273    }
274
275    #[allow(clippy::unnecessary_wraps)]
276    fn build_sink(kind: &BackendKind) -> Result<crate::sink::SharedSink, BuildError> {
277        Ok(match kind {
278            BackendKind::Console => Arc::new(ConsoleSink),
279            #[cfg(feature = "file")]
280            BackendKind::File(config) => Arc::new(Self::build_file_sink(config)?),
281            #[cfg(feature = "store")]
282            BackendKind::Store(store) => Arc::new(StoreSink::new(store.clone())),
283        })
284    }
285
286    #[cfg(feature = "file")]
287    fn build_file_sink(config: &FileConfig) -> Result<FileSink, BuildError> {
288        validate_file_config(config)?;
289        FileSink::new(config)
290    }
291
292    fn set_console_enabled(&self, enabled: bool) {
293        let backend_id = self.update_state(|state| {
294            let console_index = state
295                .backend_specs
296                .iter()
297                .position(|spec| matches!(spec.kind, BackendKind::Console));
298
299            match (enabled, console_index) {
300                (true, Some(index)) => Some(state.backend_specs[index].id),
301                (true, None) => {
302                    let id = state.next_backend_id;
303                    state.next_backend_id += 1;
304                    state.backend_specs.insert(
305                        0,
306                        BackendSpec {
307                            id,
308                            kind: BackendKind::Console,
309                        },
310                    );
311                    Some(id)
312                }
313                (false, Some(index)) => Some(state.backend_specs.remove(index).id),
314                (false, None) => None,
315            }
316        });
317
318        match (enabled, backend_id) {
319            (true, Some(id)) if !self.sinks.contains(id) => {
320                self.sinks.insert(id, Arc::new(ConsoleSink));
321            }
322            (false, Some(id)) => self.sinks.remove(id),
323            _ => {}
324        }
325    }
326
327    // This is used by other cargo features
328    #[allow(unused)]
329    fn stage_backend(&self, kind: BackendKind) -> BackendId {
330        self.update_state(|state| {
331            let id = state.next_backend_id;
332            state.next_backend_id += 1;
333            state.backend_specs.push(BackendSpec { id, kind });
334            id
335        })
336    }
337
338    fn update_state<T>(&self, update: impl FnOnce(&mut LoggerState) -> T) -> T {
339        let mut state = self.state.lock();
340        update(&mut state)
341    }
342
343    fn lock_state(&self) -> parking_lot::MutexGuard<'_, LoggerState> {
344        self.state.lock()
345    }
346}
347
348/// File output configuration.
349#[cfg(feature = "file")]
350#[cfg_attr(docsrs, doc(cfg(feature = "file")))]
351#[derive(Debug, Clone, PartialEq, Eq)]
352pub struct FileConfig {
353    pub(crate) directory: std::path::PathBuf,
354    pub(crate) latest_file_name: String,
355    pub(crate) session_file_prefix: Option<String>,
356}
357
358#[cfg(feature = "file")]
359impl FileConfig {
360    /// Create a file configuration rooted in `directory`.
361    #[must_use]
362    pub fn new(directory: impl Into<std::path::PathBuf>) -> Self {
363        Self {
364            directory: directory.into(),
365            latest_file_name: String::from("latest.log"),
366            session_file_prefix: None,
367        }
368    }
369
370    /// Change the path used for the rolling "latest" session file.
371    #[must_use]
372    pub fn with_latest_file_name(mut self, file_name: impl Into<String>) -> Self {
373        self.latest_file_name = file_name.into();
374        self
375    }
376
377    /// Prefix the generated session file name.
378    ///
379    /// With prefix `app`, session files look like `app-2026-05-25_14-22-10.log`.
380    #[must_use]
381    pub fn with_session_file_prefix(mut self, prefix: impl Into<String>) -> Self {
382        self.session_file_prefix = Some(prefix.into());
383        self
384    }
385
386    /// Return the configured output directory.
387    #[must_use]
388    pub fn directory(&self) -> &std::path::Path {
389        &self.directory
390    }
391
392    /// Return the configured latest file name.
393    #[must_use]
394    pub fn latest_file_name(&self) -> &str {
395        &self.latest_file_name
396    }
397
398    /// Return the configured session prefix, if any.
399    #[must_use]
400    pub fn session_file_prefix(&self) -> Option<&str> {
401        self.session_file_prefix.as_deref()
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::{BackendKind, Logger};
408    use crate::LogLevel;
409
410    #[test]
411    fn defaults_match_public_contract() {
412        let logger = Logger::new();
413        let state = logger.lock_state();
414
415        assert_eq!(state.max_level, LogLevel::Info);
416        assert!(state.ansi);
417        assert!(state.target);
418        assert!(state.timestamp);
419        assert_eq!(state.backend_specs.len(), 1);
420        assert!(matches!(state.backend_specs[0].kind, BackendKind::Console));
421
422        #[cfg(feature = "log")]
423        assert!(!state.log_bridge);
424    }
425
426    #[test]
427    fn max_level_is_stored() {
428        let logger = Logger::new().with_max_level(LogLevel::Debug);
429        assert_eq!(logger.lock_state().max_level, LogLevel::Debug);
430    }
431
432    #[test]
433    fn console_sink_can_be_disabled() {
434        let logger = Logger::new().with_console(false);
435        assert!(logger.lock_state().backend_specs.is_empty());
436    }
437
438    #[test]
439    fn console_sink_can_be_reenabled() {
440        let logger = Logger::new().with_console(false).with_console(true);
441        let state = logger.lock_state();
442
443        assert_eq!(state.backend_specs.len(), 1);
444        assert!(matches!(state.backend_specs[0].kind, BackendKind::Console));
445    }
446}