1use anyhow::Result;
7use std::collections::BTreeMap;
8use std::ffi::OsStr;
9use std::path::{Path, PathBuf};
10use tracing::Subscriber;
11use tracing_subscriber::{
12 EnvFilter, fmt,
13 fmt::writer::{BoxMakeWriter, MakeWriterExt},
14 util::SubscriberInitExt,
15};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum LogFormat {
20 Pretty,
22 Json,
24 Compact,
26}
27
28impl LogFormat {
29 fn parse(value: &str) -> Option<Self> {
30 match value.trim().to_lowercase().as_str() {
31 "pretty" => Some(Self::Pretty),
32 "json" => Some(Self::Json),
33 "compact" => Some(Self::Compact),
34 _ => None,
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct LogConfig {
42 pub level: String,
44 pub format: LogFormat,
46 pub file_path: Option<PathBuf>,
48 pub targets: BTreeMap<String, String>,
50 pub with_target: bool,
52 pub with_thread_ids: bool,
54 pub with_file_line: bool,
56 pub use_stderr: bool,
58}
59
60impl Default for LogConfig {
61 fn default() -> Self {
62 Self {
63 level: "info".to_string(),
64 format: LogFormat::Pretty,
65 file_path: None,
66 targets: BTreeMap::new(),
67 with_target: true,
68 with_thread_ids: true,
69 with_file_line: true,
70 use_stderr: false,
71 }
72 }
73}
74
75impl LogConfig {
76 pub fn from_env(default_level: &str) -> Self {
84 let mut config = Self {
85 level: std::env::var("RCH_LOG_LEVEL").unwrap_or_else(|_| default_level.to_string()),
86 ..Self::default()
87 };
88
89 if let Ok(format) = std::env::var("RCH_LOG_FORMAT")
90 && let Some(parsed) = LogFormat::parse(&format)
91 {
92 config.format = parsed;
93 }
94
95 if let Ok(path) = std::env::var("RCH_LOG_FILE")
96 && !path.trim().is_empty()
97 {
98 config.file_path = Some(PathBuf::from(path));
99 }
100
101 if let Ok(targets) = std::env::var("RCH_LOG_TARGETS") {
102 config.targets = parse_target_overrides(&targets);
103 }
104
105 config
106 }
107
108 pub fn with_level(mut self, level: impl Into<String>) -> Self {
110 self.level = level.into();
111 self
112 }
113
114 pub fn with_stderr(mut self) -> Self {
116 self.use_stderr = true;
117 self
118 }
119
120 pub fn env_filter(&self) -> EnvFilter {
122 if std::env::var_os("RUST_LOG").is_some()
123 && let Ok(filter) = EnvFilter::try_from_default_env()
124 {
125 return filter;
126 }
127
128 let mut filter = self.level.clone();
129 for (target, level) in &self.targets {
130 filter.push_str(&format!(",{}={}", target, level));
131 }
132 EnvFilter::new(filter)
133 }
134}
135
136pub struct LoggingGuards {
138 _file_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
139}
140
141pub fn init_logging(config: &LogConfig) -> Result<LoggingGuards> {
146 match config.format {
147 LogFormat::Pretty => init_with_format(config, LogFormat::Pretty),
148 LogFormat::Json => init_with_format(config, LogFormat::Json),
149 LogFormat::Compact => init_with_format(config, LogFormat::Compact),
150 }
151}
152
153fn build_writer(
154 config: &LogConfig,
155) -> Result<(
156 BoxMakeWriter,
157 Option<tracing_appender::non_blocking::WorkerGuard>,
158)> {
159 let base_writer = if config.use_stderr {
160 BoxMakeWriter::new(std::io::stderr)
161 } else {
162 BoxMakeWriter::new(std::io::stdout)
163 };
164
165 if let Some(path) = config.file_path.as_ref() {
166 let dir = path
167 .parent()
168 .filter(|p| !p.as_os_str().is_empty())
169 .unwrap_or_else(|| Path::new("."));
170 let file_name = path.file_name().unwrap_or_else(|| OsStr::new("rch.log"));
171 let appender = tracing_appender::rolling::daily(dir, file_name);
172 let (non_blocking, guard) = tracing_appender::non_blocking(appender);
173 let writer = BoxMakeWriter::new(base_writer.and(non_blocking));
174 Ok((writer, Some(guard)))
175 } else {
176 Ok((base_writer, None))
177 }
178}
179
180fn init_with_format(config: &LogConfig, format: LogFormat) -> Result<LoggingGuards> {
181 let filter = config.env_filter();
182 let (writer, file_guard) = build_writer(config)?;
183 let ansi = file_guard.is_none();
184
185 match format {
186 LogFormat::Pretty => {
187 let subscriber = fmt::Subscriber::builder()
188 .with_writer(writer)
189 .with_target(config.with_target)
190 .with_thread_ids(config.with_thread_ids)
191 .with_file(config.with_file_line)
192 .with_line_number(config.with_file_line)
193 .with_env_filter(filter)
194 .with_ansi(ansi)
195 .pretty()
196 .finish();
197 finish_subscriber(subscriber, file_guard)
198 }
199 LogFormat::Json => {
200 let subscriber = fmt::Subscriber::builder()
201 .with_writer(writer)
202 .with_target(config.with_target)
203 .with_thread_ids(config.with_thread_ids)
204 .with_file(config.with_file_line)
205 .with_line_number(config.with_file_line)
206 .with_env_filter(filter)
207 .with_ansi(false)
208 .json()
209 .finish();
210 finish_subscriber(subscriber, file_guard)
211 }
212 LogFormat::Compact => {
213 let subscriber = fmt::Subscriber::builder()
214 .with_writer(writer)
215 .with_target(config.with_target)
216 .with_thread_ids(config.with_thread_ids)
217 .with_file(config.with_file_line)
218 .with_line_number(config.with_file_line)
219 .with_env_filter(filter)
220 .with_ansi(ansi)
221 .compact()
222 .finish();
223 finish_subscriber(subscriber, file_guard)
224 }
225 }
226}
227
228fn finish_subscriber<S>(
229 subscriber: S,
230 file_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
231) -> Result<LoggingGuards>
232where
233 S: Subscriber + Send + Sync + 'static,
234{
235 if let Err(err) = subscriber.try_init() {
236 if err.to_string().contains("already initialized") {
237 return Ok(LoggingGuards {
238 _file_guard: file_guard,
239 });
240 }
241 return Err(err.into());
242 }
243
244 Ok(LoggingGuards {
245 _file_guard: file_guard,
246 })
247}
248
249fn parse_target_overrides(value: &str) -> BTreeMap<String, String> {
250 let mut map = BTreeMap::new();
251 for entry in value.split(',') {
252 let entry = entry.trim();
253 if entry.is_empty() {
254 continue;
255 }
256 let Some((target, level)) = entry.split_once('=') else {
257 continue;
258 };
259 let target = target.trim();
260 let level = level.trim().to_lowercase();
261 if target.is_empty() || !is_valid_level(&level) {
262 continue;
263 }
264 map.insert(target.to_string(), level);
265 }
266 map
267}
268
269fn is_valid_level(level: &str) -> bool {
270 matches!(level, "trace" | "debug" | "info" | "warn" | "error" | "off")
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn test_parse_targets() {
279 let targets = parse_target_overrides("rchd::workers=debug,hyper=warn,invalid");
280 assert_eq!(targets.get("rchd::workers"), Some(&"debug".to_string()));
281 assert_eq!(targets.get("hyper"), Some(&"warn".to_string()));
282 assert!(!targets.contains_key("invalid"));
283 }
284
285 #[test]
286 fn test_parse_targets_trims_and_filters_invalid_levels() {
287 let targets = parse_target_overrides(" rchd::api = DEBUG ,hyper=verbose,=warn,missing");
288 assert_eq!(targets.get("rchd::api"), Some(&"debug".to_string()));
289 assert!(!targets.contains_key("hyper"));
290 assert!(!targets.contains_key(""));
291 assert!(!targets.contains_key("missing"));
292 }
293
294 #[test]
295 fn test_log_format_parse() {
296 assert_eq!(LogFormat::parse("pretty"), Some(LogFormat::Pretty));
297 assert_eq!(LogFormat::parse("JSON"), Some(LogFormat::Json));
298 assert_eq!(LogFormat::parse("Compact"), Some(LogFormat::Compact));
299 assert_eq!(LogFormat::parse("invalid"), None);
300 }
301
302 #[test]
303 fn test_env_filter_builds_overrides() {
304 let mut config = LogConfig {
305 level: "info".to_string(),
306 ..LogConfig::default()
307 };
308 config
309 .targets
310 .insert("rchd::api".to_string(), "debug".to_string());
311 let filter = config.env_filter();
312 let filter_str = format!("{filter}");
313 assert!(filter_str.contains("info"));
314 assert!(filter_str.contains("rchd::api=debug"));
315 }
316
317 #[test]
318 fn test_log_config_default() {
319 let config = LogConfig::default();
320 assert_eq!(config.level, "info");
321 assert_eq!(config.format, LogFormat::Pretty);
322 assert!(config.file_path.is_none());
323 assert!(config.targets.is_empty());
324 assert!(config.with_target);
325 assert!(config.with_thread_ids);
326 assert!(config.with_file_line);
327 assert!(!config.use_stderr);
328 }
329
330 #[test]
331 fn test_log_config_with_level() {
332 let config = LogConfig::default().with_level("debug");
333 assert_eq!(config.level, "debug");
334 }
335
336 #[test]
337 fn test_log_config_with_level_owned_string() {
338 let config = LogConfig::default().with_level(String::from("trace"));
339 assert_eq!(config.level, "trace");
340 }
341
342 #[test]
343 fn test_log_config_with_stderr() {
344 let config = LogConfig::default().with_stderr();
345 assert!(config.use_stderr);
346 }
347
348 #[test]
349 fn test_log_config_chained_builders() {
350 let config = LogConfig::default().with_level("warn").with_stderr();
351 assert_eq!(config.level, "warn");
352 assert!(config.use_stderr);
353 }
354
355 #[test]
356 fn test_log_format_equality() {
357 assert_eq!(LogFormat::Pretty, LogFormat::Pretty);
358 assert_eq!(LogFormat::Json, LogFormat::Json);
359 assert_eq!(LogFormat::Compact, LogFormat::Compact);
360 assert_ne!(LogFormat::Pretty, LogFormat::Json);
361 assert_ne!(LogFormat::Json, LogFormat::Compact);
362 }
363
364 #[test]
365 fn test_log_format_copy() {
366 let format = LogFormat::Json;
367 let copy = format; assert_eq!(format, copy);
369 }
370
371 #[test]
372 fn test_log_format_clone() {
373 fn assert_clone<T: Clone>() {}
374 assert_clone::<LogFormat>();
375 }
376
377 #[test]
378 fn test_log_format_debug() {
379 let format = LogFormat::Pretty;
380 let debug = format!("{:?}", format);
381 assert!(debug.contains("Pretty"));
382 }
383
384 #[test]
385 fn test_log_format_parse_whitespace() {
386 assert_eq!(LogFormat::parse(" pretty "), Some(LogFormat::Pretty));
387 assert_eq!(LogFormat::parse("\tjson\t"), Some(LogFormat::Json));
388 assert_eq!(LogFormat::parse(" compact "), Some(LogFormat::Compact));
389 }
390
391 #[test]
392 fn test_log_format_parse_mixed_case() {
393 assert_eq!(LogFormat::parse("PRETTY"), Some(LogFormat::Pretty));
394 assert_eq!(LogFormat::parse("JsOn"), Some(LogFormat::Json));
395 assert_eq!(LogFormat::parse("cOmPaCt"), Some(LogFormat::Compact));
396 }
397
398 #[test]
399 fn test_log_format_parse_empty() {
400 assert_eq!(LogFormat::parse(""), None);
401 assert_eq!(LogFormat::parse(" "), None);
402 }
403
404 #[test]
405 fn test_is_valid_level_all_valid() {
406 assert!(is_valid_level("trace"));
407 assert!(is_valid_level("debug"));
408 assert!(is_valid_level("info"));
409 assert!(is_valid_level("warn"));
410 assert!(is_valid_level("error"));
411 assert!(is_valid_level("off"));
412 }
413
414 #[test]
415 fn test_is_valid_level_invalid() {
416 assert!(!is_valid_level(""));
417 assert!(!is_valid_level("DEBUG")); assert!(!is_valid_level("warning"));
419 assert!(!is_valid_level("fatal"));
420 assert!(!is_valid_level("verbose"));
421 }
422
423 #[test]
424 fn test_parse_target_overrides_empty() {
425 let targets = parse_target_overrides("");
426 assert!(targets.is_empty());
427 }
428
429 #[test]
430 fn test_parse_target_overrides_whitespace_only() {
431 let targets = parse_target_overrides(" , , ");
432 assert!(targets.is_empty());
433 }
434
435 #[test]
436 fn test_parse_target_overrides_single_entry() {
437 let targets = parse_target_overrides("my_crate=debug");
438 assert_eq!(targets.len(), 1);
439 assert_eq!(targets.get("my_crate"), Some(&"debug".to_string()));
440 }
441
442 #[test]
443 fn test_parse_target_overrides_multiple_entries() {
444 let targets = parse_target_overrides("a=trace,b=debug,c=info,d=warn,e=error,f=off");
445 assert_eq!(targets.len(), 6);
446 assert_eq!(targets.get("a"), Some(&"trace".to_string()));
447 assert_eq!(targets.get("b"), Some(&"debug".to_string()));
448 assert_eq!(targets.get("c"), Some(&"info".to_string()));
449 assert_eq!(targets.get("d"), Some(&"warn".to_string()));
450 assert_eq!(targets.get("e"), Some(&"error".to_string()));
451 assert_eq!(targets.get("f"), Some(&"off".to_string()));
452 }
453
454 #[test]
455 fn test_parse_target_overrides_empty_target() {
456 let targets = parse_target_overrides("=debug");
457 assert!(targets.is_empty());
458 }
459
460 #[test]
461 fn test_parse_target_overrides_no_equals() {
462 let targets = parse_target_overrides("nodebug");
463 assert!(targets.is_empty());
464 }
465
466 #[test]
467 fn test_parse_target_overrides_duplicate_target() {
468 let targets = parse_target_overrides("crate=debug,crate=warn");
470 assert_eq!(targets.len(), 1);
471 assert_eq!(targets.get("crate"), Some(&"warn".to_string()));
472 }
473
474 #[test]
475 fn test_log_config_clone() {
476 let mut config = LogConfig {
477 level: "debug".to_string(),
478 format: LogFormat::Json,
479 file_path: Some(PathBuf::from("/tmp/test.log")),
480 ..LogConfig::default()
481 };
482 config.targets.insert("a".to_string(), "trace".to_string());
483
484 let cloned = config.clone();
485 assert_eq!(config.level, cloned.level);
486 assert_eq!(config.format, cloned.format);
487 assert_eq!(config.file_path, cloned.file_path);
488 assert_eq!(config.targets, cloned.targets);
489 }
490
491 #[test]
492 fn test_log_config_debug() {
493 let config = LogConfig::default();
494 let debug = format!("{:?}", config);
495 assert!(debug.contains("LogConfig"));
496 assert!(debug.contains("info"));
497 }
498
499 #[test]
500 fn test_env_filter_no_targets() {
501 let config = LogConfig {
502 level: "warn".to_string(),
503 ..LogConfig::default()
504 };
505 let filter = config.env_filter();
506 let filter_str = format!("{filter}");
507 assert!(filter_str.contains("warn"));
508 }
509
510 #[test]
511 fn test_env_filter_multiple_targets() {
512 let mut config = LogConfig {
513 level: "error".to_string(),
514 ..LogConfig::default()
515 };
516 config
517 .targets
518 .insert("mod_a".to_string(), "debug".to_string());
519 config
520 .targets
521 .insert("mod_b".to_string(), "trace".to_string());
522 let filter = config.env_filter();
523 let filter_str = format!("{filter}");
524 assert!(filter_str.contains("error"));
525 assert!(filter_str.contains("mod_a=debug"));
526 assert!(filter_str.contains("mod_b=trace"));
527 }
528}