use crate::error::{ClamberError, Result};
use config::{Config, Environment, File, FileFormat};
use serde::Deserialize;
use std::collections::HashMap;
use std::env;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigFormat {
Yaml,
Toml,
Json,
}
impl ConfigFormat {
pub fn from_extension(path: &Path) -> Option<Self> {
match path.extension()?.to_str()? {
"yaml" | "yml" => Some(ConfigFormat::Yaml),
"toml" => Some(ConfigFormat::Toml),
"json" => Some(ConfigFormat::Json),
_ => None,
}
}
fn to_file_format(self) -> FileFormat {
match self {
ConfigFormat::Yaml => FileFormat::Yaml,
ConfigFormat::Toml => FileFormat::Toml,
ConfigFormat::Json => FileFormat::Json,
}
}
}
#[derive(Debug, Clone)]
pub struct ConfigBuilder {
files: Vec<(PathBuf, Option<ConfigFormat>)>,
env_prefix: Option<String>,
env_separator: String,
ignore_missing: bool,
defaults: HashMap<String, config::Value>,
}
impl Default for ConfigBuilder {
fn default() -> Self {
Self {
files: Vec::new(),
env_prefix: None,
env_separator: "__".to_string(),
ignore_missing: false,
defaults: HashMap::new(),
}
}
}
impl ConfigBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn add_file<P: AsRef<Path>>(mut self, path: P, format: Option<ConfigFormat>) -> Self {
self.files.push((path.as_ref().to_path_buf(), format));
self
}
pub fn add_yaml_file<P: AsRef<Path>>(self, path: P) -> Self {
self.add_file(path, Some(ConfigFormat::Yaml))
}
pub fn add_toml_file<P: AsRef<Path>>(self, path: P) -> Self {
self.add_file(path, Some(ConfigFormat::Toml))
}
pub fn add_json_file<P: AsRef<Path>>(self, path: P) -> Self {
self.add_file(path, Some(ConfigFormat::Json))
}
pub fn with_env_prefix<S: Into<String>>(mut self, prefix: S) -> Self {
self.env_prefix = Some(prefix.into());
self
}
pub fn with_env_separator<S: Into<String>>(mut self, separator: S) -> Self {
self.env_separator = separator.into();
self
}
pub fn ignore_missing_files(mut self, ignore: bool) -> Self {
self.ignore_missing = ignore;
self
}
pub fn with_default<K, V>(mut self, key: K, value: V) -> Result<Self>
where
K: Into<String>,
V: Into<config::Value>,
{
self.defaults.insert(key.into(), value.into());
Ok(self)
}
pub fn build<T>(self) -> Result<T>
where
T: for<'de> Deserialize<'de>,
{
let mut config_builder = Config::builder();
for (key, value) in self.defaults {
config_builder = config_builder.set_default(&key, value).map_err(|e| {
ClamberError::ConfigLoadError {
details: format!("设置默认值失败: {}", e),
}
})?;
}
for (path, format) in self.files {
let format = format
.or_else(|| ConfigFormat::from_extension(&path))
.ok_or_else(|| ClamberError::ConfigLoadError {
details: format!("无法推断配置文件格式: {:?}", path),
})?;
let file_config = File::from(path.clone())
.format(format.to_file_format())
.required(!self.ignore_missing);
config_builder = config_builder.add_source(file_config);
}
if let Some(prefix) = self.env_prefix {
let env_config = Environment::with_prefix(&prefix)
.separator(&self.env_separator)
.try_parsing(true)
.ignore_empty(true);
config_builder = config_builder.add_source(env_config);
}
let config = config_builder
.build()
.map_err(|e| ClamberError::ConfigLoadError {
details: e.to_string(),
})?;
config
.try_deserialize::<T>()
.map_err(|e| ClamberError::ConfigParseError {
details: e.to_string(),
})
}
pub fn build_raw(self) -> Result<Config> {
let mut config_builder = Config::builder();
for (key, value) in self.defaults {
config_builder = config_builder.set_default(&key, value).map_err(|e| {
ClamberError::ConfigLoadError {
details: format!("设置默认值失败: {}", e),
}
})?;
}
for (path, format) in self.files {
let format = format
.or_else(|| ConfigFormat::from_extension(&path))
.ok_or_else(|| ClamberError::ConfigLoadError {
details: format!("无法推断配置文件格式: {:?}", path),
})?;
let file_config = File::from(path.clone())
.format(format.to_file_format())
.required(!self.ignore_missing);
config_builder = config_builder.add_source(file_config);
}
if let Some(prefix) = self.env_prefix {
let env_config = Environment::with_prefix(&prefix)
.separator(&self.env_separator)
.try_parsing(true)
.ignore_empty(true);
config_builder = config_builder.add_source(env_config);
}
config_builder
.build()
.map_err(|e| ClamberError::ConfigLoadError {
details: e.to_string(),
})
}
}
pub struct ConfigManager;
impl ConfigManager {
pub fn load_from_file<T, P>(path: P) -> Result<T>
where
T: for<'de> Deserialize<'de>,
P: AsRef<Path>,
{
ConfigBuilder::new().add_file(path, None).build()
}
pub fn load_with_env<T, P, S>(config_path: P, env_prefix: S) -> Result<T>
where
T: for<'de> Deserialize<'de>,
P: AsRef<Path>,
S: Into<String>,
{
ConfigBuilder::new()
.add_file(config_path, None)
.with_env_prefix(env_prefix)
.build()
}
pub fn load_multiple<T, P, S>(config_paths: Vec<P>, env_prefix: Option<S>) -> Result<T>
where
T: for<'de> Deserialize<'de>,
P: AsRef<Path>,
S: Into<String>,
{
let mut builder = ConfigBuilder::new().ignore_missing_files(true);
for path in config_paths {
builder = builder.add_file(path, None);
}
if let Some(prefix) = env_prefix {
builder = builder.with_env_prefix(prefix);
}
builder.build()
}
pub fn builder() -> ConfigBuilder {
ConfigBuilder::new()
}
}
pub fn load_config<T, P>(path: P) -> Result<T>
where
T: for<'de> Deserialize<'de>,
P: AsRef<Path>,
{
ConfigManager::load_from_file(path)
}
pub fn load_config_with_env<T, P, S>(config_path: P, env_prefix: S) -> Result<T>
where
T: for<'de> Deserialize<'de>,
P: AsRef<Path>,
S: Into<String>,
{
ConfigManager::load_with_env(config_path, env_prefix)
}
pub fn get_config_paths(name: &str) -> Vec<PathBuf> {
let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
vec![
current_dir.join(format!("{}.yaml", name)),
current_dir.join(format!("{}.yml", name)),
current_dir.join(format!("{}.toml", name)),
current_dir.join(format!("{}.json", name)),
current_dir.join("config").join(format!("{}.yaml", name)),
current_dir.join("config").join(format!("{}.yml", name)),
current_dir.join("config").join(format!("{}.toml", name)),
current_dir.join("config").join(format!("{}.json", name)),
]
}
pub fn auto_load_config<T>(name: &str, env_prefix: Option<&str>) -> Result<T>
where
T: for<'de> Deserialize<'de>,
{
let config_paths = get_config_paths(name);
ConfigManager::load_multiple(config_paths, env_prefix)
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use std::fs;
use tempfile::tempdir;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct TestConfig {
name: String,
port: u16,
debug: bool,
database: DatabaseConfig,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct DatabaseConfig {
host: String,
port: u16,
username: String,
password: String,
}
impl Default for TestConfig {
fn default() -> Self {
Self {
name: "test-app".to_string(),
port: 8080,
debug: false,
database: DatabaseConfig {
host: "localhost".to_string(),
port: 5432,
username: "user".to_string(),
password: "password".to_string(),
},
}
}
}
#[test]
fn test_config_format_from_extension() {
assert_eq!(
ConfigFormat::from_extension(Path::new("config.yaml")),
Some(ConfigFormat::Yaml)
);
assert_eq!(
ConfigFormat::from_extension(Path::new("config.yml")),
Some(ConfigFormat::Yaml)
);
assert_eq!(
ConfigFormat::from_extension(Path::new("config.toml")),
Some(ConfigFormat::Toml)
);
assert_eq!(
ConfigFormat::from_extension(Path::new("config.json")),
Some(ConfigFormat::Json)
);
assert_eq!(ConfigFormat::from_extension(Path::new("config.txt")), None);
}
#[test]
fn test_load_yaml_config() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("config.yaml");
let yaml_content = r#"
name: "test-service"
port: 3000
debug: true
database:
host: "db.example.com"
port: 5432
username: "testuser"
password: "testpass"
"#;
fs::write(&config_path, yaml_content).unwrap();
let config: TestConfig = ConfigManager::load_from_file(&config_path).unwrap();
assert_eq!(config.name, "test-service");
assert_eq!(config.port, 3000);
assert_eq!(config.debug, true);
assert_eq!(config.database.host, "db.example.com");
}
#[test]
fn test_load_toml_config() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let toml_content = r#"
name = "test-service"
port = 3000
debug = true
[database]
host = "db.example.com"
port = 5432
username = "testuser"
password = "testpass"
"#;
fs::write(&config_path, toml_content).unwrap();
let config: TestConfig = ConfigManager::load_from_file(&config_path).unwrap();
assert_eq!(config.name, "test-service");
assert_eq!(config.port, 3000);
assert_eq!(config.debug, true);
assert_eq!(config.database.host, "db.example.com");
}
#[test]
fn test_load_json_config() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("config.json");
let json_content = r#"{
"name": "test-service",
"port": 3000,
"debug": true,
"database": {
"host": "db.example.com",
"port": 5432,
"username": "testuser",
"password": "testpass"
}
}"#;
fs::write(&config_path, json_content).unwrap();
let config: TestConfig = ConfigManager::load_from_file(&config_path).unwrap();
assert_eq!(config.name, "test-service");
assert_eq!(config.port, 3000);
assert_eq!(config.debug, true);
assert_eq!(config.database.host, "db.example.com");
}
#[test]
fn test_config_builder_with_defaults() {
let config: TestConfig = ConfigBuilder::new()
.with_default("name", "default-app")
.unwrap()
.with_default("port", 9000)
.unwrap()
.with_default("debug", false)
.unwrap()
.with_default("database.host", "default-host")
.unwrap()
.with_default("database.port", 3306)
.unwrap()
.with_default("database.username", "default-user")
.unwrap()
.with_default("database.password", "default-pass")
.unwrap()
.build()
.unwrap();
assert_eq!(config.name, "default-app");
assert_eq!(config.port, 9000);
assert_eq!(config.debug, false);
assert_eq!(config.database.host, "default-host");
assert_eq!(config.database.port, 3306);
}
#[test]
fn test_config_with_env_override() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("config.yaml");
let yaml_content = r#"
name: "test-service"
port: 3000
debug: false
database:
host: "localhost"
port: 5432
username: "user"
password: "password"
"#;
fs::write(&config_path, yaml_content).unwrap();
unsafe {
env::set_var("TEST_PORT", "8080");
env::set_var("TEST_DEBUG", "true");
env::set_var("TEST_DATABASE__HOST", "env-db-host");
}
let config: TestConfig = ConfigManager::load_with_env(&config_path, "TEST").unwrap();
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 {
env::remove_var("TEST_PORT");
env::remove_var("TEST_DEBUG");
env::remove_var("TEST_DATABASE__HOST");
}
}
#[test]
fn test_load_multiple_configs() {
let dir = tempdir().unwrap();
let base_config_path = dir.path().join("base.yaml");
let base_content = r#"
name: "base-service"
port: 8000
debug: false
database:
host: "base-host"
port: 5432
username: "base-user"
password: "base-pass"
"#;
fs::write(&base_config_path, base_content).unwrap();
let override_config_path = dir.path().join("override.yaml");
let override_content = r#"
port: 9000
debug: true
database:
host: "override-host"
"#;
fs::write(&override_config_path, override_content).unwrap();
let config: TestConfig = ConfigManager::load_multiple(
vec![&base_config_path, &override_config_path],
None::<&str>,
)
.unwrap();
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"); }
#[test]
fn test_get_config_paths() {
let paths = get_config_paths("myapp");
assert!(
paths
.iter()
.any(|p| p.to_string_lossy().ends_with("myapp.yaml"))
);
assert!(
paths
.iter()
.any(|p| p.to_string_lossy().ends_with("myapp.yml"))
);
assert!(
paths
.iter()
.any(|p| p.to_string_lossy().ends_with("myapp.toml"))
);
assert!(
paths
.iter()
.any(|p| p.to_string_lossy().ends_with("myapp.json"))
);
assert!(paths.iter().any(|p| p.to_string_lossy().contains("config")
&& p.to_string_lossy().ends_with("myapp.yaml")));
}
#[test]
fn test_ignore_missing_files() {
let dir = tempdir().unwrap();
let existing_config = dir.path().join("existing.yaml");
let missing_config = dir.path().join("missing.yaml");
let yaml_content = r#"
name: "test-service"
port: 3000
debug: true
database:
host: "localhost"
port: 5432
username: "user"
password: "password"
"#;
fs::write(&existing_config, yaml_content).unwrap();
let result: Result<TestConfig> = ConfigBuilder::new()
.add_file(&existing_config, None)
.add_file(&missing_config, None)
.ignore_missing_files(false)
.build();
assert!(result.is_err());
let config: TestConfig = ConfigBuilder::new()
.add_file(&existing_config, None)
.add_file(&missing_config, None)
.ignore_missing_files(true)
.build()
.unwrap();
assert_eq!(config.name, "test-service");
}
}