Skip to content

Commit 0ff1bad

Browse files
committed
feat: introduce EnvStyle configuration, max_depth limits, and ConfigEnvMapConflict error for namespace registration
Signed-off-by: tercel <[email protected]>
1 parent 1270c62 commit 0ff1bad

File tree

4 files changed

+463
-23
lines changed

4 files changed

+463
-23
lines changed

src/config.rs

Lines changed: 189 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,33 @@ pub enum MountSource {
2323
File(PathBuf),
2424
}
2525

26+
/// Default maximum nesting depth for env var key conversion.
27+
pub const DEFAULT_MAX_DEPTH: usize = 5;
28+
29+
/// Environment variable key conversion strategy for a namespace.
30+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31+
pub enum EnvStyle {
32+
/// Single `_` → `.` (section separator), double `__` → literal `_`.
33+
Nested,
34+
/// Suffix is lowercased as-is; no separator conversion.
35+
Flat,
36+
/// Match against defaults tree structure; fall back to Nested.
37+
#[default]
38+
Auto,
39+
}
40+
2641
/// Registration info for a Config Bus namespace.
2742
#[derive(Debug, Clone)]
2843
pub struct NamespaceRegistration {
2944
pub name: String,
45+
/// Env var prefix. `None` = auto-derive from name (uppercase, `-` → `_`).
3046
pub env_prefix: Option<String>,
3147
pub defaults: Option<serde_json::Value>,
3248
pub schema: Option<serde_json::Value>,
49+
pub env_style: EnvStyle,
50+
pub max_depth: usize,
51+
/// Explicit bare env var → config key mapping (e.g. `"REDIS_URL" → "cache_url"`).
52+
pub env_map: Option<HashMap<String, String>>,
3353
}
3454

3555
/// Summary of a registered namespace (returned by `registered_namespaces()`).
@@ -42,11 +62,23 @@ pub struct NamespaceInfo {
4262

4363
static GLOBAL_NS_REGISTRY: OnceLock<RwLock<HashMap<String, NamespaceRegistration>>> =
4464
OnceLock::new();
65+
/// Global bare env var → top-level config key mapping.
66+
static GLOBAL_ENV_MAP: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
67+
/// Tracks all claimed env var names (for conflict detection).
68+
static ENV_MAP_CLAIMED: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
4569

4670
fn global_ns_registry() -> &'static RwLock<HashMap<String, NamespaceRegistration>> {
4771
GLOBAL_NS_REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
4872
}
4973

74+
fn global_env_map() -> &'static RwLock<HashMap<String, String>> {
75+
GLOBAL_ENV_MAP.get_or_init(|| RwLock::new(HashMap::new()))
76+
}
77+
78+
fn env_map_claimed() -> &'static RwLock<HashMap<String, String>> {
79+
ENV_MAP_CLAIMED.get_or_init(|| RwLock::new(HashMap::new()))
80+
}
81+
5082
const RESERVED_NAMESPACES: &[&str] = &["apcore", "_config"];
5183

