1use anyhow::Result;
7use std::collections::BTreeMap;
8use std::ffi::OsStr;
9use std::path::{Path, PathBuf};
10use tracing::Subscriber;
11use tracing_subscriber::{
12 EnvFilter, Layer, Registry, fmt,
13 fmt::writer::{BoxMakeWriter, MakeWriterExt},
14 layer::{Identity, SubscriberExt},
15 util::SubscriberInitExt,
16};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum LogFormat {
21 Pretty,
23 Json,
25 Compact,
27}
28
29impl LogFormat {
30 fn parse(value: &str) -> Option<Self> {
31 match value.trim().to_lowercase().as_str() {
32 "pretty" => Some(Self::Pretty),
33 "json" => Some(Self::Json),
34 "compact" => Some(Self::Compact),
35 _ => None,
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
42pub struct LogConfig {
43 pub level: String,
45 pub format: LogFormat,
47 pub file_path: Option<PathBuf>,
49 pub targets: BTreeMap<String, String>,
51 pub with_target: bool,
53 pub with_thread_ids: bool,
55 pub with_file_line: bool,
57 pub use_stderr: bool,
59}
60
61impl Default for LogConfig {
62 fn default() -> Self {
63 Self {
64 level: "info".to_string(),
65 format: LogFormat::Pretty,
66 file_path: None,
67 targets: BTreeMap::new(),
68 with_target: true,
69 with_thread_ids: true,
70 with_file_line: true,
71 use_stderr: false,
72 }
73 }
74}
75
76impl LogConfig {
77 pub fn from_env(default_level: &str) -> Self {
85 let mut config = Self {
86 level: std::env::var("RCH_LOG_LEVEL").unwrap_or_else(|_| default_level.to_string()),
87 ..Self::default()
88 };
89
90 if let Ok(format) = std::env::var("RCH_LOG_FORMAT")
91 && let Some(parsed) = LogFormat::parse(&format)
92 {
93 config.format = parsed;
94 }
95
96 if let Ok(path) = std::env::var("RCH_LOG_FILE")
97 && !path.trim().is_empty()
98 {
99 config.file_path = Some(PathBuf::from(path));
100 }
101
102 if let Ok(targets) = std::env::var("RCH_LOG_TARGETS") {
103 config.targets = parse_target_overrides(&targets);
104 }
105
106 config
107 }
108
109 pub fn with_level(mut self, level: impl Into<String>) -> Self {
111 self.level = level.into();
112 self
113 }
114
115 pub fn with_stderr(mut self) -> Self {
117 self.use_stderr = true;
118 self
119 }
120
121 pub fn env_filter(&self) -> EnvFilter {
123 if std::env::var_os("RUST_LOG").is_some()
124 && let Ok(filter) = EnvFilter::try_from_default_env()
125 {
126 return filter;
127 }
128
129 let mut filter = self.level.clone();
130 for (target, level) in &self.targets {
131 filter.push_str(&format!(",{}={}", target, level));
132 }
133 EnvFilter::new(filter)
134 }
135}
136
137pub struct LoggingGuards {
139 _file_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
140}
141
142pub fn init_logging(config: &LogConfig) -> Result<LoggingGuards> {
147 init_with_layer(config, None::<Identity>, true)
148}
149
150pub fn init_logging_with_layer<L>(config: &LogConfig, layer: L) -> Result<LoggingGuards>
156where
157 L: Layer<Registry> + Send + Sync + 'static,
158{
159 init_with_layer(config, layer, false)
160}
161
162fn build_writer(
163 config: &LogConfig,
164) -> Result<(
165 BoxMakeWriter,
166 Option<tracing_appender::non_blocking::WorkerGuard>,
167)> {
168 let base_writer = if config.use_stderr {
169 BoxMakeWriter::new(std::io::stderr)
170 } else {
171 BoxMakeWriter::new(std::io::stdout)
172 };
173
174 if let Some(path) = config.file_path.as_ref() {
175 let dir = path
176 .parent()
177 .filter(|p| !p.as_os_str().is_empty())
178 .unwrap_or_else(|| Path::new("."));
179 let file_name = path.file_name().unwrap_or_else(|| OsStr::new("rch.log"));
180 let max_files = std::env::var("RCH_LOG_MAX_FILES")
187 .ok()
188 .and_then(|s| s.trim().parse::<usize>().ok())
189 .filter(|n| *n > 0)
190 .unwrap_or(7);
191 let appender = tracing_appender::rolling::Builder::new()
192 .rotation(tracing_appender::rolling::Rotation::DAILY)
193 .filename_prefix(file_name.to_string_lossy().into_owned())
194 .max_log_files(max_files)
195 .build(dir)
196 .map_err(|e| anyhow::anyhow!("failed to build rolling log appender: {e}"))?;
197 let (non_blocking, guard) = tracing_appender::non_blocking(appender);
198 let writer = BoxMakeWriter::new(base_writer.and(non_blocking));
199 Ok((writer, Some(guard)))
200 } else {
201 Ok((base_writer, None))
202 }
203}
204
205fn init_with_layer<L>(
206 config: &LogConfig,
207 layer: L,
208 allow_already_initialized: bool,
209) -> Result<LoggingGuards>
210where
211 L: Layer<Registry> + Send + Sync + 'static,
212{
213 let filter = config.env_filter();
214 let (writer, file_guard) = build_writer(config)?;
215 let ansi = file_guard.is_none();
216 let subscriber = build_subscriber(config, writer, ansi, filter, layer);
217 finish_subscriber(subscriber, file_guard, allow_already_initialized)
218}
219
220fn build_subscriber<L>(
221 config: &LogConfig,
222 writer: BoxMakeWriter,
223 ansi: bool,
224 filter: EnvFilter,
225 layer: L,
226) -> impl Subscriber + Send + Sync + 'static
227where
228 L: Layer<Registry> + Send + Sync + 'static,
229{
230 let formatting: Box<dyn Layer<Registry> + Send + Sync> = match config.format {
231 LogFormat::Pretty => fmt::layer()
232 .with_writer(writer)
233 .with_target(config.with_target)
234 .with_thread_ids(config.with_thread_ids)
235 .with_file(config.with_file_line)
236 .with_line_number(config.with_file_line)
237 .with_ansi(ansi)
238 .pretty()
239 .with_filter(filter)
240 .boxed(),
241 LogFormat::Json => fmt::layer()
242 .with_writer(writer)
243 .with_target(config.with_target)
244 .with_thread_ids(config.with_thread_ids)
245 .with_file(config.with_file_line)
246 .with_line_number(config.with_file_line)
247 .with_ansi(false)
248 .json()
249 .with_filter(filter)
250 .boxed(),
251 LogFormat::Compact => fmt::layer()
252 .with_writer(writer)
253 .with_target(config.with_target)
254 .with_thread_ids(config.with_thread_ids)
255 .with_file(config.with_file_line)
256 .with_line_number(config.with_file_line)
257 .with_ansi(ansi)
258 .compact()
259 .with_filter(filter)
260 .boxed(),
261 };
262 tracing_subscriber::registry().with(vec![layer.boxed(), formatting])
266}
267
268fn finish_subscriber<S>(
269 subscriber: S,
270 file_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
271 allow_already_initialized: bool,
272) -> Result<LoggingGuards>
273where
274 S: Subscriber + Send + Sync + 'static,
275{
276 if let Err(err) = subscriber.try_init() {
277 if allow_already_initialized && err.to_string().contains("already initialized") {
278 return Ok(LoggingGuards {
279 _file_guard: file_guard,
280 });
281 }
282 return Err(err.into());
283 }
284
285 Ok(LoggingGuards {
286 _file_guard: file_guard,
287 })
288}
289
290fn parse_target_overrides(value: &str) -> BTreeMap<String, String> {
291 let mut map = BTreeMap::new();
292 for entry in value.split(',') {
293 let entry = entry.trim();
294 if entry.is_empty() {
295 continue;
296 }
297 let Some((target, level)) = entry.split_once('=') else {
298 continue;
299 };
300 let target = target.trim();
301 let level = level.trim().to_lowercase();
302 if target.is_empty() || !is_valid_level(&level) {
303 continue;
304 }
305 map.insert(target.to_string(), level);
306 }
307 map
308}
309
310fn is_valid_level(level: &str) -> bool {
311 matches!(level, "trace" | "debug" | "info" | "warn" | "error" | "off")
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use std::io::Write;
318 use std::sync::{Arc, Mutex};
319
320 #[derive(Clone)]
321 struct CapturedWriter(Arc<Mutex<Vec<u8>>>);
322
323 impl Write for CapturedWriter {
324 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
325 self.0.lock().unwrap().extend_from_slice(bytes);
326 Ok(bytes.len())
327 }
328
329 fn flush(&mut self) -> std::io::Result<()> {
330 Ok(())
331 }
332 }
333
334 struct EventTargets(Arc<Mutex<Vec<&'static str>>>);
335
336 impl Layer<Registry> for EventTargets {
337 fn on_event(
338 &self,
339 event: &tracing::Event<'_>,
340 _context: tracing_subscriber::layer::Context<'_, Registry>,
341 ) {
342 self.0.lock().unwrap().push(event.metadata().target());
343 }
344 }
345
346 #[test]
347 fn attached_layer_receives_info_despite_error_only_diagnostics_in_all_formats() {
348 for format in [LogFormat::Pretty, LogFormat::Json, LogFormat::Compact] {
349 let output = Arc::new(Mutex::new(Vec::new()));
350 let writer = CapturedWriter(output.clone());
351 let observed = Arc::new(Mutex::new(Vec::new()));
352 let collector =
353 EventTargets(observed.clone()).with_filter(tracing_subscriber::filter::filter_fn(
354 |metadata| metadata.target().starts_with("rch::doctor::"),
355 ));
356 let config = LogConfig {
357 format,
358 with_target: false,
359 with_thread_ids: false,
360 with_file_line: false,
361 ..LogConfig::default()
362 };
363 let subscriber = build_subscriber(
364 &config,
365 BoxMakeWriter::new(move || writer.clone()),
366 false,
367 EnvFilter::new("error"),
368 collector,
369 );
370 tracing::subscriber::with_default(subscriber, || {
371 tracing::info!(target: "rch::doctor::verdict", "quiet doctor verdict");
372 tracing::info!(target: "rch::unrelated", "irrelevant info");
373 tracing::error!(target: "rch::unrelated", "visible diagnostic");
374 });
375 assert_eq!(
376 *observed.lock().unwrap(),
377 ["rch::doctor::verdict"],
378 "caller filtering must remain independent of diagnostic filtering: {format:?}"
379 );
380 let text = String::from_utf8(output.lock().unwrap().clone()).unwrap();
381 assert!(text.contains("visible diagnostic"), "{format:?}: {text}");
382 assert!(!text.contains("quiet doctor verdict"), "{format:?}: {text}");
383 assert!(!text.contains("irrelevant info"), "{format:?}: {text}");
384 if format == LogFormat::Json {
385 let event: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
386 assert_eq!(event["level"], "ERROR");
387 assert_eq!(event["fields"]["message"], "visible diagnostic");
388 }
389 }
390 }
391
392 #[test]
393 fn absent_extra_layer_preserves_diagnostic_filtering_in_all_formats() {
394 for format in [LogFormat::Pretty, LogFormat::Json, LogFormat::Compact] {
395 let output = Arc::new(Mutex::new(Vec::new()));
396 let writer = CapturedWriter(output.clone());
397 let config = LogConfig {
398 format,
399 ..LogConfig::default()
400 };
401 let subscriber = build_subscriber(
402 &config,
403 BoxMakeWriter::new(move || writer.clone()),
404 false,
405 EnvFilter::new("warn"),
406 None::<Identity>,
407 );
408 tracing::subscriber::with_default(subscriber, || {
409 tracing::info!("filtered info");
410 tracing::warn!("visible warning");
411 });
412 let text = String::from_utf8(output.lock().unwrap().clone()).unwrap();
413 assert!(text.contains("visible warning"), "{format:?}: {text}");
414 assert!(!text.contains("filtered info"), "{format:?}: {text}");
415 }
416 }
417
418 #[test]
419 fn test_parse_targets() {
420 let targets = parse_target_overrides("rchd::workers=debug,hyper=warn,invalid");
421 assert_eq!(targets.get("rchd::workers"), Some(&"debug".to_string()));
422 assert_eq!(targets.get("hyper"), Some(&"warn".to_string()));
423 assert!(!targets.contains_key("invalid"));
424 }
425
426 #[test]
427 fn test_parse_targets_trims_and_filters_invalid_levels() {
428 let targets = parse_target_overrides(" rchd::api = DEBUG ,hyper=verbose,=warn,missing");
429 assert_eq!(targets.get("rchd::api"), Some(&"debug".to_string()));
430 assert!(!targets.contains_key("hyper"));
431 assert!(!targets.contains_key(""));
432 assert!(!targets.contains_key("missing"));
433 }
434
435 #[test]
436 fn test_log_format_parse() {
437 assert_eq!(LogFormat::parse("pretty"), Some(LogFormat::Pretty));
438 assert_eq!(LogFormat::parse("JSON"), Some(LogFormat::Json));
439 assert_eq!(LogFormat::parse("Compact"), Some(LogFormat::Compact));
440 assert_eq!(LogFormat::parse("invalid"), None);
441 }
442
443 #[test]
444 fn test_env_filter_builds_overrides() {
445 let mut config = LogConfig {
446 level: "info".to_string(),
447 ..LogConfig::default()
448 };
449 config
450 .targets
451 .insert("rchd::api".to_string(), "debug".to_string());
452 let filter = config.env_filter();
453 let filter_str = format!("{filter}");
454 assert!(filter_str.contains("info"));
455 assert!(filter_str.contains("rchd::api=debug"));
456 }
457
458 #[test]
459 fn test_log_config_default() {
460 let config = LogConfig::default();
461 assert_eq!(config.level, "info");
462 assert_eq!(config.format, LogFormat::Pretty);
463 assert!(config.file_path.is_none());
464 assert!(config.targets.is_empty());
465 assert!(config.with_target);
466 assert!(config.with_thread_ids);
467 assert!(config.with_file_line);
468 assert!(!config.use_stderr);
469 }
470
471 #[test]
472 fn test_log_config_with_level() {
473 let config = LogConfig::default().with_level("debug");
474 assert_eq!(config.level, "debug");
475 }
476
477 #[test]
478 fn test_log_config_with_level_owned_string() {
479 let config = LogConfig::default().with_level(String::from("trace"));
480 assert_eq!(config.level, "trace");
481 }
482
483 #[test]
484 fn test_log_config_with_stderr() {
485 let config = LogConfig::default().with_stderr();
486 assert!(config.use_stderr);
487 }
488
489 #[test]
490 fn test_log_config_chained_builders() {
491 let config = LogConfig::default().with_level("warn").with_stderr();
492 assert_eq!(config.level, "warn");
493 assert!(config.use_stderr);
494 }
495
496 #[test]
497 fn test_log_format_equality() {
498 assert_eq!(LogFormat::Pretty, LogFormat::Pretty);
499 assert_eq!(LogFormat::Json, LogFormat::Json);
500 assert_eq!(LogFormat::Compact, LogFormat::Compact);
501 assert_ne!(LogFormat::Pretty, LogFormat::Json);
502 assert_ne!(LogFormat::Json, LogFormat::Compact);
503 }
504
505 #[test]
506 fn test_log_format_copy() {
507 let format = LogFormat::Json;
508 let copy = format; assert_eq!(format, copy);
510 }
511
512 #[test]
513 fn test_log_format_clone() {
514 fn assert_clone<T: Clone>() {}
515 assert_clone::<LogFormat>();
516 }
517
518 #[test]
519 fn test_log_format_debug() {
520 let format = LogFormat::Pretty;
521 let debug = format!("{:?}", format);
522 assert!(debug.contains("Pretty"));
523 }
524
525 #[test]
526 fn test_log_format_parse_whitespace() {
527 assert_eq!(LogFormat::parse(" pretty "), Some(LogFormat::Pretty));
528 assert_eq!(LogFormat::parse("\tjson\t"), Some(LogFormat::Json));
529 assert_eq!(LogFormat::parse(" compact "), Some(LogFormat::Compact));
530 }
531
532 #[test]
533 fn test_log_format_parse_mixed_case() {
534 assert_eq!(LogFormat::parse("PRETTY"), Some(LogFormat::Pretty));
535 assert_eq!(LogFormat::parse("JsOn"), Some(LogFormat::Json));
536 assert_eq!(LogFormat::parse("cOmPaCt"), Some(LogFormat::Compact));
537 }
538
539 #[test]
540 fn test_log_format_parse_empty() {
541 assert_eq!(LogFormat::parse(""), None);
542 assert_eq!(LogFormat::parse(" "), None);
543 }
544
545 #[test]
546 fn test_is_valid_level_all_valid() {
547 assert!(is_valid_level("trace"));
548 assert!(is_valid_level("debug"));
549 assert!(is_valid_level("info"));
550 assert!(is_valid_level("warn"));
551 assert!(is_valid_level("error"));
552 assert!(is_valid_level("off"));
553 }
554
555 #[test]
556 fn test_is_valid_level_invalid() {
557 assert!(!is_valid_level(""));
558 assert!(!is_valid_level("DEBUG")); assert!(!is_valid_level("warning"));
560 assert!(!is_valid_level("fatal"));
561 assert!(!is_valid_level("verbose"));
562 }
563
564 #[test]
565 fn test_parse_target_overrides_empty() {
566 let targets = parse_target_overrides("");
567 assert!(targets.is_empty());
568 }
569
570 #[test]
571 fn test_parse_target_overrides_whitespace_only() {
572 let targets = parse_target_overrides(" , , ");
573 assert!(targets.is_empty());
574 }
575
576 #[test]
577 fn test_parse_target_overrides_single_entry() {
578 let targets = parse_target_overrides("my_crate=debug");
579 assert_eq!(targets.len(), 1);
580 assert_eq!(targets.get("my_crate"), Some(&"debug".to_string()));
581 }
582
583 #[test]
584 fn test_parse_target_overrides_multiple_entries() {
585 let targets = parse_target_overrides("a=trace,b=debug,c=info,d=warn,e=error,f=off");
586 assert_eq!(targets.len(), 6);
587 assert_eq!(targets.get("a"), Some(&"trace".to_string()));
588 assert_eq!(targets.get("b"), Some(&"debug".to_string()));
589 assert_eq!(targets.get("c"), Some(&"info".to_string()));
590 assert_eq!(targets.get("d"), Some(&"warn".to_string()));
591 assert_eq!(targets.get("e"), Some(&"error".to_string()));
592 assert_eq!(targets.get("f"), Some(&"off".to_string()));
593 }
594
595 #[test]
596 fn test_parse_target_overrides_empty_target() {
597 let targets = parse_target_overrides("=debug");
598 assert!(targets.is_empty());
599 }
600
601 #[test]
602 fn test_parse_target_overrides_no_equals() {
603 let targets = parse_target_overrides("nodebug");
604 assert!(targets.is_empty());
605 }
606
607 #[test]
608 fn test_parse_target_overrides_duplicate_target() {
609 let targets = parse_target_overrides("crate=debug,crate=warn");
611 assert_eq!(targets.len(), 1);
612 assert_eq!(targets.get("crate"), Some(&"warn".to_string()));
613 }
614
615 #[test]
616 fn test_log_config_clone() {
617 let mut config = LogConfig {
618 level: "debug".to_string(),
619 format: LogFormat::Json,
620 file_path: Some(PathBuf::from("/tmp/test.log")),
621 ..LogConfig::default()
622 };
623 config.targets.insert("a".to_string(), "trace".to_string());
624
625 let cloned = config.clone();
626 assert_eq!(config.level, cloned.level);
627 assert_eq!(config.format, cloned.format);
628 assert_eq!(config.file_path, cloned.file_path);
629 assert_eq!(config.targets, cloned.targets);
630 }
631
632 #[test]
633 fn test_log_config_debug() {
634 let config = LogConfig::default();
635 let debug = format!("{:?}", config);
636 assert!(debug.contains("LogConfig"));
637 assert!(debug.contains("info"));
638 }
639
640 #[test]
641 fn test_env_filter_no_targets() {
642 let config = LogConfig {
643 level: "warn".to_string(),
644 ..LogConfig::default()
645 };
646 let filter = config.env_filter();
647 let filter_str = format!("{filter}");
648 assert!(filter_str.contains("warn"));
649 }
650
651 #[test]
652 fn test_env_filter_multiple_targets() {
653 let mut config = LogConfig {
654 level: "error".to_string(),
655 ..LogConfig::default()
656 };
657 config
658 .targets
659 .insert("mod_a".to_string(), "debug".to_string());
660 config
661 .targets
662 .insert("mod_b".to_string(), "trace".to_string());
663 let filter = config.env_filter();
664 let filter_str = format!("{filter}");
665 assert!(filter_str.contains("error"));
666 assert!(filter_str.contains("mod_a=debug"));
667 assert!(filter_str.contains("mod_b=trace"));
668 }
669}