1mod mysql;
25mod redis;
26
27pub use mysql::{MysqlConfig, MysqlConfigBuilder};
28pub use redis::{RedisConfig, RedisConfigBuilder};
29
30use std::collections::HashMap;
31use std::path::Path;
32
33use serde::Deserialize;
34
35pub trait IntoMysqlName {
41 fn into_name(self) -> String;
42}
43
44pub trait IntoRedisName {
46 fn into_name(self) -> String;
47}
48
49impl IntoMysqlName for String {
50 fn into_name(self) -> String {
51 self
52 }
53}
54
55impl IntoMysqlName for &str {
56 fn into_name(self) -> String {
57 self.to_string()
58 }
59}
60
61impl IntoRedisName for String {
62 fn into_name(self) -> String {
63 self
64 }
65}
66
67impl IntoRedisName for &str {
68 fn into_name(self) -> String {
69 self.to_string()
70 }
71}
72
73pub trait Validate {
79 fn validate(&self) -> anyhow::Result<()>;
80}
81
82#[derive(Debug, Clone, Default, Deserialize)]
88pub struct Config {
89 #[serde(default)]
90 pub mysql: HashMap<String, MysqlConfig>,
91 #[serde(default)]
92 pub redis: HashMap<String, RedisConfig>,
93}
94
95impl Config {
96 pub fn mysql(&self, name: &str) -> Option<&MysqlConfig> {
98 self.mysql.get(name)
99 }
100
101 pub fn redis(&self, name: &str) -> Option<&RedisConfig> {
103 self.redis.get(name)
104 }
105}
106
107impl Validate for Config {
108 fn validate(&self) -> anyhow::Result<()> {
109 for (name, mc) in &self.mysql {
110 mc.validate()
111 .map_err(|e| anyhow::anyhow!("MySQL[{}]: {}", name, e))?;
112 }
113 for (name, rc) in &self.redis {
114 rc.validate()
115 .map_err(|e| anyhow::anyhow!("Redis[{}]: {}", name, e))?;
116 }
117 Ok(())
118 }
119}
120
121pub struct ConfigBuilder {
137 mysql: HashMap<String, MysqlConfig>,
138 redis: HashMap<String, RedisConfig>,
139 env_prefix: String,
140}
141
142impl ConfigBuilder {
143 pub fn new() -> Self {
144 Self {
145 mysql: HashMap::new(),
146 redis: HashMap::new(),
147 env_prefix: "CC".to_string(),
148 }
149 }
150
151 pub fn from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
153 Self::new().with_file(path)
154 }
155
156 pub fn from_toml(toml_str: &str) -> anyhow::Result<Self> {
158 Self::new().with_toml(toml_str)
159 }
160
161 pub fn from_env() -> anyhow::Result<Self> {
163 Self::new().with_env()
164 }
165
166 pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
168 self.env_prefix = prefix.into();
169 self
170 }
171
172 pub fn with_file<P: AsRef<Path>>(self, path: P) -> anyhow::Result<Self> {
174 let path = path.as_ref();
175 let text = std::fs::read_to_string(path)
176 .map_err(|e| anyhow::anyhow!("读取配置文件 {} 失败: {}", path.display(), e))?;
177 self.with_toml(&text)
178 }
179
180 pub fn with_toml(self, toml_str: &str) -> anyhow::Result<Self> {
182 let file_cfg: Config =
183 toml::from_str(toml_str).map_err(|e| anyhow::anyhow!("解析 TOML 失败: {}", e))?;
184 Ok(self.merge(file_cfg))
185 }
186
187 pub fn with_env(mut self) -> anyhow::Result<Self> {
189 let prefix = &self.env_prefix;
190 self.mysql
191 .extend(mysql::collect_env_mysql(prefix, &self.mysql)?);
192 self.redis
193 .extend(redis::collect_env_redis(prefix, &self.redis)?);
194 Ok(self)
195 }
196
197 pub fn with_mysql(
199 mut self,
200 name: impl Into<String>,
201 f: impl FnOnce(MysqlConfigBuilder) -> MysqlConfigBuilder,
202 ) -> Self {
203 let name = name.into();
204 let base = self.mysql.remove(&name).unwrap_or_default();
205 let cfg = f(MysqlConfigBuilder(base)).0;
206 self.mysql.insert(name, cfg);
207 self
208 }
209
210 pub fn with_redis(
212 mut self,
213 name: impl Into<String>,
214 f: impl FnOnce(RedisConfigBuilder) -> RedisConfigBuilder,
215 ) -> Self {
216 let name = name.into();
217 let base = self.redis.remove(&name).unwrap_or_default();
218 let cfg = f(RedisConfigBuilder(base)).0;
219 self.redis.insert(name, cfg);
220 self
221 }
222
223 pub fn merge(mut self, other: Config) -> Self {
225 self.mysql.extend(other.mysql);
226 self.redis.extend(other.redis);
227 self
228 }
229
230 pub fn build(self) -> anyhow::Result<Config> {
232 let cfg = Config {
233 mysql: self.mysql,
234 redis: self.redis,
235 };
236 cfg.validate()?;
237 Ok(cfg)
238 }
239}
240
241impl Default for ConfigBuilder {
242 fn default() -> Self {
243 Self::new()
244 }
245}
246
247#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn parses_toml() {
257 let toml = r#"
258 [mysql.default]
259 host = "h1"
260 port = 3306
261 user = "u1"
262 password = "p1"
263 database = "db1"
264 max_connections = 1
265 ssl_mode = "preferred"
266
267 [redis.default]
268 url = "redis://127.0.0.1:6379"
269 "#;
270 let cfg = ConfigBuilder::from_toml(toml).unwrap().build().unwrap();
271 let d = cfg.mysql("default").unwrap();
272 assert_eq!(d.host, "h1");
273 assert_eq!(d.port, 3306);
274 assert_eq!(cfg.redis("default").unwrap().url, "redis://127.0.0.1:6379");
275 }
276
277 #[test]
278 fn builder_basic() {
279 let cfg = ConfigBuilder::new()
280 .with_mysql("default", |m| {
281 m.host("10.0.0.1")
282 .port(3307)
283 .user("root")
284 .password("pw")
285 .database("test")
286 })
287 .with_redis("cache", |r| r.url("redis://localhost:6380"))
288 .build()
289 .unwrap();
290
291 assert_eq!(cfg.mysql("default").unwrap().port, 3307);
292 assert_eq!(cfg.redis("cache").unwrap().url, "redis://localhost:6380");
293 }
294
295 #[test]
296 fn builder_merge_file() {
297 let toml = r#"
298 [mysql.default]
299 host = "file-host"
300 user = "file-user"
301 password = "file-pw"
302 database = "file-db"
303 "#;
304 let cfg = ConfigBuilder::new()
305 .with_toml(toml)
306 .unwrap()
307 .build()
308 .unwrap();
309
310 assert_eq!(cfg.mysql("default").unwrap().host, "file-host");
311 }
312
313 #[test]
314 fn from_map_works() {
315 let mut mysql = HashMap::new();
316 mysql.insert(
317 "default".into(),
318 MysqlConfig {
319 host: "h".into(),
320 port: 3306,
321 user: "u".into(),
322 password: "p".into(),
323 database: "db".into(),
324 max_connections: 5,
325 ssl_mode: "preferred".into(),
326 },
327 );
328 let cfg = ConfigBuilder::new()
329 .merge(Config {
330 mysql,
331 redis: HashMap::new(),
332 })
333 .build()
334 .unwrap();
335 assert_eq!(cfg.mysql("default").unwrap().host, "h");
336 }
337
338 #[test]
339 fn env_prefix_override() {
340 std::env::set_var("TEST_CC_MYSQL_DEFAULT_HOST", "env-host");
341 std::env::set_var("TEST_CC_REDIS_DEFAULT_URL", "redis://env:6379");
342
343 let cfg = ConfigBuilder::new()
344 .env_prefix("TEST_CC")
345 .with_mysql("default", |m| m.user("u").password("p").database("db"))
346 .with_env()
347 .unwrap()
348 .build()
349 .unwrap();
350
351 assert_eq!(cfg.mysql("default").unwrap().host, "env-host");
352 assert_eq!(cfg.redis("default").unwrap().url, "redis://env:6379");
353
354 std::env::remove_var("TEST_CC_MYSQL_DEFAULT_HOST");
355 std::env::remove_var("TEST_CC_REDIS_DEFAULT_URL");
356 }
357
358 #[test]
359 fn from_file_works() {
360 let toml = r#"
361 [mysql.default]
362 host = "file-host"
363 user = "file-user"
364 password = "file-pw"
365 database = "file-db"
366 "#;
367 let cfg = ConfigBuilder::from_toml(toml).unwrap().build().unwrap();
368 assert_eq!(cfg.mysql("default").unwrap().host, "file-host");
369 }
370
371 #[test]
372 fn from_env_works() {
373 std::env::set_var("TEST_CC_MYSQL_DEFAULT_HOST", "env-host");
374
375 let cfg = ConfigBuilder::new()
376 .env_prefix("TEST_CC")
377 .with_mysql("default", |m| m.user("u").password("p").database("db"))
378 .with_env()
379 .unwrap()
380 .build()
381 .unwrap();
382
383 assert_eq!(cfg.mysql("default").unwrap().host, "env-host");
384
385 std::env::remove_var("TEST_CC_MYSQL_DEFAULT_HOST");
386 }
387}