use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use std::time::{Duration, Instant};
use serde_json::Value;
use crate::deferred::{resolve_deferred, DeferredValue};
use crate::env_config::find_and_process_env_config_with_env;
use crate::file_config::find_and_process_file_config_with_env;
use crate::merge::merge_replace_arrays;
use crate::utils::SmooaiConfigError;
const DEFAULT_TTL_SECS: u64 = 86400;
struct CacheEntry {
value: Value,
expires_at: Instant,
}
struct ManagerInner {
initialized: bool,
config: HashMap<String, Value>,
public_cache: HashMap<String, CacheEntry>,
secret_cache: HashMap<String, CacheEntry>,
feature_flag_cache: HashMap<String, CacheEntry>,
}
pub struct ConfigManager {
inner: RwLock<ManagerInner>,
schema_keys: Option<HashSet<String>>,
env_prefix: String,
schema_types: Option<HashMap<String, String>>,
cache_ttl: Duration,
env_override: Option<HashMap<String, String>>,
api_key: Option<String>,
base_url: Option<String>,
org_id: Option<String>,
environment: Option<String>,
deferred: HashMap<String, DeferredValue>,
schema_path: Option<String>,
strict_schema_keys: bool,
}
impl ConfigManager {
pub fn new() -> Self {
Self {
inner: RwLock::new(ManagerInner {
initialized: false,
config: HashMap::new(),
public_cache: HashMap::new(),
secret_cache: HashMap::new(),
feature_flag_cache: HashMap::new(),
}),
schema_keys: None,
env_prefix: String::new(),
schema_types: None,
cache_ttl: Duration::from_secs(DEFAULT_TTL_SECS),
env_override: None,
api_key: None,
base_url: None,
org_id: None,
environment: None,
deferred: HashMap::new(),
schema_path: None,
strict_schema_keys: false,
}
}
pub fn with_schema_path(mut self, path: &str) -> Self {
self.schema_path = Some(path.to_string());
self
}
pub fn with_strict_schema_keys(mut self, strict: bool) -> Self {
self.strict_schema_keys = strict;
self
}
pub fn with_api_key(mut self, key: &str) -> Self {
self.api_key = Some(key.to_string());
self
}
pub fn with_base_url(mut self, url: &str) -> Self {
self.base_url = Some(url.to_string());
self
}
pub fn with_org_id(mut self, id: &str) -> Self {
self.org_id = Some(id.to_string());
self
}
pub fn with_environment(mut self, env: &str) -> Self {
self.environment = Some(env.to_string());
self
}
pub fn with_schema_keys(mut self, keys: HashSet<String>) -> Self {
self.schema_keys = Some(keys);
self
}
pub fn with_env_prefix(mut self, prefix: &str) -> Self {
self.env_prefix = prefix.to_string();
self
}
pub fn with_schema_types(mut self, types: HashMap<String, String>) -> Self {
self.schema_types = Some(types);
self
}
pub fn with_cache_ttl(mut self, ttl: Duration) -> Self {
self.cache_ttl = ttl;
self
}
pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
self.env_override = Some(env);
self
}
pub fn with_deferred(mut self, key: &str, resolver: DeferredValue) -> Self {
self.deferred.insert(key.to_string(), resolver);
self
}
fn get_env(&self) -> HashMap<String, String> {
self.env_override.clone().unwrap_or_else(|| std::env::vars().collect())
}
fn get_env_var(&self, key: &str) -> Option<String> {
if let Some(ref env) = self.env_override {
env.get(key).cloned()
} else {
std::env::var(key).ok()
}
}
fn resolve_environment(&self) -> String {
if let Some(ref env) = self.environment {
return env.clone();
}
if let Some(val) = self.get_env_var("SMOOAI_CONFIG_ENV") {
return val;
}
"development".to_string()
}
fn resolve_param(&self, env_var: &str, constructor_value: &Option<String>) -> Option<String> {
if let Some(ref val) = constructor_value {
return Some(val.clone());
}
self.get_env_var(env_var)
}
fn initialize_inner(&self, inner: &mut ManagerInner) -> Result<(), SmooaiConfigError> {
if inner.initialized {
return Ok(());
}
let env = self.get_env();
let file_config = find_and_process_file_config_with_env(&env).unwrap_or_default();
let schema_keys = self.schema_keys.clone().unwrap_or_default();
let env_config =
find_and_process_env_config_with_env(&schema_keys, &self.env_prefix, self.schema_types.as_ref(), &env);
let mut remote_config: HashMap<String, Value> = HashMap::new();
let api_key = self.resolve_param("SMOOAI_CONFIG_API_KEY", &self.api_key);
let base_url = self.resolve_param("SMOOAI_CONFIG_API_URL", &self.base_url);
let org_id = self.resolve_param("SMOOAI_CONFIG_ORG_ID", &self.org_id);
if let (Some(ref api_key), Some(ref base_url), Some(ref org_id)) = (&api_key, &base_url, &org_id) {
let env_name = self.resolve_environment();
let url = format!(
"{}/organizations/{}/config/values?environment={}",
base_url.trim_end_matches('/'),
org_id,
env_name
);
let client = reqwest::blocking::Client::new();
match client
.get(&url)
.header("Authorization", format!("Bearer {}", api_key))
.send()
{
Ok(resp) if resp.status().is_success() => {
if let Ok(body) = resp.json::<Value>() {
if let Some(values) = body.get("values").and_then(|v| v.as_object()) {
for (k, v) in values {
remote_config.insert(k.clone(), v.clone());
}
}
}
}
Ok(resp) => {
eprintln!(
"[Smooai Config] Warning: Remote config fetch returned HTTP {}",
resp.status()
);
}
Err(e) => {
eprintln!("[Smooai Config] Warning: Failed to fetch remote config: {}", e);
}
}
}
let file_value = serde_json::to_value(&file_config).unwrap_or(Value::Object(Default::default()));
let remote_value = serde_json::to_value(&remote_config).unwrap_or(Value::Object(Default::default()));
let env_value = serde_json::to_value(&env_config).unwrap_or(Value::Object(Default::default()));
let merged = merge_replace_arrays(&Value::Object(Default::default()), &file_value);
let merged = merge_replace_arrays(&merged, &remote_value);
let merged = merge_replace_arrays(&merged, &env_value);
if let Value::Object(map) = merged {
inner.config = map.into_iter().collect();
}
if !self.deferred.is_empty() {
resolve_deferred(&mut inner.config, &self.deferred);
}
inner.initialized = true;
Ok(())
}
fn get_value(
&self,
key: &str,
cache_selector: fn(&mut ManagerInner) -> &mut HashMap<String, CacheEntry>,
) -> Result<Option<Value>, SmooaiConfigError> {
if key.is_empty() {
return Err(SmooaiConfigError::new(
"@smooai/config: get() called with empty key. \
Most common cause: reading a typed-keys constant for a key that's not declared in your schema. \
Add it to .smooai-config/config.ts and run `smooai-config push`",
));
}
if self.strict_schema_keys {
if let Some(ref schema_keys) = self.schema_keys {
if !schema_keys.contains(key) {
return Err(SmooaiConfigError::undefined_key(key, self.schema_path.as_deref()));
}
}
}
let mut inner = self
.inner
.write()
.map_err(|_| SmooaiConfigError::new("Failed to acquire write lock"))?;
let cache = cache_selector(&mut inner);
if let Some(entry) = cache.get(key) {
if Instant::now() < entry.expires_at {
return Ok(Some(entry.value.clone()));
}
cache.remove(key);
}
self.initialize_inner(&mut inner)?;
let value = inner.config.get(key).cloned();
if let Some(ref val) = value {
let cache = cache_selector(&mut inner);
cache.insert(
key.to_string(),
CacheEntry {
value: val.clone(),
expires_at: Instant::now() + self.cache_ttl,
},
);
}
Ok(value)
}
pub fn get_public_config(&self, key: &str) -> Result<Option<Value>, SmooaiConfigError> {
self.get_value(key, |inner| &mut inner.public_cache)
}
pub fn get_secret_config(&self, key: &str) -> Result<Option<Value>, SmooaiConfigError> {
self.get_value(key, |inner| &mut inner.secret_cache)
}
pub fn get_feature_flag(&self, key: &str) -> Result<Option<Value>, SmooaiConfigError> {
self.get_value(key, |inner| &mut inner.feature_flag_cache)
}
pub fn invalidate(&self) {
if let Ok(mut inner) = self.inner.write() {
inner.initialized = false;
inner.config.clear();
inner.public_cache.clear();
inner.secret_cache.clear();
inner.feature_flag_cache.clear();
}
}
pub fn seed_from_baked(&self, values: HashMap<String, Value>) -> Result<(), SmooaiConfigError> {
let mut inner = self
.inner
.write()
.map_err(|_| SmooaiConfigError::new("Failed to acquire write lock"))?;
inner.config = values;
inner.public_cache.clear();
inner.secret_cache.clear();
inner.feature_flag_cache.clear();
inner.initialized = true;
Ok(())
}
}
impl Default for ConfigManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
use std::sync::Arc;
use wiremock::matchers::{header, method, path_regex, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn make_config_dir(dir: &std::path::Path, files: &[(&str, &str)]) -> String {
let config_dir = dir.join(".smooai-config");
fs::create_dir_all(&config_dir).unwrap();
for (name, content) in files {
let mut f = fs::File::create(config_dir.join(name)).unwrap();
f.write_all(content.as_bytes()).unwrap();
}
config_dir.to_string_lossy().to_string()
}
fn make_env(config_dir: &str, extra: &[(&str, &str)]) -> HashMap<String, String> {
let mut env: HashMap<String, String> = extra.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
env.insert("SMOOAI_ENV_CONFIG_DIR".to_string(), config_dir.to_string());
env
}
#[test]
fn test_local_only_mode() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(
dir.path(),
&[("default.json", r#"{"API_URL":"http://localhost","MAX_RETRIES":3}"#)],
);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new().with_env(env);
assert_eq!(
mgr.get_public_config("API_URL").unwrap(),
Some(Value::String("http://localhost".to_string()))
);
assert_eq!(
mgr.get_public_config("MAX_RETRIES").unwrap(),
Some(serde_json::json!(3))
);
assert_eq!(mgr.get_public_config("NONEXISTENT").unwrap(), None);
}
#[tokio::test]
async fn test_remote_enrichment() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/organizations/.+/config/values"))
.and(query_param("environment", "test"))
.and(header("Authorization", "Bearer test-api-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": {
"REMOTE_KEY": "remote-value",
"REMOTE_NUM": 42
}
})))
.mount(&mock_server)
.await;
let url = mock_server.uri();
let result = tokio::task::spawn_blocking(move || {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"LOCAL_KEY":"local-value"}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new()
.with_api_key("test-api-key")
.with_base_url(&url)
.with_org_id("org-123")
.with_environment("test")
.with_env(env);
let local = mgr.get_public_config("LOCAL_KEY").unwrap();
let remote = mgr.get_public_config("REMOTE_KEY").unwrap();
let remote_num = mgr.get_public_config("REMOTE_NUM").unwrap();
(local, remote, remote_num)
})
.await
.unwrap();
assert_eq!(result.0, Some(Value::String("local-value".to_string())));
assert_eq!(result.1, Some(Value::String("remote-value".to_string())));
assert_eq!(result.2, Some(serde_json::json!(42)));
}
#[tokio::test]
async fn test_merge_precedence() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/organizations/.+/config/values"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": {
"API_URL": "http://remote-api",
"REMOTE_ONLY": "from-remote"
}
})))
.mount(&mock_server)
.await;
let url = mock_server.uri();
let result = tokio::task::spawn_blocking(move || {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(
dir.path(),
&[(
"default.json",
r#"{"API_URL":"http://file-api","FILE_ONLY":"from-file"}"#,
)],
);
let mut schema_keys = HashSet::new();
schema_keys.insert("API_URL".to_string());
let env = make_env(
&config_dir,
&[("SMOOAI_CONFIG_ENV", "test"), ("API_URL", "http://env-api")],
);
let mgr = ConfigManager::new()
.with_api_key("test-key")
.with_base_url(&url)
.with_org_id("org-123")
.with_environment("test")
.with_schema_keys(schema_keys)
.with_env(env);
let api_url = mgr.get_public_config("API_URL").unwrap();
let remote_only = mgr.get_public_config("REMOTE_ONLY").unwrap();
let file_only = mgr.get_public_config("FILE_ONLY").unwrap();
(api_url, remote_only, file_only)
})
.await
.unwrap();
assert_eq!(result.0, Some(Value::String("http://env-api".to_string())));
assert_eq!(result.1, Some(Value::String("from-remote".to_string())));
assert_eq!(result.2, Some(Value::String("from-file".to_string())));
}
#[tokio::test]
async fn test_nested_object_merge() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/organizations/.+/config/values"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": {
"DATABASE": {"host": "remote-db.example.com", "ssl": true}
}
})))
.mount(&mock_server)
.await;
let url = mock_server.uri();
let result = tokio::task::spawn_blocking(move || {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(
dir.path(),
&[(
"default.json",
r#"{"DATABASE":{"host":"localhost","port":5432,"ssl":false}}"#,
)],
);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new()
.with_api_key("test-key")
.with_base_url(&url)
.with_org_id("org-123")
.with_environment("test")
.with_env(env);
mgr.get_public_config("DATABASE").unwrap()
})
.await
.unwrap();
let db = result.unwrap();
let obj = db.as_object().unwrap();
assert_eq!(obj["host"], serde_json::json!("remote-db.example.com"));
assert_eq!(obj["ssl"], serde_json::json!(true));
assert_eq!(obj["port"], serde_json::json!(5432));
}
#[tokio::test]
async fn test_graceful_degradation_on_server_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/organizations/.+/config/values"))
.respond_with(ResponseTemplate::new(500))
.mount(&mock_server)
.await;
let url = mock_server.uri();
let result = tokio::task::spawn_blocking(move || {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"API_URL":"http://fallback"}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new()
.with_api_key("test-key")
.with_base_url(&url)
.with_org_id("org-123")
.with_environment("test")
.with_env(env);
mgr.get_public_config("API_URL").unwrap()
})
.await
.unwrap();
assert_eq!(result, Some(Value::String("http://fallback".to_string())));
}
#[test]
fn test_three_tiers_independent() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(
dir.path(),
&[(
"default.json",
r#"{"API_URL":"http://localhost","DB_PASS":"secret123","ENABLE_BETA":true}"#,
)],
);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new().with_env(env);
assert_eq!(
mgr.get_public_config("API_URL").unwrap(),
Some(Value::String("http://localhost".to_string()))
);
assert_eq!(
mgr.get_secret_config("DB_PASS").unwrap(),
Some(Value::String("secret123".to_string()))
);
assert_eq!(mgr.get_feature_flag("ENABLE_BETA").unwrap(), Some(Value::Bool(true)));
assert_eq!(
mgr.get_secret_config("API_URL").unwrap(),
Some(Value::String("http://localhost".to_string()))
);
}
#[test]
fn test_cache_behavior() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"API_URL":"http://localhost"}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new()
.with_cache_ttl(Duration::from_millis(50))
.with_env(env);
let val1 = mgr.get_public_config("API_URL").unwrap();
assert_eq!(val1, Some(Value::String("http://localhost".to_string())));
let val2 = mgr.get_public_config("API_URL").unwrap();
assert_eq!(val2, Some(Value::String("http://localhost".to_string())));
std::thread::sleep(Duration::from_millis(60));
let val3 = mgr.get_public_config("API_URL").unwrap();
assert_eq!(val3, Some(Value::String("http://localhost".to_string())));
}
#[tokio::test]
async fn test_api_creds_from_env() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/organizations/env-org-id/config/values"))
.and(header("Authorization", "Bearer env-api-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": {
"FROM_REMOTE": "yes"
}
})))
.mount(&mock_server)
.await;
let url = mock_server.uri();
let result = tokio::task::spawn_blocking(move || {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"LOCAL":"val"}"#)]);
let env = make_env(
&config_dir,
&[
("SMOOAI_CONFIG_ENV", "test"),
("SMOOAI_CONFIG_API_KEY", "env-api-key"),
("SMOOAI_CONFIG_API_URL", &url),
("SMOOAI_CONFIG_ORG_ID", "env-org-id"),
],
);
let mgr = ConfigManager::new().with_env(env);
mgr.get_public_config("FROM_REMOTE").unwrap()
})
.await
.unwrap();
assert_eq!(result, Some(Value::String("yes".to_string())));
}
#[tokio::test]
async fn test_api_creds_from_constructor() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/organizations/ctor-org/config/values"))
.and(header("Authorization", "Bearer ctor-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": {
"CTOR_REMOTE": "works"
}
})))
.mount(&mock_server)
.await;
let url = mock_server.uri();
let result = tokio::task::spawn_blocking(move || {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"LOCAL":"val"}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new()
.with_api_key("ctor-key")
.with_base_url(&url)
.with_org_id("ctor-org")
.with_environment("test")
.with_env(env);
mgr.get_public_config("CTOR_REMOTE").unwrap()
})
.await
.unwrap();
assert_eq!(result, Some(Value::String("works".to_string())));
}
#[test]
fn test_thread_safety() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(
dir.path(),
&[("default.json", r#"{"API_URL":"http://localhost","COUNT":42}"#)],
);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = Arc::new(ConfigManager::new().with_env(env));
let mut handles = vec![];
for _ in 0..10 {
let mgr = Arc::clone(&mgr);
handles.push(std::thread::spawn(move || {
let val = mgr.get_public_config("API_URL").unwrap();
assert_eq!(val, Some(Value::String("http://localhost".to_string())));
let count = mgr.get_public_config("COUNT").unwrap();
assert_eq!(count, Some(serde_json::json!(42)));
}));
}
for handle in handles {
handle.join().unwrap();
}
}
#[tokio::test]
async fn test_full_integration() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/organizations/.+/config/values"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": {
"REMOTE_SETTING": "from-api",
"SHARED_KEY": "remote-wins-over-file"
}
})))
.mount(&mock_server)
.await;
let url = mock_server.uri();
let result = tokio::task::spawn_blocking(move || {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(
dir.path(),
&[(
"default.json",
r#"{"FILE_SETTING":"from-file","SHARED_KEY":"file-value"}"#,
)],
);
let mut schema_keys = HashSet::new();
schema_keys.insert("SHARED_KEY".to_string());
let env = make_env(
&config_dir,
&[("SMOOAI_CONFIG_ENV", "test"), ("SHARED_KEY", "env-wins-over-all")],
);
let mgr = ConfigManager::new()
.with_api_key("test-key")
.with_base_url(&url)
.with_org_id("org-123")
.with_environment("test")
.with_schema_keys(schema_keys)
.with_env(env);
let file = mgr.get_public_config("FILE_SETTING").unwrap();
let remote = mgr.get_public_config("REMOTE_SETTING").unwrap();
let shared = mgr.get_public_config("SHARED_KEY").unwrap();
(file, remote, shared)
})
.await
.unwrap();
assert_eq!(result.0, Some(Value::String("from-file".to_string())));
assert_eq!(result.1, Some(Value::String("from-api".to_string())));
assert_eq!(result.2, Some(Value::String("env-wins-over-all".to_string())));
}
#[test]
fn test_environment_resolution_from_constructor() {
let mgr = ConfigManager::new().with_environment("staging");
assert_eq!(mgr.resolve_environment(), "staging");
}
#[test]
fn test_environment_resolution_from_env_var() {
let env: HashMap<String, String> = [("SMOOAI_CONFIG_ENV".to_string(), "production".to_string())]
.into_iter()
.collect();
let mgr = ConfigManager::new().with_env(env);
assert_eq!(mgr.resolve_environment(), "production");
}
#[test]
fn test_environment_resolution_default() {
let env: HashMap<String, String> = HashMap::new();
let mgr = ConfigManager::new().with_env(env);
assert_eq!(mgr.resolve_environment(), "development");
}
#[test]
fn test_environment_constructor_overrides_env_var() {
let env: HashMap<String, String> = [("SMOOAI_CONFIG_ENV".to_string(), "from-env".to_string())]
.into_iter()
.collect();
let mgr = ConfigManager::new().with_environment("from-constructor").with_env(env);
assert_eq!(mgr.resolve_environment(), "from-constructor");
}
#[tokio::test]
async fn test_invalidation_refetches() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/organizations/.+/config/values"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": {
"DYNAMIC": "value-1"
}
})))
.expect(2) .mount(&mock_server)
.await;
let url = mock_server.uri();
let result = tokio::task::spawn_blocking(move || {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"LOCAL":"val"}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new()
.with_api_key("test-key")
.with_base_url(&url)
.with_org_id("org-123")
.with_environment("test")
.with_env(env);
let val1 = mgr.get_public_config("DYNAMIC").unwrap();
mgr.invalidate();
let val2 = mgr.get_public_config("DYNAMIC").unwrap();
(val1, val2)
})
.await
.unwrap();
assert_eq!(result.0, Some(Value::String("value-1".to_string())));
assert_eq!(result.1, Some(Value::String("value-1".to_string())));
}
#[test]
fn test_lazy_initialization() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"API_URL":"http://localhost"}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new().with_env(env);
assert!(!mgr.inner.read().unwrap().initialized);
mgr.get_public_config("API_URL").unwrap();
assert!(mgr.inner.read().unwrap().initialized);
}
#[test]
fn test_returns_none_for_missing_key() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"API_URL":"test"}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new().with_env(env);
assert_eq!(mgr.get_public_config("NONEXISTENT").unwrap(), None);
}
#[test]
fn test_invalidate_clears_state() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"API_URL":"http://localhost"}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new().with_env(env);
mgr.get_public_config("API_URL").unwrap();
assert!(mgr.inner.read().unwrap().initialized);
mgr.invalidate();
assert!(!mgr.inner.read().unwrap().initialized);
assert!(mgr.inner.read().unwrap().public_cache.is_empty());
assert!(mgr.inner.read().unwrap().config.is_empty());
}
#[test]
fn test_invalidate_allows_reinitialization() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"API_URL":"http://localhost"}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new().with_env(env);
mgr.get_public_config("API_URL").unwrap();
mgr.invalidate();
let result = mgr.get_public_config("API_URL").unwrap();
assert_eq!(result, Some(Value::String("http://localhost".to_string())));
}
#[test]
fn test_basic_deferred_value() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"HOST":"localhost","PORT":5432}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new().with_env(env).with_deferred(
"FULL_URL",
Box::new(|config| {
let host = config["HOST"].as_str().unwrap_or("unknown");
let port = config["PORT"].as_u64().unwrap_or(0);
serde_json::json!(format!("{}:{}", host, port))
}),
);
assert_eq!(
mgr.get_public_config("FULL_URL").unwrap(),
Some(serde_json::json!("localhost:5432"))
);
assert_eq!(
mgr.get_public_config("HOST").unwrap(),
Some(serde_json::json!("localhost"))
);
assert_eq!(mgr.get_public_config("PORT").unwrap(), Some(serde_json::json!(5432)));
}
#[test]
fn test_multiple_deferred_see_snapshot() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"BASE":"hello"}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new()
.with_env(env)
.with_deferred(
"A",
Box::new(|config| {
let base = config["BASE"].as_str().unwrap_or("");
serde_json::json!(format!("{}-a", base))
}),
)
.with_deferred(
"B",
Box::new(|config| {
serde_json::json!(config.contains_key("A"))
}),
);
assert_eq!(mgr.get_public_config("A").unwrap(), Some(serde_json::json!("hello-a")));
assert_eq!(mgr.get_public_config("B").unwrap(), Some(serde_json::json!(false)));
}
#[test]
fn test_deferred_runs_after_merge() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"HOST":"file-host"}"#)]);
let mut schema_keys = HashSet::new();
schema_keys.insert("HOST".to_string());
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test"), ("HOST", "env-host")]);
let mgr = ConfigManager::new()
.with_env(env)
.with_schema_keys(schema_keys)
.with_deferred(
"API_URL",
Box::new(|config| {
let host = config["HOST"].as_str().unwrap_or("unknown");
serde_json::json!(format!("https://{}/api", host))
}),
);
assert_eq!(
mgr.get_public_config("API_URL").unwrap(),
Some(serde_json::json!("https://env-host/api"))
);
}
#[test]
fn test_no_remote_without_credentials() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"API_URL":"http://localhost"}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new().with_env(env);
assert_eq!(
mgr.get_public_config("API_URL").unwrap(),
Some(Value::String("http://localhost".to_string()))
);
}
#[test]
fn test_graceful_fallback_no_config_files() {
let dir = tempfile::tempdir().unwrap();
let empty_dir = dir.path().join("empty");
fs::create_dir_all(&empty_dir).unwrap();
let env: HashMap<String, String> = [(
"SMOOAI_ENV_CONFIG_DIR".to_string(),
empty_dir.to_string_lossy().to_string(),
)]
.into_iter()
.collect();
let mgr = ConfigManager::new().with_env(env);
let result = mgr.get_public_config("ANYTHING").unwrap();
assert_eq!(result, None);
}
#[tokio::test]
async fn test_constructor_params_override_env_vars() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/organizations/ctor-org/config/values"))
.and(header("Authorization", "Bearer ctor-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": {"RESULT": "from-ctor-params"}
})))
.mount(&mock_server)
.await;
let url = mock_server.uri();
let result = tokio::task::spawn_blocking(move || {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"L":"v"}"#)]);
let env = make_env(
&config_dir,
&[
("SMOOAI_CONFIG_ENV", "test"),
("SMOOAI_CONFIG_API_KEY", "env-key"),
("SMOOAI_CONFIG_API_URL", "http://should-not-use"),
("SMOOAI_CONFIG_ORG_ID", "env-org"),
],
);
let mgr = ConfigManager::new()
.with_api_key("ctor-key")
.with_base_url(&url)
.with_org_id("ctor-org")
.with_environment("test")
.with_env(env);
mgr.get_public_config("RESULT").unwrap()
})
.await
.unwrap();
assert_eq!(result, Some(Value::String("from-ctor-params".to_string())));
}
#[test]
fn test_undefined_key_returns_friendly_error() {
use crate::utils::SmooaiConfigErrorKind;
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"KNOWN":"v"}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mut schema: HashSet<String> = HashSet::new();
schema.insert("KNOWN".to_string());
let mgr = ConfigManager::new()
.with_schema_keys(schema)
.with_strict_schema_keys(true)
.with_env(env);
let err = mgr.get_public_config("BOGUS").unwrap_err();
assert!(err.message.contains("'BOGUS'"));
assert!(err.message.contains("not defined"));
assert!(err.message.contains("SecretConfigKeys"));
assert!(err.message.contains(".smooai-config/config.ts"));
match err.kind {
SmooaiConfigErrorKind::UndefinedKey { key, schema_path } => {
assert_eq!(key, "BOGUS");
assert_eq!(schema_path, ".smooai-config/config.ts");
}
_ => panic!("expected UndefinedKey kind"),
}
}
#[test]
fn test_undefined_key_honors_custom_schema_path() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mut schema: HashSet<String> = HashSet::new();
schema.insert("KNOWN".to_string());
let mgr = ConfigManager::new()
.with_schema_keys(schema)
.with_strict_schema_keys(true)
.with_schema_path("custom/path.ts")
.with_env(env);
let err = mgr.get_secret_config("BOGUS").unwrap_err();
assert!(err.message.contains("custom/path.ts"));
}
#[test]
fn test_known_key_passes_through() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{"KNOWN":"v"}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mut schema: HashSet<String> = HashSet::new();
schema.insert("KNOWN".to_string());
let mgr = ConfigManager::new().with_schema_keys(schema).with_env(env);
assert_eq!(
mgr.get_public_config("KNOWN").unwrap(),
Some(Value::String("v".to_string()))
);
}
#[test]
fn test_no_schema_keys_disables_guard() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mgr = ConfigManager::new().with_env(env);
assert_eq!(mgr.get_public_config("WHATEVER").unwrap(), None);
}
#[test]
fn test_strict_off_with_schema_keys_does_not_error() {
let dir = tempfile::tempdir().unwrap();
let config_dir = make_config_dir(dir.path(), &[("default.json", r#"{}"#)]);
let env = make_env(&config_dir, &[("SMOOAI_CONFIG_ENV", "test")]);
let mut schema: HashSet<String> = HashSet::new();
schema.insert("KNOWN".to_string());
let mgr = ConfigManager::new().with_schema_keys(schema).with_env(env);
assert_eq!(mgr.get_public_config("UNDECLARED").unwrap(), None);
}
}