Skip to main content

rs_zero/core/
service_config.rs

1use std::{fmt, path::PathBuf};
2
3use serde::Deserialize;
4
5use crate::core::logging::{
6    LogConfig, LogFormat, LogWriterConfig, RollingFileConfig, RotationPolicy,
7};
8
9/// Warning emitted when runtime config enables behavior unavailable in this Cargo feature build.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct ConfigFeatureWarning {
12    /// Configuration option that requested the behavior.
13    pub option: &'static str,
14    /// Cargo feature required for the option to take effect.
15    pub required_feature: &'static str,
16    /// Human-readable warning message.
17    pub message: String,
18}
19
20impl ConfigFeatureWarning {
21    /// Creates a feature warning for an ignored runtime option.
22    pub fn ignored(option: &'static str, required_feature: &'static str) -> Self {
23        Self {
24            option,
25            required_feature,
26            message: format!(
27                "config option `{option}` requires Cargo feature `{required_feature}`; it will be ignored by this build"
28            ),
29        }
30    }
31}
32
33impl fmt::Display for ConfigFeatureWarning {
34    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
35        formatter.write_str(&self.message)
36    }
37}
38
39/// Logs service configuration warnings with `tracing::warn!`.
40pub fn emit_config_warnings(warnings: &[ConfigFeatureWarning]) {
41    for warning in warnings {
42        tracing::warn!(
43            target: "rs_zero::core::config",
44            option = warning.option,
45            required_feature = warning.required_feature,
46            "{}",
47            warning.message
48        );
49    }
50}
51
52/// Shared service section used by framework-owned REST/RPC configuration.
53#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
54#[serde(default, deny_unknown_fields)]
55pub struct ServiceConfig {
56    /// Logical service name used in logs, traces and metrics.
57    pub name: String,
58    /// Deployment mode. Kept as a string to match go-zero style `dev/test/pro` values.
59    pub mode: String,
60    /// Logging configuration.
61    pub log: LogSection,
62}
63
64impl Default for ServiceConfig {
65    fn default() -> Self {
66        Self {
67            name: "rs-zero".to_string(),
68            mode: "pro".to_string(),
69            log: LogSection::default(),
70        }
71    }
72}
73
74impl ServiceConfig {
75    /// Converts this file-backed config into the runtime [`LogConfig`].
76    pub fn log_config(&self) -> LogConfig {
77        self.log.to_log_config(&self.name)
78    }
79}
80
81/// Logging destination mode.
82#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
83#[serde(rename_all = "snake_case")]
84pub enum LogMode {
85    /// Write logs to standard output.
86    #[default]
87    Console,
88    /// Write logs to a local file.
89    File,
90    /// Write logs to a volume-style service file. Currently maps to file mode with host prefix.
91    Volume,
92}
93
94/// Logging encoding.
95#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
96#[serde(rename_all = "snake_case")]
97pub enum LogEncoding {
98    /// Human-readable text logs.
99    #[default]
100    Plain,
101    /// Structured JSON logs.
102    Json,
103}
104
105/// Serializable logging section modelled after go-zero log config.
106#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
107#[serde(default, deny_unknown_fields)]
108pub struct LogSection {
109    /// Destination mode: `console`, `file`, or `volume`.
110    pub mode: LogMode,
111    /// Log format: `plain` or `json`.
112    pub encoding: LogEncoding,
113    /// Env filter directive. `RUST_LOG` takes precedence when set.
114    pub level: String,
115    /// Directory used by file-backed modes.
116    pub path: PathBuf,
117    /// Rotation policy: `daily` or `size`.
118    pub rotation: RotationPolicy,
119    /// Whether rotated logs are compressed as gzip.
120    pub compress: bool,
121    /// Number of days to retain rotated logs. `0` disables age cleanup.
122    pub keep_days: u64,
123    /// Maximum number of rotated files to retain. `0` disables count cleanup.
124    pub max_backups: usize,
125    /// Size rotation boundary in MiB. `0` uses a practical default for size rotation.
126    pub max_size_mb: u64,
127    /// Emit ANSI colors for console plain logs.
128    pub ansi: bool,
129}
130
131impl Default for LogSection {
132    fn default() -> Self {
133        Self {
134            mode: LogMode::Console,
135            encoding: LogEncoding::Plain,
136            level: "info".to_string(),
137            path: PathBuf::from("logs"),
138            rotation: RotationPolicy::Daily,
139            compress: false,
140            keep_days: 0,
141            max_backups: 0,
142            max_size_mb: 0,
143            ansi: true,
144        }
145    }
146}
147
148impl LogSection {
149    /// Converts this serializable section into runtime logging config.
150    pub fn to_log_config(&self, service_name: &str) -> LogConfig {
151        let filter = std::env::var("RUST_LOG").unwrap_or_else(|_| self.level.clone());
152        let format = match self.encoding {
153            LogEncoding::Plain => LogFormat::Text,
154            LogEncoding::Json => LogFormat::Json,
155        };
156        let writer = match self.mode {
157            LogMode::Console => LogWriterConfig::Stdout,
158            LogMode::File => LogWriterConfig::RollingFile(self.rolling_file_config(service_name)),
159            LogMode::Volume => LogWriterConfig::RollingFile(
160                self.rolling_file_config(&volume_log_name(service_name)),
161            ),
162        };
163
164        LogConfig {
165            filter,
166            ansi: self.ansi && matches!(self.mode, LogMode::Console),
167            format,
168            service: Some(service_name.to_string()),
169            writer,
170            ..LogConfig::default()
171        }
172    }
173
174    fn rolling_file_config(&self, service_name: &str) -> RollingFileConfig {
175        let max_bytes = match self.rotation {
176            RotationPolicy::Size => Some(mib_to_bytes(if self.max_size_mb == 0 {
177                100
178            } else {
179                self.max_size_mb
180            })),
181            RotationPolicy::Daily => None,
182        };
183        RollingFileConfig {
184            path: self.path.join(format!("{service_name}.log")),
185            rotation: self.rotation,
186            max_bytes,
187            max_files: self.max_backups,
188            keep_days: (self.keep_days > 0).then_some(self.keep_days),
189            compress: self.compress,
190        }
191    }
192}
193
194fn mib_to_bytes(value: u64) -> u64 {
195    value.saturating_mul(1024).saturating_mul(1024)
196}
197
198fn volume_log_name(service_name: &str) -> String {
199    let host = std::env::var("HOSTNAME")
200        .or_else(|_| std::env::var("COMPUTERNAME"))
201        .unwrap_or_else(|_| "localhost".to_string());
202    format!("{host}-{service_name}")
203}
204
205#[cfg(test)]
206mod tests {
207    use super::{ConfigFeatureWarning, LogEncoding, LogMode, LogSection, RotationPolicy};
208    use crate::core::LogWriterConfig;
209
210    #[test]
211    fn feature_warning_formats_ignored_option() {
212        let warning = ConfigFeatureWarning::ignored("middlewares.metrics", "observability");
213        assert_eq!(warning.option, "middlewares.metrics");
214        assert_eq!(warning.required_feature, "observability");
215        assert!(warning.to_string().contains("will be ignored"));
216    }
217
218    #[test]
219    fn maps_file_log_section_to_runtime_config() {
220        let section = LogSection {
221            mode: LogMode::File,
222            encoding: LogEncoding::Json,
223            path: "target/test-logs".into(),
224            rotation: RotationPolicy::Size,
225            max_size_mb: 1,
226            max_backups: 2,
227            compress: true,
228            ..LogSection::default()
229        };
230
231        let config = section.to_log_config("svc");
232        assert_eq!(config.service.as_deref(), Some("svc"));
233        assert_eq!(config.format, crate::core::LogFormat::Json);
234        match config.writer {
235            LogWriterConfig::RollingFile(rolling) => {
236                assert_eq!(
237                    rolling.path,
238                    std::path::PathBuf::from("target/test-logs/svc.log")
239                );
240                assert_eq!(rolling.max_bytes, Some(1024 * 1024));
241                assert_eq!(rolling.max_files, 2);
242                assert!(rolling.compress);
243            }
244            other => panic!("unexpected writer: {other:?}"),
245        }
246    }
247}