|
|
|
|
@@ -72,6 +72,11 @@ pub trait ConfigSource: Send + Sync {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build a composite key for a named config instance: `{base_key}/{name}`.
|
|
|
|
|
fn named_key(base_key: &str, name: &str) -> String {
|
|
|
|
|
format!("{}/{}", base_key, name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct ConfigManager {
|
|
|
|
|
sources: Vec<Arc<dyn ConfigSource>>,
|
|
|
|
|
}
|
|
|
|
|
@@ -82,24 +87,62 @@ impl ConfigManager {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn get<T: Config>(&self) -> Result<T, ConfigError> {
|
|
|
|
|
self.get_by_key(T::KEY).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Retrieve a named instance of a config type.
|
|
|
|
|
///
|
|
|
|
|
/// The storage key becomes `{T::KEY}/{name}`, allowing multiple instances
|
|
|
|
|
/// of the same config type (e.g., separate credentials for primary and
|
|
|
|
|
/// backup firewalls).
|
|
|
|
|
pub async fn get_named<T: Config>(&self, name: &str) -> Result<T, ConfigError> {
|
|
|
|
|
let key = named_key(T::KEY, name);
|
|
|
|
|
self.get_by_key(&key).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn get_or_prompt<T: Config>(&self) -> Result<T, ConfigError> {
|
|
|
|
|
self.get_or_prompt_by_key(T::KEY).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Retrieve a named instance, falling back to interactive prompt if not
|
|
|
|
|
/// found in any source. The prompt will display the instance name for
|
|
|
|
|
/// clarity.
|
|
|
|
|
pub async fn get_or_prompt_named<T: Config>(&self, name: &str) -> Result<T, ConfigError> {
|
|
|
|
|
let key = named_key(T::KEY, name);
|
|
|
|
|
self.get_or_prompt_by_key(&key).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn set<T: Config>(&self, config: &T) -> Result<(), ConfigError> {
|
|
|
|
|
self.set_by_key(T::KEY, config).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Store a named instance of a config type.
|
|
|
|
|
pub async fn set_named<T: Config>(&self, name: &str, config: &T) -> Result<(), ConfigError> {
|
|
|
|
|
let key = named_key(T::KEY, name);
|
|
|
|
|
self.set_by_key(&key, config).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Internal helpers ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async fn get_by_key<T: Config>(&self, key: &str) -> Result<T, ConfigError> {
|
|
|
|
|
for source in &self.sources {
|
|
|
|
|
if let Some(value) = source.get(T::KEY).await? {
|
|
|
|
|
if let Some(value) = source.get(key).await? {
|
|
|
|
|
let config: T =
|
|
|
|
|
serde_json::from_value(value).map_err(|e| ConfigError::Deserialization {
|
|
|
|
|
key: T::KEY.to_string(),
|
|
|
|
|
key: key.to_string(),
|
|
|
|
|
source: e,
|
|
|
|
|
})?;
|
|
|
|
|
debug!("Retrieved config for key {} from source", T::KEY);
|
|
|
|
|
debug!("Retrieved config for key {} from source", key);
|
|
|
|
|
return Ok(config);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(ConfigError::NotFound {
|
|
|
|
|
key: T::KEY.to_string(),
|
|
|
|
|
key: key.to_string(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn get_or_prompt<T: Config>(&self) -> Result<T, ConfigError> {
|
|
|
|
|
match self.get::<T>().await {
|
|
|
|
|
async fn get_or_prompt_by_key<T: Config>(&self, key: &str) -> Result<T, ConfigError> {
|
|
|
|
|
match self.get_by_key::<T>(key).await {
|
|
|
|
|
Ok(config) => Ok(config),
|
|
|
|
|
Err(ConfigError::NotFound { .. }) => {
|
|
|
|
|
let config =
|
|
|
|
|
@@ -107,7 +150,7 @@ impl ConfigManager {
|
|
|
|
|
|
|
|
|
|
let value =
|
|
|
|
|
serde_json::to_value(&config).map_err(|e| ConfigError::Serialization {
|
|
|
|
|
key: T::KEY.to_string(),
|
|
|
|
|
key: key.to_string(),
|
|
|
|
|
source: e,
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
@@ -115,7 +158,7 @@ impl ConfigManager {
|
|
|
|
|
if !source.should_persist() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if source.set(T::KEY, &value).await.is_ok() {
|
|
|
|
|
if source.set(key, &value).await.is_ok() {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -126,14 +169,14 @@ impl ConfigManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn set<T: Config>(&self, config: &T) -> Result<(), ConfigError> {
|
|
|
|
|
async fn set_by_key<T: Config>(&self, key: &str, config: &T) -> Result<(), ConfigError> {
|
|
|
|
|
let value = serde_json::to_value(config).map_err(|e| ConfigError::Serialization {
|
|
|
|
|
key: T::KEY.to_string(),
|
|
|
|
|
key: key.to_string(),
|
|
|
|
|
source: e,
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
for source in &self.sources {
|
|
|
|
|
source.set(T::KEY, &value).await?;
|
|
|
|
|
source.set(key, &value).await?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
@@ -174,6 +217,33 @@ pub async fn set<T: Config>(config: &T) -> Result<(), ConfigError> {
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn get_named<T: Config>(name: &str) -> Result<T, ConfigError> {
|
|
|
|
|
let manager = CONFIG_MANAGER.lock().await;
|
|
|
|
|
manager
|
|
|
|
|
.as_ref()
|
|
|
|
|
.ok_or(ConfigError::NoSources)?
|
|
|
|
|
.get_named::<T>(name)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn get_or_prompt_named<T: Config>(name: &str) -> Result<T, ConfigError> {
|
|
|
|
|
let manager = CONFIG_MANAGER.lock().await;
|
|
|
|
|
manager
|
|
|
|
|
.as_ref()
|
|
|
|
|
.ok_or(ConfigError::NoSources)?
|
|
|
|
|
.get_or_prompt_named::<T>(name)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn set_named<T: Config>(name: &str, config: &T) -> Result<(), ConfigError> {
|
|
|
|
|
let manager = CONFIG_MANAGER.lock().await;
|
|
|
|
|
manager
|
|
|
|
|
.as_ref()
|
|
|
|
|
.ok_or(ConfigError::NoSources)?
|
|
|
|
|
.set_named::<T>(name, config)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn default_config_dir() -> Option<PathBuf> {
|
|
|
|
|
ProjectDirs::from("io", "NationTech", "Harmony").map(|dirs| dirs.data_dir().join("config"))
|
|
|
|
|
}
|
|
|
|
|
@@ -817,4 +887,155 @@ mod tests {
|
|
|
|
|
assert_eq!(result.name, "from_sqlite");
|
|
|
|
|
assert_eq!(result.count, 99);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Named config instance tests ───────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_named_get_returns_value_for_named_key() {
|
|
|
|
|
let primary = TestConfig {
|
|
|
|
|
name: "primary".to_string(),
|
|
|
|
|
count: 1,
|
|
|
|
|
};
|
|
|
|
|
let mut data = std::collections::HashMap::new();
|
|
|
|
|
data.insert(
|
|
|
|
|
"TestConfig/primary".to_string(),
|
|
|
|
|
serde_json::to_value(&primary).unwrap(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let source = Arc::new(MockSource::with_data(data));
|
|
|
|
|
let manager = ConfigManager::new(vec![source]);
|
|
|
|
|
|
|
|
|
|
let result: TestConfig = manager.get_named("primary").await.unwrap();
|
|
|
|
|
assert_eq!(result, primary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_named_and_unnamed_keys_do_not_collide() {
|
|
|
|
|
let unnamed = TestConfig {
|
|
|
|
|
name: "unnamed".to_string(),
|
|
|
|
|
count: 0,
|
|
|
|
|
};
|
|
|
|
|
let named_primary = TestConfig {
|
|
|
|
|
name: "primary".to_string(),
|
|
|
|
|
count: 1,
|
|
|
|
|
};
|
|
|
|
|
let named_backup = TestConfig {
|
|
|
|
|
name: "backup".to_string(),
|
|
|
|
|
count: 2,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut data = std::collections::HashMap::new();
|
|
|
|
|
data.insert(
|
|
|
|
|
"TestConfig".to_string(),
|
|
|
|
|
serde_json::to_value(&unnamed).unwrap(),
|
|
|
|
|
);
|
|
|
|
|
data.insert(
|
|
|
|
|
"TestConfig/primary".to_string(),
|
|
|
|
|
serde_json::to_value(&named_primary).unwrap(),
|
|
|
|
|
);
|
|
|
|
|
data.insert(
|
|
|
|
|
"TestConfig/backup".to_string(),
|
|
|
|
|
serde_json::to_value(&named_backup).unwrap(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let source = Arc::new(MockSource::with_data(data));
|
|
|
|
|
let manager = ConfigManager::new(vec![source]);
|
|
|
|
|
|
|
|
|
|
let r_unnamed: TestConfig = manager.get().await.unwrap();
|
|
|
|
|
let r_primary: TestConfig = manager.get_named("primary").await.unwrap();
|
|
|
|
|
let r_backup: TestConfig = manager.get_named("backup").await.unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(r_unnamed, unnamed);
|
|
|
|
|
assert_eq!(r_primary, named_primary);
|
|
|
|
|
assert_eq!(r_backup, named_backup);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_named_set_and_get_roundtrip() {
|
|
|
|
|
let source = Arc::new(MockSource::new());
|
|
|
|
|
let manager = ConfigManager::new(vec![source.clone()]);
|
|
|
|
|
|
|
|
|
|
let config = TestConfig {
|
|
|
|
|
name: "instance_a".to_string(),
|
|
|
|
|
count: 42,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
manager.set_named("instance_a", &config).await.unwrap();
|
|
|
|
|
|
|
|
|
|
let result: TestConfig = manager.get_named("instance_a").await.unwrap();
|
|
|
|
|
assert_eq!(result, config);
|
|
|
|
|
|
|
|
|
|
// Unnamed get should NOT find the named value
|
|
|
|
|
let unnamed: Result<TestConfig, ConfigError> = manager.get().await;
|
|
|
|
|
assert!(matches!(unnamed, Err(ConfigError::NotFound { .. })));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_named_resolution_through_source_chain() {
|
|
|
|
|
use tempfile::NamedTempFile;
|
|
|
|
|
|
|
|
|
|
let temp_file = NamedTempFile::new().unwrap();
|
|
|
|
|
let sqlite = SqliteSource::open(temp_file.path().to_path_buf())
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
let sqlite = Arc::new(sqlite);
|
|
|
|
|
|
|
|
|
|
// Empty first source, config in sqlite
|
|
|
|
|
let source1 = Arc::new(MockSource::new());
|
|
|
|
|
let manager = ConfigManager::new(vec![source1.clone(), sqlite.clone()]);
|
|
|
|
|
|
|
|
|
|
let config = TestConfig {
|
|
|
|
|
name: "from_sqlite_named".to_string(),
|
|
|
|
|
count: 77,
|
|
|
|
|
};
|
|
|
|
|
sqlite
|
|
|
|
|
.set(
|
|
|
|
|
"TestConfig/my-instance",
|
|
|
|
|
&serde_json::to_value(&config).unwrap(),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let result: TestConfig = manager.get_named("my-instance").await.unwrap();
|
|
|
|
|
assert_eq!(result, config);
|
|
|
|
|
|
|
|
|
|
// First source was checked but had nothing
|
|
|
|
|
assert_eq!(source1.get_call_count(), 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_named_env_var_format() {
|
|
|
|
|
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
|
|
|
|
|
|
|
|
let config = TestConfig {
|
|
|
|
|
name: "from_env_named".to_string(),
|
|
|
|
|
count: 55,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Named key "TestConfig/fw-primary" should map to env var
|
|
|
|
|
// HARMONY_CONFIG_TestConfig_fw_primary
|
|
|
|
|
let env_key = "HARMONY_CONFIG_TestConfig_fw_primary";
|
|
|
|
|
unsafe {
|
|
|
|
|
std::env::set_var(env_key, serde_json::to_string(&config).unwrap());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let env_source = Arc::new(EnvSource);
|
|
|
|
|
let manager = ConfigManager::new(vec![env_source]);
|
|
|
|
|
|
|
|
|
|
let result: TestConfig = manager.get_named("fw-primary").await.unwrap();
|
|
|
|
|
assert_eq!(result, config);
|
|
|
|
|
|
|
|
|
|
unsafe {
|
|
|
|
|
std::env::remove_var(env_key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_named_not_found() {
|
|
|
|
|
let source = Arc::new(MockSource::new());
|
|
|
|
|
let manager = ConfigManager::new(vec![source]);
|
|
|
|
|
|
|
|
|
|
let result: Result<TestConfig, ConfigError> = manager.get_named("nonexistent").await;
|
|
|
|
|
assert!(matches!(result, Err(ConfigError::NotFound { ref key }) if key == "TestConfig/nonexistent"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|