Skip to main content

apcore/
config.rs

1// APCore Protocol — Configuration
2// Spec reference: Configuration loading, validation, and environment variable overrides (Algorithm A12)
3
4use parking_lot::RwLock;
5use serde::de::{DeserializeOwned, Error as DeError};
6use serde::{Deserialize, Deserializer, Serialize};
7use serde_yaml_ng as serde_yaml;
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::OnceLock;
11
12use crate::errors::{ErrorCode, ModuleError};
13
14/// Configuration mode detected from YAML content.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
16#[serde(rename_all = "snake_case")]
17pub enum ConfigMode {
18    #[default]
19    Legacy,
20    Namespace,
21}
22
23/// Source for a `config.mount()` operation.
24pub enum MountSource {
25    Dict(serde_json::Value),
26    File(PathBuf),
27}
28
29/// Default maximum nesting depth for env var key conversion.
30pub const DEFAULT_MAX_DEPTH: usize = 5;
31
32/// Environment variable key conversion strategy for a namespace.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum EnvStyle {
35    /// Single `_` → `.` (section separator), double `__` → literal `_`.
36    Nested,
37    /// Suffix is lowercased as-is; no separator conversion.
38    Flat,
39    /// Match against defaults tree structure; fall back to Nested.
40    #[default]
41    Auto,
42}
43
44/// Registration info for a Config Bus namespace.
45#[derive(Debug, Clone)]
46pub struct NamespaceRegistration {
47    pub name: String,
48    /// Env var prefix. `None` = auto-derive from name (uppercase, `-` → `_`).
49    pub env_prefix: Option<String>,
50    pub defaults: Option<serde_json::Value>,
51    pub schema: Option<serde_json::Value>,
52    pub env_style: EnvStyle,
53    pub max_depth: usize,
54    /// Explicit bare env var → config key mapping (e.g. `"REDIS_URL" → "cache_url"`).
55    pub env_map: Option<HashMap<String, String>>,
56}
57
58/// Summary of a registered namespace (returned by `registered_namespaces()`).
59#[derive(Debug, Clone)]
60pub struct NamespaceInfo {
61    pub name: String,
62    pub env_prefix: Option<String>,
63    pub has_schema: bool,
64}
65
66static GLOBAL_NS_REGISTRY: OnceLock<RwLock<HashMap<String, NamespaceRegistration>>> =
67    OnceLock::new();
68/// Global bare env var → top-level config key mapping.
69static GLOBAL_ENV_MAP: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
70/// Tracks all claimed env var names (for conflict detection).
71static ENV_MAP_CLAIMED: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
72
73fn global_ns_registry() -> &'static RwLock<HashMap<String, NamespaceRegistration>> {
74    GLOBAL_NS_REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
75}
76
77fn global_env_map() -> &'static RwLock<HashMap<String, String>> {
78    GLOBAL_ENV_MAP.get_or_init(|| RwLock::new(HashMap::new()))
79}
80
81fn env_map_claimed() -> &'static RwLock<HashMap<String, String>> {
82    ENV_MAP_CLAIMED.get_or_init(|| RwLock::new(HashMap::new()))
83}
84
85const RESERVED_NAMESPACES: &[&str] = &["apcore", "_config"];
86
87/// Executor namespace configuration (`PROTOCOL_SPEC` §9.1).
88///
89/// All timeouts are in milliseconds.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(default)]
92pub struct ExecutorConfig {
93    /// Per-module execution timeout (milliseconds). 0 means no per-module timeout.
94    pub default_timeout: u64,
95    /// Whole-call-chain deadline (milliseconds). 0 means no global deadline.
96    pub global_timeout: u64,
97    /// Maximum call chain depth before `MODULE_CALL_DEPTH_EXCEEDED` is raised.
98    pub max_call_depth: u32,
99    /// Maximum repeat count for the same module within a single call chain.
100    pub max_module_repeat: u32,
101}
102
103impl Default for ExecutorConfig {
104    fn default() -> Self {
105        Self {
106            default_timeout: 30_000,
107            global_timeout: 60_000,
108            max_call_depth: 32,
109            max_module_repeat: 3,
110        }
111    }
112}
113
114/// Observability namespace configuration (`PROTOCOL_SPEC` §9.1).
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116#[serde(default)]
117pub struct ObservabilityConfig {
118    pub tracing: TracingConfig,
119    pub metrics: MetricsConfig,
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
123#[serde(default)]
124pub struct TracingConfig {
125    pub enabled: bool,
126}
127
128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
129#[serde(default)]
130pub struct MetricsConfig {
131    pub enabled: bool,
132}
133
134/// Top-level apcore configuration (`PROTOCOL_SPEC` §9.1).
135///
136/// Canonical wire format is a nested JSON/YAML object with `executor`,
137/// `observability`, and any user-defined namespaces as siblings:
138///
139/// ```yaml
140/// modules_path: ./modules
141/// executor:
142///   max_call_depth: 32
143///   default_timeout: 30000
144/// observability:
145///   tracing:
146///     enabled: true
147/// my_vendor:
148///   custom_setting: foo
149/// ```
150///
151/// **v0.18.0 BREAKING CHANGE.** Prior versions accepted root-level
152/// `max_call_depth`, `default_timeout_ms`, etc. The custom `Deserialize` impl
153/// now rejects these with a hard error pointing at `MIGRATION-v0.18.md`.
154#[derive(Debug, Clone, Default, Serialize)]
155pub struct Config {
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub modules_path: Option<PathBuf>,
158    #[serde(default)]
159    pub executor: ExecutorConfig,
160    #[serde(default)]
161    pub observability: ObservabilityConfig,
162    /// User-defined and vendor namespaces. Captures any top-level key not
163    /// matching a canonical namespace above. Per spec §9.1, custom namespace
164    /// names should follow `[a-z][a-z0-9-]*`.
165    #[serde(flatten)]
166    pub user_namespaces: HashMap<String, serde_json::Value>,
167    #[serde(skip)]
168    pub yaml_path: Option<PathBuf>,
169    #[serde(skip)]
170    pub mode: ConfigMode,
171}
172
173/// Legacy v0.17.x root-level field names that are no longer accepted in v0.18.0.
174const LEGACY_ROOT_FIELDS: &[(&str, &str)] = &[
175    ("max_call_depth", "executor.max_call_depth"),
176    ("max_module_repeat", "executor.max_module_repeat"),
177    ("default_timeout_ms", "executor.default_timeout"),
178    ("global_timeout_ms", "executor.global_timeout"),
179    ("enable_tracing", "observability.tracing.enabled"),
180    ("enable_metrics", "observability.metrics.enabled"),
181];
182
183// Helper struct for two-pass deserialization of Config.
184// Defined outside the fn body to satisfy items_after_statements lint.
185#[derive(Deserialize)]
186struct ConfigHelper {
187    #[serde(default)]
188    modules_path: Option<PathBuf>,
189    #[serde(default)]
190    executor: ExecutorConfig,
191    #[serde(default)]
192    observability: ObservabilityConfig,
193    #[serde(flatten, default)]
194    user_namespaces: HashMap<String, serde_json::Value>,
195}
196
197impl<'de> Deserialize<'de> for Config {
198    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
199        // Two-pass: first parse the wire form into a generic JSON object,
200        // detect any v0.17.x legacy root-level fields, then materialize the
201        // canonical struct via a helper that mirrors the serialized shape.
202        let raw = serde_json::Map::<String, serde_json::Value>::deserialize(deserializer)?;
203
204        let mut violations: Vec<String> = Vec::new();
205        for (legacy, canonical) in LEGACY_ROOT_FIELDS {
206            if raw.contains_key(*legacy) {
207                violations.push(format!("'{legacy}' → '{canonical}'"));
208            }
209        }
210        if !violations.is_empty() {
211            return Err(D::Error::custom(format!(
212                "apcore v0.18.0 changed Config layout: root-level fields {} are no longer accepted. \
213                 Move them to their canonical nested namespace. \
214                 See MIGRATION-v0.18.md for the full migration guide.",
215                violations.join(", ")
216            )));
217        }
218
219        let mut core_data = raw.clone();
220        let mut mode = ConfigMode::Legacy;
221
222        // §9.6: If "apcore" key is present, it's namespace mode.
223        if let Some(apcore_val) = raw.get("apcore") {
224            if let Some(apcore_obj) = apcore_val.as_object() {
225                mode = ConfigMode::Namespace;
226                // Merge apcore-namespace fields into the top-level core_data
227                // so ConfigHelper can find them.
228                for (k, v) in apcore_obj {
229                    core_data.insert(k.clone(), v.clone());
230                }
231            }
232        }
233
234        let helper: ConfigHelper = serde_json::from_value(serde_json::Value::Object(core_data))
235            .map_err(D::Error::custom)?;
236
237        Ok(Config {
238            modules_path: helper.modules_path,
239            executor: helper.executor,
240            observability: helper.observability,
241            user_namespaces: helper.user_namespaces,
242            yaml_path: None,
243            mode,
244        })
245    }
246}
247
248impl Config {
249    /// Load config from a JSON file, apply env overrides, and validate.
250    pub fn from_json_file(path: &std::path::Path) -> Result<Self, ModuleError> {
251        let file = std::fs::File::open(path).map_err(|e| {
252            ModuleError::new(
253                ErrorCode::ConfigNotFound,
254                format!("Config file not found: {}: {}", path.display(), e),
255            )
256        })?;
257        let reader = std::io::BufReader::new(file);
258        let mut config: Config = serde_json::from_reader(reader).map_err(|e| {
259            ModuleError::new(
260                ErrorCode::ConfigInvalid,
261                format!("Failed to parse JSON config: {}: {}", path.display(), e),
262            )
263        })?;
264        config.detect_mode();
265        init_builtin_namespaces();
266        config.apply_env_overrides();
267        config.validate()?;
268        Ok(config)
269    }
270
271    /// Load config from a YAML file, apply env overrides, and validate.
272    pub fn from_yaml_file(path: &std::path::Path) -> Result<Self, ModuleError> {
273        let file = std::fs::File::open(path).map_err(|e| {
274            ModuleError::new(
275                ErrorCode::ConfigNotFound,
276                format!("Config file not found: {}: {}", path.display(), e),
277            )
278        })?;
279        let reader = std::io::BufReader::new(file);
280        let mut config: Config = serde_yaml::from_reader(reader).map_err(|e| {
281            ModuleError::new(
282                ErrorCode::ConfigInvalid,
283                format!("Failed to parse YAML config: {}: {}", path.display(), e),
284            )
285        })?;
286        config.yaml_path = Some(path.to_path_buf());
287        config.detect_mode();
288        init_builtin_namespaces();
289        config.apply_env_overrides();
290        config.validate()?;
291        Ok(config)
292    }
293
294    /// Auto-detect format by file extension and load.
295    pub fn load(path: &std::path::Path) -> Result<Self, ModuleError> {
296        match path.extension().and_then(|e| e.to_str()) {
297            Some("json") => Self::from_json_file(path),
298            Some("yaml" | "yml") => Self::from_yaml_file(path),
299            _ => {
300                // Default to YAML
301                Self::from_yaml_file(path)
302            }
303        }
304    }
305
306    /// Validate config constraints. Returns an error listing all violations.
307    pub fn validate(&self) -> Result<(), ModuleError> {
308        let mut errors: Vec<String> = Vec::new();
309
310        if self.executor.max_call_depth < 1 {
311            errors.push("executor.max_call_depth must be >= 1".to_string());
312        }
313        if self.executor.max_module_repeat < 1 {
314            errors.push("executor.max_module_repeat must be >= 1".to_string());
315        }
316        // default_timeout == 0 means no timeout, which is allowed.
317        if self.executor.global_timeout > 0
318            && self.executor.default_timeout > 0
319            && self.executor.global_timeout < self.executor.default_timeout
320        {
321            errors.push(format!(
322                "executor.global_timeout ({}) must be >= executor.default_timeout ({})",
323                self.executor.global_timeout, self.executor.default_timeout
324            ));
325        }
326
327        if errors.is_empty() {
328            Ok(())
329        } else {
330            let message = format!("Config validation failed: {}", errors.join("; "));
331            Err(ModuleError::new(ErrorCode::ConfigInvalid, message))
332        }
333    }
334
335    /// Build config from defaults, applying env var overrides.
336    #[must_use]
337    pub fn from_defaults() -> Self {
338        let mut config = Self::default();
339        config.detect_mode();
340        init_builtin_namespaces();
341        config.apply_env_overrides();
342        config
343    }
344
345    /// Discover and load config using the §9.14 search order.
346    ///
347    /// If no file is found, returns `Config::from_defaults()`.
348    pub fn discover() -> Result<Self, ModuleError> {
349        match discover_config_file() {
350            Some(path) => Self::load(&path),
351            None => Ok(Self::from_defaults()),
352        }
353    }
354
355    /// Get a config value by dot-path key.
356    ///
357    /// Walks the canonical nested namespace tree (`executor.*`,
358    /// `observability.*`, `modules_path`) and falls back to user-defined
359    /// namespaces. Per spec §9.1, all keys MUST use the canonical
360    /// `<namespace>.<field>` form. Legacy v0.17.x short-form aliases
361    /// (e.g. bare `max_call_depth`) are NOT accepted.
362    #[must_use]
363    pub fn get(&self, key: &str) -> Option<serde_json::Value> {
364        // Check canonical typed fields first.
365        if let Some(val) = self.get_typed_field(key) {
366            return Some(val);
367        }
368
369        // Fall back to user namespaces with dot-path traversal.
370        let parts: Vec<&str> = key.split('.').collect();
371        if parts.is_empty() {
372            return None;
373        }
374        let top = self.user_namespaces.get(parts[0])?;
375        if parts.len() == 1 {
376            return Some(top.clone());
377        }
378        let mut current = top;
379        for part in &parts[1..] {
380            current = current.get(*part)?;
381        }
382        Some(current.clone())
383    }
384
385    /// Set a config value by dot-path key.
386    ///
387    /// Attempts to set canonical typed fields first, then falls back to
388    /// user namespaces. Returns silently on type mismatch.
389    pub fn set(&mut self, key: &str, value: serde_json::Value) {
390        // Try canonical typed fields.
391        if self.set_typed_field(key, &value) {
392            return;
393        }
394
395        // Fall back to user namespaces.
396        let parts: Vec<&str> = key.split('.').collect();
397        if parts.is_empty() {
398            return;
399        }
400        if parts.len() == 1 {
401            self.user_namespaces.insert(key.to_string(), value);
402            return;
403        }
404        let root = self
405            .user_namespaces
406            .entry(parts[0].to_string())
407            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
408        let mut current = root;
409        for part in &parts[1..parts.len() - 1] {
410            if !current.is_object() {
411                *current = serde_json::Value::Object(serde_json::Map::new());
412            }
413            // INVARIANT: the preceding `if !current.is_object()` branch guarantees object shape.
414            current = current
415                .as_object_mut()
416                .unwrap()
417                .entry(part.to_string())
418                .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
419        }
420        if !current.is_object() {
421            *current = serde_json::Value::Object(serde_json::Map::new());
422        }
423        // INVARIANT: the preceding `if !current.is_object()` branch guarantees object shape.
424        current
425            .as_object_mut()
426            .unwrap()
427            .insert(parts[parts.len() - 1].to_string(), value);
428    }
429
430    /// Reload config from the stored `yaml_path`. Returns error if no path stored.
431    pub fn reload(&mut self) -> Result<(), ModuleError> {
432        let path = self.yaml_path.clone().ok_or_else(|| {
433            ModuleError::new(
434                ErrorCode::ReloadFailed,
435                "Cannot reload: no yaml_path stored (config was not loaded from a file)",
436            )
437        })?;
438        let reloaded = Self::load(&path)?;
439        // Preserve the yaml_path through reload
440        let yaml_path = self.yaml_path.take();
441        *self = reloaded;
442        self.yaml_path = yaml_path;
443        Ok(())
444    }
445
446    /// Return a `serde_json::Value` representing the full config as the
447    /// canonical nested JSON object (`PROTOCOL_SPEC` §9.1 wire format).
448    #[must_use]
449    pub fn data(&self) -> serde_json::Value {
450        serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
451    }
452
453    // --- Namespace registration (class methods) ---
454
455    pub fn register_namespace(mut reg: NamespaceRegistration) -> Result<(), ModuleError> {
456        if RESERVED_NAMESPACES.contains(&reg.name.as_str()) {
457            return Err(ModuleError::config_namespace_reserved(&reg.name));
458        }
459        // Auto-derive env_prefix from name if not provided.
460        if reg.env_prefix.is_none() {
461            reg.env_prefix = Some(reg.name.to_uppercase().replace('-', "_"));
462        }
463        let mut map = global_ns_registry().write();
464        if map.contains_key(&reg.name) {
465            return Err(ModuleError::config_namespace_duplicate(&reg.name));
466        }
467        // Check for duplicate env_prefix.
468        let prefix = reg.env_prefix.as_deref().unwrap_or("");
469        for existing in map.values() {
470            if existing.env_prefix.as_deref() == Some(prefix) {
471                return Err(ModuleError::config_env_prefix_conflict(prefix));
472            }
473        }
474        // Validate env_map: no env var can be claimed twice.
475        if let Some(ref em) = reg.env_map {
476            let claimed = env_map_claimed().read();
477            for env_var in em.keys() {
478                if let Some(owner) = claimed.get(env_var) {
479                    return Err(ModuleError::config_env_map_conflict(env_var, owner));
480                }
481            }
482            drop(claimed);
483            let mut claimed = env_map_claimed().write();
484            for env_var in em.keys() {
485                claimed.insert(env_var.clone(), reg.name.clone());
486            }
487        }
488        map.insert(reg.name.clone(), reg);
489        Ok(())
490    }
491
492    /// Register global bare env var → top-level config key mappings.
493    pub fn env_map(mapping: HashMap<String, String>) -> Result<(), ModuleError> {
494        let claimed_lock = env_map_claimed();
495        let claimed = claimed_lock.read();
496        for env_var in mapping.keys() {
497            if let Some(owner) = claimed.get(env_var) {
498                return Err(ModuleError::config_env_map_conflict(env_var, owner));
499            }
500        }
501        drop(claimed);
502        let mut claimed = claimed_lock.write();
503        let mut gmap = global_env_map().write();
504        for (env_var, config_key) in mapping {
505            claimed.insert(env_var.clone(), "__global__".to_string());
506            gmap.insert(env_var, config_key);
507        }
508        Ok(())
509    }
510
511    #[must_use]
512    pub fn registered_namespaces() -> Vec<NamespaceInfo> {
513        global_ns_registry()
514            .read()
515            .values()
516            .map(|r| NamespaceInfo {
517                name: r.name.clone(),
518                env_prefix: r.env_prefix.clone(),
519                has_schema: r.schema.is_some(),
520            })
521            .collect()
522    }
523
524    // --- Namespace instance methods ---
525
526    #[must_use]
527    pub fn namespace(&self, name: &str) -> Option<serde_json::Value> {
528        self.user_namespaces.get(name).cloned()
529    }
530
531    pub fn mount(&mut self, namespace: &str, source: MountSource) -> Result<(), ModuleError> {
532        // W-2: Reject reserved namespace per §9.7 spec.
533        if namespace == "_config" {
534            return Err(ModuleError::config_mount_error(
535                namespace,
536                "cannot mount to reserved namespace '_config'",
537            ));
538        }
539        let data = match source {
540            MountSource::Dict(v) => v,
541            MountSource::File(path) => {
542                let content = std::fs::read_to_string(&path)
543                    .map_err(|e| ModuleError::config_mount_error(namespace, &e.to_string()))?;
544                serde_yaml::from_str(&content)
545                    .map_err(|e| ModuleError::config_mount_error(namespace, &e.to_string()))?
546            }
547        };
548        if !data.is_object() {
549            return Err(ModuleError::config_mount_error(
550                namespace,
551                "mount source must be a JSON object",
552            ));
553        }
554        let entry = self
555            .user_namespaces
556            .entry(namespace.to_string())
557            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
558        if let (Some(target), Some(source_map)) = (entry.as_object_mut(), data.as_object()) {
559            for (k, v) in source_map {
560                target.insert(k.clone(), v.clone());
561            }
562        }
563        Ok(())
564    }
565
566    pub fn bind<T: DeserializeOwned>(&self, namespace: &str) -> Result<T, ModuleError> {
567        // Special-case canonical namespaces so `bind::<ExecutorConfig>("executor")`
568        // returns the typed struct directly.
569        match namespace {
570            "executor" => {
571                return serde_json::from_value(
572                    serde_json::to_value(&self.executor)
573                        .map_err(|e| ModuleError::config_bind_error(namespace, &e.to_string()))?,
574                )
575                .map_err(|e| ModuleError::config_bind_error(namespace, &e.to_string()))
576            }
577            "observability" => {
578                return serde_json::from_value(
579                    serde_json::to_value(&self.observability)
580                        .map_err(|e| ModuleError::config_bind_error(namespace, &e.to_string()))?,
581                )
582                .map_err(|e| ModuleError::config_bind_error(namespace, &e.to_string()))
583            }
584            _ => {}
585        }
586
587        let value = self
588            .user_namespaces
589            .get(namespace)
590            .ok_or_else(|| ModuleError::config_bind_error(namespace, "namespace not found"))?;
591        serde_json::from_value(value.clone())
592            .map_err(|e| ModuleError::config_bind_error(namespace, &e.to_string()))
593    }
594
595    pub fn get_typed<T: DeserializeOwned>(&self, key: &str) -> Result<T, ModuleError> {
596        let value = self
597            .get(key)
598            .ok_or_else(|| ModuleError::config_bind_error(key, "key not found"))?;
599        serde_json::from_value(value)
600            .map_err(|e| ModuleError::config_bind_error(key, &e.to_string()))
601    }
602
603    // --- Private helpers ---
604
605    fn detect_mode(&mut self) {
606        // W-3: Only activate namespace mode when "apcore" key is a mapping.
607        // A null or scalar value is not a valid namespace indicator.
608        self.mode = match self.user_namespaces.get("apcore") {
609            Some(serde_json::Value::Object(_)) => ConfigMode::Namespace,
610            _ => ConfigMode::Legacy,
611        };
612    }
613
614    /// Apply APCORE_* environment variable overrides to both typed fields and settings.
615    ///
616    /// In legacy mode, all `APCORE_*` vars are mapped via `env_key_to_dot_path`.
617    /// In namespace mode, registered `env_prefix` values are dispatched via
618    /// longest-prefix-match (§9.10).
619    fn apply_env_overrides(&mut self) {
620        if self.mode == ConfigMode::Namespace {
621            self.apply_namespace_env_overrides();
622            return;
623        }
624        // Legacy mode: flat APCORE_ prefix stripping.
625        for (key, value) in std::env::vars() {
626            if let Some(suffix) = key.strip_prefix("APCORE_") {
627                let dot_path = Self::env_key_to_dot_path(suffix);
628                let parsed = Self::coerce_env_value(&value);
629                tracing::debug!(env = %key, path = %dot_path, "Applying legacy env override");
630                self.set(&dot_path, parsed);
631            }
632        }
633    }
634
635    /// §9.10: Namespace-aware env routing via longest-prefix-match.
636    fn apply_namespace_env_overrides(&mut self) {
637        let registry = global_ns_registry().read();
638        let gmap = global_env_map().read();
639
640        // Build namespace env_map lookup.
641        let mut ns_env_maps: HashMap<&str, (&str, &str)> = HashMap::new();
642        for reg in registry.values() {
643            if let Some(ref em) = reg.env_map {
644                for (env_var, config_key) in em {
645                    ns_env_maps.insert(env_var.as_str(), (reg.name.as_str(), config_key.as_str()));
646                }
647            }
648        }
649
650        // Prefix table: sorted by length descending for longest-prefix-match.
651        let mut prefixed: Vec<&NamespaceRegistration> = registry
652            .values()
653            .filter(|r| r.env_prefix.is_some())
654            .collect();
655        prefixed.sort_by(|a, b| {
656            b.env_prefix
657                .as_ref()
658                .map_or(0, std::string::String::len)
659                .cmp(&a.env_prefix.as_ref().map_or(0, std::string::String::len))
660        });
661
662        for (env_key, env_value) in std::env::vars() {
663            let parsed = Self::coerce_env_value(&env_value);
664
665            // 1. Global env_map (bare env var → top-level key).
666            if let Some(config_key) = gmap.get(&env_key) {
667                self.set(config_key, parsed);
668                continue;
669            }
670
671            // 2. Namespace env_map (bare env var → namespace key).
672            if let Some(&(ns_name, config_key)) = ns_env_maps.get(env_key.as_str()) {
673                let full_path = format!("{ns_name}.{config_key}");
674                self.set(&full_path, parsed);
675                continue;
676            }
677
678            // 3. Prefix-based dispatch.
679            let mut matched = false;
680            for reg in &prefixed {
681                let prefix = reg.env_prefix.as_deref().unwrap_or("");
682                if let Some(suffix) = env_key.strip_prefix(prefix) {
683                    let suffix = suffix.strip_prefix('_').unwrap_or(suffix);
684                    if suffix.is_empty() {
685                        continue;
686                    }
687                    let key = Self::resolve_env_suffix(suffix, reg);
688                    let full_path = format!("{}.{key}", reg.name);
689                    tracing::debug!(env = %env_key, path = %full_path, "Applying namespace env override");
690                    self.set(&full_path, parsed.clone());
691                    matched = true;
692                    break;
693                }
694            }
695            // Fallback: APCORE_ prefix with no matching namespace → treat as
696            // top-level key (same as legacy mode). Per spec §9.8, un-matched
697            // env vars resolve to their natural dot-path without namespace prefix.
698            if !matched {
699                if let Some(suffix) = env_key.strip_prefix("APCORE_") {
700                    let dot_path = Self::env_key_to_dot_path(suffix);
701                    tracing::debug!(env = %env_key, path = %dot_path, "Applying fallback env override (no namespace match)");
702                    self.set(&dot_path, parsed);
703                }
704            }
705        }
706    }
707
708    /// Map a canonical dot-path key to a typed field value.
709    ///
710    /// Recognizes only the canonical `<namespace>.<field>` form per spec §9.1.
711    /// Legacy bare-name aliases are NOT accepted.
712    fn get_typed_field(&self, key: &str) -> Option<serde_json::Value> {
713        match key {
714            "executor.max_call_depth" => Some(serde_json::Value::Number(
715                self.executor.max_call_depth.into(),
716            )),
717            "executor.max_module_repeat" => Some(serde_json::Value::Number(
718                self.executor.max_module_repeat.into(),
719            )),
720            "executor.default_timeout" => Some(serde_json::Value::Number(
721                self.executor.default_timeout.into(),
722            )),
723            "executor.global_timeout" => Some(serde_json::Value::Number(
724                self.executor.global_timeout.into(),
725            )),
726            "observability.tracing.enabled" => {
727                Some(serde_json::Value::Bool(self.observability.tracing.enabled))
728            }
729            "observability.metrics.enabled" => {
730                Some(serde_json::Value::Bool(self.observability.metrics.enabled))
731            }
732            "modules_path" => self
733                .modules_path
734                .as_ref()
735                .map(|p| serde_json::Value::String(p.to_string_lossy().into_owned())),
736            _ => None,
737        }
738    }
739
740    /// Try to set a canonical typed field. Returns true if matched.
741    fn set_typed_field(&mut self, key: &str, value: &serde_json::Value) -> bool {
742        match key {
743            "executor.max_call_depth" => {
744                if let Some(n) = value.as_u64() {
745                    #[allow(clippy::cast_possible_truncation)]
746                    // config values are small and won't exceed u32::MAX
747                    {
748                        self.executor.max_call_depth = n as u32;
749                    }
750                    return true;
751                }
752            }
753            "executor.max_module_repeat" => {
754                if let Some(n) = value.as_u64() {
755                    #[allow(clippy::cast_possible_truncation)]
756                    // config values are small and won't exceed u32::MAX
757                    {
758                        self.executor.max_module_repeat = n as u32;
759                    }
760                    return true;
761                }
762            }
763            "executor.default_timeout" => {
764                if let Some(n) = value.as_u64() {
765                    self.executor.default_timeout = n;
766                    return true;
767                }
768            }
769            "executor.global_timeout" => {
770                if let Some(n) = value.as_u64() {
771                    self.executor.global_timeout = n;
772                    return true;
773                }
774            }
775            "observability.tracing.enabled" => {
776                if let Some(b) = value.as_bool() {
777                    self.observability.tracing.enabled = b;
778                    return true;
779                }
780            }
781            "observability.metrics.enabled" => {
782                if let Some(b) = value.as_bool() {
783                    self.observability.metrics.enabled = b;
784                    return true;
785                }
786            }
787            "modules_path" => {
788                if let Some(s) = value.as_str() {
789                    self.modules_path = Some(PathBuf::from(s));
790                    return true;
791                }
792            }
793            _ => {}
794        }
795        false
796    }
797
798    /// Convert an env-var suffix to a dot-path config key.
799    ///
800    /// Convention (matches Python reference):
801    ///   - Single `_` → `.` (section separator)
802    ///   - Double `__` → literal `_` (underscore within a field name)
803    ///
804    /// Example: `EXECUTOR_MAX__CALL__DEPTH` → `executor.max_call_depth`
805    ///
806    /// So to set `max_call_depth` via env, use `APCORE_EXECUTOR_MAX__CALL__DEPTH`.
807    fn env_key_to_dot_path(raw: &str) -> String {
808        Self::env_key_to_dot_path_with_depth(raw, usize::MAX)
809    }
810
811    /// Convert env var suffix to dot-path, stopping at `max_depth` segments.
812    fn env_key_to_dot_path_with_depth(raw: &str, max_depth: usize) -> String {
813        let lower = raw.to_lowercase();
814        let chars: Vec<char> = lower.chars().collect();
815        let mut result = String::with_capacity(chars.len());
816        let mut dot_count: usize = 0;
817        let mut i = 0;
818        while i < chars.len() {
819            if chars[i] == '_' {
820                if i + 1 < chars.len() && chars[i + 1] == '_' {
821                    result.push('_'); // double __ → literal _
822                    i += 2;
823                } else if dot_count < max_depth.saturating_sub(1) {
824                    result.push('.');
825                    dot_count += 1;
826                    i += 1;
827                } else {
828                    result.push('_'); // depth limit reached
829                    i += 1;
830                }
831            } else {
832                result.push(chars[i]);
833                i += 1;
834            }
835        }
836        result
837    }
838
839    /// Try to match suffix against keys in a JSON object tree (recursive).
840    fn match_suffix_to_tree(
841        suffix: &str,
842        tree: &serde_json::Map<String, serde_json::Value>,
843        depth: usize,
844        max_depth: usize,
845    ) -> Option<String> {
846        // 1. Try full suffix as a flat key.
847        if tree.contains_key(suffix) {
848            return Some(suffix.to_string());
849        }
850        // 2. Depth limit.
851        if depth >= max_depth.saturating_sub(1) {
852            return None;
853        }
854        // 3. Try splitting at each underscore.
855        for (i, ch) in suffix.char_indices() {
856            if ch != '_' || i == 0 || i == suffix.len() - 1 {
857                continue;
858            }
859            let prefix_part = &suffix[..i];
860            let remainder = &suffix[i + 1..];
861            if let Some(serde_json::Value::Object(subtree)) = tree.get(prefix_part) {
862                if let Some(sub) =
863                    Self::match_suffix_to_tree(remainder, subtree, depth + 1, max_depth)
864                {
865                    return Some(format!("{prefix_part}.{sub}"));
866                }
867            }
868        }
869        None
870    }
871
872    /// Resolve an env var suffix based on the registration's `env_style`.
873    fn resolve_env_suffix(suffix: &str, reg: &NamespaceRegistration) -> String {
874        match reg.env_style {
875            EnvStyle::Flat => suffix.to_lowercase(),
876            EnvStyle::Auto => {
877                let lower = suffix.to_lowercase();
878                if let Some(serde_json::Value::Object(tree)) = reg.defaults.as_ref() {
879                    if let Some(resolved) =
880                        Self::match_suffix_to_tree(&lower, tree, 0, reg.max_depth)
881                    {
882                        return resolved;
883                    }
884                }
885                // Fall back to nested with depth.
886                Self::env_key_to_dot_path_with_depth(suffix, reg.max_depth)
887            }
888            EnvStyle::Nested => Self::env_key_to_dot_path_with_depth(suffix, reg.max_depth),
889        }
890    }
891
892    fn coerce_env_value(value: &str) -> serde_json::Value {
893        if value.eq_ignore_ascii_case("true") {
894            return serde_json::Value::Bool(true);
895        }
896        if value.eq_ignore_ascii_case("false") {
897            return serde_json::Value::Bool(false);
898        }
899        if let Ok(n) = value.parse::<i64>() {
900            return serde_json::Value::Number(n.into());
901        }
902        if let Ok(f) = value.parse::<f64>() {
903            if let Some(n) = serde_json::Number::from_f64(f) {
904                return serde_json::Value::Number(n);
905            }
906        }
907        serde_json::Value::String(value.to_string())
908    }
909}
910
911// ---------------------------------------------------------------------------
912// Built-in namespace initialization (§9.15)
913// ---------------------------------------------------------------------------
914
915fn init_builtin_namespaces() {
916    static INIT: OnceLock<()> = OnceLock::new();
917    INIT.get_or_init(|| {
918        let namespaces = vec![
919            NamespaceRegistration {
920                name: "observability".to_string(),
921                env_prefix: Some("APCORE_OBSERVABILITY".to_string()),
922                defaults: Some(serde_json::json!({
923                    "tracing": {
924                        "enabled": false,
925                        "sampling_rate": 1.0,
926                        "strategy": "full",
927                        "exporter": "stdout",
928                        "otlp_endpoint": "http://localhost:4318"
929                    },
930                    "metrics": {
931                        "enabled": false,
932                        "exporter": "in_memory"
933                    },
934                    "logging": {
935                        "level": "info",
936                        "format": "json",
937                        "redact_keys": ["password", "secret", "token", "api_key"]
938                    },
939                    "error_history": {
940                        "max_entries_per_module": 50,
941                        "max_total_entries": 1000
942                    },
943                    "platform_notify": {
944                        "error_rate_threshold": 0.1,
945                        "latency_p99_threshold_ms": 5000.0
946                    }
947                })),
948                schema: None,
949                env_style: EnvStyle::Auto,
950                max_depth: DEFAULT_MAX_DEPTH,
951                env_map: None,
952            },
953            NamespaceRegistration {
954                name: "sys_modules".to_string(),
955                env_prefix: Some("APCORE_SYS".to_string()),
956                defaults: Some(serde_json::json!({
957                    "enabled": true,
958                    "health": { "enabled": true },
959                    "manifest": { "enabled": true },
960                    "usage": { "enabled": true, "retention_hours": 168, "bucketing_strategy": "hourly" },
961                    "control": { "enabled": true },
962                    "events": {
963                        "enabled": false,
964                        "subscribers": [],
965                        "thresholds": { "error_rate": 0.1, "latency_p99_ms": 5000.0 }
966                    }
967                })),
968                schema: None,
969                env_style: EnvStyle::Auto,
970                max_depth: DEFAULT_MAX_DEPTH,
971                env_map: None,
972            },
973        ];
974        for ns in namespaces {
975            // Ignore duplicate errors on re-init
976            let _ = Config::register_namespace(ns);
977        }
978    });
979}
980
981// ---------------------------------------------------------------------------
982// Config discovery (§9.14)
983// ---------------------------------------------------------------------------
984
985fn discover_config_file() -> Option<std::path::PathBuf> {
986    if let Ok(env_path) = std::env::var("APCORE_CONFIG_FILE") {
987        if !env_path.is_empty() {
988            return Some(std::path::PathBuf::from(env_path));
989        }
990    }
991
992    let cwd_candidates = ["project.yaml", "project.yml", "apcore.yaml", "apcore.yml"];
993    for name in &cwd_candidates {
994        let p = std::path::Path::new(name);
995        if p.exists() {
996            return Some(p.to_path_buf());
997        }
998    }
999
1000    if let Some(home) = dirs_home() {
1001        #[cfg(target_os = "macos")]
1002        let xdg = home
1003            .join("Library")
1004            .join("Application Support")
1005            .join("apcore")
1006            .join("config.yaml");
1007        #[cfg(not(target_os = "macos"))]
1008        let xdg = home.join(".config").join("apcore").join("config.yaml");
1009
1010        if xdg.exists() {
1011            return Some(xdg);
1012        }
1013
1014        let legacy = home.join(".apcore").join("config.yaml");
1015        if legacy.exists() {
1016            return Some(legacy);
1017        }
1018    }
1019
1020    None
1021}
1022
1023fn dirs_home() -> Option<std::path::PathBuf> {
1024    std::env::var("HOME").ok().map(std::path::PathBuf::from)
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029    use super::*;
1030
1031    // -------------------------------------------------------------------------
1032    // Config::default and ExecutorConfig defaults
1033    // -------------------------------------------------------------------------
1034
1035    #[test]
1036    fn default_config_has_expected_executor_values() {
1037        let cfg = Config::default();
1038        assert_eq!(cfg.executor.max_call_depth, 32);
1039        assert_eq!(cfg.executor.max_module_repeat, 3);
1040        assert_eq!(cfg.executor.default_timeout, 30_000);
1041        assert_eq!(cfg.executor.global_timeout, 60_000);
1042    }
1043
1044    #[test]
1045    fn default_config_validates_successfully() {
1046        let cfg = Config::default();
1047        assert!(cfg.validate().is_ok());
1048    }
1049
1050    // -------------------------------------------------------------------------
1051    // Config::get / set for canonical typed fields
1052    // -------------------------------------------------------------------------
1053
1054    #[test]
1055    fn get_canonical_executor_key() {
1056        let cfg = Config::default();
1057        let depth = cfg
1058            .get("executor.max_call_depth")
1059            .expect("key should exist");
1060        assert_eq!(depth, serde_json::json!(32u64));
1061    }
1062
1063    #[test]
1064    fn set_then_get_canonical_executor_key() {
1065        let mut cfg = Config::default();
1066        cfg.set("executor.max_call_depth", serde_json::json!(10u64));
1067        let val = cfg.get("executor.max_call_depth").unwrap();
1068        assert_eq!(val.as_u64().unwrap(), 10);
1069    }
1070
1071    #[test]
1072    fn get_observability_tracing_enabled() {
1073        let cfg = Config::default();
1074        let enabled = cfg.get("observability.tracing.enabled").unwrap();
1075        // Default is false
1076        assert_eq!(enabled, serde_json::json!(false));
1077    }
1078
1079    #[test]
1080    fn set_observability_tracing_enabled() {
1081        let mut cfg = Config::default();
1082        cfg.set("observability.tracing.enabled", serde_json::json!(true));
1083        assert!(cfg.observability.tracing.enabled);
1084    }
1085
1086    // -------------------------------------------------------------------------
1087    // Config::get / set for user namespaces (dot-path traversal)
1088    // -------------------------------------------------------------------------
1089
1090    #[test]
1091    fn set_and_get_user_namespace_key() {
1092        let mut cfg = Config::default();
1093        cfg.set(
1094            "myapp.db.url",
1095            serde_json::json!("postgres://localhost/test"),
1096        );
1097        let val = cfg.get("myapp.db.url").expect("should exist");
1098        assert_eq!(val.as_str().unwrap(), "postgres://localhost/test");
1099    }
1100
1101    #[test]
1102    fn get_returns_none_for_missing_key() {
1103        let cfg = Config::default();
1104        assert!(cfg.get("nonexistent.key").is_none());
1105    }
1106
1107    #[test]
1108    fn set_top_level_user_namespace_key() {
1109        let mut cfg = Config::default();
1110        cfg.set("myns", serde_json::json!("value"));
1111        assert_eq!(cfg.get("myns").unwrap(), serde_json::json!("value"));
1112    }
1113
1114    // -------------------------------------------------------------------------
1115    // Config::validate
1116    // -------------------------------------------------------------------------
1117
1118    #[test]
1119    fn validate_rejects_zero_max_call_depth() {
1120        let mut cfg = Config::default();
1121        cfg.executor.max_call_depth = 0;
1122        assert!(cfg.validate().is_err());
1123    }
1124
1125    #[test]
1126    fn validate_rejects_zero_max_module_repeat() {
1127        let mut cfg = Config::default();
1128        cfg.executor.max_module_repeat = 0;
1129        assert!(cfg.validate().is_err());
1130    }
1131
1132    #[test]
1133    fn validate_rejects_global_timeout_less_than_default_timeout() {
1134        let mut cfg = Config::default();
1135        cfg.executor.global_timeout = 1_000; // less than default_timeout (30_000)
1136        cfg.executor.default_timeout = 5_000;
1137        assert!(cfg.validate().is_err());
1138    }
1139
1140    #[test]
1141    fn validate_allows_zero_global_timeout_meaning_no_deadline() {
1142        let mut cfg = Config::default();
1143        cfg.executor.global_timeout = 0; // 0 = no global deadline
1144        assert!(cfg.validate().is_ok());
1145    }
1146
1147    // -------------------------------------------------------------------------
1148    // Config deserialization — legacy field rejection
1149    // -------------------------------------------------------------------------
1150
1151    #[test]
1152    fn deserialize_rejects_legacy_root_fields() {
1153        let json_str = r#"{"max_call_depth": 10}"#;
1154        let result: Result<Config, _> = serde_json::from_str(json_str);
1155        assert!(result.is_err(), "legacy root field should be rejected");
1156        let err_msg = result.unwrap_err().to_string();
1157        assert!(
1158            err_msg.contains("v0.18.0") || err_msg.contains("max_call_depth"),
1159            "error should mention legacy key"
1160        );
1161    }
1162
1163    #[test]
1164    fn deserialize_canonical_format_succeeds() {
1165        let json_str = r#"{"executor": {"max_call_depth": 16}}"#;
1166        let cfg: Config = serde_json::from_str(json_str).expect("canonical format should work");
1167        assert_eq!(cfg.executor.max_call_depth, 16);
1168    }
1169
1170    // -------------------------------------------------------------------------
1171    // Config::data
1172    // -------------------------------------------------------------------------
1173
1174    #[test]
1175    fn data_returns_json_object() {
1176        let cfg = Config::default();
1177        let data = cfg.data();
1178        assert!(data.is_object(), "data() should return a JSON object");
1179        assert!(data.get("executor").is_some());
1180    }
1181
1182    // -------------------------------------------------------------------------
1183    // Config::reload without path
1184    // -------------------------------------------------------------------------
1185
1186    #[test]
1187    fn reload_without_path_returns_error() {
1188        let mut cfg = Config::default();
1189        assert!(
1190            cfg.reload().is_err(),
1191            "reload without yaml_path should fail"
1192        );
1193    }
1194
1195    // -------------------------------------------------------------------------
1196    // Config::mount
1197    // -------------------------------------------------------------------------
1198
1199    #[test]
1200    fn mount_dict_into_user_namespace() {
1201        let mut cfg = Config::default();
1202        let data = serde_json::json!({"host": "localhost", "port": 5432});
1203        cfg.mount("database", MountSource::Dict(data)).unwrap();
1204        let host = cfg.get("database.host").unwrap();
1205        assert_eq!(host.as_str().unwrap(), "localhost");
1206    }
1207
1208    #[test]
1209    fn mount_rejects_reserved_namespace() {
1210        let mut cfg = Config::default();
1211        let data = serde_json::json!({"key": "value"});
1212        let result = cfg.mount("_config", MountSource::Dict(data));
1213        assert!(
1214            result.is_err(),
1215            "should reject reserved namespace '_config'"
1216        );
1217    }
1218
1219    #[test]
1220    fn mount_rejects_non_object_source() {
1221        let mut cfg = Config::default();
1222        let result = cfg.mount("ns", MountSource::Dict(serde_json::json!([1, 2, 3])));
1223        assert!(result.is_err(), "non-object source should be rejected");
1224    }
1225
1226    // -------------------------------------------------------------------------
1227    // ConfigMode detection
1228    // -------------------------------------------------------------------------
1229
1230    #[test]
1231    fn namespace_mode_detected_when_apcore_key_present() {
1232        let json_str = r#"{"apcore": {"executor": {"max_call_depth": 8}}}"#;
1233        let cfg: Config = serde_json::from_str(json_str).expect("should parse");
1234        // detect_mode() is called in from_yaml_file / from_json_file / from_defaults;
1235        // when deserializing raw, mode stays Legacy; we call detect_mode via from_defaults
1236        // which relies on from_defaults path. Test via from_defaults behavior:
1237        // Just verify the config parsed correctly.
1238        assert_eq!(cfg.executor.max_call_depth, 8);
1239    }
1240}