lxy 0.1.1

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

use tracing::warn;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{
  EnvFilter, Layer, Registry, fmt,
  layer::{Layered, SubscriberExt},
};

use super::{config::*, writer};

/// Keeps the non-blocking writer guards alive for the lifetime of the application.
///
/// This struct holds the `WorkerGuard`s returned by `tracing_appender::non_blocking`.
/// These guards must be kept alive to ensure that the non-blocking writers continue
/// to flush logs in the background. When this struct is dropped, all pending logs
/// will be flushed automatically.
#[derive(Debug)]
pub(crate) struct TracingHouseKeeper {
  #[allow(dead_code)]
  guards: Vec<WorkerGuard>,
}

type BoxedLayer = Box<dyn Layer<tracing_subscriber::Registry> + Send + Sync + 'static>;
pub(crate) type Subscriber = Layered<Vec<BoxedLayer>, Registry>;

/// Builder for configuring and initializing the tracing system.
///
/// The `TracingBuilder` allows you to configure tracing layers from configuration
/// and optionally add custom layers.
///
/// It implements lazy loading - only the layers specified in `use` will be initialized.
///
/// # Examples
///
/// ```rust
/// use lxy::App;
///
/// App::builder()
///   .with_tracing(|builder| {
///      // All config of [tracing] section will be automatically loaded,
///      // or you may disable it by calling `with_config(false)`.
///      // You can add custom layers and they will be automatically included.
///      // builder.add_layer("stderr", tracing_subscriber::fmt::layer());
///   });
/// ```
#[derive(Default)]
pub struct TracingBuilder {
  use_config: bool,
  /// Custom tracing layers for output targets
  layers: HashMap<String, BoxedLayer>,
}

impl TracingBuilder {
  pub fn new() -> Self {
    Self {
      use_config: true,
      layers: HashMap::new(),
    }
  }

  /// Adds a custom layer with the given name.
  ///
  /// Custom layers are already instantiated and will be added directly without lazy loading.
  /// The layer will be automatically added to targets.
  pub fn add_layer<N: Into<String>, L>(&mut self, name: N, layer: L) -> &mut Self
  where
    L: Layer<Registry> + Send + Sync + 'static,
  {
    let name = name.into();
    self.layers.insert(name.clone(), Box::new(layer));
    self
  }

  /// Specifies whether to use the tracing configuration from the TOML file.
  pub fn with_config(&mut self, use_config: bool) -> &mut Self {
    self.use_config = use_config;
    self
  }

  /// Builds the tracing system and initializes the global tracing subscriber.
  pub(crate) fn build(&mut self, config: &TracingConfig) -> (Subscriber, TracingHouseKeeper) {
    let mut layers: Vec<BoxedLayer> = self.layers.drain().map(|(_, v)| v).collect();
    let mut guards: Vec<WorkerGuard> = Vec::new();

    // load and target and used layers from config if enabled
    if self.use_config {
      let rust_log = get_rust_log_env();
      let selected_layers = get_selected_layers(config);

      for target in &selected_layers {
        // Create a layer from configuration
        if let Some(config_layer) = config.layers.get(target) {
          let (layer, guard) = create_layer(config, config_layer, rust_log.as_deref());
          layers.push(layer);
          guards.push(guard);
        } else {
          // Warn about missing layer configuration
          warn!(
            "Tracing target '{}' is not configured in [tracing.layers]",
            target
          );
        }
      }
    }

    let subscriber: Subscriber = Registry::default().with(layers);

    (subscriber, TracingHouseKeeper { guards })
  }
}

/// Gets the RUST_LOG environment variable value if set.
fn get_rust_log_env() -> Option<String> {
  std::env::var("RUST_LOG").ok()
}

/// Resolves the filter string for a layer based on priority:
/// 1. Layer-specific filter
/// 2. RUST_LOG environment variable
/// 3. Config global filter
/// 4. Default: "info"
fn resolve_filter_string(
  layer_filter: Option<&str>,
  rust_log_env: Option<&str>,
  config_filter: &str,
) -> String {
  if let Some(filter) = layer_filter {
    return filter.to_string();
  }

  if let Some(env_filter) = rust_log_env {
    return env_filter.to_string();
  }

  if !config_filter.is_empty() {
    return config_filter.to_string();
  }

  "info".to_string()
}

