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 disable_sql_mode: false,
327 },
328 );
329 let cfg = ConfigBuilder::new()
330 .merge(Config {
331 mysql,
332 redis: HashMap::new(),
333 })
334 .build()
335 .unwrap();
336 assert_eq!(cfg.mysql("default").unwrap().host, "h");
337 }
338
339 #[test]
340 fn env_prefix_override() {
341 std::env::set_var("TEST_CC_MYSQL_DEFAULT_HOST", "env-host");
342 std::env::set_var("TEST_CC_REDIS_DEFAULT_URL", "redis://env:6379");
343
344 let cfg = ConfigBuilder::new()
345 .env_prefix("TEST_CC")
346 .with_mysql("default", |m| m.user("u").password("p").database("db"))
347 .with_env()
348 .unwrap()
349 .build()
350 .unwrap();
351
352 assert_eq!(cfg.mysql("default").unwrap().host, "env-host");
353 assert_eq!(cfg.redis("default").unwrap().url, "redis://env:6379");
354
355 std::env::remove_var("TEST_CC_MYSQL_DEFAULT_HOST");
356 std::env::remove_var("TEST_CC_REDIS_DEFAULT_URL");
357 }
358
359 #[test]
360 fn from_file_works() {
361 let toml = r#"
362 [mysql.default]
363 host = "file-host"
364 user = "file-user"
365 password = "file-pw"
366 database = "file-db"
367 "#;
368 let cfg = ConfigBuilder::from_toml(toml).unwrap().build().unwrap();
369 assert_eq!(cfg.mysql("default").unwrap().host, "file-host");
370 }
371
372 #[test]
373 fn from_env_works() {
374 std::env::set_var("TEST_CC_MYSQL_DEFAULT_HOST", "env-host");
375
376 let cfg = ConfigBuilder::new()
377 .env_prefix("TEST_CC")
378 .with_mysql("default", |m| m.user("u").password("p").database("db"))
379 .with_env()
380 .unwrap()
381 .build()
382 .unwrap();
383
384 assert_eq!(cfg.mysql("default").unwrap().host, "env-host");
385
386 std::env::remove_var("TEST_CC_MYSQL_DEFAULT_HOST");
387 }
388}