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}