use std::path::{Path, PathBuf};
use apcore::{Config as CoreConfig, ModuleError};
use serde::{Deserialize, Serialize};
use tracing::warn;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ApexeConfig {
pub modules_dir: PathBuf,
pub cache_dir: PathBuf,
pub config_dir: PathBuf,
pub audit_log: PathBuf,
pub log_level: String,
pub default_timeout: u64,
pub scan_depth: u32,
pub json_output_preference: bool,
pub additional_denied_paths: Vec<PathBuf>,
pub allowed_paths: Vec<PathBuf>,
#[serde(skip)]
pub core_config: Option<CoreConfig>,
}
impl Default for ApexeConfig {
fn default() -> Self {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let apexe_dir = home.join(".apexe");
Self {
modules_dir: apexe_dir.join("modules"),
cache_dir: apexe_dir.join("cache"),
config_dir: apexe_dir.clone(),
audit_log: apexe_dir.join("audit.jsonl"),
log_level: "info".to_string(),
default_timeout: 30,
scan_depth: 2,
json_output_preference: true,
additional_denied_paths: Vec::new(),
allowed_paths: Vec::new(),
core_config: None,
}
}
}
impl ApexeConfig {
pub fn core_config(&self) -> CoreConfig {
self.core_config.clone().unwrap_or_default()
}
pub fn with_timeout_override(mut self, timeout: Option<u64>) -> Self {
if let Some(seconds) = timeout {
self.default_timeout = seconds;
}
self
}
#[allow(clippy::result_large_err)] pub fn ensure_dirs(&self) -> Result<(), ModuleError> {
for dir in [&self.modules_dir, &self.cache_dir, &self.config_dir] {
std::fs::create_dir_all(dir).map_err(|e| {
let context = std::io::Error::new(
e.kind(),
format!("failed to create {}: {e}", dir.display()),
);
ModuleError::from(crate::errors::ApexeError::Io(context))
})?;
}
Ok(())
}
}
pub fn load_config(config_path: Option<&Path>) -> anyhow::Result<ApexeConfig> {
let mut config = ApexeConfig::default();
let file_path = config_path
.map(PathBuf::from)
.unwrap_or_else(|| config.config_dir.join("config.yaml"));
apply_file_config(&mut config, &file_path)?;
apply_env_overrides(&mut config);
load_core_config(&mut config);
Ok(config)
}
const PATH_GUARD_KEYS: &[&str] = &["additional_denied_paths", "allowed_paths"];
fn known_config_keys() -> Vec<String> {
match serde_yaml::to_value(ApexeConfig::default()) {
Ok(serde_yaml::Value::Mapping(map)) => map
.keys()
.filter_map(|k| k.as_str().map(str::to_string))
.collect(),
_ => Vec::new(),
}
}
fn normalize_config_key(key: &str) -> String {
key.trim()
.to_ascii_lowercase()
.chars()
.filter(|c| *c != '_' && *c != '-')
.collect()
}
fn suggest_config_key(unknown: &str, known: &[String]) -> Option<String> {
let needle = normalize_config_key(unknown);
if needle.is_empty() {
return None;
}
known.iter().find_map(|candidate| {
let normalized = normalize_config_key(candidate);
(normalized == needle || normalized.starts_with(&needle) || needle.starts_with(&normalized))
.then(|| candidate.clone())
})
}
fn unrecognized_config_keys(contents: &str) -> Vec<(String, Option<String>)> {
let Ok(serde_yaml::Value::Mapping(map)) = serde_yaml::from_str::<serde_yaml::Value>(contents)
else {
return Vec::new();
};
let known = known_config_keys();
map.keys()
.filter_map(|k| k.as_str())
.filter(|k| !known.iter().any(|known_key| known_key == k))
.map(|k| (k.to_string(), suggest_config_key(k, &known)))
.collect()
}
fn apply_file_config(config: &mut ApexeConfig, file_path: &Path) -> anyhow::Result<()> {
if !file_path.exists() {
return Ok(());
}
let contents = std::fs::read_to_string(file_path)?;
for (key, suggestion) in unrecognized_config_keys(&contents) {
let consequence = match suggestion.as_deref() {
Some(known) if PATH_GUARD_KEYS.contains(&known) => {
" That path-guard setting is NOT in force."
}
_ => "",
};
match suggestion {
Some(known) => warn!(
path = %file_path.display(),
"Ignoring unrecognised config key '{key}' -- did you mean \
'{known}'? It has no effect.{consequence}"
),
None => warn!(
path = %file_path.display(),
"Ignoring unrecognised config key '{key}'. It has no effect."
),
}
}
match serde_yaml::from_str::<ApexeConfig>(&contents) {
Ok(file_config) => *config = file_config,
Err(e) => warn!(
path = %file_path.display(),
"Malformed config file, using defaults: {e}"
),
}
Ok(())
}
fn apply_env_overrides(config: &mut ApexeConfig) {
if let Ok(val) = std::env::var("APEXE_MODULES_DIR") {
config.modules_dir = PathBuf::from(val);
}
if let Ok(val) = std::env::var("APEXE_CACHE_DIR") {
config.cache_dir = PathBuf::from(val);
}
if let Ok(val) = std::env::var("APEXE_LOG_LEVEL") {
config.log_level = val;
}
if let Ok(val) = std::env::var("APEXE_TIMEOUT") {
match val.parse::<u64>() {
Ok(t) => config.default_timeout = t,
Err(_) => warn!("Invalid APEXE_TIMEOUT value: {val}, using default"),
}
}
if let Ok(val) = std::env::var("APEXE_SCAN_DEPTH") {
match val.parse::<u32>() {
Ok(d) if (1..=5).contains(&d) => config.scan_depth = d,
_ => warn!("Invalid APEXE_SCAN_DEPTH value, using default"),
}
}
}
fn load_core_config(config: &mut ApexeConfig) {
let core_config_path = config.config_dir.join("apcore.yaml");
if !core_config_path.exists() {
return;
}
match CoreConfig::load(&core_config_path) {
Ok(cc) => config.core_config = Some(cc),
Err(e) => warn!(
path = %core_config_path.display(),
"Failed to load apcore config: {e}"
),
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_known_config_keys_match_the_struct_fields() {
let keys = known_config_keys();
for expected in [
"modules_dir",
"cache_dir",
"config_dir",
"audit_log",
"log_level",
"default_timeout",
"scan_depth",
"json_output_preference",
"additional_denied_paths",
"allowed_paths",
] {
assert!(keys.iter().any(|k| k == expected), "missing {expected}");
}
assert!(
!keys.iter().any(|k| k == "core_config"),
"`core_config` is #[serde(skip)] and is not a config key"
);
}
#[test]
fn test_a_misspelled_denied_paths_key_is_reported_not_dropped_silently() {
let found = unrecognized_config_keys(
"log_level: info\n\
additional_denied_path:\n\
\x20 - /srv/production-data\n",
);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].0, "additional_denied_path");
assert_eq!(
found[0].1.as_deref(),
Some("additional_denied_paths"),
"the singular form must name the plural it was meant to be"
);
}
#[test]
fn test_a_separator_variant_is_matched_to_its_real_key() {
let found = unrecognized_config_keys("allowed-paths:\n\x20 - /etc/nginx\n");
assert_eq!(found.len(), 1);
assert_eq!(found[0].1.as_deref(), Some("allowed_paths"));
}
#[test]
fn test_path_guard_keys_are_the_two_that_widen_permission() {
for key in PATH_GUARD_KEYS {
assert!(
known_config_keys().iter().any(|k| k == key),
"{key} must be a real config key"
);
}
assert_eq!(PATH_GUARD_KEYS.len(), 2);
}
#[test]
fn test_an_unrelated_key_is_reported_without_a_suggestion() {
let found = unrecognized_config_keys("telemetry_endpoint: https://example.test\n");
assert_eq!(found.len(), 1);
assert_eq!(found[0].0, "telemetry_endpoint");
assert_eq!(found[0].1, None);
}
#[test]
fn test_a_fully_valid_config_reports_nothing() {
let found = unrecognized_config_keys(
"log_level: debug\n\
default_timeout: 30\n\
allowed_paths:\n\
\x20 - /etc/nginx\n",
);
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn test_a_non_mapping_document_reports_no_keys() {
assert!(unrecognized_config_keys("- just\n- a\n- list\n").is_empty());
assert!(unrecognized_config_keys("42\n").is_empty());
}
#[test]
fn test_suggestion_does_not_invent_a_match_between_unrelated_keys() {
let known = known_config_keys();
assert_eq!(suggest_config_key("zzz_nothing_like_it", &known), None);
}
#[test]
fn test_path_guard_lists_round_trip_through_config_yaml() {
let configured: ApexeConfig = serde_yaml::from_str(
"log_level: info\n\
additional_denied_paths:\n\
\x20 - /srv/production-data\n\
allowed_paths:\n\
\x20 - /etc/nginx/conf.d\n",
)
.expect("config with both path lists must parse");
assert_eq!(
configured.additional_denied_paths,
vec![PathBuf::from("/srv/production-data")]
);
assert_eq!(
configured.allowed_paths,
vec![PathBuf::from("/etc/nginx/conf.d")]
);
assert_eq!(configured.log_level, "info");
assert_eq!(configured.scan_depth, ApexeConfig::default().scan_depth);
let silent: ApexeConfig =
serde_yaml::from_str("log_level: debug\n").expect("config without them must parse");
assert!(silent.additional_denied_paths.is_empty());
assert!(
silent.allowed_paths.is_empty(),
"carve-outs must be empty unless an operator writes them"
);
}
use super::*;
use std::sync::Mutex;
use tempfile::TempDir;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn test_default_modules_dir_ends_with_apexe_modules() {
let config = ApexeConfig::default();
assert!(
config.modules_dir.ends_with(".apexe/modules"),
"modules_dir should end with .apexe/modules, got: {:?}",
config.modules_dir
);
}
#[test]
fn test_default_log_level_is_info() {
let config = ApexeConfig::default();
assert_eq!(config.log_level, "info");
}
#[test]
fn test_default_timeout_is_30() {
let config = ApexeConfig::default();
assert_eq!(config.default_timeout, 30);
}
#[test]
fn test_default_scan_depth_is_2() {
let config = ApexeConfig::default();
assert_eq!(config.scan_depth, 2);
}
#[test]
fn test_default_json_output_preference_is_true() {
let config = ApexeConfig::default();
assert!(config.json_output_preference);
}
#[test]
fn test_load_config_no_file_returns_defaults() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("nonexistent.yaml");
let config = load_config(Some(config_path.as_path())).unwrap();
assert_eq!(config.log_level, "info");
assert_eq!(config.default_timeout, 30);
}
#[test]
fn test_load_config_valid_yaml() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.yaml");
let default = ApexeConfig {
modules_dir: tmp.path().join("my_modules"),
cache_dir: tmp.path().join("my_cache"),
config_dir: tmp.path().to_path_buf(),
audit_log: tmp.path().join("audit.jsonl"),
log_level: "debug".to_string(),
default_timeout: 60,
scan_depth: 3,
json_output_preference: false,
..ApexeConfig::default()
};
let yaml = serde_yaml::to_string(&default).unwrap();
std::fs::write(&config_path, &yaml).unwrap();
let config = load_config(Some(config_path.as_path())).unwrap();
assert_eq!(config.log_level, "debug");
assert_eq!(config.default_timeout, 60);
assert_eq!(config.scan_depth, 3);
assert!(!config.json_output_preference);
}
#[test]
fn test_load_config_partial_yaml_merges_over_defaults() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.yaml");
std::fs::write(&config_path, "default_timeout: 99\n").unwrap();
let config = load_config(Some(config_path.as_path())).unwrap();
assert_eq!(
config.default_timeout, 99,
"the field set in the partial config.yaml must take effect"
);
assert!(
config.modules_dir.ends_with(".apexe/modules"),
"a field omitted from the partial config.yaml must keep its default, got: {:?}",
config.modules_dir
);
}
#[test]
fn test_load_config_malformed_yaml_returns_defaults() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.yaml");
std::fs::write(&config_path, "this is not: [valid: yaml: config").unwrap();
let config = load_config(Some(config_path.as_path())).unwrap();
assert_eq!(config.log_level, "info");
assert_eq!(config.default_timeout, 30);
}
#[test]
fn test_env_var_override_modules_dir() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("nonexistent.yaml");
let unique_dir = "/tmp/apexe_test_modules_dir_unique";
unsafe { std::env::set_var("APEXE_MODULES_DIR", unique_dir) };
let config = load_config(Some(config_path.as_path())).unwrap();
unsafe { std::env::remove_var("APEXE_MODULES_DIR") };
assert_eq!(config.modules_dir, PathBuf::from(unique_dir));
}
#[test]
fn test_env_var_override_cache_dir() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("nonexistent.yaml");
let unique_dir = "/tmp/apexe_test_cache_dir_unique";
unsafe { std::env::set_var("APEXE_CACHE_DIR", unique_dir) };
let config = load_config(Some(config_path.as_path())).unwrap();
unsafe { std::env::remove_var("APEXE_CACHE_DIR") };
assert_eq!(config.cache_dir, PathBuf::from(unique_dir));
}
#[test]
fn test_env_var_override_log_level() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("nonexistent.yaml");
unsafe { std::env::set_var("APEXE_LOG_LEVEL", "trace") };
let config = load_config(Some(config_path.as_path())).unwrap();
unsafe { std::env::remove_var("APEXE_LOG_LEVEL") };
assert_eq!(config.log_level, "trace");
}
#[test]
fn test_env_var_override_timeout() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("nonexistent.yaml");
unsafe { std::env::set_var("APEXE_TIMEOUT", "120") };
let config = load_config(Some(config_path.as_path())).unwrap();
unsafe { std::env::remove_var("APEXE_TIMEOUT") };
assert_eq!(config.default_timeout, 120);
}
#[test]
fn test_env_var_invalid_timeout_falls_back() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("nonexistent.yaml");
unsafe { std::env::set_var("APEXE_TIMEOUT", "not_a_number") };
let config = load_config(Some(config_path.as_path())).unwrap();
unsafe { std::env::remove_var("APEXE_TIMEOUT") };
assert_eq!(config.default_timeout, 30);
}
#[test]
fn test_ensure_dirs_creates_directories() {
let tmp = TempDir::new().unwrap();
let config = ApexeConfig {
modules_dir: tmp.path().join("m"),
cache_dir: tmp.path().join("c"),
config_dir: tmp.path().join("cfg"),
..ApexeConfig::default()
};
assert!(!config.modules_dir.exists());
assert!(!config.cache_dir.exists());
assert!(!config.config_dir.exists());
config.ensure_dirs().unwrap();
assert!(config.modules_dir.exists());
assert!(config.cache_dir.exists());
assert!(config.config_dir.exists());
}
#[test]
fn test_env_var_scan_depth_override() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("nonexistent.yaml");
unsafe { std::env::set_var("APEXE_SCAN_DEPTH", "3") };
let config = load_config(Some(config_path.as_path())).unwrap();
unsafe { std::env::remove_var("APEXE_SCAN_DEPTH") };
assert_eq!(config.scan_depth, 3);
}
#[test]
fn test_env_var_scan_depth_invalid_range() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("nonexistent.yaml");
unsafe { std::env::set_var("APEXE_SCAN_DEPTH", "10") };
let config = load_config(Some(config_path.as_path())).unwrap();
unsafe { std::env::remove_var("APEXE_SCAN_DEPTH") };
assert_eq!(config.scan_depth, 2); }
#[test]
fn test_core_config_none_when_file_missing() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("nonexistent.yaml");
let config = load_config(Some(config_path.as_path())).unwrap();
assert!(config.core_config.is_none());
}
#[test]
fn test_core_config_accessor_returns_default() {
let config = ApexeConfig::default();
let core = config.core_config();
assert!(core.executor.max_call_depth > 0);
}
#[test]
fn test_ensure_dirs_reports_a_domain_error_not_a_bare_io_error() {
let tmp = tempfile::TempDir::new().unwrap();
let blocker = tmp.path().join("not-a-dir");
std::fs::write(&blocker, b"x").unwrap();
let config = ApexeConfig {
modules_dir: blocker.join("modules"),
cache_dir: tmp.path().join("cache"),
config_dir: tmp.path().join("config"),
..ApexeConfig::default()
};
let err = config
.ensure_dirs()
.expect_err("a file where a directory must go cannot be created");
assert_eq!(err.code, apcore::ErrorCode::GeneralInternalError);
assert!(
err.message.contains("not-a-dir"),
"the message must name the path that could not be created: {}",
err.message
);
}
#[test]
fn test_ensure_dirs_idempotent() {
let tmp = TempDir::new().unwrap();
let config = ApexeConfig {
modules_dir: tmp.path().join("m"),
cache_dir: tmp.path().join("c"),
config_dir: tmp.path().join("cfg"),
..ApexeConfig::default()
};
config.ensure_dirs().unwrap();
config.ensure_dirs().unwrap();
assert!(config.modules_dir.exists());
assert!(config.cache_dir.exists());
assert!(config.config_dir.exists());
}
}