Skip to main content

mabi_core/logging/
config.rs

1//! Logging configuration types.
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6use tracing::Level;
7
8use crate::error::{Error, Result};
9
10use super::rotation::RotationConfig;
11
12/// Log level configuration.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "lowercase")]
15pub enum LogLevel {
16    /// Trace level - most verbose.
17    Trace,
18    /// Debug level.
19    Debug,
20    /// Info level - default.
21    #[default]
22    Info,
23    /// Warn level.
24    Warn,
25    /// Error level - least verbose.
26    Error,
27    /// Off - disable logging.
28    Off,
29}
30
31impl LogLevel {
32    /// Convert to tracing Level.
33    pub fn to_tracing_level(&self) -> Option<Level> {
34        match self {
35            Self::Trace => Some(Level::TRACE),
36            Self::Debug => Some(Level::DEBUG),
37            Self::Info => Some(Level::INFO),
38            Self::Warn => Some(Level::WARN),
39            Self::Error => Some(Level::ERROR),
40            Self::Off => None,
41        }
42    }
43
44    /// Convert to string filter.
45    pub fn as_filter_str(&self) -> &'static str {
46        match self {
47            Self::Trace => "trace",
48            Self::Debug => "debug",
49            Self::Info => "info",
50            Self::Warn => "warn",
51            Self::Error => "error",
52            Self::Off => "off",
53        }
54    }
55
56    /// Get all log levels ordered by verbosity (most to least).
57    pub fn all() -> &'static [LogLevel] {
58        &[
59            Self::Trace,
60            Self::Debug,
61            Self::Info,
62            Self::Warn,
63            Self::Error,
64            Self::Off,
65        ]
66    }
67
68    /// Check if this level is more verbose than another.
69    pub fn is_more_verbose_than(&self, other: &LogLevel) -> bool {
70        Self::verbosity_order(self) < Self::verbosity_order(other)
71    }
72
73    fn verbosity_order(level: &LogLevel) -> u8 {
74        match level {
75            Self::Trace => 0,
76            Self::Debug => 1,
77            Self::Info => 2,
78            Self::Warn => 3,
79            Self::Error => 4,
80            Self::Off => 5,
81        }
82    }
83}
84
85impl std::str::FromStr for LogLevel {
86    type Err = Error;
87
88    fn from_str(s: &str) -> Result<Self> {
89        match s.to_lowercase().as_str() {
90            "trace" => Ok(Self::Trace),
91            "debug" => Ok(Self::Debug),
92            "info" => Ok(Self::Info),
93            "warn" | "warning" => Ok(Self::Warn),
94            "error" | "err" => Ok(Self::Error),
95            "off" | "none" | "disabled" => Ok(Self::Off),
96            _ => Err(Error::Config(format!("Invalid log level: {}", s))),
97        }
98    }
99}
100
101impl std::fmt::Display for LogLevel {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        write!(f, "{}", self.as_filter_str())
104    }
105}
106
107impl From<LogLevel> for tracing_subscriber::filter::LevelFilter {
108    fn from(level: LogLevel) -> Self {
109        match level {
110            LogLevel::Trace => Self::TRACE,
111            LogLevel::Debug => Self::DEBUG,
112            LogLevel::Info => Self::INFO,
113            LogLevel::Warn => Self::WARN,
114            LogLevel::Error => Self::ERROR,
115            LogLevel::Off => Self::OFF,
116        }
117    }
118}
119
120/// Log output format.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
122#[serde(rename_all = "lowercase")]
123pub enum LogFormat {
124    /// Pretty format for human readability.
125    #[default]
126    Pretty,
127    /// Compact format.
128    Compact,
129    /// JSON format for machine parsing.
130    Json,
131    /// Full format with all details.
132    Full,
133}
134
135impl LogFormat {
136    /// Check if this format is suitable for human reading.
137    pub fn is_human_readable(&self) -> bool {
138        matches!(self, Self::Pretty | Self::Compact | Self::Full)
139    }
140
141    /// Check if this format is suitable for machine parsing.
142    pub fn is_machine_parseable(&self) -> bool {
143        matches!(self, Self::Json)
144    }
145}
146
147/// Log output target.
148#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(tag = "type", rename_all = "lowercase")]
150pub enum LogTarget {
151    /// Write to stdout.
152    #[default]
153    Stdout,
154    /// Write to stderr.
155    Stderr,
156    /// Write to a file with optional rotation.
157    File {
158        /// Directory to store log files.
159        directory: PathBuf,
160        /// Filename prefix (e.g., "simulator" -> "simulator.log").
161        #[serde(default = "default_filename_prefix")]
162        filename_prefix: String,
163        /// Rotation configuration.
164        #[serde(default)]
165        rotation: RotationConfig,
166    },
167    /// Write to multiple targets.
168    Multi(Vec<LogTarget>),
169}
170
171fn default_filename_prefix() -> String {
172    "trap-simulator".to_string()
173}
174
175impl LogTarget {
176    /// Create a file target with default settings.
177    pub fn file(directory: impl Into<PathBuf>) -> Self {
178        Self::File {
179            directory: directory.into(),
180            filename_prefix: default_filename_prefix(),
181            rotation: RotationConfig::default(),
182        }
183    }
184
185    /// Create a file target with daily rotation.
186    pub fn daily_file(directory: impl Into<PathBuf>, prefix: impl Into<String>) -> Self {
187        Self::File {
188            directory: directory.into(),
189            filename_prefix: prefix.into(),
190            rotation: RotationConfig::daily(),
191        }
192    }
193
194    /// Create a file target with hourly rotation.
195    pub fn hourly_file(directory: impl Into<PathBuf>, prefix: impl Into<String>) -> Self {
196        Self::File {
197            directory: directory.into(),
198            filename_prefix: prefix.into(),
199            rotation: RotationConfig::hourly(),
200        }
201    }
202
203    /// Check if this target includes file output.
204    pub fn has_file_output(&self) -> bool {
205        match self {
206            Self::File { .. } => true,
207            Self::Multi(targets) => targets.iter().any(|t| t.has_file_output()),
208            _ => false,
209        }
210    }
211
212    /// Check if this target includes stdout/stderr.
213    pub fn has_console_output(&self) -> bool {
214        match self {
215            Self::Stdout | Self::Stderr => true,
216            Self::Multi(targets) => targets.iter().any(|t| t.has_console_output()),
217            _ => false,
218        }
219    }
220}
221
222/// Logging configuration.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct LogConfig {
225    /// Log level.
226    #[serde(default)]
227    pub level: LogLevel,
228
229    /// Output format.
230    #[serde(default)]
231    pub format: LogFormat,
232
233    /// Output target.
234    #[serde(default)]
235    pub target: LogTarget,
236
237    /// Include file and line numbers.
238    #[serde(default = "default_true")]
239    pub include_location: bool,
240
241    /// Include target (module path).
242    #[serde(default = "default_true")]
243    pub include_target: bool,
244
245    /// Include span events (enter/exit).
246    #[serde(default)]
247    pub include_span_events: bool,
248
249    /// Include thread IDs.
250    #[serde(default)]
251    pub include_thread_ids: bool,
252
253    /// Include thread names.
254    #[serde(default)]
255    pub include_thread_names: bool,
256
257    /// Custom filter directives (e.g., "trap_sim=debug,tokio=warn").
258    #[serde(default)]
259    pub filter: Option<String>,
260
261    /// Enable ANSI colors (only for Pretty/Compact formats on console).
262    #[serde(default = "default_true")]
263    pub ansi_colors: bool,
264
265    /// Enable dynamic log level changes at runtime.
266    #[serde(default = "default_true")]
267    pub dynamic_level: bool,
268
269    /// Per-module log levels.
270    #[serde(default)]
271    pub module_levels: std::collections::HashMap<String, LogLevel>,
272}
273
274fn default_true() -> bool {
275    true
276}
277
278impl Default for LogConfig {
279    fn default() -> Self {
280        Self {
281            level: LogLevel::default(),
282            format: LogFormat::default(),
283            target: LogTarget::default(),
284            include_location: true,
285            include_target: true,
286            include_span_events: false,
287            include_thread_ids: false,
288            include_thread_names: false,
289            filter: None,
290            ansi_colors: true,
291            dynamic_level: true,
292            module_levels: std::collections::HashMap::new(),
293        }
294    }
295}
296
297impl LogConfig {
298    /// Create a new builder.
299    pub fn builder() -> LogConfigBuilder {
300        LogConfigBuilder::default()
301    }
302
303    /// Create a development configuration (pretty, debug level).
304    pub fn development() -> Self {
305        Self {
306            level: LogLevel::Debug,
307            format: LogFormat::Pretty,
308            include_span_events: true,
309            ..Default::default()
310        }
311    }
312
313    /// Create a production configuration (JSON, info level).
314    pub fn production() -> Self {
315        Self {
316            level: LogLevel::Info,
317            format: LogFormat::Json,
318            include_location: false,
319            ansi_colors: false,
320            ..Default::default()
321        }
322    }
323
324    /// Create a test configuration (compact, debug level, no colors).
325    pub fn test() -> Self {
326        Self {
327            level: LogLevel::Debug,
328            format: LogFormat::Compact,
329            ansi_colors: false,
330            ..Default::default()
331        }
332    }
333
334    /// Create a production file logging configuration.
335    pub fn production_file(log_dir: impl Into<PathBuf>) -> Self {
336        Self {
337            level: LogLevel::Info,
338            format: LogFormat::Json,
339            target: LogTarget::File {
340                directory: log_dir.into(),
341                filename_prefix: "trap-simulator".to_string(),
342                rotation: RotationConfig::daily().with_max_files(30),
343            },
344            include_location: false,
345            ansi_colors: false,
346            ..Default::default()
347        }
348    }
349
350    /// Create a dual output configuration (console + file).
351    pub fn dual_output(log_dir: impl Into<PathBuf>) -> Self {
352        Self {
353            level: LogLevel::Info,
354            format: LogFormat::Pretty,
355            target: LogTarget::Multi(vec![
356                LogTarget::Stdout,
357                LogTarget::File {
358                    directory: log_dir.into(),
359                    filename_prefix: "trap-simulator".to_string(),
360                    rotation: RotationConfig::daily().with_max_files(7),
361                },
362            ]),
363            ..Default::default()
364        }
365    }
366
367    /// Build the filter string from configuration.
368    pub fn build_filter_string(&self) -> String {
369        let mut parts = Vec::new();
370
371        // Base level
372        let base_level = self.level.as_filter_str();
373        parts.push(format!("trap_sim={}", base_level));
374
375        // Per-module levels
376        for (module, level) in &self.module_levels {
377            parts.push(format!("{}={}", module, level.as_filter_str()));
378        }
379
380        // Default external crate levels
381        if !self.module_levels.contains_key("tokio") {
382            parts.push("tokio=warn".to_string());
383        }
384        if !self.module_levels.contains_key("hyper") {
385            parts.push("hyper=warn".to_string());
386        }
387        if !self.module_levels.contains_key("tower_http") {
388            parts.push(format!("tower_http={}", base_level));
389        }
390
391        // Custom filter takes precedence if specified
392        if let Some(ref filter) = self.filter {
393            return filter.clone();
394        }
395
396        parts.join(",")
397    }
398
399    /// Validate the configuration.
400    pub fn validate(&self) -> Result<()> {
401        // Validate file target paths
402        if let LogTarget::File { ref directory, .. } = self.target {
403            // Check if parent exists or can be created
404            if !directory.exists() {
405                std::fs::create_dir_all(directory).map_err(|e| {
406                    Error::Config(format!(
407                        "Cannot create log directory '{}': {}",
408                        directory.display(),
409                        e
410                    ))
411                })?;
412            }
413        }
414
415        // Validate module names in module_levels
416        for module in self.module_levels.keys() {
417            if module.is_empty() {
418                return Err(Error::Config("Module name cannot be empty".to_string()));
419            }
420        }
421
422        Ok(())
423    }
424}
425
426/// Builder for LogConfig.
427#[derive(Debug, Default)]
428pub struct LogConfigBuilder {
429    config: LogConfig,
430}
431
432impl LogConfigBuilder {
433    /// Set the log level.
434    pub fn level(mut self, level: LogLevel) -> Self {
435        self.config.level = level;
436        self
437    }
438
439    /// Set the output format.
440    pub fn format(mut self, format: LogFormat) -> Self {
441        self.config.format = format;
442        self
443    }
444
445    /// Set the output target.
446    pub fn target(mut self, target: LogTarget) -> Self {
447        self.config.target = target;
448        self
449    }
450
451    /// Set whether to include file/line location.
452    pub fn include_location(mut self, include: bool) -> Self {
453        self.config.include_location = include;
454        self
455    }
456
457    /// Set whether to include module target.
458    pub fn include_target(mut self, include: bool) -> Self {
459        self.config.include_target = include;
460        self
461    }
462
463    /// Set whether to include span events.
464    pub fn include_span_events(mut self, include: bool) -> Self {
465        self.config.include_span_events = include;
466        self
467    }
468
469    /// Set whether to include thread IDs.
470    pub fn include_thread_ids(mut self, include: bool) -> Self {
471        self.config.include_thread_ids = include;
472        self
473    }
474
475    /// Set whether to include thread names.
476    pub fn include_thread_names(mut self, include: bool) -> Self {
477        self.config.include_thread_names = include;
478        self
479    }
480
481    /// Set custom filter directives.
482    pub fn filter(mut self, filter: impl Into<String>) -> Self {
483        self.config.filter = Some(filter.into());
484        self
485    }
486
487    /// Set whether to enable ANSI colors.
488    pub fn ansi_colors(mut self, enable: bool) -> Self {
489        self.config.ansi_colors = enable;
490        self
491    }
492
493    /// Set whether to enable dynamic log level changes.
494    pub fn dynamic_level(mut self, enable: bool) -> Self {
495        self.config.dynamic_level = enable;
496        self
497    }
498
499    /// Add a per-module log level.
500    pub fn module_level(mut self, module: impl Into<String>, level: LogLevel) -> Self {
501        self.config.module_levels.insert(module.into(), level);
502        self
503    }
504
505    /// Build the configuration.
506    pub fn build(self) -> LogConfig {
507        self.config
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn test_log_level_from_str() {
517        assert_eq!("trace".parse::<LogLevel>().unwrap(), LogLevel::Trace);
518        assert_eq!("debug".parse::<LogLevel>().unwrap(), LogLevel::Debug);
519        assert_eq!("info".parse::<LogLevel>().unwrap(), LogLevel::Info);
520        assert_eq!("warn".parse::<LogLevel>().unwrap(), LogLevel::Warn);
521        assert_eq!("warning".parse::<LogLevel>().unwrap(), LogLevel::Warn);
522        assert_eq!("error".parse::<LogLevel>().unwrap(), LogLevel::Error);
523        assert_eq!("off".parse::<LogLevel>().unwrap(), LogLevel::Off);
524        assert!("invalid".parse::<LogLevel>().is_err());
525    }
526
527    #[test]
528    fn test_log_level_display() {
529        assert_eq!(LogLevel::Trace.to_string(), "trace");
530        assert_eq!(LogLevel::Debug.to_string(), "debug");
531        assert_eq!(LogLevel::Info.to_string(), "info");
532        assert_eq!(LogLevel::Warn.to_string(), "warn");
533        assert_eq!(LogLevel::Error.to_string(), "error");
534        assert_eq!(LogLevel::Off.to_string(), "off");
535    }
536
537    #[test]
538    fn test_log_level_verbosity() {
539        assert!(LogLevel::Trace.is_more_verbose_than(&LogLevel::Debug));
540        assert!(LogLevel::Debug.is_more_verbose_than(&LogLevel::Info));
541        assert!(LogLevel::Info.is_more_verbose_than(&LogLevel::Warn));
542        assert!(LogLevel::Warn.is_more_verbose_than(&LogLevel::Error));
543        assert!(LogLevel::Error.is_more_verbose_than(&LogLevel::Off));
544    }
545
546    #[test]
547    fn test_log_config_builder() {
548        let config = LogConfig::builder()
549            .level(LogLevel::Debug)
550            .format(LogFormat::Json)
551            .include_location(false)
552            .module_level("tokio", LogLevel::Warn)
553            .build();
554
555        assert_eq!(config.level, LogLevel::Debug);
556        assert_eq!(config.format, LogFormat::Json);
557        assert!(!config.include_location);
558        assert_eq!(config.module_levels.get("tokio"), Some(&LogLevel::Warn));
559    }
560
561    #[test]
562    fn test_log_config_presets() {
563        let dev = LogConfig::development();
564        assert_eq!(dev.level, LogLevel::Debug);
565        assert_eq!(dev.format, LogFormat::Pretty);
566
567        let prod = LogConfig::production();
568        assert_eq!(prod.level, LogLevel::Info);
569        assert_eq!(prod.format, LogFormat::Json);
570    }
571
572    #[test]
573    fn test_log_config_serialization() {
574        let config = LogConfig::default();
575        let yaml = serde_yaml::to_string(&config).unwrap();
576        let parsed: LogConfig = serde_yaml::from_str(&yaml).unwrap();
577
578        assert_eq!(config.level, parsed.level);
579        assert_eq!(config.format, parsed.format);
580    }
581
582    #[test]
583    fn test_log_target_helpers() {
584        let file_target = LogTarget::file("/tmp/logs");
585        assert!(file_target.has_file_output());
586        assert!(!file_target.has_console_output());
587
588        let stdout_target = LogTarget::Stdout;
589        assert!(!stdout_target.has_file_output());
590        assert!(stdout_target.has_console_output());
591
592        let multi_target = LogTarget::Multi(vec![LogTarget::Stdout, LogTarget::file("/tmp/logs")]);
593        assert!(multi_target.has_file_output());
594        assert!(multi_target.has_console_output());
595    }
596
597    #[test]
598    fn test_build_filter_string() {
599        let config = LogConfig::builder()
600            .level(LogLevel::Debug)
601            .module_level("my_module", LogLevel::Trace)
602            .build();
603
604        let filter = config.build_filter_string();
605        assert!(filter.contains("trap_sim=debug"));
606        assert!(filter.contains("my_module=trace"));
607        assert!(filter.contains("tokio=warn"));
608    }
609
610    #[test]
611    fn test_build_filter_string_custom() {
612        let config = LogConfig::builder()
613            .filter("custom=trace,other=debug")
614            .build();
615
616        let filter = config.build_filter_string();
617        assert_eq!(filter, "custom=trace,other=debug");
618    }
619}