Skip to main content

tiny_tracing/
config.rs

1use std::fs::{File, OpenOptions};
2use std::path::{Path, PathBuf};
3use std::str::FromStr;
4use std::sync::Mutex;
5
6use crate::{errors::LoggerError, time::LocalTimer};
7use tracing::Level;
8use tracing_subscriber::{
9    EnvFilter, Layer, Registry,
10    filter::LevelFilter,
11    fmt::{MakeWriter, format::FmtSpan},
12    layer::SubscriberExt,
13    util::SubscriberInitExt,
14};
15
16/// Output format for log lines.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum LogFormat {
19    /// Human-readable single-line text output.
20    #[default]
21    Text,
22    /// One JSON object per log line.
23    Json,
24}
25
26impl LogFormat {
27    fn as_str(self) -> &'static str {
28        match self {
29            Self::Text => "text",
30            Self::Json => "json",
31        }
32    }
33}
34
35impl std::fmt::Display for LogFormat {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.write_str(self.as_str())
38    }
39}
40
41impl FromStr for LogFormat {
42    type Err = LoggerError;
43
44    fn from_str(s: &str) -> Result<Self, Self::Err> {
45        match s.to_lowercase().as_str() {
46            "text" => Ok(Self::Text),
47            "json" => Ok(Self::Json),
48            other => Err(LoggerError::InvalidFormat(other.to_string())),
49        }
50    }
51}
52
53/// Where log lines are written.
54#[derive(Debug, Clone, PartialEq, Eq, Default)]
55pub enum Output {
56    /// Standard output only. This is the default.
57    #[default]
58    Stdout,
59    /// The given file only. Created if missing, appended otherwise.
60    File(PathBuf),
61    /// Both standard output and the given file.
62    ///
63    /// ANSI colours are kept on stdout but stripped from the file, so the
64    /// on-disk log stays free of escape codes.
65    Both(PathBuf),
66}
67
68/// Builder for initializing the global tracing subscriber.
69///
70/// Uses a fluent builder pattern to configure log level, output format,
71/// and optional environment-based filtering before calling [`init`](Logger::init).
72///
73/// # Examples
74///
75/// Minimal setup (text output at INFO level):
76///
77/// ```rust
78/// use tiny_tracing::Logger;
79///
80/// Logger::new().init().unwrap();
81/// tiny_tracing::info!("Ready");
82/// ```
83///
84/// JSON output with environment filter:
85///
86/// ```rust
87/// use tiny_tracing::{Logger, LogFormat, Level};
88///
89/// Logger::new()
90///     .with_level(Level::DEBUG)
91///     .with_format(LogFormat::Json)
92///     .with_env_filter("info,my_crate=trace")
93///     .init()
94///     .unwrap();
95/// ```
96pub struct Logger {
97    level: Level,
98    format: LogFormat,
99    env_filter: Option<String>,
100    with_file: bool,
101    with_target: bool,
102    output: Output,
103}
104
105impl Logger {
106    /// Creates a new [`Logger`] with default values.
107    ///
108    /// Defaults: [`Level::INFO`], [`LogFormat::Text`], no env filter,
109    /// file location off, target on, output to stdout.
110    #[must_use]
111    pub fn new() -> Self {
112        Self {
113            level: Level::INFO,
114            format: LogFormat::Text,
115            env_filter: None,
116            with_file: false,
117            with_target: true,
118            output: Output::Stdout,
119        }
120    }
121
122    /// Sets the global (default) log level.
123    ///
124    /// This is the base level applied to every target. Per-target directives
125    /// passed to [`with_env_filter`](Self::with_env_filter) refine it on top —
126    /// the level is never silently ignored. A global directive inside the env
127    /// filter string (e.g. the `info` in `"info,my_crate=debug"`) does take
128    /// precedence over this value.
129    #[must_use]
130    pub fn with_level(mut self, level: Level) -> Self {
131        self.level = level;
132        self
133    }
134
135    /// Sets the output format.
136    #[must_use]
137    pub fn with_format(mut self, format: LogFormat) -> Self {
138        self.format = format;
139        self
140    }
141
142    /// Adds environment-based, per-target filtering via [`EnvFilter`].
143    ///
144    /// The directives here are layered on top of the global
145    /// [`with_level`](Logger::with_level) value. Supported syntax: `"info"`,
146    /// `"info,my_crate=debug"`, etc.
147    ///
148    /// If not called, only the static [`with_level`](Logger::with_level) value applies.
149    #[must_use]
150    pub fn with_env_filter(mut self, filter: impl Into<String>) -> Self {
151        self.env_filter = Some(filter.into());
152        self
153    }
154
155    /// Controls whether the source file name appears in log lines.
156    ///
157    /// Disabled by default.
158    #[must_use]
159    pub fn with_file(mut self, enabled: bool) -> Self {
160        self.with_file = enabled;
161        self
162    }
163
164    /// Controls whether the module path (target) appears in log lines.
165    ///
166    /// Enabled by default.
167    #[must_use]
168    pub fn with_target(mut self, enabled: bool) -> Self {
169        self.with_target = enabled;
170        self
171    }
172
173    /// Sets where log lines are written: stdout, a file, or both.
174    ///
175    /// Defaults to [`Output::Stdout`]. When a file is involved it is opened in
176    /// append mode (created if missing) and writes are synchronised, so the
177    /// call stays panic-free — an unopenable path yields
178    /// [`LoggerError::OpenLogFile`] from [`init`](Self::init).
179    #[must_use]
180    pub fn with_output(mut self, output: Output) -> Self {
181        self.output = output;
182        self
183    }
184
185    /// Returns the configured log level.
186    #[must_use]
187    pub fn level(&self) -> Level {
188        self.level
189    }
190
191    /// Returns the configured output format.
192    #[must_use]
193    pub fn format(&self) -> LogFormat {
194        self.format
195    }
196
197    /// Initializes the global tracing subscriber.
198    ///
199    /// Consumes the builder. Must be called only once per process;
200    /// subsequent calls return [`LoggerError::TryInitError`].
201    ///
202    /// # Errors
203    ///
204    /// - [`LoggerError::InvalidEnvFilter`] if an env filter was set and
205    ///   [`EnvFilter`] rejects it.
206    /// - [`LoggerError::OpenLogFile`] if a file output was requested but the
207    ///   path could not be opened for writing.
208    /// - [`LoggerError::TryInitError`] if a global subscriber is already set.
209    pub fn init(self) -> Result<(), LoggerError> {
210        let filter = EnvFilter::builder()
211            .with_default_directive(LevelFilter::from_level(self.level).into())
212            .parse(self.env_filter.as_deref().unwrap_or(""))
213            .map_err(|e| LoggerError::InvalidEnvFilter(e.to_string()))?;
214
215        let (to_stdout, file_path) = match &self.output {
216            Output::Stdout => (true, None),
217            Output::File(path) => (false, Some(path.clone())),
218            Output::Both(path) => (true, Some(path.clone())),
219        };
220
221        let mut layers: Vec<Box<dyn Layer<Registry> + Send + Sync>> = Vec::new();
222
223        if to_stdout {
224            layers.push(self.fmt_layer(std::io::stdout, true));
225        }
226
227        if let Some(path) = file_path {
228            let file = open_log_file(&path)?;
229            layers.push(self.fmt_layer(Mutex::new(file), false));
230        }
231
232        tracing_subscriber::registry()
233            .with(layers)
234            .with(filter)
235            .try_init()
236            .map_err(|e| LoggerError::TryInitError(e.to_string()))
237    }
238
239    /// Builds a boxed `fmt` layer for the given writer, honouring the
240    /// configured format and field flags. `ansi` toggles colour output —
241    /// enabled for terminals, disabled for files.
242    fn fmt_layer<W>(&self, writer: W, ansi: bool) -> Box<dyn Layer<Registry> + Send + Sync>
243    where
244        W: for<'writer> MakeWriter<'writer> + Send + Sync + 'static,
245    {
246        let layer = tracing_subscriber::fmt::layer()
247            .with_writer(writer)
248            .with_ansi(ansi)
249            .with_thread_names(false)
250            .with_span_events(FmtSpan::NONE)
251            .with_file(self.with_file)
252            .with_target(self.with_target)
253            .with_timer(LocalTimer);
254
255        match self.format {
256            LogFormat::Json => layer.json().boxed(),
257            LogFormat::Text => layer.boxed(),
258        }
259    }
260}
261
262/// Opens `path` in append mode (creating it if absent) for log output.
263fn open_log_file(path: &Path) -> Result<File, LoggerError> {
264    OpenOptions::new()
265        .create(true)
266        .append(true)
267        .open(path)
268        .map_err(|e| LoggerError::OpenLogFile {
269            path: path.display().to_string(),
270            message: e.to_string(),
271        })
272}
273
274impl Default for Logger {
275    fn default() -> Self {
276        Self::new()
277    }
278}