rs-zero 0.2.9

Rust-first microservice framework inspired by go-zero engineering practices
Documentation
use std::{fmt, path::PathBuf};

use serde::Deserialize;

use crate::core::logging::{
    LogConfig, LogFormat, LogWriterConfig, RollingFileConfig, RotationPolicy,
};

/// Warning emitted when runtime config enables behavior unavailable in this Cargo feature build.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigFeatureWarning {
    /// Configuration option that requested the behavior.
    pub option: &'static str,
    /// Cargo feature required for the option to take effect.
    pub required_feature: &'static str,
    /// Human-readable warning message.
    pub message: String,
}

impl ConfigFeatureWarning {
    /// Creates a feature warning for an ignored runtime option.
    pub fn ignored(option: &'static str, required_feature: &'static str) -> Self {
        Self {
            option,
            required_feature,
            message: format!(
                "config option `{option}` requires Cargo feature `{required_feature}`; it will be ignored by this build"
            ),
        }
    }
}

impl fmt::Display for ConfigFeatureWarning {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.message)
    }
}

/// Logs service configuration warnings with `tracing::warn!`.
pub fn emit_config_warnings(warnings: &[ConfigFeatureWarning]) {
    for warning in warnings {
        tracing::warn!(
            target: "rs_zero::core::config",
            option = warning.option,
            required_feature = warning.required_feature,
            "{}",
            warning.message
        );
    }
}

/// Shared service section used by framework-owned REST/RPC configuration.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct ServiceConfig {
    /// Logical service name used in logs, traces and metrics.
    pub name: String,
    /// Deployment mode. Kept as a string to match go-zero style `dev/test/pro` values.
    pub mode: String,
    /// Logging configuration.
    pub log: LogSection,
}

impl Default for ServiceConfig {
    fn default() -> Self {
        Self {
            name: "rs-zero".to_string(),
            mode: "pro".to_string(),
            log: LogSection::default(),
        }
    }
}

impl ServiceConfig {
    /// Converts this file-backed config into the runtime [`LogConfig`].
    pub fn log_config(&self) -> LogConfig {
        self.log.to_log_config(&self.name)
    }
}

/// Logging destination mode.
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum LogMode {
    /// Write logs to standard output.
    #[default]
    Console,
    /// Write logs to a local file.
    File,
    /// Write logs to a volume-style service file. Currently maps to file mode with host prefix.
    Volume,
}

/// Logging encoding.
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum LogEncoding {
    /// Human-readable text logs.
    #[default]
    Plain,
    /// Structured JSON logs.
    Json,
}

/// Serializable logging section modelled after go-zero log config.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct LogSection {
    /// Destination mode: `console`, `file`, or `volume`.
    pub mode: LogMode,
    /// Log format: `plain` or `json`.
    pub encoding: LogEncoding,
    /// Env filter directive. `RUST_LOG` takes precedence when set.
    pub level: String,
    /// Directory used by file-backed modes.
    pub path: PathBuf,
    /// Rotation policy: `daily` or `size`.
    pub rotation: RotationPolicy,
    /// Whether rotated logs are compressed as gzip.
    pub compress: bool,
    /// Number of days to retain rotated logs. `0` disables age cleanup.
    pub keep_days: u64,
    /// Maximum number of rotated files to retain. `0` disables count cleanup.
    pub max_backups: usize,
    /// Size rotation boundary in MiB. `0` uses a practical default for size rotation.
    pub max_size_mb: u64,
    /// Emit ANSI colors for console plain logs.
    pub ansi: bool,
}

impl Default for LogSection {
    fn default() -> Self {
        Self {
            mode: LogMode::Console,
            encoding: LogEncoding::Plain,
            level: "info".to_string(),
            path: PathBuf::from("logs"),
            rotation: RotationPolicy::Daily,
            compress: false,
            keep_days: 0,
            max_backups: 0,
            max_size_mb: 0,
            ansi: true,
        }
    }
}

impl LogSection {
    /// Converts this serializable section into runtime logging config.
    pub fn to_log_config(&self, service_name: &str) -> LogConfig {
        let filter = std::env::var("RUST_LOG").unwrap_or_else(|_| self.level.clone());
        let format = match self.encoding {
            LogEncoding::Plain => LogFormat::Text,
            LogEncoding::Json => LogFormat::Json,
        };
        let writer = match self.mode {
            LogMode::Console => LogWriterConfig::Stdout,
            LogMode::File => LogWriterConfig::RollingFile(self.rolling_file_config(service_name)),
            LogMode::Volume => LogWriterConfig::RollingFile(
                self.rolling_file_config(&volume_log_name(service_name)),
            ),
        };

        LogConfig {
            filter,
            ansi: self.ansi && matches!(self.mode, LogMode::Console),
            format,
            service: Some(service_name.to_string()),
            writer,
            ..LogConfig::default()
        }
    }

    fn rolling_file_config(&self, service_name: &str) -> RollingFileConfig {
        let max_bytes = match self.rotation {
            RotationPolicy::Size => Some(mib_to_bytes(if self.max_size_mb == 0 {
                100
            } else {
                self.max_size_mb
            })),
            RotationPolicy::Daily => None,
        };
        RollingFileConfig {
            path: self.path.join(format!("{service_name}.log")),
            rotation: self.rotation,
            max_bytes,
            max_files: self.max_backups,
            keep_days: (self.keep_days > 0).then_some(self.keep_days),
            compress: self.compress,
        }
    }
}

fn mib_to_bytes(value: u64) -> u64 {
    value.saturating_mul(1024).saturating_mul(1024)
}

fn volume_log_name(service_name: &str) -> String {
    let host = std::env::var("HOSTNAME")
        .or_else(|_| std::env::var("COMPUTERNAME"))
        .unwrap_or_else(|_| "localhost".to_string());
    format!("{host}-{service_name}")
}

#[cfg(test)]
mod tests {
    use super::{ConfigFeatureWarning, LogEncoding, LogMode, LogSection, RotationPolicy};
    use crate::core::LogWriterConfig;

    #[test]
    fn feature_warning_formats_ignored_option() {
        let warning = ConfigFeatureWarning::ignored("middlewares.metrics", "observability");
        assert_eq!(warning.option, "middlewares.metrics");
        assert_eq!(warning.required_feature, "observability");
        assert!(warning.to_string().contains("will be ignored"));
    }

    #[test]
    fn maps_file_log_section_to_runtime_config() {
        let section = LogSection {
            mode: LogMode::File,
            encoding: LogEncoding::Json,
            path: "target/test-logs".into(),
            rotation: RotationPolicy::Size,
            max_size_mb: 1,
            max_backups: 2,
            compress: true,
            ..LogSection::default()
        };

        let config = section.to_log_config("svc");
        assert_eq!(config.service.as_deref(), Some("svc"));
        assert_eq!(config.format, crate::core::LogFormat::Json);
        match config.writer {
            LogWriterConfig::RollingFile(rolling) => {
                assert_eq!(
                    rolling.path,
                    std::path::PathBuf::from("target/test-logs/svc.log")
                );
                assert_eq!(rolling.max_bytes, Some(1024 * 1024));
                assert_eq!(rolling.max_files, 2);
                assert!(rolling.compress);
            }
            other => panic!("unexpected writer: {other:?}"),
        }
    }
}