Skip to main content

cc_core/config/
mod.rs

1//! 分层配置系统:支持文件 → 环境变量 → 程序化覆盖的合并策略。
2//!
3//! # 配置来源优先级(从低到高)
4//!
5//! 1. **ConfigBuilder 默认值** — Builder 自带的默认值
6//! 2. **TOML 文件** — `Config::from_file("config.toml")`
7//! 3. **环境变量** — `CC_MYSQL_<name>_<field>=value` 格式
8//! 4. **程序化覆盖** — `ConfigBuilder::with_mysql()` / `with_redis()`
9//!
10//! # 环境变量格式
11//!
12//! ```text
13//! CC_MYSQL_<NAME>_HOST=127.0.0.1
14//! CC_MYSQL_<NAME>_PORT=3306
15//! CC_MYSQL_<NAME>_USER=root
16//! CC_MYSQL_<NAME>_PASSWORD=secret
17//! CC_MYSQL_<NAME>_DATABASE=mydb
18//! CC_MYSQL_<NAME>_MAX_CONNECTIONS=10
19//! CC_MYSQL_<NAME>_SSL_MODE=preferred
20//!
21//! CC_REDIS_<NAME>_URL=redis://localhost:6379
22//! ```
23
24mod 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
35// ──────────────────────────────────────────────
36// 连接名抽象
37// ──────────────────────────────────────────────
38
39/// MySQL 连接名的抽象,用户可为枚举实现此 trait 以获得编译时检查。
40pub trait IntoMysqlName {
41    fn into_name(self) -> String;
42}
43
44/// Redis 连接名的抽象,用户可为枚举实现此 trait 以获得编译时检查。
45pub 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
73// ──────────────────────────────────────────────
74// 验证 trait
75// ──────────────────────────────────────────────
76
77/// 配置项验证。`Config::build()` 会自动调用。
78pub trait Validate {
79    fn validate(&self) -> anyhow::Result<()>;
80}
81
82// ──────────────────────────────────────────────
83// Config — 顶层容器
84// ──────────────────────────────────────────────
85
86/// 整个配置:多个命名 MySQL / Redis 连接。
87#[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    /// 按名取 MySQL 配置。
97    pub fn mysql(&self, name: &str) -> Option<&MysqlConfig> {
98        self.mysql.get(name)
99    }
100
101    /// 按名取 Redis 配置。
102    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
121// ──────────────────────────────────────────────
122// ConfigBuilder
123// ──────────────────────────────────────────────
124
125/// 分层配置构建器,支持文件 → 环境变量 → 程序化覆盖。
126///
127/// ```rust
128/// use cc_core::ConfigBuilder;
129///
130/// let cfg = ConfigBuilder::new()
131///     .with_mysql("default", |m| m.host("127.0.0.1").user("root").password("pw").database("mydb"))
132///     .with_redis("cache", |r| r.url("redis://localhost:6379"))
133///     .build()
134///     .unwrap();
135/// ```
136pub 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    /// 从 TOML 文件创建 ConfigBuilder。
152    pub fn from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
153        Self::new().with_file(path)
154    }
155
156    /// 从 TOML 字符串创建 ConfigBuilder。
157    pub fn from_toml(toml_str: &str) -> anyhow::Result<Self> {
158        Self::new().with_toml(toml_str)
159    }
160
161    /// 从环境变量创建 ConfigBuilder。
162    pub fn from_env() -> anyhow::Result<Self> {
163        Self::new().with_env()
164    }
165
166    /// 设置环境变量前缀(默认 "CC")。
167    pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
168        self.env_prefix = prefix.into();
169        self
170    }
171
172    /// 从 TOML 文件加载(与已有配置合并,文件值覆盖已有值)。
173    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    /// 从内联 TOML 字符串加载(方便测试和动态配置)。
181    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    /// 读取环境变量覆盖。格式:`<PREFIX>_MYSQL_<NAME>_<FIELD>` / `<PREFIX>_REDIS_<NAME>_<FIELD>`
188    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    /// 程序化添加 / 覆盖单个 MySQL 连接。
198    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    /// 程序化添加 / 覆盖单个 Redis 连接。
211    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    /// 合并另一个 Config(other 覆盖 self)。
224    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    /// 构建最终配置并验证。
231    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// ──────────────────────────────────────────────
248// Tests
249// ──────────────────────────────────────────────
250
251#[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}