1use std::{fmt, path::PathBuf};
2
3use serde::Deserialize;
4
5use crate::core::logging::{
6 LogConfig, LogFormat, LogWriterConfig, RollingFileConfig, RotationPolicy,
7};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct ConfigFeatureWarning {
12 pub option: &'static str,
14 pub required_feature: &'static str,
16 pub message: String,
18}
19
20impl ConfigFeatureWarning {
21 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
39pub 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#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
54#[serde(default, deny_unknown_fields)]
55pub struct ServiceConfig {
56 pub name: String,
58 pub mode: String,
60 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 pub fn log_config(&self) -> LogConfig {
77 self.log.to_log_config(&self.name)
78 }
79}
80
81#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
83#[serde(rename_all = "snake_case")]
84pub enum LogMode {
85 #[default]
87 Console,
88 File,
90 Volume,
92}
93
94#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
96#[serde(rename_all = "snake_case")]
97pub enum LogEncoding {
98 #[default]
100 Plain,
101 Json,
103}
104
105#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
107#[serde(default, deny_unknown_fields)]
108pub struct LogSection {
109 pub mode: LogMode,
111 pub encoding: LogEncoding,
113 pub level: String,
115 pub path: PathBuf,
117 pub rotation: RotationPolicy,
119 pub compress: bool,
121 pub keep_days: u64,
123 pub max_backups: usize,
125 pub max_size_mb: u64,
127 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 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}