lxy 0.1.1

A convenient async http and RPC framework in Rust
Documentation
use std::collections::HashMap;

use serde::Deserialize;

use crate::config::Config;

/// The format of the log output
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
  /// 2024-01-09 10:00:00 INFO [module] message
  #[default]
  Plaintext,
  /// {"timestamp":"...","level":"INFO","message":"..."}
  Json,
}

/// Options for the stdout tracing layer
#[derive(Debug, Clone, Deserialize, Default)]
pub struct StdoutLayerOptions {
  #[serde(default = "default_color")]
  pub color: bool,
}

fn default_color() -> bool {
  true
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RotationPeriod {
  Hourly,
  #[default]
  Daily,
  Weekly,
}

/// Options for the auto rotating file tracing layer
#[derive(Debug, Clone, Deserialize)]
pub struct FileLayerOptions {
  /// The file path to write tracing output to.
  pub path: String,

  /// Period to rotate logs
  /// Options: "hourly", "daily", "weekly"
  #[serde(default)]
  pub rotation: Option<RotationPeriod>,
}

/// The log output layer
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "lowercase", tag = "type")]
pub enum TracingLayerType {
  Stdout(Box<StdoutLayerOptions>),
  File(Box<FileLayerOptions>),
}

/// Helper struct for deserializing tracing layers with names
#[derive(Debug, Deserialize, Clone)]
pub struct TracingLayer {
  pub name: String,

  #[serde(default)]
  pub format: Option<LogFormat>,

  #[serde(default)]
  pub filter: Option<String>,

  #[serde(flatten)]
  pub kind: TracingLayerType,
}

/// Custom deserializer for layers field that converts Vec<TracingLayer> to HashMap<String, TracingLayer>
fn deserialize_layers<'de, D>(deserializer: D) -> Result<HashMap<String, TracingLayer>, D::Error>
where
  D: serde::Deserializer<'de>,
{
  let layers_vec: Vec<TracingLayer> = Vec::deserialize(deserializer)?;
  Ok(
    layers_vec
      .into_iter()
      .map(|l| (l.name.clone(), l))
      .collect(),
  )
}

/// Configuration for tracing and logging
///
/// Lxy allows you to configure logging with filter, format, and layers.
///
/// ```toml
/// [tracing]
/// filter = "info"   # The default filter for all layers (supports EnvFilter syntax)
/// format = "json"   # The default format for all layers, if not specified in layer config
/// layers = [        # The layers supported to be targeted as tracing outputs
///   { name = "stdout", type = "stdout", format = "plaintext" },
///   { name = "dev_file", type = "file", path = "logs/dev.log", format = "json", filter = "debug" },
///   { name = "prod_file", type = "file", path = "logs/prod.log", rotation = "daily", format = "json" },
/// ]
/// use = ["stdout", "dev_file"] # The layers to activate for tracing output
/// ```
#[derive(Debug, Clone, Deserialize)]
pub struct TracingConfig {
  /// Default filter for all channels (supports EnvFilter syntax)
  #[serde(default = "default_filter")]
  pub filter: String,

  /// Default format for all tracing layers
  #[serde(default)]
  pub format: LogFormat,

  /// Available tracing layers
  #[serde(default, deserialize_with = "deserialize_layers")]
  pub layers: HashMap<String, TracingLayer>,

  /// The selected tracing layers to activate
  #[serde(default, rename = "use", alias = "targets")]
  pub targets: Vec<String>,
}

fn default_filter() -> String {
  "info".to_string()
}

impl Default for TracingConfig {
  fn default() -> Self {
    Self {
      filter: default_filter(),
      format: LogFormat::default(),
      layers: HashMap::new(),
      targets: Vec::new(),
    }
  }
}

impl Config for TracingConfig {
  fn name() -> &'static str {
    "tracing"
  }
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn test_tracing_config_deserialize() {
    let toml_str = r#"
      filter = "info"
      format = "json"
      layers = [
        { name = "stdout", type = "stdout", format = "plaintext" },
        { name = "dev_file", type = "file", path = "logs/dev.log" },
        { name = "prod_file", type = "file", path = "logs/prod.log", rotation = "daily" }
      ]
      use = ["stdout", "dev_file"]
    "#;

    let config: TracingConfig = toml::from_str(toml_str).unwrap();

    assert_eq!(config.filter, "info");
    assert_eq!(config.layers.len(), 3);
    assert!(config.layers.contains_key("stdout"));
    assert!(config.layers.contains_key("dev_file"));
    assert!(config.layers.contains_key("prod_file"));
    assert_eq!(config.targets.len(), 2);
    assert_eq!(config.targets[0], "stdout");
    assert_eq!(config.targets[1], "dev_file");
  }

  #[test]
  fn test_tracing_config_with_filter_field() {
    let toml_str = r#"
      filter = "my_crate=debug,other_crate=trace"
      layers = [
        { name = "stdout", type = "stdout", filter = "debug" }
      ]
    "#;

    let config: TracingConfig = toml::from_str(toml_str).unwrap();

    assert_eq!(config.filter, "my_crate=debug,other_crate=trace");
    assert_eq!(
      config.layers.get("stdout").unwrap().filter.as_deref(),
      Some("debug")
    );
  }

  #[test]
  fn test_tracing_config_with_targets_alias() {
    let toml_str = r#"
      layers = [
        { name = "stdout", type = "stdout" }
      ]
      targets = ["stdout"]
    "#;

    let config: TracingConfig = toml::from_str(toml_str).unwrap();

    assert_eq!(config.targets, vec!["stdout"]);
  }

  #[test]
  fn test_tracing_config_deserialize_duplicate_names() {
    let toml_str = r#"
      layers = [
        { name = "stdout", type = "stdout", format = "plaintext" },
        { name = "stdout", type = "stdout", format = "json" }
      ]
    "#;

    let config: TracingConfig = toml::from_str(toml_str).unwrap();

    // Should keep the last one
    assert_eq!(config.layers.len(), 1);
    assert!(config.layers.contains_key("stdout"));
  }
}