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, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(tag = "type", rename_all = "lowercase")]
150pub enum LogTarget {
151 Stdout,
153 Stderr,
155 File {
157 directory: PathBuf,
159 #[serde(default = "default_filename_prefix")]
161 filename_prefix: String,
162 #[serde(default)]
164 rotation: RotationConfig,
165 },
166 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct LogConfig {
230 #[serde(default)]
232 pub level: LogLevel,
233
234 #[serde(default)]
236 pub format: LogFormat,
237
238 #[serde(default)]
240 pub target: LogTarget,
241
242 #[serde(default = "default_true")]
244 pub include_location: bool,
245
246 #[serde(default = "default_true")]
248 pub include_target: bool,
249
250 #[serde(default)]
252 pub include_span_events: bool,
253
254 #[serde(default)]
256 pub include_thread_ids: bool,
257
258 #[serde(default)]
260 pub include_thread_names: bool,
261
262 #[serde(default)]
264 pub filter: Option<String>,
265
266 #[serde(default = "default_true")]
268 pub ansi_colors: bool,
269
270 #[serde(default = "default_true")]
272 pub dynamic_level: bool,
273
274 #[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 pub fn builder() -> LogConfigBuilder {
305 LogConfigBuilder::default()
306 }
307
308 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 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 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 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 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 pub fn build_filter_string(&self) -> String {
374 let mut parts = Vec::new();
375
376 let base_level = self.level.as_filter_str();
378 parts.push(format!("trap_sim={}", base_level));
379
380 for (module, level) in &self.module_levels {
382 parts.push(format!("{}={}", module, level.as_filter_str()));
383 }
384
385 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 if let Some(ref filter) = self.filter {
398 return filter.clone();
399 }
400
401 parts.join(",")
402 }
403
404 pub fn validate(&self) -> Result<()> {
406 if let LogTarget::File { ref directory, .. } = self.target {
408 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 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#[derive(Debug, Default)]
433pub struct LogConfigBuilder {
434 config: LogConfig,
435}
436
437impl LogConfigBuilder {
438 pub fn level(mut self, level: LogLevel) -> Self {
440 self.config.level = level;
441 self
442 }
443
444 pub fn format(mut self, format: LogFormat) -> Self {
446 self.config.format = format;
447 self
448 }
449
450 pub fn target(mut self, target: LogTarget) -> Self {
452 self.config.target = target;
453 self
454 }
455
456 pub fn include_location(mut self, include: bool) -> Self {
458 self.config.include_location = include;
459 self
460 }
461
462 pub fn include_target(mut self, include: bool) -> Self {
464 self.config.include_target = include;
465 self
466 }
467
468 pub fn include_span_events(mut self, include: bool) -> Self {
470 self.config.include_span_events = include;
471 self
472 }
473
474 pub fn include_thread_ids(mut self, include: bool) -> Self {
476 self.config.include_thread_ids = include;
477 self
478 }
479
480 pub fn include_thread_names(mut self, include: bool) -> Self {
482 self.config.include_thread_names = include;
483 self
484 }
485
486 pub fn filter(mut self, filter: impl Into<String>) -> Self {
488 self.config.filter = Some(filter.into());
489 self
490 }
491
492 pub fn ansi_colors(mut self, enable: bool) -> Self {
494 self.config.ansi_colors = enable;
495 self
496 }
497
498 pub fn dynamic_level(mut self, enable: bool) -> Self {
500 self.config.dynamic_level = enable;
501 self
502 }
503
504 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 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}