/// Builds an EnvFilter from a filter string, falling back to "info" on parse errors.
fn build_env_filter(filter_str: &str) -> EnvFilter {
  EnvFilter::try_new(filter_str).unwrap_or_else(|e| {
    warn!(
      "Invalid filter string '{}': {}. Falling back to 'info'",
      filter_str, e
    );
    EnvFilter::new("info")
  })
}

/// Gets the list of layers to activate based on priority:
/// 1. LXY_LOG environment variable (comma-separated)
/// 2. Config use field
/// 3. Default: empty list
fn get_selected_layers(config: &TracingConfig) -> Vec<String> {
  if let Ok(lxy_log) = std::env::var("LXY_LOG") {
    return lxy_log
      .split(',')
      .map(|s| s.trim().to_string())
      .filter(|s| !s.is_empty())
      .collect();
  }

  config.targets.clone()
}

/// Creates a layer from configuration.
fn create_layer(
  config: &TracingConfig,
  layer_config: &TracingLayer,
  rust_log: Option<&str>,
) -> (BoxedLayer, WorkerGuard) {
  let (writer, guard) = match &layer_config.kind {
    TracingLayerType::Stdout(_) => writer::create_stdout_writer(),
    TracingLayerType::File(opts) => writer::create_rolling_file_writer(opts),
  };

  let filter_str = resolve_filter_string(layer_config.filter.as_deref(), rust_log, &config.filter);
  let env_filter = build_env_filter(&filter_str);

  let format = layer_config
    .format
    .clone()
    .unwrap_or_else(|| config.format.clone());

  let mut layer = fmt::layer().with_writer(writer).with_target(true);

  if let TracingLayerType::Stdout(opts) = &layer_config.kind {
    layer = layer.with_ansi(opts.color);
  }

  let layer: BoxedLayer = match format {
    LogFormat::Json => Box::new(layer.json().with_filter(env_filter)),
    LogFormat::Plaintext => Box::new(layer.with_filter(env_filter)),
  };

  (layer, guard)
}

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

  /// Helper function to create a test TracingConfig from TOML string
  fn create_test_config() -> TracingConfig {
    toml::from_str(
      r#"
      filter = "info"
      format = "plaintext"
      layers = [
        { name = "test_stdout", type = "stdout" }
      ]
      use = ["test_stdout"]
    "#,
    )
    .unwrap()
  }

  #[test]
  fn test_builder_with_config_enabled() {
    let config = create_test_config();
    let mut builder = TracingBuilder::new();
    builder.with_config(true);

    let (subscriber, housekeeper) = builder.build(&config);

    let inner = subscriber.downcast_ref::<Vec<BoxedLayer>>().unwrap();

    assert_eq!(housekeeper.guards.len(), 1);
    assert_eq!(inner.len(), 1);
  }

  #[test]
  fn test_builder_with_config_disabled() {
    let config = create_test_config();
    let mut builder = TracingBuilder::new();
    builder.with_config(false);

    let (subscriber, housekeeper) = builder.build(&config);
    let inner = subscriber.downcast_ref::<Vec<BoxedLayer>>().unwrap();

    assert_eq!(housekeeper.guards.len(), 0);
    assert_eq!(inner.len(), 0);
  }

  #[test]
  fn test_builder_add_custom_layer() {
    let config = TracingConfig::default();
    let mut builder = TracingBuilder::new();
    builder
      .with_config(false)
      .add_layer("custom1", fmt::layer().with_filter(EnvFilter::new("info")))
      .add_layer("custom2", fmt::layer().with_filter(EnvFilter::new("warn")));

    let (subscriber, _) = builder.build(&config);

    let inner = subscriber.downcast_ref::<Vec<BoxedLayer>>().unwrap();

    assert_eq!(inner.len(), 2);
  }

  #[test]
  fn test_builder_config_with_custom_layer() {
    let config = create_test_config();
    let mut builder = TracingBuilder::new();
    builder.add_layer(
      "custom_stdout",
      fmt::layer().with_filter(EnvFilter::new("debug")),
    );

    let (subscriber, housekeeper) = builder.build(&config);

    let inner = subscriber.downcast_ref::<Vec<BoxedLayer>>().unwrap();

    assert_eq!(housekeeper.guards.len(), 1);
    assert_eq!(inner.len(), 2);
  }
}