5284
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -65,7 +97,7 @@ pub struct Config {
6597
pub enable_tracing: bool,
6698
#[serde(default)]
6799
pub enable_metrics: bool,
68-
#[serde(default)]
100+
#[serde(flatten)]
69101
pub settings: HashMap<String, serde_json::Value>,
70102
#[serde(skip)]
71103
pub yaml_path: Option<PathBuf>,
@@ -106,9 +138,9 @@ impl Config {
106138
format!("Failed to parse JSON config: {}: {}", path.display(), e),
107139
)
108140
})?;
109-
config.apply_env_overrides();
110141
config.detect_mode();
111142
init_builtin_namespaces();
143+
config.apply_env_overrides();
112144
config.validate()?;
113145
Ok(config)
114146
}
@@ -129,9 +161,9 @@ impl Config {
129161
)
130162
})?;
131163
config.yaml_path = Some(path.to_path_buf());
132-
config.apply_env_overrides();
133164
config.detect_mode();
134165
init_builtin_namespaces();
166+
config.apply_env_overrides();
135167
config.validate()?;
136168
Ok(config)
137169
}
@@ -181,9 +213,9 @@ impl Config {
181213
/// Build config from defaults, applying env var overrides.
182214
pub fn from_defaults() -> Self {
183215
let mut config = Self::default();
184-
config.apply_env_overrides();
185216
config.detect_mode();
186217
init_builtin_namespaces();
218+
config.apply_env_overrides();
187219
config
188220
}
189221

@@ -341,28 +373,64 @@ impl Config {
341373

342374
// --- Namespace registration (class methods) ---
343375

344-
pub fn register_namespace(reg: NamespaceRegistration) -> Result<(), ModuleError> {
376+
pub fn register_namespace(mut reg: NamespaceRegistration) -> Result<(), ModuleError> {
345377
if RESERVED_NAMESPACES.contains(&reg.name.as_str()) {
346378
return Err(ModuleError::config_namespace_reserved(&reg.name));
347379
}
380+
// Auto-derive env_prefix from name if not provided.
381+
if reg.env_prefix.is_none() {
382+
reg.env_prefix = Some(reg.name.to_uppercase().replace('-', "_"));
383+
}
348384
let mut map = global_ns_registry()
349385
.write()
350386
.map_err(|_| ModuleError::config_mount_error(&reg.name, "registry lock poisoned"))?;
351387
if map.contains_key(&reg.name) {
352388
return Err(ModuleError::config_namespace_duplicate(&reg.name));
353389
}
354-
// Check for duplicate env_prefix
355-
if let Some(ref prefix) = reg.env_prefix {
356-
for existing in map.values() {
357-
if existing.env_prefix.as_deref() == Some(prefix.as_str()) {
358-
return Err(ModuleError::config_env_prefix_conflict(prefix));
390+
// Check for duplicate env_prefix.
391+
let prefix = reg.env_prefix.as_deref().unwrap_or("");
392+
for existing in map.values() {
393+
if existing.env_prefix.as_deref() == Some(prefix) {
394+
return Err(ModuleError::config_env_prefix_conflict(prefix));
395+
}
396+
}
397+
// Validate env_map: no env var can be claimed twice.
398+
if let Some(ref em) = reg.env_map {
399+
let claimed = env_map_claimed().read().unwrap_or_else(|e| e.into_inner());
400+
for env_var in em.keys() {
401+
if let Some(owner) = claimed.get(env_var) {
402+
return Err(ModuleError::config_env_map_conflict(env_var, owner));
359403
}
360404
}
405+
drop(claimed);
406+
let mut claimed = env_map_claimed().write().unwrap_or_else(|e| e.into_inner());
407+
for env_var in em.keys() {
408+
claimed.insert(env_var.clone(), reg.name.clone());
409+
}
361410
}
362411
map.insert(reg.name.clone(), reg);
363412
Ok(())
364413
}
365414

415+
/// Register global bare env var → top-level config key mappings.
416+
pub fn env_map(mapping: HashMap<String, String>) -> Result<(), ModuleError> {
417+
let claimed_lock = env_map_claimed();
418+
let claimed = claimed_lock.read().unwrap_or_else(|e| e.into_inner());
419+
for env_var in mapping.keys() {
420+
if let Some(owner) = claimed.get(env_var) {
421+
return Err(ModuleError::config_env_map_conflict(env_var, owner));
422+
}
423+
}
424+
drop(claimed);
425+
let mut claimed = claimed_lock.write().unwrap_or_else(|e| e.into_inner());
426+
let mut gmap = global_env_map().write().unwrap_or_else(|e| e.into_inner());
427+
for (env_var, config_key) in mapping {
428+
claimed.insert(env_var.clone(), "__global__".to_string());
429+
gmap.insert(env_var, config_key);
430+
}
431+
Ok(())
432+
}
433+
366434
pub fn registered_namespaces() -> Vec<NamespaceInfo> {
367435
global_ns_registry()
368436
.read()
@@ -473,26 +541,57 @@ impl Config {
473541
let registry = global_ns_registry()
474542
.read()
475543
.unwrap_or_else(|e| e.into_inner());
476-
// Collect registered prefixes, sorted by length descending (longest first).
477-
let mut prefixed: Vec<(&str, &str)> = registry
544+
let gmap = global_env_map().read().unwrap_or_else(|e| e.into_inner());
545+
546+
// Build namespace env_map lookup.
547+
let mut ns_env_maps: HashMap<&str, (&str, &str)> = HashMap::new();
548+
for reg in registry.values() {
549+
if let Some(ref em) = reg.env_map {
550+
for (env_var, config_key) in em {
551+
ns_env_maps.insert(env_var.as_str(), (reg.name.as_str(), config_key.as_str()));
552+
}
553+
}
554+
}
555+
556+
// Prefix table: sorted by length descending for longest-prefix-match.
557+
let mut prefixed: Vec<&NamespaceRegistration> = registry
478558
.values()
479-
.filter_map(|r| r.env_prefix.as_deref().map(|pfx| (pfx, r.name.as_str())))
559+
.filter(|r| r.env_prefix.is_some())
480560
.collect();
481-
prefixed.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
561+
prefixed.sort_by(|a, b| {
562+
b.env_prefix
563+
.as_ref()
564+
.map_or(0, |p| p.len())
565+
.cmp(&a.env_prefix.as_ref().map_or(0, |p| p.len()))
566+
});
482567

483568
for (env_key, env_value) in std::env::vars() {
484569
let parsed = Self::coerce_env_value(&env_value);
485-
// Try namespace-aware routing first.
570+
571+
// 1. Global env_map (bare env var → top-level key).
572+
if let Some(config_key) = gmap.get(&env_key) {
573+
self.set(config_key, parsed);
574+
continue;
575+
}
576+
577+
// 2. Namespace env_map (bare env var → namespace key).
578+
if let Some(&(ns_name, config_key)) = ns_env_maps.get(env_key.as_str()) {
579+
let full_path = format!("{ns_name}.{config_key}");
580+
self.set(&full_path, parsed);
581+
continue;
582+
}
583+
584+
// 3. Prefix-based dispatch.
486585
let mut matched = false;
487-
for &(prefix, ns_name) in &prefixed {
586+
for reg in &prefixed {
587+
let prefix = reg.env_prefix.as_deref().unwrap_or("");
488588
if let Some(suffix) = env_key.strip_prefix(prefix) {
489-
// Strip the leading separator (usually '_').
490589
let suffix = suffix.strip_prefix('_').unwrap_or(suffix);
491590
if suffix.is_empty() {
492591
continue;
493592
}
494-
let dot_path = Self::env_key_to_dot_path(suffix);
495-
let full_path = format!("{ns_name}.{dot_path}");
593+
let key = Self::resolve_env_suffix(suffix, reg);
594+
let full_path = format!("{}.{key}", reg.name);
496595
tracing::debug!(env = %env_key, path = %full_path, "Applying namespace env override");
497596
self.set(&full_path, parsed.clone());
498597
matched = true;
@@ -600,17 +699,27 @@ impl Config {
600699
///
601700
/// So to set `max_call_depth` via env, use `APCORE_EXECUTOR_MAX__CALL__DEPTH`.
602701
fn env_key_to_dot_path(raw: &str) -> String {
702+
Self::env_key_to_dot_path_with_depth(raw, usize::MAX)
703+
}
704+
705+
/// Convert env var suffix to dot-path, stopping at `max_depth` segments.
706+
fn env_key_to_dot_path_with_depth(raw: &str, max_depth: usize) -> String {
603707
let lower = raw.to_lowercase();
604708
let chars: Vec<char> = lower.chars().collect();
605709
let mut result = String::with_capacity(chars.len());
710+
let mut dot_count: usize = 0;
606711
let mut i = 0;
607712
while i < chars.len() {
608713
if chars[i] == '_' {
609714
if i + 1 < chars.len() && chars[i + 1] == '_' {
610-
result.push('_');
715+
result.push('_'); // double __ → literal _
611716
i += 2;
612-
} else {
717+
} else if dot_count < max_depth.saturating_sub(1) {
613718
result.push('.');
719+
dot_count += 1;
720+
i += 1;
721+
} else {
722+
result.push('_'); // depth limit reached
614723
i += 1;
615724
}
616725
} else {
@@ -621,6 +730,59 @@ impl Config {
621730
result
622731
}
623732

733+
/// Try to match suffix against keys in a JSON object tree (recursive).
734+
fn match_suffix_to_tree(
735+
suffix: &str,
736+
tree: &serde_json::Map<String, serde_json::Value>,
737+
depth: usize,
738+
max_depth: usize,
739+
) -> Option<String> {
740+
// 1. Try full suffix as a flat key.
741+
if tree.contains_key(suffix) {
742+
return Some(suffix.to_string());
743+
}
744+
// 2. Depth limit.
745+
if depth >= max_depth.saturating_sub(1) {
746+
return None;
747+
}
748+
// 3. Try splitting at each underscore.
749+
for (i, ch) in suffix.char_indices() {
750+
if ch != '_' || i == 0 || i == suffix.len() - 1 {
751+
continue;
752+
}
753+
let prefix_part = &suffix[..i];
754+
let remainder = &suffix[i + 1..];
755+
if let Some(serde_json::Value::Object(subtree)) = tree.get(prefix_part) {
756+
if let Some(sub) =
757+
Self::match_suffix_to_tree(remainder, subtree, depth + 1, max_depth)
758+
{
759+
return Some(format!("{prefix_part}.{sub}"));
760+
}
761+
}
762+
}
763+
None
764+
}
765+
766+
/// Resolve an env var suffix based on the registration's env_style.
767+
fn resolve_env_suffix(suffix: &str, reg: &NamespaceRegistration) -> String {
768+
match reg.env_style {
769+
EnvStyle::Flat => suffix.to_lowercase(),
770+
EnvStyle::Auto => {
771+
let lower = suffix.to_lowercase();
772+
if let Some(serde_json::Value::Object(tree)) = reg.defaults.as_ref() {
773+
if let Some(resolved) =
774+
Self::match_suffix_to_tree(&lower, tree, 0, reg.max_depth)
775+
{
776+
return resolved;
777+
}
778+
}
779+
// Fall back to nested with depth.
780+
Self::env_key_to_dot_path_with_depth(suffix, reg.max_depth)
781+
}
782+
EnvStyle::Nested => Self::env_key_to_dot_path_with_depth(suffix, reg.max_depth),
783+
}
784+
}
785+
624786
fn coerce_env_value(value: &str) -> serde_json::Value {
625787
if value.eq_ignore_ascii_case("true") {
626788
return serde_json::Value::Bool(true);
@@ -656,6 +818,9 @@ fn init_builtin_namespaces() {
656818
"metrics": { "enabled": false }
657819
})),
658820
schema: None,
821+
env_style: EnvStyle::Nested,
822+
max_depth: DEFAULT_MAX_DEPTH,
823+
env_map: None,
659824
},
660825
NamespaceRegistration {
661826
name: "sys_modules".to_string(),
@@ -677,6 +842,9 @@ fn init_builtin_namespaces() {
677842
}
678843
})),
679844
schema: None,
845+
env_style: EnvStyle::Nested,
846+
max_depth: DEFAULT_MAX_DEPTH,
847+
env_map: None,
680848
},
681849
];
682850
for ns in namespaces {

src/errors.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ pub enum ErrorCode {
7373
ConfigEnvPrefixConflict,
7474
ConfigMountError,
7575
ConfigBindError,
76+
ConfigEnvMapConflict,
7677
ErrorFormatterDuplicate,
7778
}
7879

@@ -181,6 +182,20 @@ impl ModuleError {
181182
.with_details(details)
182183
}
183184

185+
pub fn config_env_map_conflict(env_var: &str, owner: &str) -> Self {
186+
let mut details = HashMap::new();
187+
details.insert("env_var".to_string(), serde_json::json!(env_var));
188+
details.insert("owner".to_string(), serde_json::json!(owner));
189+
Self::new(
190+
ErrorCode::ConfigEnvMapConflict,
191+
format!(
192+
"Environment variable '{}' is already mapped by '{}'",
193+
env_var, owner
194+
),
195+
)
196+
.with_details(details)
197+
}
198+
184199
pub fn config_mount_error(namespace: &str, reason: &str) -> Self {
185200
let mut details = HashMap::new();
186201
details.insert("namespace".to_string(), serde_json::json!(namespace));

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ pub use approval::{
3535
};
3636
pub use async_task::TaskStatus;
3737
pub use client::APCore;
38-
pub use config::{Config, ConfigMode, MountSource, NamespaceInfo, NamespaceRegistration};
38+
pub use config::{Config, ConfigMode, EnvStyle, MountSource, NamespaceInfo, NamespaceRegistration};
3939
pub use context::{Context, ContextFactory, Identity};
4040
pub use errors::{ErrorCode, ModuleError};
4141
pub use events::emitter::{ApCoreEvent, EventEmitter};

0 commit comments

Comments
 (0)