use parking_lot::RwLock;
use serde::de::{DeserializeOwned, Error as DeError};
use serde::{Deserialize, Deserializer, Serialize};
use serde_yaml_ng as serde_yaml;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
use crate::errors::{ErrorCode, ModuleError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConfigMode {
#[default]
Legacy,
Namespace,
}
pub enum MountSource {
Dict(serde_json::Value),
File(PathBuf),
}
pub const DEFAULT_MAX_DEPTH: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EnvStyle {
Nested,
Flat,
#[default]
Auto,
}
#[derive(Debug, Clone)]
pub struct NamespaceRegistration {
pub name: String,
pub env_prefix: Option<String>,
pub defaults: Option<serde_json::Value>,
pub schema: Option<serde_json::Value>,
pub env_style: EnvStyle,
pub max_depth: usize,
pub env_map: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone)]
pub struct NamespaceInfo {
pub name: String,
pub env_prefix: Option<String>,
pub has_schema: bool,
}
static GLOBAL_NS_REGISTRY: OnceLock<RwLock<HashMap<String, NamespaceRegistration>>> =
OnceLock::new();
static GLOBAL_ENV_MAP: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
static ENV_MAP_CLAIMED: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
fn global_ns_registry() -> &'static RwLock<HashMap<String, NamespaceRegistration>> {
GLOBAL_NS_REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
}
fn global_env_map() -> &'static RwLock<HashMap<String, String>> {
GLOBAL_ENV_MAP.get_or_init(|| RwLock::new(HashMap::new()))
}
fn env_map_claimed() -> &'static RwLock<HashMap<String, String>> {
ENV_MAP_CLAIMED.get_or_init(|| RwLock::new(HashMap::new()))
}
const RESERVED_NAMESPACES: &[&str] = &["apcore", "_config"];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ExecutorConfig {
pub default_timeout: u64,
pub global_timeout: u64,
pub max_call_depth: u32,
pub max_module_repeat: u32,
}
impl Default for ExecutorConfig {
fn default() -> Self {
Self {
default_timeout: 30_000,
global_timeout: 60_000,
max_call_depth: 32,
max_module_repeat: 3,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ObservabilityConfig {
pub tracing: TracingConfig,
pub metrics: MetricsConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct TracingConfig {
pub enabled: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct MetricsConfig {
pub enabled: bool,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modules_path: Option<PathBuf>,
#[serde(default)]
pub executor: ExecutorConfig,
#[serde(default)]
pub observability: ObservabilityConfig,
#[serde(flatten)]
pub user_namespaces: HashMap<String, serde_json::Value>,
#[serde(skip)]
pub yaml_path: Option<PathBuf>,
#[serde(skip)]
pub mode: ConfigMode,
}
const LEGACY_ROOT_FIELDS: &[(&str, &str)] = &[
("max_call_depth", "executor.max_call_depth"),
("max_module_repeat", "executor.max_module_repeat"),
("default_timeout_ms", "executor.default_timeout"),
("global_timeout_ms", "executor.global_timeout"),
("enable_tracing", "observability.tracing.enabled"),
("enable_metrics", "observability.metrics.enabled"),
];
#[derive(Deserialize)]
struct ConfigHelper {
#[serde(default)]
modules_path: Option<PathBuf>,
#[serde(default)]
executor: ExecutorConfig,
#[serde(default)]
observability: ObservabilityConfig,
#[serde(flatten, default)]
user_namespaces: HashMap<String, serde_json::Value>,
}
impl<'de> Deserialize<'de> for Config {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = serde_json::Map::<String, serde_json::Value>::deserialize(deserializer)?;
let mut violations: Vec<String> = Vec::new();
for (legacy, canonical) in LEGACY_ROOT_FIELDS {
if raw.contains_key(*legacy) {
violations.push(format!("'{legacy}' → '{canonical}'"));
}
}
if !violations.is_empty() {
return Err(D::Error::custom(format!(
"apcore v0.18.0 changed Config layout: root-level fields {} are no longer accepted. \
Move them to their canonical nested namespace. \
See MIGRATION-v0.18.md for the full migration guide.",
violations.join(", ")
)));
}
let mut core_data = raw.clone();
let mut mode = ConfigMode::Legacy;
if let Some(apcore_val) = raw.get("apcore") {
if let Some(apcore_obj) = apcore_val.as_object() {
mode = ConfigMode::Namespace;
for (k, v) in apcore_obj {
core_data.insert(k.clone(), v.clone());
}
}
}
let helper: ConfigHelper = serde_json::from_value(serde_json::Value::Object(core_data))
.map_err(D::Error::custom)?;
Ok(Config {
modules_path: helper.modules_path,
executor: helper.executor,
observability: helper.observability,
user_namespaces: helper.user_namespaces,
yaml_path: None,
mode,
})
}
}
impl Config {
pub fn from_json_file(path: &std::path::Path) -> Result<Self, ModuleError> {
let file = std::fs::File::open(path).map_err(|e| {
ModuleError::new(
ErrorCode::ConfigNotFound,
format!("Config file not found: {}: {}", path.display(), e),
)
})?;
let reader = std::io::BufReader::new(file);
let mut config: Config = serde_json::from_reader(reader).map_err(|e| {
ModuleError::new(
ErrorCode::ConfigInvalid,
format!("Failed to parse JSON config: {}: {}", path.display(), e),
)
})?;
config.detect_mode();
init_builtin_namespaces();
config.apply_env_overrides();
config.validate()?;
Ok(config)
}
pub fn from_yaml_file(path: &std::path::Path) -> Result<Self, ModuleError> {
let file = std::fs::File::open(path).map_err(|e| {
ModuleError::new(
ErrorCode::ConfigNotFound,
format!("Config file not found: {}: {}", path.display(), e),
)
})?;
let reader = std::io::BufReader::new(file);
let mut config: Config = serde_yaml::from_reader(reader).map_err(|e| {
ModuleError::new(
ErrorCode::ConfigInvalid,
format!("Failed to parse YAML config: {}: {}", path.display(), e),
)
})?;
config.yaml_path = Some(path.to_path_buf());
config.detect_mode();
init_builtin_namespaces();
config.apply_env_overrides();
config.validate()?;
Ok(config)
}
pub fn load(path: &std::path::Path) -> Result<Self, ModuleError> {
match path.extension().and_then(|e| e.to_str()) {
Some("json") => Self::from_json_file(path),
Some("yaml" | "yml") => Self::from_yaml_file(path),
_ => {
Self::from_yaml_file(path)
}
}
}
pub fn load_or_discover() -> Result<Self, ModuleError> {
match discover_config_file() {
Some(path) => Self::load(&path),
None => Ok(Self::from_defaults()),
}
}
pub fn validate(&self) -> Result<(), ModuleError> {
let mut errors: Vec<String> = Vec::new();
if self.executor.max_call_depth < 1 {
errors.push("executor.max_call_depth must be >= 1".to_string());
}
if self.executor.max_module_repeat < 1 {
errors.push("executor.max_module_repeat must be >= 1".to_string());
}
if self.executor.global_timeout > 0
&& self.executor.default_timeout > 0
&& self.executor.global_timeout < self.executor.default_timeout
{
errors.push(format!(
"executor.global_timeout ({}) must be >= executor.default_timeout ({})",
self.executor.global_timeout, self.executor.default_timeout
));
}
if let Some(de) = self.get("acl.default_effect") {
match de.as_str() {
Some("allow" | "deny") => {}
Some(other) => {
errors.push(format!(
"acl.default_effect must be 'allow' or 'deny' (got '{other}')"
));
}
None => {
errors.push("acl.default_effect must be a string".to_string());
}
}
}
if let Some(rate) = self.get("observability.tracing.sampling_rate") {
let rate_ok = rate.as_f64().is_some_and(|f| (0.0..=1.0).contains(&f));
if !rate_ok {
errors.push(format!(
"observability.tracing.sampling_rate must be a number in [0.0, 1.0] (got {rate})"
));
}
}
if let Some(threshold) = self.get("sys_modules.events.thresholds.error_rate") {
let ok = threshold.as_f64().is_some_and(|f| (0.0..=1.0).contains(&f));
if !ok {
errors.push(format!(
"sys_modules.events.thresholds.error_rate must be a number in [0.0, 1.0] (got {threshold})"
));
}
}
if let Some(latency) = self.get("sys_modules.events.thresholds.latency_p99_ms") {
let ok = latency.as_f64().is_some_and(|f| f > 0.0);
if !ok {
errors.push(format!(
"sys_modules.events.thresholds.latency_p99_ms must be a positive number (got {latency})"
));
}
}
if errors.is_empty() {
Ok(())
} else {
let message = format!("Config validation failed: {}", errors.join("; "));
Err(ModuleError::new(ErrorCode::ConfigInvalid, message))
}
}
#[must_use]
pub fn from_defaults() -> Self {
let mut config = Self::default();
config.detect_mode();
init_builtin_namespaces();
config.apply_env_overrides();
config
}
pub fn discover() -> Result<Self, ModuleError> {
match discover_config_file() {
Some(path) => Self::load(&path),
None => Ok(Self::from_defaults()),
}
}
#[must_use]
pub fn get(&self, key: &str) -> Option<serde_json::Value> {
if let Some(val) = self.get_typed_field(key) {
return Some(val);
}
if let Some((ns_name, rest)) = Self::match_registered_namespace(key) {
let top = self.user_namespaces.get(&ns_name)?;
if rest.is_empty() {
return Some(top.clone());
}
let mut current = top;
for part in rest.split('.') {
current = current.get(part)?;
}
return Some(current.clone());
}
let parts: Vec<&str> = key.split('.').collect();
if parts.is_empty() {
return None;
}
let top = self.user_namespaces.get(parts[0])?;
if parts.len() == 1 {
return Some(top.clone());
}
let mut current = top;
for part in &parts[1..] {
current = current.get(*part)?;
}
Some(current.clone())
}
fn match_registered_namespace(key: &str) -> Option<(String, String)> {
let registry = global_ns_registry().read();
let mut names: Vec<&String> = registry.keys().collect();
names.sort_by_key(|s| std::cmp::Reverse(s.len()));
for name in names {
if key == name.as_str() {
return Some((name.clone(), String::new()));
}
let dotted = format!("{name}.");
if key.starts_with(&dotted) {
return Some((name.clone(), key[dotted.len()..].to_string()));
}
}
None
}
pub fn set(&mut self, key: &str, value: serde_json::Value) {
if self.set_typed_field(key, &value) {
return;
}
let parts: Vec<&str> = key.split('.').collect();
if parts.is_empty() {
return;
}
if parts.len() == 1 {
self.user_namespaces.insert(key.to_string(), value);
return;
}
let root = self
.user_namespaces
.entry(parts[0].to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let mut current = root;
for part in &parts[1..parts.len() - 1] {
if !current.is_object() {
*current = serde_json::Value::Object(serde_json::Map::new());
}
current = current
.as_object_mut()
.unwrap()
.entry(part.to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
}
if !current.is_object() {
*current = serde_json::Value::Object(serde_json::Map::new());
}
current
.as_object_mut()
.unwrap()
.insert(parts[parts.len() - 1].to_string(), value);
}
pub fn reload(&mut self) -> Result<(), ModuleError> {
let path = self.yaml_path.clone().ok_or_else(|| {
ModuleError::new(
ErrorCode::ReloadFailed,
"Cannot reload: no yaml_path stored (config was not loaded from a file)",
)
})?;
let reloaded = Self::load(&path)?;
let yaml_path = self.yaml_path.take();
*self = reloaded;
self.yaml_path = yaml_path;
Ok(())
}
pub fn reload_from_disk(&mut self) -> Result<(), ModuleError> {
self.reload()
}
#[must_use]
pub fn data(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
}
pub fn register_namespace(mut reg: NamespaceRegistration) -> Result<(), ModuleError> {
if RESERVED_NAMESPACES.contains(®.name.as_str()) {
return Err(ModuleError::config_namespace_reserved(®.name));
}
if reg.env_prefix.is_none() {
reg.env_prefix = Some(reg.name.to_uppercase().replace('-', "_"));
}
let mut map = global_ns_registry().write();
if map.contains_key(®.name) {
return Err(ModuleError::config_namespace_duplicate(®.name));
}
let prefix = reg.env_prefix.as_deref().unwrap_or("");
for existing in map.values() {
if existing.env_prefix.as_deref() == Some(prefix) {
return Err(ModuleError::config_env_prefix_conflict(prefix));
}
}
if let Some(ref em) = reg.env_map {
let claimed = env_map_claimed().read();
for env_var in em.keys() {
if let Some(owner) = claimed.get(env_var) {
return Err(ModuleError::config_env_map_conflict(env_var, owner));
}
}
drop(claimed);
let mut claimed = env_map_claimed().write();
for env_var in em.keys() {
claimed.insert(env_var.clone(), reg.name.clone());
}
}
map.insert(reg.name.clone(), reg);
Ok(())
}
pub fn env_map(mapping: HashMap<String, String>) -> Result<(), ModuleError> {
let claimed_lock = env_map_claimed();
let claimed = claimed_lock.read();
for env_var in mapping.keys() {
if let Some(owner) = claimed.get(env_var) {
return Err(ModuleError::config_env_map_conflict(env_var, owner));
}
}
drop(claimed);
let mut claimed = claimed_lock.write();
let mut gmap = global_env_map().write();
for (env_var, config_key) in mapping {
claimed.insert(env_var.clone(), "__global__".to_string());
gmap.insert(env_var, config_key);
}
Ok(())
}
#[must_use]
pub fn registered_namespaces() -> Vec<NamespaceInfo> {
global_ns_registry()
.read()
.values()
.map(|r| NamespaceInfo {
name: r.name.clone(),
env_prefix: r.env_prefix.clone(),
has_schema: r.schema.is_some(),
})
.collect()
}
#[must_use]
pub fn namespace(&self, name: &str) -> Option<serde_json::Value> {
self.user_namespaces.get(name).cloned()
}
pub fn mount(&mut self, namespace: &str, source: MountSource) -> Result<(), ModuleError> {
if namespace == "_config" {
return Err(ModuleError::config_mount_error(
namespace,
"cannot mount to reserved namespace '_config'",
));
}
let data = match source {
MountSource::Dict(v) => v,
MountSource::File(path) => {
let content = std::fs::read_to_string(&path)
.map_err(|e| ModuleError::config_mount_error(namespace, &e.to_string()))?;
serde_yaml::from_str(&content)
.map_err(|e| ModuleError::config_mount_error(namespace, &e.to_string()))?
}
};
if !data.is_object() {
return Err(ModuleError::config_mount_error(
namespace,
"mount source must be a JSON object",
));
}
let entry = self
.user_namespaces
.entry(namespace.to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if let (Some(target), Some(source_map)) = (entry.as_object_mut(), data.as_object()) {
for (k, v) in source_map {
match target.get_mut(k) {
Some(existing) => {
deep_merge_value(existing, v);
}
None => {
target.insert(k.clone(), v.clone());
}
}
}
}
Ok(())
}
pub fn bind<T: DeserializeOwned>(&self, namespace: &str) -> Result<T, ModuleError> {
match namespace {
"executor" => {
return serde_json::from_value(
serde_json::to_value(&self.executor)
.map_err(|e| ModuleError::config_bind_error(namespace, &e.to_string()))?,
)
.map_err(|e| ModuleError::config_bind_error(namespace, &e.to_string()))
}
"observability" => {
return serde_json::from_value(
serde_json::to_value(&self.observability)
.map_err(|e| ModuleError::config_bind_error(namespace, &e.to_string()))?,
)
.map_err(|e| ModuleError::config_bind_error(namespace, &e.to_string()))
}
_ => {}
}
let owned;
let value: &serde_json::Value = if let Some(v) = self.user_namespaces.get(namespace) {
v
} else {
owned = serde_json::Value::Object(serde_json::Map::new());
&owned
};
serde_json::from_value(value.clone())
.map_err(|e| ModuleError::config_bind_error(namespace, &e.to_string()))
}
pub fn get_typed<T: DeserializeOwned>(&self, key: &str) -> Result<T, ModuleError> {
let value = self
.get(key)
.ok_or_else(|| ModuleError::config_bind_error(key, "key not found"))?;
serde_json::from_value(value)
.map_err(|e| ModuleError::config_bind_error(key, &e.to_string()))
}
fn detect_mode(&mut self) {
self.mode = match self.user_namespaces.get("apcore") {
Some(serde_json::Value::Object(_)) => ConfigMode::Namespace,
_ => ConfigMode::Legacy,
};
}
fn apply_env_overrides(&mut self) {
if self.mode == ConfigMode::Namespace {
self.apply_namespace_env_overrides();
return;
}
for (key, value) in std::env::vars() {
if let Some(suffix) = key.strip_prefix("APCORE_") {
let dot_path = Self::env_key_to_dot_path(suffix);
let parsed = Self::coerce_env_value(&value);
tracing::debug!(env = %key, path = %dot_path, "Applying legacy env override");
self.set(&dot_path, parsed);
}
}
}
fn apply_namespace_env_overrides(&mut self) {
let registry = global_ns_registry().read();
let gmap = global_env_map().read();
let mut ns_env_maps: HashMap<&str, (&str, &str)> = HashMap::new();
for reg in registry.values() {
if let Some(ref em) = reg.env_map {
for (env_var, config_key) in em {
ns_env_maps.insert(env_var.as_str(), (reg.name.as_str(), config_key.as_str()));
}
}
}
let mut prefixed: Vec<&NamespaceRegistration> = registry
.values()
.filter(|r| r.env_prefix.is_some())
.collect();
prefixed.sort_by(|a, b| {
b.env_prefix
.as_ref()
.map_or(0, std::string::String::len)
.cmp(&a.env_prefix.as_ref().map_or(0, std::string::String::len))
});
for (env_key, env_value) in std::env::vars() {
let parsed = Self::coerce_env_value(&env_value);
if let Some(config_key) = gmap.get(&env_key) {
self.set(config_key, parsed);
continue;
}
if let Some(&(ns_name, config_key)) = ns_env_maps.get(env_key.as_str()) {
let full_path = format!("{ns_name}.{config_key}");
self.set(&full_path, parsed);
continue;
}
let mut matched = false;
for reg in &prefixed {
let prefix = reg.env_prefix.as_deref().unwrap_or("");
if let Some(suffix) = env_key.strip_prefix(prefix) {
let suffix = suffix.strip_prefix('_').unwrap_or(suffix);
if suffix.is_empty() {
continue;
}
let key = Self::resolve_env_suffix(suffix, reg);
let full_path = format!("{}.{key}", reg.name);
tracing::debug!(env = %env_key, path = %full_path, "Applying namespace env override");
self.set(&full_path, parsed.clone());
matched = true;
break;
}
}
if !matched {
if let Some(suffix) = env_key.strip_prefix("APCORE_") {
let dot_path = Self::env_key_to_dot_path(suffix);
tracing::debug!(env = %env_key, path = %dot_path, "Applying fallback env override (no namespace match)");
self.set(&dot_path, parsed);
}
}
}
}
fn get_typed_field(&self, key: &str) -> Option<serde_json::Value> {
match key {
"executor.max_call_depth" => Some(serde_json::Value::Number(
self.executor.max_call_depth.into(),
)),
"executor.max_module_repeat" => Some(serde_json::Value::Number(
self.executor.max_module_repeat.into(),
)),
"executor.default_timeout" => Some(serde_json::Value::Number(
self.executor.default_timeout.into(),
)),
"executor.global_timeout" => Some(serde_json::Value::Number(
self.executor.global_timeout.into(),
)),
"observability.tracing.enabled" => {
Some(serde_json::Value::Bool(self.observability.tracing.enabled))
}
"observability.metrics.enabled" => {
Some(serde_json::Value::Bool(self.observability.metrics.enabled))
}
"modules_path" => self
.modules_path
.as_ref()
.map(|p| serde_json::Value::String(p.to_string_lossy().into_owned())),
_ => None,
}
}
fn set_typed_field(&mut self, key: &str, value: &serde_json::Value) -> bool {
match key {
"executor.max_call_depth" => {
if let Some(n) = value.as_u64() {
#[allow(clippy::cast_possible_truncation)]
{
self.executor.max_call_depth = n as u32;
}
return true;
}
}
"executor.max_module_repeat" => {
if let Some(n) = value.as_u64() {
#[allow(clippy::cast_possible_truncation)]
{
self.executor.max_module_repeat = n as u32;
}
return true;
}
}
"executor.default_timeout" => {
if let Some(n) = value.as_u64() {
self.executor.default_timeout = n;
return true;
}
}
"executor.global_timeout" => {
if let Some(n) = value.as_u64() {
self.executor.global_timeout = n;
return true;
}
}
"observability.tracing.enabled" => {
if let Some(b) = value.as_bool() {
self.observability.tracing.enabled = b;
return true;
}
}
"observability.metrics.enabled" => {
if let Some(b) = value.as_bool() {
self.observability.metrics.enabled = b;
return true;
}
}
"modules_path" => {
if let Some(s) = value.as_str() {
self.modules_path = Some(PathBuf::from(s));
return true;
}
}
_ => {}
}
false
}
fn env_key_to_dot_path(raw: &str) -> String {
Self::env_key_to_dot_path_with_depth(raw, usize::MAX)
}
fn env_key_to_dot_path_with_depth(raw: &str, max_depth: usize) -> String {
let lower = raw.to_lowercase();
let chars: Vec<char> = lower.chars().collect();
let mut result = String::with_capacity(chars.len());
let mut dot_count: usize = 0;
let mut i = 0;
while i < chars.len() {
if chars[i] == '_' {
if i + 1 < chars.len() && chars[i + 1] == '_' {
result.push('_'); i += 2;
} else if dot_count < max_depth.saturating_sub(1) {
result.push('.');
dot_count += 1;
i += 1;
} else {
result.push('_'); i += 1;
}
} else {
result.push(chars[i]);
i += 1;
}
}
result
}
fn match_suffix_to_tree(
suffix: &str,
tree: &serde_json::Map<String, serde_json::Value>,
depth: usize,
max_depth: usize,
) -> Option<String> {
if tree.contains_key(suffix) {
return Some(suffix.to_string());
}
if depth >= max_depth.saturating_sub(1) {
return None;
}
for (i, ch) in suffix.char_indices() {
if ch != '_' || i == 0 || i == suffix.len() - 1 {
continue;
}
let prefix_part = &suffix[..i];
let remainder = &suffix[i + 1..];
if let Some(serde_json::Value::Object(subtree)) = tree.get(prefix_part) {
if let Some(sub) =
Self::match_suffix_to_tree(remainder, subtree, depth + 1, max_depth)
{
return Some(format!("{prefix_part}.{sub}"));
}
}
}
None
}
fn resolve_env_suffix(suffix: &str, reg: &NamespaceRegistration) -> String {
match reg.env_style {
EnvStyle::Flat => suffix.to_lowercase(),
EnvStyle::Auto => {
let lower = suffix.to_lowercase();
if let Some(serde_json::Value::Object(tree)) = reg.defaults.as_ref() {
if let Some(resolved) =
Self::match_suffix_to_tree(&lower, tree, 0, reg.max_depth)
{
return resolved;
}
}
Self::env_key_to_dot_path_with_depth(suffix, reg.max_depth)
}
EnvStyle::Nested => Self::env_key_to_dot_path_with_depth(suffix, reg.max_depth),
}
}
fn coerce_env_value(value: &str) -> serde_json::Value {
if value.eq_ignore_ascii_case("true") {
return serde_json::Value::Bool(true);
}
if value.eq_ignore_ascii_case("false") {
return serde_json::Value::Bool(false);
}
if let Ok(n) = value.parse::<i64>() {
return serde_json::Value::Number(n.into());
}
if let Ok(f) = value.parse::<f64>() {
if let Some(n) = serde_json::Number::from_f64(f) {
return serde_json::Value::Number(n);
}
}
serde_json::Value::String(value.to_string())
}
}
fn init_builtin_namespaces() {
static INIT: OnceLock<()> = OnceLock::new();
INIT.get_or_init(|| {
let namespaces = vec![
NamespaceRegistration {
name: "observability".to_string(),
env_prefix: Some("APCORE_OBSERVABILITY".to_string()),
defaults: Some(serde_json::json!({
"tracing": {
"enabled": false,
"sampling_rate": 1.0,
"strategy": "full",
"exporter": "stdout",
"otlp_endpoint": "http://localhost:4318"
},
"metrics": {
"enabled": false,
"exporter": "in_memory"
},
"logging": {
"level": "info",
"format": "json",
"redact_keys": ["password", "secret", "token", "api_key"]
},
"error_history": {
"max_entries_per_module": 50,
"max_total_entries": 1000
},
"platform_notify": {
"error_rate_threshold": 0.1,
"latency_p99_threshold_ms": 5000.0
}
})),
schema: None,
env_style: EnvStyle::Auto,
max_depth: DEFAULT_MAX_DEPTH,
env_map: None,
},
NamespaceRegistration {
name: "sys_modules".to_string(),
env_prefix: Some("APCORE_SYS".to_string()),
defaults: Some(serde_json::json!({
"enabled": true,
"health": { "enabled": true },
"manifest": { "enabled": true },
"usage": { "enabled": true, "retention_hours": 168, "bucketing_strategy": "hourly" },
"control": { "enabled": true },
"events": {
"enabled": false,
"subscribers": [],
"thresholds": { "error_rate": 0.1, "latency_p99_ms": 5000.0 }
}
})),
schema: None,
env_style: EnvStyle::Auto,
max_depth: DEFAULT_MAX_DEPTH,
env_map: None,
},
];
for ns in namespaces {
let _ = Config::register_namespace(ns);
}
});
}
fn discover_config_file() -> Option<std::path::PathBuf> {
if let Ok(env_path) = std::env::var("APCORE_CONFIG_FILE") {
if !env_path.is_empty() {
return Some(std::path::PathBuf::from(env_path));
}
}
let cwd_candidates = ["project.yaml", "project.yml", "apcore.yaml", "apcore.yml"];
for name in &cwd_candidates {
let p = std::path::Path::new(name);
if p.exists() {
return Some(p.to_path_buf());
}
}
if let Some(home) = dirs_home() {
#[cfg(target_os = "macos")]
let xdg = home
.join("Library")
.join("Application Support")
.join("apcore")
.join("config.yaml");
#[cfg(not(target_os = "macos"))]
let xdg = home.join(".config").join("apcore").join("config.yaml");
if xdg.exists() {
return Some(xdg);
}
let legacy = home.join(".apcore").join("config.yaml");
if legacy.exists() {
return Some(legacy);
}
}
None
}
fn dirs_home() -> Option<std::path::PathBuf> {
std::env::var("HOME").ok().map(std::path::PathBuf::from)
}
fn deep_merge_value(base: &mut serde_json::Value, overlay: &serde_json::Value) {
match (base, overlay) {
(serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
for (k, v) in overlay_map {
match base_map.get_mut(k) {
Some(existing) => deep_merge_value(existing, v),
None => {
base_map.insert(k.clone(), v.clone());
}
}
}
}
(slot, value) => {
*slot = value.clone();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_has_expected_executor_values() {
let cfg = Config::default();
assert_eq!(cfg.executor.max_call_depth, 32);
assert_eq!(cfg.executor.max_module_repeat, 3);
assert_eq!(cfg.executor.default_timeout, 30_000);
assert_eq!(cfg.executor.global_timeout, 60_000);
}
#[test]
fn default_config_validates_successfully() {
let cfg = Config::default();
assert!(cfg.validate().is_ok());
}
#[test]
fn get_canonical_executor_key() {
let cfg = Config::default();
let depth = cfg
.get("executor.max_call_depth")
.expect("key should exist");
assert_eq!(depth, serde_json::json!(32u64));
}
#[test]
fn set_then_get_canonical_executor_key() {
let mut cfg = Config::default();
cfg.set("executor.max_call_depth", serde_json::json!(10u64));
let val = cfg.get("executor.max_call_depth").unwrap();
assert_eq!(val.as_u64().unwrap(), 10);
}
#[test]
fn get_observability_tracing_enabled() {
let cfg = Config::default();
let enabled = cfg.get("observability.tracing.enabled").unwrap();
assert_eq!(enabled, serde_json::json!(false));
}
#[test]
fn set_observability_tracing_enabled() {
let mut cfg = Config::default();
cfg.set("observability.tracing.enabled", serde_json::json!(true));
assert!(cfg.observability.tracing.enabled);
}
#[test]
fn set_and_get_user_namespace_key() {
let mut cfg = Config::default();
cfg.set(
"myapp.db.url",
serde_json::json!("postgres://localhost/test"),
);
let val = cfg.get("myapp.db.url").expect("should exist");
assert_eq!(val.as_str().unwrap(), "postgres://localhost/test");
}
#[test]
fn get_returns_none_for_missing_key() {
let cfg = Config::default();
assert!(cfg.get("nonexistent.key").is_none());
}
#[test]
fn set_top_level_user_namespace_key() {
let mut cfg = Config::default();
cfg.set("myns", serde_json::json!("value"));
assert_eq!(cfg.get("myns").unwrap(), serde_json::json!("value"));
}
#[test]
fn validate_rejects_zero_max_call_depth() {
let mut cfg = Config::default();
cfg.executor.max_call_depth = 0;
assert!(cfg.validate().is_err());
}
#[test]
fn validate_rejects_zero_max_module_repeat() {
let mut cfg = Config::default();
cfg.executor.max_module_repeat = 0;
assert!(cfg.validate().is_err());
}
#[test]
fn validate_rejects_global_timeout_less_than_default_timeout() {
let mut cfg = Config::default();
cfg.executor.global_timeout = 1_000; cfg.executor.default_timeout = 5_000;
assert!(cfg.validate().is_err());
}
#[test]
fn validate_allows_zero_global_timeout_meaning_no_deadline() {
let mut cfg = Config::default();
cfg.executor.global_timeout = 0; assert!(cfg.validate().is_ok());
}
#[test]
fn deserialize_rejects_legacy_root_fields() {
let json_str = r#"{"max_call_depth": 10}"#;
let result: Result<Config, _> = serde_json::from_str(json_str);
assert!(result.is_err(), "legacy root field should be rejected");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("v0.18.0") || err_msg.contains("max_call_depth"),
"error should mention legacy key"
);
}
#[test]
fn deserialize_canonical_format_succeeds() {
let json_str = r#"{"executor": {"max_call_depth": 16}}"#;
let cfg: Config = serde_json::from_str(json_str).expect("canonical format should work");
assert_eq!(cfg.executor.max_call_depth, 16);
}
#[test]
fn data_returns_json_object() {
let cfg = Config::default();
let data = cfg.data();
assert!(data.is_object(), "data() should return a JSON object");
assert!(data.get("executor").is_some());
}
#[test]
fn reload_without_path_returns_error() {
let mut cfg = Config::default();
assert!(
cfg.reload().is_err(),
"reload without yaml_path should fail"
);
}
#[test]
fn mount_dict_into_user_namespace() {
let mut cfg = Config::default();
let data = serde_json::json!({"host": "localhost", "port": 5432});
cfg.mount("database", MountSource::Dict(data)).unwrap();
let host = cfg.get("database.host").unwrap();
assert_eq!(host.as_str().unwrap(), "localhost");
}
#[test]
fn mount_rejects_reserved_namespace() {
let mut cfg = Config::default();
let data = serde_json::json!({"key": "value"});
let result = cfg.mount("_config", MountSource::Dict(data));
assert!(
result.is_err(),
"should reject reserved namespace '_config'"
);
}
#[test]
fn mount_rejects_non_object_source() {
let mut cfg = Config::default();
let result = cfg.mount("ns", MountSource::Dict(serde_json::json!([1, 2, 3])));
assert!(result.is_err(), "non-object source should be rejected");
}
#[test]
fn namespace_mode_detected_when_apcore_key_present() {
let json_str = r#"{"apcore": {"executor": {"max_call_depth": 8}}}"#;
let cfg: Config = serde_json::from_str(json_str).expect("should parse");
assert_eq!(cfg.executor.max_call_depth, 8);
}
}