Skip to main content

tiny_tracing/
config.rs

1use std::fs::{File, OpenOptions};
2use std::io::IsTerminal;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5use std::sync::Mutex;
6
7use crate::errors::LoggerError;
8use tracing::Level;
9use tracing_subscriber::fmt::time::UtcTime;
10use tracing_subscriber::{
11    EnvFilter, Layer, Registry,
12    filter::LevelFilter,
13    fmt::{MakeWriter, format::FmtSpan},
14    layer::SubscriberExt,
15    util::SubscriberInitExt,
16};
17
18/// Output format for log lines.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20#[non_exhaustive]
21pub enum LogFormat {
22    /// Human-readable single-line text output.
23    #[default]
24    Text,
25    /// One JSON object per log line.
26    Json,
27}
28
29impl LogFormat {
30    fn as_str(self) -> &'static str {
31        match self {
32            Self::Text => "text",
33            Self::Json => "json",
34        }
35    }
36}
37
38impl std::fmt::Display for LogFormat {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.write_str(self.as_str())
41    }
42}
43
44impl FromStr for LogFormat {
45    type Err = LoggerError;
46
47    fn from_str(s: &str) -> Result<Self, Self::Err> {
48        match s.to_lowercase().as_str() {
49            "text" => Ok(Self::Text),
50            "json" => Ok(Self::Json),
51            other => Err(LoggerError::InvalidFormat(other.to_string())),
52        }
53    }
54}
55
56/// Where log lines are written.
57#[derive(Debug, Clone, PartialEq, Eq, Default)]
58#[non_exhaustive]
59pub enum Output {
60    /// Standard output only. This is the default.
61    #[default]
62    Stdout,
63    /// Standard error only.
64    ///
65    /// Keeps stdout free for program output, which is what command-line tools
66    /// usually want. The automatic colour decision checks stderr, not stdout.
67    Stderr,
68    /// The given file only. Created if missing, appended otherwise.
69    File(PathBuf),
70    /// Both standard output and the given file.
71    ///
72    /// ANSI colours are kept on stdout but stripped from the file, so the
73    /// on-disk log stays free of escape codes.
74    Both(PathBuf),
75}
76
77/// Builder for initializing the global tracing subscriber.
78///
79/// Uses a fluent builder pattern to configure log level, output format,
80/// and optional environment-based filtering before calling [`init`](Logger::init).
81///
82/// # Examples
83///
84/// Minimal setup (text output at INFO level):
85///
86/// ```rust
87/// use tiny_tracing::Logger;
88///
89/// Logger::new().init().unwrap();
90/// tiny_tracing::info!("Ready");
91/// ```
92///
93/// JSON output with environment filter:
94///
95/// ```rust
96/// use tiny_tracing::{Logger, LogFormat, Level};
97///
98/// Logger::new()
99///     .with_level(Level::DEBUG)
100///     .with_format(LogFormat::Json)
101///     .with_env_filter("info,my_crate=trace")
102///     .init()
103///     .unwrap();
104/// ```
105pub struct Logger {
106    level: Level,
107    format: LogFormat,
108    env_filter: Option<String>,
109    with_file: bool,
110    with_target: bool,
111    output: Output,
112    colored: Option<bool>,
113    timestamp: bool,
114}
115
116impl Logger {
117    /// Creates a new [`Logger`] with default values.
118    ///
119    /// Defaults: [`Level::INFO`], [`LogFormat::Text`], no env filter,
120    /// file location off, target on, timestamps on, output to stdout, colours
121    /// chosen automatically.
122    #[must_use]
123    pub fn new() -> Self {
124        Self {
125            level: Level::INFO,
126            format: LogFormat::Text,
127            env_filter: None,
128            with_file: false,
129            with_target: true,
130            output: Output::Stdout,
131            colored: None,
132            timestamp: true,
133        }
134    }
135
136    /// Sets the global (default) log level.
137    ///
138    /// This is the base level applied to every target. Per-target directives
139    /// passed to [`with_env_filter`](Self::with_env_filter) refine it on top —
140    /// the level is never silently ignored. A global directive inside the env
141    /// filter string (e.g. the `info` in `"info,my_crate=debug"`) does take
142    /// precedence over this value.
143    #[must_use]
144    pub fn with_level(mut self, level: Level) -> Self {
145        self.level = level;
146        self
147    }
148
149    /// Sets the output format.
150    #[must_use]
151    pub fn with_format(mut self, format: LogFormat) -> Self {
152        self.format = format;
153        self
154    }
155
156    /// Reads the filter directives from the `RUST_LOG` environment variable.
157    ///
158    /// When `RUST_LOG` is set (and valid UTF-8) it is used exactly as if it had
159    /// been passed to [`with_env_filter`](Self::with_env_filter), replacing any
160    /// filter set earlier. When it is not set, nothing changes and only the
161    /// [`with_level`](Self::with_level) value (plus any earlier env filter)
162    /// applies. A `RUST_LOG` that [`EnvFilter`] rejects makes
163    /// [`init`](Self::init) return [`LoggerError::InvalidEnvFilter`].
164    ///
165    /// The precedence rules of [`with_level`](Self::with_level) apply: a global
166    /// directive inside `RUST_LOG` (e.g. `RUST_LOG=warn`) takes precedence over
167    /// the configured level.
168    #[must_use]
169    pub fn with_env_filter_from_env(self) -> Self {
170        self.env_filter_from(std::env::var("RUST_LOG").ok())
171    }
172
173    /// Applies `value` as the env filter when present; keeps the current one otherwise.
174    fn env_filter_from(mut self, value: Option<String>) -> Self {
175        if let Some(filter) = value {
176            self.env_filter = Some(filter);
177        }
178        self
179    }
180
181    /// Forces ANSI colours on or off for stdout or stderr.
182    ///
183    /// By default colours are chosen automatically (on only when the stream
184    /// being written to, stdout or stderr, is a terminal). File output is never
185    /// coloured, regardless of this setting.
186    #[must_use]
187    pub fn colored(mut self, colored: bool) -> Self {
188        self.colored = Some(colored);
189        self
190    }
191
192    /// Adds environment-based, per-target filtering via [`EnvFilter`].
193    ///
194    /// The directives here are layered on top of the global
195    /// [`with_level`](Logger::with_level) value. Supported syntax: `"info"`,
196    /// `"info,my_crate=debug"`, etc.
197    ///
198    /// To take the directives from `RUST_LOG` instead of passing them in code, use
199    /// [`with_env_filter_from_env`](Logger::with_env_filter_from_env).
200    ///
201    /// If not called, only the static [`with_level`](Logger::with_level) value applies.
202    #[must_use]
203    pub fn with_env_filter(mut self, filter: impl Into<String>) -> Self {
204        self.env_filter = Some(filter.into());
205        self
206    }
207
208    /// Controls whether the source file name appears in log lines.
209    ///
210    /// Disabled by default.
211    #[must_use]
212    pub fn with_file(mut self, enabled: bool) -> Self {
213        self.with_file = enabled;
214        self
215    }
216
217    /// Controls whether each log line starts with a timestamp.
218    ///
219    /// Enabled by default. When on, timestamps are always UTC and formatted as
220    /// RFC 3339 with a sub-second fraction (e.g. `2026-10-01T12:00:00.123456Z`),
221    /// in both text and JSON output. Trailing zeros of the fraction are trimmed,
222    /// so its length varies. Turn it off when something else already adds
223    /// one, such as journald or a container runtime.
224    #[must_use]
225    pub fn with_timestamp(mut self, enabled: bool) -> Self {
226        self.timestamp = enabled;
227        self
228    }
229
230    /// Controls whether the module path (target) appears in log lines.
231    ///
232    /// Enabled by default.
233    #[must_use]
234    pub fn with_target(mut self, enabled: bool) -> Self {
235        self.with_target = enabled;
236        self
237    }
238
239    /// Sets where log lines are written: stdout, stderr, a file, or stdout and a file.
240    ///
241    /// Defaults to [`Output::Stdout`]. When a file is involved it is opened in
242    /// append mode (created if missing) and writes are synchronised, so the
243    /// call stays panic-free — an unopenable path yields
244    /// [`LoggerError::OpenLogFile`] from [`init`](Self::init).
245    #[must_use]
246    pub fn with_output(mut self, output: Output) -> Self {
247        self.output = output;
248        self
249    }
250
251    /// Returns the configured log level.
252    #[must_use]
253    pub fn level(&self) -> Level {
254        self.level
255    }
256
257    /// Returns the configured output format.
258    #[must_use]
259    pub fn format(&self) -> LogFormat {
260        self.format
261    }
262
263    /// Initializes the global tracing subscriber.
264    ///
265    /// Consumes the builder. Must be called only once per process;
266    /// subsequent calls return [`LoggerError::TryInitError`].
267    ///
268    /// # Errors
269    ///
270    /// - [`LoggerError::InvalidEnvFilter`] if an env filter was set and
271    ///   [`EnvFilter`] rejects it.
272    /// - [`LoggerError::OpenLogFile`] if a file output was requested but the
273    ///   path could not be opened for writing.
274    /// - [`LoggerError::TryInitError`] if a global subscriber is already set.
275    pub fn init(self) -> Result<(), LoggerError> {
276        let filter = EnvFilter::builder()
277            .with_default_directive(LevelFilter::from_level(self.level).into())
278            .parse(self.env_filter.as_deref().unwrap_or(""))
279            .map_err(|e| LoggerError::InvalidEnvFilter(e.to_string()))?;
280
281        let (to_stdout, to_stderr, file_path) = match &self.output {
282            Output::Stdout => (true, false, None),
283            Output::Stderr => (false, true, None),
284            Output::File(path) => (false, false, Some(path.clone())),
285            Output::Both(path) => (true, false, Some(path.clone())),
286        };
287
288        let mut layers: Vec<Box<dyn Layer<Registry> + Send + Sync>> = Vec::new();
289
290        if to_stdout {
291            let ansi = self
292                .colored
293                .unwrap_or_else(|| std::io::stdout().is_terminal());
294            layers.push(self.fmt_layer(std::io::stdout, ansi));
295        }
296
297        if to_stderr {
298            let ansi = self
299                .colored
300                .unwrap_or_else(|| std::io::stderr().is_terminal());
301            layers.push(self.fmt_layer(std::io::stderr, ansi));
302        }
303
304        if let Some(path) = file_path {
305            let file = open_log_file(&path)?;
306            layers.push(self.fmt_layer(Mutex::new(file), false));
307        }
308
309        tracing_subscriber::registry()
310            .with(layers)
311            .with(filter)
312            .try_init()
313            .map_err(|e| LoggerError::TryInitError(e.to_string()))
314    }
315
316    /// Builds a boxed `fmt` layer for the given writer, honouring the
317    /// configured format, field flags and timestamp setting. `ansi` toggles
318    /// colour output — enabled for terminals, disabled for files.
319    ///
320    /// The timer is a type parameter of the layer, so the timestamp on/off
321    /// choice cannot be a conditional inside the builder chain; each
322    /// format/timestamp combination is built in its own `match` arm.
323    fn fmt_layer<W>(&self, writer: W, ansi: bool) -> Box<dyn Layer<Registry> + Send + Sync>
324    where
325        W: for<'writer> MakeWriter<'writer> + Send + Sync + 'static,
326    {
327        let layer = tracing_subscriber::fmt::layer()
328            .with_writer(writer)
329            .with_ansi(ansi)
330            .with_thread_names(false)
331            .with_span_events(FmtSpan::NONE)
332            .with_file(self.with_file)
333            .with_target(self.with_target);
334
335        match (self.format, self.timestamp) {
336            (LogFormat::Json, true) => layer.with_timer(UtcTime::rfc_3339()).json().boxed(),
337            (LogFormat::Json, false) => layer.without_time().json().boxed(),
338            (LogFormat::Text, true) => layer.with_timer(UtcTime::rfc_3339()).boxed(),
339            (LogFormat::Text, false) => layer.without_time().boxed(),
340        }
341    }
342}
343
344/// Opens `path` in append mode (creating it if absent) for log output.
345fn open_log_file(path: &Path) -> Result<File, LoggerError> {
346    OpenOptions::new()
347        .create(true)
348        .append(true)
349        .open(path)
350        .map_err(|e| LoggerError::OpenLogFile {
351            path: path.display().to_string(),
352            message: e.to_string(),
353        })
354}
355
356impl Default for Logger {
357    fn default() -> Self {
358        Self::new()
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn env_filter_from_uses_value_when_present() {
368        let logger = Logger::new().env_filter_from(Some("debug,my_crate=trace".to_string()));
369        assert_eq!(logger.env_filter.as_deref(), Some("debug,my_crate=trace"));
370    }
371
372    #[test]
373    fn env_filter_from_replaces_earlier_filter() {
374        let logger = Logger::new()
375            .with_env_filter("warn")
376            .env_filter_from(Some("debug".to_string()));
377        assert_eq!(logger.env_filter.as_deref(), Some("debug"));
378    }
379
380    #[test]
381    fn env_filter_from_keeps_current_filter_when_absent() {
382        let logger = Logger::new().env_filter_from(None);
383        assert_eq!(logger.env_filter, None);
384
385        let logger = Logger::new().with_env_filter("warn").env_filter_from(None);
386        assert_eq!(logger.env_filter.as_deref(), Some("warn"));
387    }
388}