@@ -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 ) ]
2843pub 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
4363static 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
4670fn 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+
5082const 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 {
0 commit comments