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, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(tag = "type", rename_all = "lowercase")]
150pub enum LogTarget {
151    /// Write to stdout.
152    Stdout,
153    /// Write to stderr.
154    Stderr,
155    /// Write to a file with optional rotation.
156    File {
157        /// Directory to store log files.
158        directory: PathBuf,
159        /// Filename prefix (e.g., "simulator" -> "simulator.log").
160        #[serde(default = "default_filename_prefix")]
161        filename_prefix: String,
162        /// Rotation configuration.
163        #[serde(default)]
164        rotation: RotationConfig,
165    },
166    /// Write to multiple targets.
167    Multi(Vec<LogTarget>),
168}
169
170fn default_filename_prefix() -> String {
171    "trap-simulator".to_string()
172}
173
174impl Default for LogTarget {
175    fn default() -> Self {
176        Self::Stdout
177    }
178}
179
180impl LogTarget {
181    /// Create a file target with default settings.
182    pub fn file(directory: impl Into<PathBuf>) -> Self {
183        Self::File {
184            directory: directory.into(),
185            filename_prefix: default_filename_prefix(),
186            rotation: RotationConfig::default(),
187        }
188    }
189
190    /// Create a file target with daily rotation.
191    pub fn daily_file(directory: impl Into<PathBuf>, prefix: impl Into<String>) -> Self {
192        Self::File {
193            directory: directory.into(),
194            filename_prefix: prefix.into(),
195            rotation: RotationConfig::daily(),
196        }
197    }
198
199    /// Create a file target with hourly rotation.
200    pub fn hourly_file(directory: impl Into<PathBuf>, prefix: impl Into<String>) -> Self {
201        Self::File {
202            directory: directory.into(),
203            filename_prefix: prefix.into(),
204            rotation: RotationConfig::hourly(),
205        }
206    }
207
208    /// Check if this target includes file output.
209    pub fn has_file_output(&self) -> bool {
210        match self {
211            Self::File { .. } => true,
212            Self::Multi(targets) => targets.iter().any(|t| t.has_file_output()),
213            _ => false,
214        }
215    }
216
217    /// Check if this target includes stdout/stderr.
218    pub fn has_console_output(&self) -> bool {
219        match self {
220            Self::Stdout | Self::Stderr => true,
221            Self::Multi(targets) => targets.iter().any(|t| t.has_console_output()),
222            _ => false,
223        }
224    }
225}
226
227/// Logging configuration.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct LogConfig {
230    /// Log level.
231    #[serde(default)]
232    pub level: LogLevel,
233
234    /// Output format.
235    #[serde(default)]
236    pub format: LogFormat,
237
238    /// Output target.
239    #[serde(default)]
240    pub target: LogTarget,
241
242    /// Include file and line numbers.
243    #[serde(default = "default_true")]
244    pub include_location: bool,
245
246    /// Include target (module path).
247    #[serde(default = "default_true")]
248    pub include_target: bool,
249
250    /// Include span events (enter/exit).
251    #[serde(default)]
252    pub include_span_events: bool,
253
254    /// Include thread IDs.
255    #[serde(default)]
256    pub include_thread_ids: bool,
257
258    /// Include thread names.
259    #[serde(default)]
260    pub include_thread_names: bool,
261
262    /// Custom filter directives (e.g., "trap_sim=debug,tokio=warn").
263    #[serde(default)]
264    pub filter: Option<String>,
265
266    /// Enable ANSI colors (only for Pretty/Compact formats on console).
267    #[serde(default = "default_true")]
268    pub ansi_colors: bool,
269
270    /// Enable dynamic log level changes at runtime.
271    #[serde(default = "default_true")]
272    pub dynamic_level: bool,
273
274    /// Per-module log levels.
275    #[serde(default)]
276    pub module_levels: std::collections::HashMap<String, LogLevel>,
277}
278
279fn default_true() -> bool {
280    true
281}
282
283impl Default for LogConfig {
284    fn default() -> Self {
285        Self {
286            level: LogLevel::default(),
287            format: LogFormat::default(),
288            target: LogTarget::default(),
289            include_location: true,
290            include_target: true,
291            include_span_events: false,
292            include_thread_ids: false,
293            include_thread_names: false,
294            filter: None,
295            ansi_colors: true,
296            dynamic_level: true,
297            module_levels: std::collections::HashMap::new(),
298        }
299    }
300}
301
302impl LogConfig {
303    /// Create a new builder.
304    pub fn builder() -> LogConfigBuilder {
305        LogConfigBuilder::default()
306    }
307
308    /// Create a development configuration (pretty, debug level).
309    pub fn development() -> Self {
310        Self {
311            level: LogLevel::Debug,
312            format: LogFormat::Pretty,
313            include_span_events: true,
314            ..Default::default()
315        }
316    }
317
318    /// Create a production configuration (JSON, info level).
319    pub fn production() -> Self {
320        Self {
321            level: LogLevel::Info,
322            format: LogFormat::Json,
323            include_location: false,
324            ansi_colors: false,
325            ..Default::default()
326        }
327    }
328
329    /// Create a test configuration (compact, debug level, no colors).
330    pub fn test() -> Self {
331        Self {
332            level: LogLevel::Debug,
333            format: LogFormat::Compact,
334            ansi_colors: false,
335            ..Default::default()
336        }
337    }
338
339    /// Create a production file logging configuration.
340    pub fn production_file(log_dir: impl Into<PathBuf>) -> Self {
341        Self {
342            level: LogLevel::Info,
343            format: LogFormat::Json,
344            target: LogTarget::File {
345                directory: log_dir.into(),
346                filename_prefix: "trap-simulator".to_string(),
347                rotation: RotationConfig::daily().with_max_files(30),
348            },
349            include_location: false,
350            ansi_colors: false,
351            ..Default::default()
352        }
353    }
354
355    /// Create a dual output configuration (console + file).
356    pub fn dual_output(log_dir: impl Into<PathBuf>) -> Self {
357        Self {
358            level: LogLevel::Info,
359            format: LogFormat::Pretty,
360            target: LogTarget::Multi(vec![
361                LogTarget::Stdout,
362                LogTarget::File {
363                    directory: log_dir.into(),
364                    filename_prefix: "trap-simulator".to_string(),
365                    rotation: RotationConfig::daily().with_max_files(7),
366                },
367            ]),
368            ..Default::default()
369        }
370    }
371
372    /// Build the filter string from configuration.
373    pub fn build_filter_string(&self) -> String {
374        let mut parts = Vec::new();
375
376        // Base level
377        let base_level = self.level.as_filter_str();
378        parts.push(format!("trap_sim={}", base_level));
379
380        // Per-module levels
381        for (module, level) in &self.module_levels {
382            parts.push(format!("{}={}", module, level.as_filter_str()));
383        }
384
385        // Default external crate levels
386        if !self.module_levels.contains_key("tokio") {
387            parts.push("tokio=warn".to_string());
388        }
389        if !self.module_levels.contains_key("hyper") {
390            parts.push("hyper=warn".to_string());
391        }
392        if !self.module_levels.contains_key("tower_http") {
393            parts.push(format!("tower_http={}", base_level));
394        }
395
396        // Custom filter takes precedence if specified
397        if let Some(ref filter) = self.filter {
398            return filter.clone();
399        }
400
401        parts.join(",")
402    }
403
404    /// Validate the configuration.
405    pub fn validate(&self) -> Result<()> {
406        // Validate file target paths
407        if let LogTarget::File { ref directory, .. } = self.target {
408            // Check if parent exists or can be created
409            if !directory.exists() {
410                std::fs::create_dir_all(directory).map_err(|e| {
411                    Error::Config(format!(
412                        "Cannot create log directory '{}': {}",
413                        directory.display(),
414                        e
415                    ))
416                })?;
417            }
418        }
419
420        // Validate module names in module_levels
421        for (module, _) in &self.module_levels {
422            if module.is_empty() {
423                return Err(Error::Config("Module name cannot be empty".to_string()));
424            }
425        }
426
427        Ok(())
428    }
429}
430
431/// Builder for LogConfig.
432#[derive(Debug, Default)]
433pub struct LogConfigBuilder {
434    config: LogConfig,
435}
436
437impl LogConfigBuilder {
438    /// Set the log level.
439    pub fn level(mut self, level: LogLevel) -> Self {
440        self.config.level = level;
441        self
442    }
443
444    /// Set the output format.
445    pub fn format(mut self, format: LogFormat) -> Self {
446        self.config.format = format;
447        self
448    }
449
450    /// Set the output target.
451    pub fn target(mut self, target: LogTarget) -> Self {
452        self.config.target = target;
453        self
454    }
455
456    /// Set whether to include file/line location.
457    pub fn include_location(mut self, include: bool) -> Self {
458        self.config.include_location = include;
459        self
460    }
461
462    /// Set whether to include module target.
463    pub fn include_target(mut self, include: bool) -> Self {
464        self.config.include_target = include;
465        self
466    }
467
468    /// Set whether to include span events.
469    pub fn include_span_events(mut self, include: bool) -> Self {
470        self.config.include_span_events = include;
471        self
472    }
473
474    /// Set whether to include thread IDs.
475    pub fn include_thread_ids(mut self, include: bool) -> Self {
476        self.config.include_thread_ids = include;
477        self
478    }
479
480    /// Set whether to include thread names.
481    pub fn include_thread_names(mut self, include: bool) -> Self {
482        self.config.include_thread_names = include;
483        self
484    }
485
486    /// Set custom filter directives.
487    pub fn filter(mut self, filter: impl Into<String>) -> Self {
488        self.config.filter = Some(filter.into());
489        self
490    }
491
492    /// Set whether to enable ANSI colors.
493    pub fn ansi_colors(mut self, enable: bool) -> Self {
494        self.config.ansi_colors = enable;
495        self
496    }
497
498    /// Set whether to enable dynamic log level changes.
499    pub fn dynamic_level(mut self, enable: bool) -> Self {
500        self.config.dynamic_level = enable;
501        self
502    }
503
504    /// Add a per-module log level.
505    pub fn module_level(mut self, module: impl Into<String>, level: LogLevel) -> Self {
506        self.config.module_levels.insert(module.into(), level);
507        self
508    }
509
510    /// Build the configuration.
511    pub fn build(self) -> LogConfig {
512        self.config
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn test_log_level_from_str() {
522        assert_eq!("trace".parse::<LogLevel>().unwrap(), LogLevel::Trace);
523        assert_eq!("debug".parse::<LogLevel>().unwrap(), LogLevel::Debug);
524        assert_eq!("info".parse::<LogLevel>().unwrap(), LogLevel::Info);
525        assert_eq!("warn".parse::<LogLevel>().unwrap(), LogLevel::Warn);
526        assert_eq!("warning".parse::<LogLevel>().unwrap(), LogLevel::Warn);
527        assert_eq!("error".parse::<LogLevel>().unwrap(), LogLevel::Error);
528        assert_eq!("off".parse::<LogLevel>().unwrap(), LogLevel::Off);
529        assert!("invalid".parse::<LogLevel>().is_err());
530    }
531
532    #[test]
533    fn test_log_level_display() {
534        assert_eq!(LogLevel::Trace.to_string(), "trace");
535        assert_eq!(LogLevel::Debug.to_string(), "debug");
536        assert_eq!(LogLevel::Info.to_string(), "info");
537        assert_eq!(LogLevel::Warn.to_string(), "warn");
538        assert_eq!(LogLevel::Error.to_string(), "error");
539        assert_eq!(LogLevel::Off.to_string(), "off");
540    }
541
542    #[test]
543    fn test_log_level_verbosity() {
544        assert!(LogLevel::Trace.is_more_verbose_than(&LogLevel::Debug));
545        assert!(LogLevel::Debug.is_more_verbose_than(&LogLevel::Info));
546        assert!(LogLevel::Info.is_more_verbose_than(&LogLevel::Warn));
547        assert!(LogLevel::Warn.is_more_verbose_than(&LogLevel::Error));
548        assert!(LogLevel::Error.is_more_verbose_than(&LogLevel::Off));
549    }
550
551    #[test]
552    fn test_log_config_builder() {
553        let config = LogConfig::builder()
554            .level(LogLevel::Debug)
555            .format(LogFormat::Json)
556            .include_location(false)
557            .module_level("tokio", LogLevel::Warn)
558            .build();
559
560        assert_eq!(config.level, LogLevel::Debug);
561        assert_eq!(config.format, LogFormat::Json);
562        assert!(!config.include_location);
563        assert_eq!(config.module_levels.get("tokio"), Some(&LogLevel::Warn));
564    }
565
566    #[test]
567    fn test_log_config_presets() {
568        let dev = LogConfig::development();
569        assert_eq!(dev.level, LogLevel::Debug);
570        assert_eq!(dev.format, LogFormat::Pretty);
571
572        let prod = LogConfig::production();
573        assert_eq!(prod.level, LogLevel::Info);
574        assert_eq!(prod.format, LogFormat::Json);
575    }
576
577    #[test]
578    fn test_log_config_serialization() {
579        let config = LogConfig::default();
580        let yaml = serde_yaml::to_string(&config).unwrap();
581        let parsed: LogConfig = serde_yaml::from_str(&yaml).unwrap();
582
583        assert_eq!(config.level, parsed.level);
584        assert_eq!(config.format, parsed.format);
585    }
586
587    #[test]
588    fn test_log_target_helpers() {
589        let file_target = LogTarget::file("/tmp/logs");
590        assert!(file_target.has_file_output());
591        assert!(!file_target.has_console_output());
592
593        let stdout_target = LogTarget::Stdout;
594        assert!(!stdout_target.has_file_output());
595        assert!(stdout_target.has_console_output());
596
597        let multi_target = LogTarget::Multi(vec![LogTarget::Stdout, LogTarget::file("/tmp/logs")]);
598        assert!(multi_target.has_file_output());
599        assert!(multi_target.has_console_output());
600    }
601
602    #[test]
603    fn test_build_filter_string() {
604        let config = LogConfig::builder()
605            .level(LogLevel::Debug)
606            .module_level("my_module", LogLevel::Trace)
607            .build();
608
609        let filter = config.build_filter_string();
610        assert!(filter.contains("trap_sim=debug"));
611        assert!(filter.contains("my_module=trace"));
612        assert!(filter.contains("tokio=warn"));
613    }
614
615    #[test]
616    fn test_build_filter_string_custom() {
617        let config = LogConfig::builder()
618            .filter("custom=trace,other=debug")
619            .build();
620
621        let filter = config.build_filter_string();
622        assert_eq!(filter, "custom=trace,other=debug");
623    }
624}