Skip to main content

rch_common/
logging.rs

1//! Structured logging initialization for RCH components.
2//!
3//! Provides a shared logging configuration and initialization routine for
4//! binaries (rch, rchd, rch-wkr) and libraries that need consistent output.
5
6use 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/// Logging output format.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum LogFormat {
20    /// Human-friendly, pretty-printed logs.
21    Pretty,
22    /// JSON-formatted logs for machine parsing.
23    Json,
24    /// Compact single-line logs.
25    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/// Configuration for logging initialization.
40#[derive(Debug, Clone)]
41pub struct LogConfig {
42    /// Base log level (trace, debug, info, warn, error, off).
43    pub level: String,
44    /// Output format.
45    pub format: LogFormat,
46    /// Optional file path for rotating logs.
47    pub file_path: Option<PathBuf>,
48    /// Per-target log level overrides.
49    pub targets: BTreeMap<String, String>,
50    /// Include target in log output.
51    pub with_target: bool,
52    /// Include thread IDs in log output.
53    pub with_thread_ids: bool,
54    /// Include file and line number in log output.
55    pub with_file_line: bool,
56    /// Write console logs to stderr instead of stdout.
57    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    /// Build a logging configuration from environment variables.
77    ///
78    /// Supported environment variables:
79    /// - RCH_LOG_LEVEL
80    /// - RCH_LOG_FORMAT (pretty|json|compact)
81    /// - RCH_LOG_FILE (path to rotating log file)
82    /// - RCH_LOG_TARGETS (comma-separated target=level list)
83    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    /// Override the base log level.
109    pub fn with_level(mut self, level: impl Into<String>) -> Self {
110        self.level = level.into();
111        self
112    }
113
114    /// Write console logs to stderr.
115    pub fn with_stderr(mut self) -> Self {
116        self.use_stderr = true;
117        self
118    }
119
120    /// Build the effective EnvFilter, honoring RUST_LOG if set.
121    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
136/// Guards required to keep background logging workers alive.
137pub struct LoggingGuards {
138    _file_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
139}
140
141/// Initialize tracing-based logging for the current process.
142///
143/// Returns guards that must be kept alive for the duration of the program
144/// (particularly when file logging is enabled).
145pub 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; // Copy trait
368        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")); // Case sensitive
418        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        // Later entry should win (BTreeMap behavior)
469        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}