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}