1use 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#[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
23pub enum MountSource {
25 Dict(serde_json::Value),
26 File(PathBuf),
27}
28
29pub const DEFAULT_MAX_DEPTH: usize = 5;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum EnvStyle {
35 Nested,
37 Flat,
39 #[default]
41 Auto,
42}
43
44#[derive(Debug, Clone)]
46pub struct NamespaceRegistration {
47 pub name: String,
48 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 pub env_map: Option<HashMap<String, String>>,
56}
57
58#[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();
68static GLOBAL_ENV_MAP: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
70static 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#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(default)]
92pub struct ExecutorConfig {
93 pub default_timeout: u64,
95 pub global_timeout: u64,
97 pub max_call_depth: u32,
99 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#[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#[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 #[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
173const 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#[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 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 if let Some(apcore_val) = raw.get("apcore") {
224 if let Some(apcore_obj) = apcore_val.as_object() {
225 mode = ConfigMode::Namespace;
226 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 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 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 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 Self::from_yaml_file(path)
302 }
303 }
304 }
305
306 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 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 #[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 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 #[must_use]
363 pub fn get(&self, key: &str) -> Option<serde_json::Value> {
364 if let Some(val) = self.get_typed_field(key) {
366 return Some(val);
367 }
368
369 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 pub fn set(&mut self, key: &str, value: serde_json::Value) {
390 if self.set_typed_field(key, &value) {
392 return;
393 }
394
395 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 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 current
425 .as_object_mut()
426 .unwrap()
427 .insert(parts[parts.len() - 1].to_string(), value);
428 }
429
430 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 let yaml_path = self.yaml_path.take();
441 *self = reloaded;
442 self.yaml_path = yaml_path;
443 Ok(())
444 }
445
446 #[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 pub fn register_namespace(mut reg: NamespaceRegistration) -> Result<(), ModuleError> {
456 if RESERVED_NAMESPACES.contains(®.name.as_str()) {
457 return Err(ModuleError::config_namespace_reserved(®.name));
458 }
459 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(®.name) {
465 return Err(ModuleError::config_namespace_duplicate(®.name));
466 }
467 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 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 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 #[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 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 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 fn detect_mode(&mut self) {
606 self.mode = match self.user_namespaces.get("apcore") {
609 Some(serde_json::Value::Object(_)) => ConfigMode::Namespace,
610 _ => ConfigMode::Legacy,
611 };
612 }
613
614 fn apply_env_overrides(&mut self) {
620 if self.mode == ConfigMode::Namespace {
621 self.apply_namespace_env_overrides();
622 return;
623 }
624 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 fn apply_namespace_env_overrides(&mut self) {
637 let registry = global_ns_registry().read();
638 let gmap = global_env_map().read();
639
640 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 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 if let Some(config_key) = gmap.get(&env_key) {
667 self.set(config_key, parsed);
668 continue;
669 }
670
671 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 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 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 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 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 {
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 {
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 fn env_key_to_dot_path(raw: &str) -> String {
808 Self::env_key_to_dot_path_with_depth(raw, usize::MAX)
809 }
810
811 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('_'); 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('_'); i += 1;
830 }
831 } else {
832 result.push(chars[i]);
833 i += 1;
834 }
835 }
836 result
837 }
838
839 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 if tree.contains_key(suffix) {
848 return Some(suffix.to_string());
849 }
850 if depth >= max_depth.saturating_sub(1) {
852 return None;
853 }
854 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 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 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
911fn 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 let _ = Config::register_namespace(ns);
977 }
978 });
979}
980
981fn 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 #[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 #[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 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 #[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 #[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; 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; assert!(cfg.validate().is_ok());
1145 }
1146
1147 #[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 #[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 #[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 #[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 #[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 assert_eq!(cfg.executor.max_call_depth, 8);
1239 }
1240}