1use crate::error::{ClamberError, Result};
2use config::{Config, Environment, File, FileFormat};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::env;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ConfigFormat {
11 Yaml,
13 Toml,
15 Json,
17}
18
19impl ConfigFormat {
20 pub fn from_extension(path: &Path) -> Option<Self> {
22 match path.extension()?.to_str()? {
23 "yaml" | "yml" => Some(ConfigFormat::Yaml),
24 "toml" => Some(ConfigFormat::Toml),
25 "json" => Some(ConfigFormat::Json),
26 _ => None,
27 }
28 }
29
30 fn to_file_format(self) -> FileFormat {
32 match self {
33 ConfigFormat::Yaml => FileFormat::Yaml,
34 ConfigFormat::Toml => FileFormat::Toml,
35 ConfigFormat::Json => FileFormat::Json,
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
42pub struct ConfigBuilder {
43 files: Vec<(PathBuf, Option<ConfigFormat>)>,
45 env_prefix: Option<String>,
47 env_separator: String,
49 ignore_missing: bool,
51 defaults: HashMap<String, config::Value>,
53}
54
55impl Default for ConfigBuilder {
56 fn default() -> Self {
57 Self {
58 files: Vec::new(),
59 env_prefix: None,
60 env_separator: "__".to_string(),
61 ignore_missing: false,
62 defaults: HashMap::new(),
63 }
64 }
65}
66
67impl ConfigBuilder {
68 pub fn new() -> Self {
70 Self::default()
71 }
72
73 pub fn add_file<P: AsRef<Path>>(mut self, path: P, format: Option<ConfigFormat>) -> Self {
79 self.files.push((path.as_ref().to_path_buf(), format));
80 self
81 }
82
83 pub fn add_yaml_file<P: AsRef<Path>>(self, path: P) -> Self {
85 self.add_file(path, Some(ConfigFormat::Yaml))
86 }
87
88 pub fn add_toml_file<P: AsRef<Path>>(self, path: P) -> Self {
90 self.add_file(path, Some(ConfigFormat::Toml))
91 }
92
93 pub fn add_json_file<P: AsRef<Path>>(self, path: P) -> Self {
95 self.add_file(path, Some(ConfigFormat::Json))
96 }
97
98 pub fn with_env_prefix<S: Into<String>>(mut self, prefix: S) -> Self {
103 self.env_prefix = Some(prefix.into());
104 self
105 }
106
107 pub fn with_env_separator<S: Into<String>>(mut self, separator: S) -> Self {
112 self.env_separator = separator.into();
113 self
114 }
115
116 pub fn ignore_missing_files(mut self, ignore: bool) -> Self {
118 self.ignore_missing = ignore;
119 self
120 }
121
122 pub fn with_default<K, V>(mut self, key: K, value: V) -> Result<Self>
128 where
129 K: Into<String>,
130 V: Into<config::Value>,
131 {
132 self.defaults.insert(key.into(), value.into());
133 Ok(self)
134 }
135
136 pub fn build<T>(self) -> Result<T>
141 where
142 T: for<'de> Deserialize<'de>,
143 {
144 let mut config_builder = Config::builder();
145
146 for (key, value) in self.defaults {
148 config_builder = config_builder.set_default(&key, value).map_err(|e| {
149 ClamberError::ConfigLoadError {
150 details: format!("设置默认值失败: {}", e),
151 }
152 })?;
153 }
154
155 for (path, format) in self.files {
157 let format = format
158 .or_else(|| ConfigFormat::from_extension(&path))
159 .ok_or_else(|| ClamberError::ConfigLoadError {
160 details: format!("无法推断配置文件格式: {:?}", path),
161 })?;
162
163 let file_config = File::from(path.clone())
164 .format(format.to_file_format())
165 .required(!self.ignore_missing);
166
167 config_builder = config_builder.add_source(file_config);
168 }
169
170 if let Some(prefix) = self.env_prefix {
172 let env_config = Environment::with_prefix(&prefix)
173 .separator(&self.env_separator)
174 .try_parsing(true)
175 .ignore_empty(true);
176 config_builder = config_builder.add_source(env_config);
177 }
178
179 let config = config_builder
181 .build()
182 .map_err(|e| ClamberError::ConfigLoadError {
183 details: e.to_string(),
184 })?;
185
186 config
188 .try_deserialize::<T>()
189 .map_err(|e| ClamberError::ConfigParseError {
190 details: e.to_string(),
191 })
192 }
193
194 pub fn build_raw(self) -> Result<Config> {
196 let mut config_builder = Config::builder();
197
198 for (key, value) in self.defaults {
200 config_builder = config_builder.set_default(&key, value).map_err(|e| {
201 ClamberError::ConfigLoadError {
202 details: format!("设置默认值失败: {}", e),
203 }
204 })?;
205 }
206
207 for (path, format) in self.files {
209 let format = format
210 .or_else(|| ConfigFormat::from_extension(&path))
211 .ok_or_else(|| ClamberError::ConfigLoadError {
212 details: format!("无法推断配置文件格式: {:?}", path),
213 })?;
214
215 let file_config = File::from(path.clone())
216 .format(format.to_file_format())
217 .required(!self.ignore_missing);
218
219 config_builder = config_builder.add_source(file_config);
220 }
221
222 if let Some(prefix) = self.env_prefix {
224 let env_config = Environment::with_prefix(&prefix)
225 .separator(&self.env_separator)
226 .try_parsing(true)
227 .ignore_empty(true);
228 config_builder = config_builder.add_source(env_config);
229 }
230
231 config_builder
233 .build()
234 .map_err(|e| ClamberError::ConfigLoadError {
235 details: e.to_string(),
236 })
237 }
238}
239
240pub struct ConfigManager;
242
243impl ConfigManager {
244 pub fn load_from_file<T, P>(path: P) -> Result<T>
252 where
253 T: for<'de> Deserialize<'de>,
254 P: AsRef<Path>,
255 {
256 ConfigBuilder::new().add_file(path, None).build()
257 }
258
259 pub fn load_with_env<T, P, S>(config_path: P, env_prefix: S) -> Result<T>
268 where
269 T: for<'de> Deserialize<'de>,
270 P: AsRef<Path>,
271 S: Into<String>,
272 {
273 ConfigBuilder::new()
274 .add_file(config_path, None)
275 .with_env_prefix(env_prefix)
276 .build()
277 }
278
279 pub fn load_multiple<T, P, S>(config_paths: Vec<P>, env_prefix: Option<S>) -> Result<T>
288 where
289 T: for<'de> Deserialize<'de>,
290 P: AsRef<Path>,
291 S: Into<String>,
292 {
293 let mut builder = ConfigBuilder::new().ignore_missing_files(true);
294
295 for path in config_paths {
296 builder = builder.add_file(path, None);
297 }
298
299 if let Some(prefix) = env_prefix {
300 builder = builder.with_env_prefix(prefix);
301 }
302
303 builder.build()
304 }
305
306 pub fn builder() -> ConfigBuilder {
308 ConfigBuilder::new()
309 }
310}
311
312pub fn load_config<T, P>(path: P) -> Result<T>
314where
315 T: for<'de> Deserialize<'de>,
316 P: AsRef<Path>,
317{
318 ConfigManager::load_from_file(path)
319}
320
321pub fn load_config_with_env<T, P, S>(config_path: P, env_prefix: S) -> Result<T>
323where
324 T: for<'de> Deserialize<'de>,
325 P: AsRef<Path>,
326 S: Into<String>,
327{
328 ConfigManager::load_with_env(config_path, env_prefix)
329}
330
331pub fn get_config_paths(name: &str) -> Vec<PathBuf> {
333 let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
334
335 vec![
336 current_dir.join(format!("{}.yaml", name)),
337 current_dir.join(format!("{}.yml", name)),
338 current_dir.join(format!("{}.toml", name)),
339 current_dir.join(format!("{}.json", name)),
340 current_dir.join("config").join(format!("{}.yaml", name)),
341 current_dir.join("config").join(format!("{}.yml", name)),
342 current_dir.join("config").join(format!("{}.toml", name)),
343 current_dir.join("config").join(format!("{}.json", name)),
344 ]
345}
346
347pub fn auto_load_config<T>(name: &str, env_prefix: Option<&str>) -> Result<T>
349where
350 T: for<'de> Deserialize<'de>,
351{
352 let config_paths = get_config_paths(name);
353 ConfigManager::load_multiple(config_paths, env_prefix)
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use serde::{Deserialize, Serialize};
360 use std::fs;
361 use tempfile::tempdir;
362
363 #[derive(Debug, Serialize, Deserialize, PartialEq)]
364 struct TestConfig {
365 name: String,
366 port: u16,
367 debug: bool,
368 database: DatabaseConfig,
369 }
370
371 #[derive(Debug, Serialize, Deserialize, PartialEq)]
372 struct DatabaseConfig {
373 host: String,
374 port: u16,
375 username: String,
376 password: String,
377 }
378
379 impl Default for TestConfig {
380 fn default() -> Self {
381 Self {
382 name: "test-app".to_string(),
383 port: 8080,
384 debug: false,
385 database: DatabaseConfig {
386 host: "localhost".to_string(),
387 port: 5432,
388 username: "user".to_string(),
389 password: "password".to_string(),
390 },
391 }
392 }
393 }
394
395 #[test]
396 fn test_config_format_from_extension() {
397 assert_eq!(
398 ConfigFormat::from_extension(Path::new("config.yaml")),
399 Some(ConfigFormat::Yaml)
400 );
401 assert_eq!(
402 ConfigFormat::from_extension(Path::new("config.yml")),
403 Some(ConfigFormat::Yaml)
404 );
405 assert_eq!(
406 ConfigFormat::from_extension(Path::new("config.toml")),
407 Some(ConfigFormat::Toml)
408 );
409 assert_eq!(
410 ConfigFormat::from_extension(Path::new("config.json")),
411 Some(ConfigFormat::Json)
412 );
413 assert_eq!(ConfigFormat::from_extension(Path::new("config.txt")), None);
414 }
415
416 #[test]
417 fn test_load_yaml_config() {
418 let dir = tempdir().unwrap();
419 let config_path = dir.path().join("config.yaml");
420
421 let yaml_content = r#"
422name: "test-service"
423port: 3000
424debug: true
425database:
426 host: "db.example.com"
427 port: 5432
428 username: "testuser"
429 password: "testpass"
430"#;
431
432 fs::write(&config_path, yaml_content).unwrap();
433
434 let config: TestConfig = ConfigManager::load_from_file(&config_path).unwrap();
435
436 assert_eq!(config.name, "test-service");
437 assert_eq!(config.port, 3000);
438 assert_eq!(config.debug, true);
439 assert_eq!(config.database.host, "db.example.com");
440 }
441
442 #[test]
443 fn test_load_toml_config() {
444 let dir = tempdir().unwrap();
445 let config_path = dir.path().join("config.toml");
446
447 let toml_content = r#"
448name = "test-service"
449port = 3000
450debug = true
451
452[database]
453host = "db.example.com"
454port = 5432
455username = "testuser"
456password = "testpass"
457"#;
458
459 fs::write(&config_path, toml_content).unwrap();
460
461 let config: TestConfig = ConfigManager::load_from_file(&config_path).unwrap();
462
463 assert_eq!(config.name, "test-service");
464 assert_eq!(config.port, 3000);
465 assert_eq!(config.debug, true);
466 assert_eq!(config.database.host, "db.example.com");
467 }
468
469 #[test]
470 fn test_load_json_config() {
471 let dir = tempdir().unwrap();
472 let config_path = dir.path().join("config.json");
473
474 let json_content = r#"{
475 "name": "test-service",
476 "port": 3000,
477 "debug": true,
478 "database": {
479 "host": "db.example.com",
480 "port": 5432,
481 "username": "testuser",
482 "password": "testpass"
483 }
484}"#;
485
486 fs::write(&config_path, json_content).unwrap();
487
488 let config: TestConfig = ConfigManager::load_from_file(&config_path).unwrap();
489
490 assert_eq!(config.name, "test-service");
491 assert_eq!(config.port, 3000);
492 assert_eq!(config.debug, true);
493 assert_eq!(config.database.host, "db.example.com");
494 }
495
496 #[test]
497 fn test_config_builder_with_defaults() {
498 let config: TestConfig = ConfigBuilder::new()
499 .with_default("name", "default-app")
500 .unwrap()
501 .with_default("port", 9000)
502 .unwrap()
503 .with_default("debug", false)
504 .unwrap()
505 .with_default("database.host", "default-host")
506 .unwrap()
507 .with_default("database.port", 3306)
508 .unwrap()
509 .with_default("database.username", "default-user")
510 .unwrap()
511 .with_default("database.password", "default-pass")
512 .unwrap()
513 .build()
514 .unwrap();
515
516 assert_eq!(config.name, "default-app");
517 assert_eq!(config.port, 9000);
518 assert_eq!(config.debug, false);
519 assert_eq!(config.database.host, "default-host");
520 assert_eq!(config.database.port, 3306);
521 }
522
523 #[test]
524 fn test_config_with_env_override() {
525 let dir = tempdir().unwrap();
526 let config_path = dir.path().join("config.yaml");
527
528 let yaml_content = r#"
529name: "test-service"
530port: 3000
531debug: false
532database:
533 host: "localhost"
534 port: 5432
535 username: "user"
536 password: "password"
537"#;
538
539 fs::write(&config_path, yaml_content).unwrap();
540
541 unsafe {
543 env::set_var("TEST_PORT", "8080");
544 env::set_var("TEST_DEBUG", "true");
545 env::set_var("TEST_DATABASE__HOST", "env-db-host");
546 }
547
548 let config: TestConfig = ConfigManager::load_with_env(&config_path, "TEST").unwrap();
549
550 assert_eq!(config.name, "test-service"); assert_eq!(config.port, 8080); assert_eq!(config.debug, true); assert_eq!(config.database.host, "env-db-host"); unsafe {
557 env::remove_var("TEST_PORT");
558 env::remove_var("TEST_DEBUG");
559 env::remove_var("TEST_DATABASE__HOST");
560 }
561 }
562
563 #[test]
564 fn test_load_multiple_configs() {
565 let dir = tempdir().unwrap();
566
567 let base_config_path = dir.path().join("base.yaml");
569 let base_content = r#"
570name: "base-service"
571port: 8000
572debug: false
573database:
574 host: "base-host"
575 port: 5432
576 username: "base-user"
577 password: "base-pass"
578"#;
579 fs::write(&base_config_path, base_content).unwrap();
580
581 let override_config_path = dir.path().join("override.yaml");
583 let override_content = r#"
584port: 9000
585debug: true
586database:
587 host: "override-host"
588"#;
589 fs::write(&override_config_path, override_content).unwrap();
590
591 let config: TestConfig = ConfigManager::load_multiple(
592 vec![&base_config_path, &override_config_path],
593 None::<&str>,
594 )
595 .unwrap();
596
597 assert_eq!(config.name, "base-service"); assert_eq!(config.port, 9000); assert_eq!(config.debug, true); assert_eq!(config.database.host, "override-host"); assert_eq!(config.database.username, "base-user"); }
603
604 #[test]
605 fn test_get_config_paths() {
606 let paths = get_config_paths("myapp");
607
608 assert!(
609 paths
610 .iter()
611 .any(|p| p.to_string_lossy().ends_with("myapp.yaml"))
612 );
613 assert!(
614 paths
615 .iter()
616 .any(|p| p.to_string_lossy().ends_with("myapp.yml"))
617 );
618 assert!(
619 paths
620 .iter()
621 .any(|p| p.to_string_lossy().ends_with("myapp.toml"))
622 );
623 assert!(
624 paths
625 .iter()
626 .any(|p| p.to_string_lossy().ends_with("myapp.json"))
627 );
628 assert!(paths.iter().any(|p| p.to_string_lossy().contains("config")
629 && p.to_string_lossy().ends_with("myapp.yaml")));
630 }
631
632 #[test]
633 fn test_ignore_missing_files() {
634 let dir = tempdir().unwrap();
635 let existing_config = dir.path().join("existing.yaml");
636 let missing_config = dir.path().join("missing.yaml");
637
638 let yaml_content = r#"
639name: "test-service"
640port: 3000
641debug: true
642database:
643 host: "localhost"
644 port: 5432
645 username: "user"
646 password: "password"
647"#;
648
649 fs::write(&existing_config, yaml_content).unwrap();
650
651 let result: Result<TestConfig> = ConfigBuilder::new()
653 .add_file(&existing_config, None)
654 .add_file(&missing_config, None)
655 .ignore_missing_files(false)
656 .build();
657 assert!(result.is_err());
658
659 let config: TestConfig = ConfigBuilder::new()
661 .add_file(&existing_config, None)
662 .add_file(&missing_config, None)
663 .ignore_missing_files(true)
664 .build()
665 .unwrap();
666
667 assert_eq!(config.name, "test-service");
668 }
669}