1use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6use tracing::Level;
7
8use crate::error::{Error, Result};
9
10use super::rotation::RotationConfig;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "lowercase")]
15pub enum LogLevel {
16 Trace,
18 Debug,
20 #[default]
22 Info,
23 Warn,
25 Error,
27 Off,
29}
30
31impl LogLevel {
32 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
122#[serde(rename_all = "lowercase")]
123pub enum LogFormat {
124 #[default]
126 Pretty,
127 Compact,
129 Json,
131 Full,
133}
134
135impl LogFormat {
136 pub fn is_human_readable(&self) -> bool {
138 matches!(self, Self::Pretty | Self::Compact | Self::Full)
139 }
140
141 pub fn is_machine_parseable(&self) -> bool {
143 matches!(self, Self::Json)
144 }
145}
146
147#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(tag = "type", rename_all = "lowercase")]
150pub enum LogTarget {
151 #[default]
153 Stdout,
154 Stderr,
156 File {
158 directory: PathBuf,
160 #[serde(default = "default_filename_prefix")]
162 filename_prefix: String,
163 #[serde(default)]
165 rotation: RotationConfig,
166 },
167 Multi(Vec<LogTarget>),
169}
170
171fn default_filename_prefix() -> String {
172 "trap-simulator".to_string()
173}
174
175impl LogTarget {
176 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct LogConfig {
225 #[serde(default)]
227 pub level: LogLevel,
228
229 #[serde(default)]
231 pub format: LogFormat,
232
233 #[serde(default)]
235 pub target: LogTarget,
236
237 #[serde(default = "default_true")]
239 pub include_location: bool,
240
241 #[serde(default = "default_true")]
243 pub include_target: bool,
244
245 #[serde(default)]
247 pub include_span_events: bool,
248
249 #[serde(default)]
251 pub include_thread_ids: bool,
252
253 #[serde(default)]
255 pub include_thread_names: bool,
256
257 #[serde(default)]
259 pub filter: Option<String>,
260
261 #[serde(default = "default_true")]
263 pub ansi_colors: bool,
264
265 #[serde(default = "default_true")]
267 pub dynamic_level: bool,
268
269 #[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 pub fn builder() -> LogConfigBuilder {
300 LogConfigBuilder::default()
301 }
302
303 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 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 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 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 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 pub fn build_filter_string(&self) -> String {
369 let mut parts = Vec::new();
370
371 let base_level = self.level.as_filter_str();
373 parts.push(format!("trap_sim={}", base_level));
374
375 for (module, level) in &self.module_levels {
377 parts.push(format!("{}={}", module, level.as_filter_str()));
378 }
379
380 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 if let Some(ref filter) = self.filter {
393 return filter.clone();
394 }
395
396 parts.join(",")
397 }
398
399 pub fn validate(&self) -> Result<()> {
401 if let LogTarget::File { ref directory, .. } = self.target {
403 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 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#[derive(Debug, Default)]
428pub struct LogConfigBuilder {
429 config: LogConfig,
430}
431
432impl LogConfigBuilder {
433 pub fn level(mut self, level: LogLevel) -> Self {
435 self.config.level = level;
436 self
437 }
438
439 pub fn format(mut self, format: LogFormat) -> Self {
441 self.config.format = format;
442 self
443 }
444
445 pub fn target(mut self, target: LogTarget) -> Self {
447 self.config.target = target;
448 self
449 }
450
451 pub fn include_location(mut self, include: bool) -> Self {
453 self.config.include_location = include;
454 self
455 }
456
457 pub fn include_target(mut self, include: bool) -> Self {
459 self.config.include_target = include;
460 self
461 }
462
463 pub fn include_span_events(mut self, include: bool) -> Self {
465 self.config.include_span_events = include;
466 self
467 }
468
469 pub fn include_thread_ids(mut self, include: bool) -> Self {
471 self.config.include_thread_ids = include;
472 self
473 }
474
475 pub fn include_thread_names(mut self, include: bool) -> Self {
477 self.config.include_thread_names = include;
478 self
479 }
480
481 pub fn filter(mut self, filter: impl Into<String>) -> Self {
483 self.config.filter = Some(filter.into());
484 self
485 }
486
487 pub fn ansi_colors(mut self, enable: bool) -> Self {
489 self.config.ansi_colors = enable;
490 self
491 }
492
493 pub fn dynamic_level(mut self, enable: bool) -> Self {
495 self.config.dynamic_level = enable;
496 self
497 }
498
499 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 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}