mod dump;
use anyhow::{Context, Result, ensure};
pub use modkit_db::{DbConnConfig, GlobalDatabaseConfig, PoolCfg};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::Level;
use crate::ConfigProvider;
use crate::telemetry::OpenTelemetryConfig;
use url::Url;
fn normalize_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
#[derive(thiserror::Error, Debug)]
pub enum VendorConfigError {
#[error("vendor '{vendor}' not found in configuration")]
NotFound { vendor: String },
#[error("invalid config for vendor '{vendor}': {source}")]
InvalidConfig {
vendor: String,
#[source]
source: serde_json::Error,
},
}
pub use dump::{
dump_effective_modules_config_json, dump_effective_modules_config_yaml, list_module_names,
redact_dsn_password, render_effective_modules_config,
};
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ModuleConfig {
#[serde(default)]
pub database: Option<DbConnConfig>,
#[serde(default)]
pub config: serde_json::Value,
#[serde(default)]
pub runtime: Option<ModuleRuntime>,
#[serde(default)] pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ModuleRuntime {
#[serde(default, rename = "type")]
pub mod_type: RuntimeKind,
#[serde(default)]
pub execution: Option<ExecutionConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ExecutionConfig {
pub executable_path: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub working_directory: Option<String>,
#[serde(default)]
pub environment: HashMap<String, String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum RuntimeKind {
#[default]
Local,
Oop,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AppConfig {
pub server: ServerConfig,
pub database: Option<GlobalDatabaseConfig>,
#[serde(default = "default_logging_config")]
pub logging: LoggingConfig,
#[serde(default)]
pub opentelemetry: OpenTelemetryConfig,
#[serde(default)]
pub modules_dir: Option<String>,
#[serde(default)]
pub modules: HashMap<String, serde_json::Value>,
#[serde(default)]
pub vendor: VendorConfig,
}
impl Default for AppConfig {
fn default() -> Self {
let server = ServerConfig::default();
Self {
server,
database: None,
logging: default_logging_config(),
opentelemetry: OpenTelemetryConfig::default(),
modules_dir: None,
modules: HashMap::new(),
vendor: VendorConfig::new(),
}
}
}
impl ConfigProvider for AppConfig {
fn get_module_config(&self, module_name: &str) -> Option<&serde_json::Value> {
self.modules.get(module_name)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ServerConfig {
#[serde(default = "default_server_name")]
pub name: String,
#[serde(default = "default_home_dir")]
pub home_dir: PathBuf, }
fn default_server_name() -> String {
"cyberfabric".to_owned()
}
fn default_home_dir() -> PathBuf {
super::host::paths::default_home_dir().join(".cyberfabric")
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
name: default_server_name(),
home_dir: default_home_dir(),
}
}
}
impl ServerConfig {
fn normalize_home_dir_inplace(&mut self) -> Result<()> {
self.home_dir = super::host::normalize_path(
self.home_dir
.to_str()
.context("home directory configuration is not a valid path")?,
)
.context("home_dir normalization failed")?;
std::fs::create_dir_all(&self.home_dir).context("Failed to create home_dir")?;
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ConsoleFormat {
#[default]
Text,
Json,
}
pub type LoggingConfig = HashMap<String, Section>;
pub type VendorConfig = HashMap<String, serde_json::Value>;
mod optional_level_serde {
use serde::{Deserialize, Deserializer, Serializer};
use tracing::Level;
#[allow(clippy::ref_option, clippy::trivially_copy_pass_by_ref)]
pub fn serialize<S>(level: &Option<Level>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match level {
Some(l) => serializer.serialize_str(l.as_str()),
None => serializer.serialize_str("off"),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Level>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.to_lowercase().as_str() {
"trace" => Ok(Some(Level::TRACE)),
"debug" => Ok(Some(Level::DEBUG)),
"info" => Ok(Some(Level::INFO)),
"warn" => Ok(Some(Level::WARN)),
"error" => Ok(Some(Level::ERROR)),
"off" | "none" => Ok(None),
_ => Err(serde::de::Error::custom(format!("invalid level: {s}"))),
}
}
#[allow(clippy::unnecessary_wraps)]
pub fn default() -> Option<Level> {
Some(Level::INFO)
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SectionFile {
pub file: String,
#[serde(
default = "optional_level_serde::default",
with = "optional_level_serde"
)]
pub file_level: Option<Level>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Section {
#[serde(default)]
pub console_format: ConsoleFormat,
#[serde(
default = "optional_level_serde::default",
with = "optional_level_serde"
)]
pub console_level: Option<Level>,
#[serde(flatten)]
pub section_file: Option<SectionFile>,
pub max_age_days: Option<u32>, #[serde(default)]
pub max_backups: Option<usize>, #[serde(default)]
pub max_size_mb: Option<u64>, }
impl Section {
#[must_use]
pub fn file(&self) -> Option<&str> {
self.section_file
.as_ref()
.map(|f| f.file.as_str())
.filter(|s| !s.is_empty())
}
#[must_use]
pub fn file_level(&self) -> Option<Level> {
self.section_file.as_ref().and_then(|f| f.file_level)
}
}
#[must_use]
pub fn default_logging_config() -> LoggingConfig {
let mut logging = HashMap::new();
logging.insert(
"default".to_owned(),
Section {
console_level: Some(Level::INFO),
section_file: Some(SectionFile {
file: "logs/cyberfabric.log".to_owned(),
file_level: Some(Level::DEBUG),
}),
console_format: ConsoleFormat::default(),
max_age_days: Some(7),
max_backups: Some(3),
max_size_mb: Some(100),
},
);
logging
}
impl AppConfig {
pub fn load_layered(config_path: &PathBuf) -> Result<Self> {
use figment::{
Figment,
providers::{Env, Format, Serialized},
};
let figment = Figment::new()
.merge(Serialized::defaults(AppConfig::default()))
.merge(StrictYaml::file(config_path))
.merge(Env::prefixed("APP__").split("__"));
let mut config: AppConfig = figment
.extract()
.with_context(|| "Failed to extract config from figment".to_owned())?;
config
.server
.normalize_home_dir_inplace()
.context("Failed to resolve server.home_dir")?;
if let Some(dir) = config.modules_dir.as_ref() {
merge_module_files(&mut config.modules, dir)?;
}
Ok(config)
}
pub fn load_or_default(config_path: Option<&PathBuf>) -> Result<Self> {
if let Some(path) = config_path {
ensure!(
path.is_file(),
"config file does not exist: {}",
path.to_string_lossy()
);
Self::load_layered(path)
} else {
let mut c = Self::default();
c.server
.normalize_home_dir_inplace()
.context("Failed to resolve server.home_dir (defaults)")?;
Ok(c)
}
}
pub fn to_yaml(&self) -> Result<String> {
serde_saphyr::to_string(self).context("Failed to serialize config to YAML")
}
pub fn vendor_config<T: DeserializeOwned>(
&self,
vendor_name: &str,
) -> Result<T, VendorConfigError> {
let raw = self
.vendor
.get(vendor_name)
.ok_or_else(|| VendorConfigError::NotFound {
vendor: vendor_name.to_owned(),
})?;
T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
vendor: vendor_name.to_owned(),
source: e,
})
}
pub fn vendor_config_or_default<T: DeserializeOwned + Default>(
&self,
vendor_name: &str,
) -> Result<T, VendorConfigError> {
let Some(raw) = self.vendor.get(vendor_name) else {
return Ok(T::default());
};
T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
vendor: vendor_name.to_owned(),
source: e,
})
}
pub fn apply_cli_overrides(&mut self, verbose: u8) {
if let Some(default_section) = self.logging.get_mut("default") {
default_section.console_level = match verbose {
0 => default_section.console_level, 1 => Some(Level::DEBUG),
_ => Some(Level::TRACE),
};
}
}
}
#[derive(Debug, Clone)]
pub struct CliArgs {
pub config: Option<String>,
pub print_config: bool,
pub verbose: u8,
pub mock: bool,
}
fn strict_yaml_parse<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, serde_saphyr::Error> {
let opts = serde_saphyr::Options {
duplicate_keys: serde_saphyr::DuplicateKeyPolicy::Error,
..serde_saphyr::Options::default()
};
serde_saphyr::from_str_with_options(s, opts)
}
struct StrictYaml;
impl figment::providers::Format for StrictYaml {
type Error = serde_saphyr::Error;
const NAME: &'static str = "YAML";
fn from_str<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, Self::Error> {
strict_yaml_parse(s)
}
}
fn merge_module_files(
bag: &mut HashMap<String, serde_json::Value>,
dir: impl AsRef<Path>,
) -> Result<()> {
use std::fs;
let dir = dir.as_ref();
if !dir.exists() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if ext != "yml" && ext != "yaml" {
continue;
}
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_owned();
let raw = fs::read_to_string(&path)?;
let json: serde_json::Value = strict_yaml_parse(&raw)
.with_context(|| format!("failed to parse module file: {}", path.display()))?;
bag.insert(name, json);
}
Ok(())
}
pub fn expand_env_in_dsn(dsn: &str) -> Result<String> {
modkit_utils::var_expand::expand_env_vars(dsn).map_err(|e| anyhow::anyhow!("{e}"))
}
pub fn resolve_password(password: Option<&str>) -> Result<Option<String>> {
if let Some(pwd) = password {
if pwd.starts_with("${") && pwd.ends_with('}') {
let var_name = &pwd[2..pwd.len() - 1];
let resolved = std::env::var(var_name).with_context(|| {
format!("Environment variable '{var_name}' not found for password")
})?;
Ok(Some(resolved))
} else {
Ok(Some(pwd.to_owned()))
}
} else {
Ok(None)
}
}
pub fn validate_dsn(dsn: &str) -> Result<()> {
if dsn.starts_with("sqlite:") {
return Ok(());
}
let _parsed = dsn::parse(dsn).map_err(|e| anyhow::anyhow!("Invalid DSN '{dsn}': {e}"))?;
Ok(())
}
fn resolve_sqlite_dsn(
dsn: &str,
home_dir: &Path,
module_name: &str,
dry_run: bool,
) -> Result<String> {
if dsn.contains("@file(") {
if let Some(start) = dsn.find("@file(")
&& let Some(end) = dsn[start..].find(')')
{
let file_path = &dsn[start + 6..start + end];
let resolved_path = if file_path.starts_with('/')
|| (file_path.len() > 1 && file_path.chars().nth(1) == Some(':'))
{
PathBuf::from(file_path)
} else {
let module_dir = home_dir.join(module_name);
if !dry_run {
std::fs::create_dir_all(&module_dir).with_context(|| {
format!(
"Failed to create module directory: {}",
module_dir.display()
)
})?;
}
module_dir.join(file_path)
};
let normalized_path = normalize_path(&resolved_path);
if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
return Ok(format!("sqlite:{normalized_path}"));
}
return Ok(format!("sqlite://{normalized_path}"));
}
return Err(anyhow::anyhow!(
"Invalid @file() syntax in SQLite DSN: {dsn}"
));
}
if dsn == "sqlite://" || dsn == "sqlite:///" || dsn == "sqlite:" {
let module_dir = home_dir.join(module_name);
if !dry_run {
std::fs::create_dir_all(&module_dir).with_context(|| {
format!(
"Failed to create module directory: {}",
module_dir.display()
)
})?;
}
let db_path = module_dir.join(format!("{module_name}.sqlite"));
let normalized_path = normalize_path(&db_path);
if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
return Ok(format!("sqlite:{normalized_path}"));
}
return Ok(format!("sqlite://{normalized_path}"));
}
Ok(dsn.to_owned())
}
fn build_server_dsn(
scheme: &str,
host: Option<&str>,
port: Option<u16>,
user: Option<&str>,
password: Option<&str>,
dbname: Option<&str>,
params: &HashMap<String, String>,
) -> Result<String> {
let host = host.unwrap_or("localhost");
let user = user.unwrap_or("postgres");
let mut url = Url::parse(&format!("{scheme}://dummy/"))
.with_context(|| format!("Invalid scheme: {scheme}"))?;
url.set_host(Some(host))
.with_context(|| format!("Invalid host: {host}"))?;
if let Some(port) = port {
url.set_port(Some(port))
.map_err(|()| anyhow::anyhow!("Invalid port: {port}"))?;
}
url.set_username(user)
.map_err(|()| anyhow::anyhow!("Failed to set username: {user}"))?;
if let Some(password) = password {
url.set_password(Some(password))
.map_err(|()| anyhow::anyhow!("Failed to set password"))?;
}
if let Some(dbname) = dbname {
let encoded_dbname = urlencoding::encode(dbname);
url.set_path(&format!("/{encoded_dbname}"));
} else {
url.set_path("/");
}
if !params.is_empty() {
let mut query_pairs = url.query_pairs_mut();
for (key, value) in params {
query_pairs.append_pair(key, value);
}
}
Ok(url.to_string())
}
fn build_sqlite_dsn_with_dbname_override(
original_dsn: &str,
dbname: &str,
module_name: &str,
home_dir: &Path,
dry_run: bool,
) -> Result<String> {
let query_params = if let Some(query_start) = original_dsn.find('?') {
&original_dsn[query_start..]
} else {
""
};
let module_dir = home_dir.join(module_name);
if !dry_run {
std::fs::create_dir_all(&module_dir).with_context(|| {
format!(
"Failed to create module directory: {}",
module_dir.display()
)
})?;
}
let db_path = module_dir.join(dbname);
let normalized_path = normalize_path(&db_path);
let dsn_base = if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
format!("sqlite:{normalized_path}")
} else {
format!("sqlite://{normalized_path}")
};
Ok(format!("{dsn_base}{query_params}"))
}
fn build_sqlite_dsn(
dsn: Option<&str>,
file: Option<&str>,
path: Option<&PathBuf>,
dbname: Option<&str>,
module_name: &str,
home_dir: &Path,
dry_run: bool,
) -> Result<String> {
if let Some(dsn) = dsn {
let resolved_dsn = resolve_sqlite_dsn(dsn, home_dir, module_name, dry_run)?;
if let Some(dbname) = dbname {
return build_sqlite_dsn_with_dbname_override(
&resolved_dsn,
dbname,
module_name,
home_dir,
dry_run,
);
}
validate_dsn(&resolved_dsn)?;
return Ok(resolved_dsn);
}
if let Some(path) = path {
let absolute_path = if path.is_absolute() {
path.clone()
} else {
home_dir.join(path)
};
let normalized_path = normalize_path(&absolute_path);
if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
return Ok(format!("sqlite:{normalized_path}"));
}
return Ok(format!("sqlite://{normalized_path}"));
}
if let Some(file) = file {
let module_dir = home_dir.join(module_name);
if !dry_run {
std::fs::create_dir_all(&module_dir).with_context(|| {
format!(
"Failed to create module directory: {}",
module_dir.display()
)
})?;
}
let db_path = module_dir.join(file);
let normalized_path = normalize_path(&db_path);
if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
return Ok(format!("sqlite:{normalized_path}"));
}
return Ok(format!("sqlite://{normalized_path}"));
}
let module_dir = home_dir.join(module_name);
if !dry_run {
std::fs::create_dir_all(&module_dir).with_context(|| {
format!(
"Failed to create module directory: {}",
module_dir.display()
)
})?;
}
let db_path = module_dir.join(format!("{module_name}.sqlite"));
let normalized_path = normalize_path(&db_path);
if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
Ok(format!("sqlite:{normalized_path}"))
} else {
Ok(format!("sqlite://{normalized_path}"))
}
}
type DbConfigResult = Result<Option<(String /* final_dsn */, PoolCfg)>>;
#[derive(Default)]
struct DbConfigBuilder {
dsn: Option<String>,
host: Option<String>,
port: Option<u16>,
user: Option<String>,
password: Option<String>,
dbname: Option<String>,
params: HashMap<String, String>,
pool: PoolCfg,
}
impl DbConfigBuilder {
fn new() -> Self {
Self::default()
}
fn apply_global_server(
&mut self,
global_server: &DbConnConfig,
home_dir: &Path,
module_name: &str,
dry_run: bool,
) -> Result<()> {
if let Some(global_dsn) = &global_server.dsn {
let expanded_dsn = expand_env_in_dsn(global_dsn)?;
let resolved_dsn = if expanded_dsn.starts_with("sqlite") {
resolve_sqlite_dsn(&expanded_dsn, home_dir, module_name, dry_run)?
} else {
expanded_dsn
};
validate_dsn(&resolved_dsn)?;
self.dsn = Some(resolved_dsn);
}
if let Some(host) = &global_server.host {
self.host = Some(host.clone());
}
if let Some(port) = global_server.port {
self.port = Some(port);
}
if let Some(user) = &global_server.user {
self.user = Some(user.clone());
}
if let Some(password) = resolve_password(global_server.password.as_deref())? {
self.password = Some(password);
}
if let Some(dbname) = &global_server.dbname {
self.dbname = Some(dbname.clone());
}
if let Some(params) = &global_server.params {
self.params.extend(params.clone());
}
if let Some(pool) = &global_server.pool {
self.pool = pool.clone();
}
Ok(())
}
fn apply_module_dsn(
&mut self,
module_dsn: &str,
home_dir: &Path,
module_name: &str,
dry_run: bool,
) -> Result<()> {
let resolved_dsn = if module_dsn.starts_with("sqlite") {
resolve_sqlite_dsn(module_dsn, home_dir, module_name, dry_run)?
} else {
module_dsn.to_owned()
};
validate_dsn(&resolved_dsn)?;
self.dsn = Some(resolved_dsn);
Ok(())
}
fn apply_module_fields(&mut self, module_db_config: &DbConnConfig) -> Result<()> {
if let Some(host) = &module_db_config.host {
self.host = Some(host.clone());
}
if let Some(port) = module_db_config.port {
self.port = Some(port);
}
if let Some(user) = &module_db_config.user {
self.user = Some(user.clone());
}
if let Some(password) = resolve_password(module_db_config.password.as_deref())? {
self.password = Some(password);
}
if let Some(dbname) = &module_db_config.dbname {
self.dbname = Some(dbname.clone());
}
if let Some(params) = &module_db_config.params {
self.params.extend(params.clone());
}
if let Some(pool) = &module_db_config.pool {
if let Some(max_conns) = pool.max_conns {
self.pool.max_conns = Some(max_conns);
}
if let Some(acquire_timeout) = pool.acquire_timeout {
self.pool.acquire_timeout = Some(acquire_timeout);
}
}
Ok(())
}
fn has_field_overrides(&self) -> bool {
self.host.is_some()
|| self.port.is_some()
|| self.user.is_some()
|| self.password.is_some()
|| !self.params.is_empty()
}
}
fn decide_backend(builder: &DbConfigBuilder, module_db_config: &DbConnConfig) -> bool {
module_db_config.file.is_some()
|| module_db_config.path.is_some()
|| builder
.dsn
.as_ref()
.is_some_and(|dsn| dsn.starts_with("sqlite"))
|| (module_db_config.server.is_none() && builder.dsn.is_none())
}
fn finalize_sqlite_dsn(
builder: &DbConfigBuilder,
module_db_config: &DbConnConfig,
module_name: &str,
home_dir: &Path,
dry_run: bool,
) -> Result<String> {
build_sqlite_dsn(
builder.dsn.as_deref(),
module_db_config.file.as_deref(),
module_db_config.path.as_ref(),
builder.dbname.as_deref(),
module_name,
home_dir,
dry_run,
)
}
fn finalize_server_dsn(builder: &DbConfigBuilder, module_name: &str) -> Result<String> {
let dbname = if let Some(dbname) = builder.dbname.as_deref() {
dbname.to_owned()
} else if let Some(dsn) = builder.dsn.as_ref() {
if let Ok(parsed) = url::Url::parse(dsn) {
let path = parsed.path();
if path.len() > 1 {
path[1..].to_string()
} else {
return Err(anyhow::anyhow!(
"Server-based database config for module '{module_name}' missing required 'dbname'"
));
}
} else {
return Err(anyhow::anyhow!(
"Server-based database config for module '{module_name}' missing required 'dbname'"
));
}
} else {
return Err(anyhow::anyhow!(
"Server-based database config for module '{module_name}' missing required 'dbname'"
));
};
if builder.has_field_overrides() || builder.dsn.is_none() {
let scheme = if let Some(dsn) = &builder.dsn {
let parsed = Url::parse(dsn)?;
parsed.scheme().to_owned()
} else {
"postgresql".to_owned() };
build_server_dsn(
&scheme,
builder.host.as_deref(),
builder.port,
builder.user.as_deref(),
builder.password.as_deref(),
Some(&dbname),
&builder.params,
)
} else if let Some(original_dsn) = &builder.dsn {
if let Ok(mut parsed) = Url::parse(original_dsn) {
let original_dbname = parsed.path().trim_start_matches('/');
if original_dbname != dbname {
parsed.set_path(&format!("/{dbname}"));
}
Ok(parsed.to_string())
} else {
build_server_dsn(
"postgresql",
builder.host.as_deref(),
builder.port,
builder.user.as_deref(),
builder.password.as_deref(),
Some(&dbname),
&builder.params,
)
}
} else {
unreachable!("final_dsn should not be None when has_field_overrides is false")
}
}
fn redact_dsn_for_logging(dsn: &str) -> Result<String> {
if dsn.contains('@') {
let parsed = Url::parse(dsn)?;
let mut log_url = parsed;
if log_url.password().is_some() {
log_url.set_password(Some("***")).ok();
}
Ok(log_url.to_string())
} else {
Ok(dsn.to_owned())
}
}
pub const MODKIT_MODULE_CONFIG_ENV: &str = "MODKIT_MODULE_CONFIG";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenderedDbConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub global: Option<GlobalDatabaseConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub module: Option<DbConnConfig>,
}
impl RenderedDbConfig {
#[must_use]
pub fn new(global: Option<GlobalDatabaseConfig>, module: Option<DbConnConfig>) -> Self {
Self { global, module }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenderedModuleConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub database: Option<RenderedDbConfig>,
#[serde(default)]
pub config: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub logging: Option<LoggingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub opentelemetry: Option<OpenTelemetryConfig>,
}
impl RenderedModuleConfig {
pub fn from_json(json: &str) -> Result<Self> {
serde_json::from_str(json).context("Failed to parse RenderedModuleConfig from JSON")
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string(self).context("Failed to serialize RenderedModuleConfig to JSON")
}
}
pub fn render_module_config_for_oop(
app: &AppConfig,
module_name: &str,
_home_dir: &std::path::Path,
) -> Result<RenderedModuleConfig> {
let module_db_config = parse_module_config(app, module_name)
.ok()
.and_then(|entry| entry.database);
let database = if module_db_config.is_some() || app.database.is_some() {
Some(RenderedDbConfig::new(
app.database.clone(),
module_db_config,
))
} else {
None
};
let config = parse_module_config(app, module_name)
.map(|entry| entry.config)
.unwrap_or_default();
let logging = app.logging.clone();
let opentelemetry = if app.opentelemetry.tracing.enabled || app.opentelemetry.metrics.enabled {
Some(app.opentelemetry.clone())
} else {
None
};
Ok(RenderedModuleConfig {
database,
config,
logging: Some(logging),
opentelemetry,
})
}
pub fn parse_module_config(app: &AppConfig, module_name: &str) -> Result<ModuleConfig> {
let module_raw = app
.modules
.get(module_name)
.cloned()
.ok_or_else(|| anyhow::anyhow!("Module '{module_name}' not found in config"))?;
let module_config: ModuleConfig = serde_json::from_value(module_raw)?;
Ok(module_config)
}
pub fn get_module_runtime_config(
app: &AppConfig,
module_name: &str,
) -> Result<Option<ModuleRuntime>> {
let entry = parse_module_config(app, module_name)?;
Ok(entry.runtime)
}
pub fn build_final_db_for_module(
app: &AppConfig,
module_name: &str,
home_dir: &Path,
dry_run: bool,
) -> DbConfigResult {
let Some(module_raw) = app.modules.get(module_name) else {
return Ok(None); };
let module_entry: ModuleConfig = serde_json::from_value(module_raw.clone())
.with_context(|| format!("Invalid module config structure for '{module_name}'"))?;
let Some(module_db_config) = module_entry.database else {
tracing::warn!(
"Module '{}' has no database configuration; DB capability disabled",
module_name
);
return Ok(None);
};
let global_db_config = app.database.as_ref();
let mut builder = DbConfigBuilder::new();
if let Some(server_name) = &module_db_config.server {
let global_server = global_db_config
.and_then(|gc| gc.servers.get(server_name))
.ok_or_else(|| {
anyhow::anyhow!("Referenced server '{server_name}' not found in global config")
})?;
builder.apply_global_server(global_server, home_dir, module_name, dry_run)?;
}
if let Some(module_dsn) = &module_db_config.dsn {
builder.apply_module_dsn(module_dsn, home_dir, module_name, dry_run)?;
}
builder.apply_module_fields(&module_db_config)?;
let is_sqlite = decide_backend(&builder, &module_db_config);
let result_dsn = if is_sqlite {
finalize_sqlite_dsn(&builder, &module_db_config, module_name, home_dir, dry_run)?
} else {
finalize_server_dsn(&builder, module_name)?
};
validate_dsn(&result_dsn)?;
let log_dsn = redact_dsn_for_logging(&result_dsn)?;
tracing::info!(
"Built final DB config for module '{}': {}",
module_name,
log_dsn
);
Ok(Some((result_dsn, builder.pool)))
}
#[must_use]
pub fn get_module_db_config(app: &AppConfig, module_name: &str) -> Option<DbConnConfig> {
let module_raw = app.modules.get(module_name)?;
let module_entry: ModuleConfig = serde_json::from_value(module_raw.clone()).ok()?;
module_entry.database
}
#[must_use]
pub fn module_home(app: &AppConfig, module_name: &str) -> PathBuf {
PathBuf::from(&app.server.home_dir).join(module_name)
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use std::fs;
use temp_env::with_var;
use tempfile::tempdir;
fn is_normalized_path(p: &Path) -> bool {
p.is_absolute() && !p.starts_with("~")
}
fn default_subdir() -> &'static str {
".cyberfabric"
}
#[test]
fn test_default_config_structure() {
let config = AppConfig::default();
assert!(config.database.is_none());
let logging = config.logging;
assert!(logging.contains_key("default"));
let default_section = &logging["default"];
assert_eq!(default_section.console_level, Some(Level::INFO));
assert_eq!(default_section.file().unwrap(), "logs/cyberfabric.log");
assert!(config.modules.is_empty());
}
#[test]
fn test_load_layered_normalizes_home_dir() {
let tmp = tempdir().unwrap();
let cfg_path = tmp.path().join("cfg.yaml");
let yaml = r#"
server:
home_dir: "~/.test_hyperspot"
database:
servers:
test_postgres:
dsn: "postgres://user:pass@localhost/db"
pool:
max_conns: 20
logging:
default:
console_level: debug
file: "logs/default.log"
"#;
fs::write(&cfg_path, yaml).unwrap();
let config = AppConfig::load_layered(&cfg_path).unwrap();
assert!(is_normalized_path(&config.server.home_dir));
assert!(config.server.home_dir.ends_with(".test_hyperspot"));
let logging = &config.logging;
let def = &logging["default"];
assert_eq!(def.console_level, Some(Level::DEBUG));
assert_eq!(def.section_file.as_ref().unwrap().file, "logs/default.log");
}
#[test]
fn test_load_or_default_normalizes_home_dir_when_none() {
let tmp = tempdir().unwrap();
let env_var = if cfg!(target_os = "windows") {
"APPDATA"
} else {
"HOME"
};
with_var(env_var, Some(tmp.path().to_str().unwrap()), || {
let config = AppConfig::load_or_default(None).unwrap();
assert!(is_normalized_path(&config.server.home_dir));
assert!(config.server.home_dir.ends_with(default_subdir()));
});
}
#[test]
fn test_minimal_yaml_config() {
let tmp = tempdir().unwrap();
let cfg_path = tmp.path().join("cfg.yaml");
let yaml = r#"
server:
home_dir: "~/.minimal"
"#;
fs::write(&cfg_path, yaml).unwrap();
let config = AppConfig::load_layered(&cfg_path).unwrap();
assert!(is_normalized_path(&config.server.home_dir));
assert!(config.server.home_dir.ends_with(".minimal"));
assert!(config.database.is_none());
assert!(config.modules.is_empty());
}
#[test]
fn test_cli_overrides() {
let mut config = AppConfig::default();
let args = CliArgs {
config: None,
print_config: false,
verbose: 2, mock: false,
};
config.apply_cli_overrides(args.verbose);
let logging = &config.logging;
let default_section = &logging["default"];
assert_eq!(default_section.console_level, Some(Level::TRACE));
}
#[test]
fn test_cli_verbose_levels_matrix() {
for (verbose_level, expected_log_level) in [
(0, Some(Level::INFO)), (1, Some(Level::DEBUG)),
(2, Some(Level::TRACE)),
(3, Some(Level::TRACE)), ] {
let mut config = AppConfig::default();
let args = CliArgs {
config: None,
print_config: false,
verbose: verbose_level,
mock: false,
};
config.apply_cli_overrides(args.verbose);
let logging = &config.logging;
let default_section = &logging["default"];
if verbose_level == 0 {
assert_eq!(default_section.console_level, Some(Level::INFO));
} else {
assert_eq!(default_section.console_level, expected_log_level);
}
}
}
#[test]
fn test_layered_config_loading_with_modules_dir() {
let tmp = tempdir().unwrap();
let cfg_path = tmp.path().join("modules_dir.yaml");
let modules_dir = tmp.path().join("modules");
fs::create_dir_all(&modules_dir).unwrap();
let module_cfg = modules_dir.join("test_module.yaml");
fs::write(
&module_cfg,
r#"
setting1: "value1"
setting2: 42
"#,
)
.unwrap();
let modules_dir_str = normalize_path(&modules_dir);
let yaml = format!(
r#"
server:
home_dir: "~/.modules_test"
modules_dir: "{modules_dir_str}"
modules:
existing_module:
key: "value"
"#
);
fs::write(&cfg_path, yaml).unwrap();
let config = AppConfig::load_layered(&cfg_path).unwrap();
assert!(config.modules.contains_key("existing_module"));
assert!(config.modules.contains_key("test_module"));
let test_module = &config.modules["test_module"];
assert_eq!(test_module["setting1"], "value1");
assert_eq!(test_module["setting2"], 42);
}
#[test]
fn test_load_and_init_logging_smoke() {
let tmp = tempdir().unwrap();
let cfg_path = tmp.path().join("logging.yaml");
let yaml = r#"
server:
home_dir: "~/.logging_test"
logging:
default:
console_level: debug
file: ""
file_level: info
"#;
fs::write(&cfg_path, yaml).unwrap();
let config = AppConfig::load_layered(&cfg_path).unwrap();
let logging = &config.logging;
assert!(logging.contains_key("default"));
let default_section = &logging["default"];
assert_eq!(default_section.console_level, Some(Level::DEBUG));
assert_eq!(default_section.file_level(), Some(Level::INFO));
}
fn create_app_with_server(server_name: &str, db_config: DbConnConfig) -> AppConfig {
let mut servers = HashMap::new();
servers.insert(server_name.to_owned(), db_config);
AppConfig {
database: Some(GlobalDatabaseConfig {
servers,
auto_provision: None,
}),
..Default::default()
}
}
fn add_module_to_app(
app: &mut AppConfig,
module_name: &str,
database_config: &serde_json::Value,
) {
app.modules.insert(
module_name.to_owned(),
serde_json::json!({
"database": database_config,
"config": {}
}),
);
}
fn add_module_with_config(app: &mut AppConfig, module_name: &str, config: &serde_json::Value) {
app.modules.insert(
module_name.to_owned(),
serde_json::json!({
"database": {},
"config": config
}),
);
}
fn create_minimal_app() -> AppConfig {
AppConfig {
database: None,
modules: HashMap::new(),
..Default::default()
}
}
#[test]
fn test_precedence_global_dsn_only() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let mut app = create_app_with_server(
"test_server",
DbConnConfig {
dsn: Some(
"postgresql://global_user:global_pass@global_host:5432/global_db".to_owned(),
),
..Default::default()
},
);
add_module_to_app(
&mut app,
"test_module",
&serde_json::json!({
"server": "test_server"
}),
);
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _pool) = result.unwrap();
assert!(dsn.contains("global_user"));
assert!(dsn.contains("global_host"));
assert!(dsn.contains("global_db"));
}
#[test]
fn test_precedence_global_fields_only() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let mut app = create_app_with_server(
"test_server",
DbConnConfig {
host: Some("field_host".to_owned()),
port: Some(5433),
user: Some("field_user".to_owned()),
dbname: Some("field_db".to_owned()),
..Default::default()
},
);
add_module_to_app(
&mut app,
"test_module",
&serde_json::json!({
"server": "test_server"
}),
);
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _pool) = result.unwrap();
assert!(dsn.contains("field_host"));
assert!(dsn.contains("5433"));
assert!(dsn.contains("field_user"));
assert!(dsn.contains("field_db"));
}
#[test]
fn test_precedence_module_dsn_only() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let app = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"test_module".to_owned(),
serde_json::json!({
"database": {
"dsn": "sqlite://module_test.db?wal=true&synchronous=NORMAL"
},
"config": {}
}),
);
modules
},
..Default::default()
};
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _pool) = result.unwrap();
assert!(dsn.contains("module_test.db"));
assert!(dsn.contains("wal=true"));
}
#[test]
fn test_precedence_module_fields_only() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let app = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"test_module".to_owned(),
serde_json::json!({
"database": {
"file": "module_fields.db"
},
"config": {}
}),
);
modules
},
..Default::default()
};
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _pool) = result.unwrap();
assert!(dsn.contains("module_fields.db"));
#[cfg(windows)]
assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
#[cfg(unix)]
assert!(dsn.starts_with("sqlite://"));
}
#[test]
fn test_precedence_fields_override_dsn() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let mut app = create_app_with_server(
"test_server",
DbConnConfig {
dsn: Some("postgresql://old_user:old_pass@old_host:5432/old_db".to_owned()),
host: Some("new_host".to_owned()), port: Some(5433), user: Some("new_user".to_owned()), dbname: Some("new_db".to_owned()), ..Default::default()
},
);
add_module_to_app(
&mut app,
"test_module",
&serde_json::json!({
"server": "test_server",
"port": 5434 }),
);
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _pool) = result.unwrap();
assert!(dsn.contains("new_host"));
assert!(dsn.contains("5434")); assert!(dsn.contains("new_user"));
assert!(dsn.contains("new_db"));
assert!(!dsn.contains("old_host"));
assert!(!dsn.contains("5432"));
assert!(!dsn.contains("old_user"));
assert!(!dsn.contains("old_db"));
}
#[test]
fn test_env_expansion_password() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
with_var("TEST_DB_PASSWORD", Some("secret123"), || {
let mut app = create_app_with_server(
"test_server",
DbConnConfig {
host: Some("localhost".to_owned()),
port: Some(5432),
user: Some("testuser".to_owned()),
password: Some("${TEST_DB_PASSWORD}".to_owned()), dbname: Some("testdb".to_owned()),
..Default::default()
},
);
add_module_to_app(
&mut app,
"test_module",
&serde_json::json!({
"server": "test_server"
}),
);
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _pool) = result.unwrap();
assert!(dsn.contains("secret123"));
});
}
#[test]
fn test_env_expansion_in_dsn() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
temp_env::with_vars(
[
("DB_HOST", Some("test-server")),
("DB_PASSWORD", Some("env_secret")),
],
|| {
let mut app = create_app_with_server(
"test_server",
DbConnConfig {
dsn: Some(
"postgresql://user:${DB_PASSWORD}@${DB_HOST}:5432/mydb".to_owned(),
),
..Default::default()
},
);
add_module_to_app(
&mut app,
"test_module",
&serde_json::json!({
"server": "test_server"
}),
);
let result =
build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _pool) = result.unwrap();
assert!(dsn.contains("test-server"));
assert!(dsn.contains("env_secret"));
assert!(!dsn.contains("${DB_HOST}"));
assert!(!dsn.contains("${DB_PASSWORD}"));
},
);
}
#[test]
fn test_sqlite_file_path_resolution() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let app1 = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"test_module".to_owned(),
serde_json::json!({
"database": {
"file": "test.db"
},
"config": {}
}),
);
modules
},
..Default::default()
};
let result1 = build_final_db_for_module(&app1, "test_module", home_dir, false).unwrap();
assert!(result1.is_some());
let (dsn1, _) = result1.unwrap();
assert!(dsn1.contains("test_module"));
assert!(dsn1.contains("test.db"));
let abs_path = tmp.path().join("absolute.db");
let app2 = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"test_module".to_owned(),
serde_json::json!({
"database": {
"path": abs_path.to_string_lossy()
},
"config": {}
}),
);
modules
},
..Default::default()
};
let result2 = build_final_db_for_module(&app2, "test_module", home_dir, false).unwrap();
assert!(result2.is_some());
let (dsn2, _) = result2.unwrap();
assert!(dsn2.contains("absolute.db"));
let app3 = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"test_module".to_owned(),
serde_json::json!({
"database": {},
"config": {}
}),
);
modules
},
..Default::default()
};
let result3 = build_final_db_for_module(&app3, "test_module", home_dir, false).unwrap();
assert!(result3.is_some());
let (dsn3, _) = result3.unwrap();
assert!(dsn3.contains("test_module.sqlite"));
}
#[cfg(windows)]
#[test]
fn test_sqlite_path_resolution_windows() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let app = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"test_module".to_owned(),
serde_json::json!({
"database": {
"file": "test.db"
},
"config": {}
}),
);
modules
},
..Default::default()
};
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _) = result.unwrap();
assert!(!dsn.contains('\\'));
assert!(dsn.contains('/'));
}
#[test]
fn test_sqlite_dsn_with_server_reference_and_dbname_override() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let mut app = AppConfig::default();
let mut servers = HashMap::new();
servers.insert(
"sqlite_users".to_owned(),
DbConnConfig {
engine: None,
dsn: Some(
"sqlite://users_info.db?WAL=true&synchronous=NORMAL&busy_timeout=5000"
.to_owned(),
),
host: None,
port: None,
user: None,
password: None,
dbname: None,
params: None,
pool: None,
file: None,
path: None,
server: None,
},
);
app.database = Some(GlobalDatabaseConfig {
servers,
auto_provision: None,
});
app.modules.insert(
"users_info".to_owned(),
serde_json::json!({
"database": {
"server": "sqlite_users",
"dbname": "users_info.db"
},
"config": {}
}),
);
let result = build_final_db_for_module(&app, "users_info", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _) = result.unwrap();
assert!(dsn.contains("?WAL=true&synchronous=NORMAL&busy_timeout=5000"));
assert!(dsn.contains("users_info/users_info.db"));
#[cfg(windows)]
{
assert!(dsn.starts_with("sqlite:"));
assert!(!dsn.starts_with("sqlite://"));
}
#[cfg(unix)]
{
assert!(dsn.starts_with("sqlite://"));
}
}
#[cfg(unix)]
#[test]
fn test_sqlite_path_resolution_unix() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let app = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"test_module".to_owned(),
serde_json::json!({
"database": {
"file": "test.db"
},
"config": {}
}),
);
modules
},
..Default::default()
};
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _) = result.unwrap();
assert!(dsn.starts_with("sqlite://"));
assert!(dsn.contains("/test_module/test.db"));
}
#[test]
fn test_server_based_db_missing_dbname_error() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let mut app = create_app_with_server(
"test_server",
DbConnConfig {
host: Some("localhost".to_owned()),
port: Some(5432),
user: Some("testuser".to_owned()),
..Default::default()
},
);
add_module_to_app(
&mut app,
"test_module",
&serde_json::json!({
"server": "test_server"
}),
);
let result = build_final_db_for_module(&app, "test_module", home_dir, false);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("missing required 'dbname'"));
}
#[test]
fn test_module_no_database_config() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let app = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"no_db_module".to_owned(),
serde_json::json!({
"config": {
"some_setting": "value"
}
}),
);
modules
},
..Default::default()
};
let result = build_final_db_for_module(&app, "no_db_module", home_dir, false).unwrap();
assert!(result.is_none());
}
#[test]
fn test_module_empty_database_config() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let app = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"empty_db_module".to_owned(),
serde_json::json!({
"database": null,
"config": {}
}),
);
modules
},
..Default::default()
};
let result = build_final_db_for_module(&app, "empty_db_module", home_dir, false).unwrap();
assert!(result.is_none());
}
#[test]
fn test_referenced_server_not_found() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let app = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"test_module".to_owned(),
serde_json::json!({
"database": {
"server": "nonexistent_server"
},
"config": {}
}),
);
modules
},
..Default::default()
};
let result = build_final_db_for_module(&app, "test_module", home_dir, false);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("Referenced server 'nonexistent_server' not found"));
}
#[test]
fn test_dsn_validation_invalid_url() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let app = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"test_module".to_owned(),
serde_json::json!({
"database": {
"dsn": "invalid://not-a-valid[url"
},
"config": {}
}),
);
modules
},
..Default::default()
};
let result = build_final_db_for_module(&app, "test_module", home_dir, false);
assert!(result.is_err());
}
#[test]
fn test_env_variable_not_found() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
with_var("NONEXISTENT_PASSWORD", None::<&str>, || {
let mut app = create_app_with_server(
"test_server",
DbConnConfig {
host: Some("localhost".to_owned()),
password: Some("${NONEXISTENT_PASSWORD}".to_owned()),
dbname: Some("testdb".to_owned()),
..Default::default()
},
);
add_module_to_app(
&mut app,
"test_module",
&serde_json::json!({
"server": "test_server"
}),
);
let result = build_final_db_for_module(&app, "test_module", home_dir, false);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("NONEXISTENT_PASSWORD"));
});
}
#[test]
fn test_sqlite_at_file_relative_path() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let app = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"test_module".to_owned(),
serde_json::json!({
"database": {
"dsn": "sqlite://@file(users.db)"
},
"config": {}
}),
);
modules
},
..Default::default()
};
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _pool) = result.unwrap();
assert!(dsn.contains("test_module"));
assert!(dsn.contains("users.db"));
#[cfg(windows)]
assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
#[cfg(unix)]
assert!(dsn.starts_with("sqlite:///"));
}
#[test]
fn test_sqlite_at_file_absolute_path() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let abs_path = tmp.path().join("absolute_db.sqlite");
let app = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"test_module".to_owned(),
serde_json::json!({
"database": {
"dsn": format!("sqlite://@file({})", abs_path.to_string_lossy())
},
"config": {}
}),
);
modules
},
..Default::default()
};
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _pool) = result.unwrap();
assert!(dsn.contains("absolute_db.sqlite"));
#[cfg(windows)]
assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
#[cfg(unix)]
assert!(dsn.starts_with("sqlite:///"));
}
#[test]
fn test_sqlite_empty_dsn_default() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let app = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"test_module".to_owned(),
serde_json::json!({
"database": {
"dsn": "sqlite://"
},
"config": {}
}),
);
modules
},
..Default::default()
};
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _pool) = result.unwrap();
assert!(dsn.contains("test_module"));
assert!(dsn.contains("test_module.sqlite"));
#[cfg(windows)]
assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
#[cfg(unix)]
assert!(dsn.starts_with("sqlite:///"));
}
#[test]
fn test_sqlite_at_file_invalid_syntax() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let app = AppConfig {
modules: {
let mut modules = HashMap::new();
modules.insert(
"test_module".to_owned(),
serde_json::json!({
"database": {
"dsn": "sqlite://@file(missing_closing_paren"
},
"config": {}
}),
);
modules
},
..Default::default()
};
let result = build_final_db_for_module(&app, "test_module", home_dir, false);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("Invalid @file() syntax"));
}
#[test]
fn test_dsn_special_characters_in_credentials() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let mut app = create_app_with_server(
"test_server",
DbConnConfig {
host: Some("localhost".to_owned()),
port: Some(5432),
user: Some("user@domain".to_owned()),
password: Some("pa@ss:w0rd/with%special&chars".to_owned()),
dbname: Some("test/db".to_owned()),
..Default::default()
},
);
add_module_to_app(
&mut app,
"test_module",
&serde_json::json!({
"server": "test_server"
}),
);
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _pool) = result.unwrap();
assert!(dsn.starts_with("postgresql://"));
assert!(dsn.contains("user%40domain")); assert!(dsn.contains("/test%2Fdb"));
validate_dsn(&dsn).expect("DSN with special characters should be valid");
let parsed_dsn = dsn::parse(&dsn).expect("DSN should be parseable");
assert_eq!(parsed_dsn.username.as_deref(), Some("user@domain"));
assert_eq!(
parsed_dsn.password.as_deref(),
Some("pa@ss:w0rd/with%special&chars")
);
}
#[test]
#[allow(clippy::non_ascii_literal)]
fn test_dsn_unicode_characters() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let mut app = create_app_with_server(
"test_server",
DbConnConfig {
host: Some("localhost".to_owned()),
user: Some("ユーザー".to_owned()), dbname: Some("unicode_db".to_owned()),
..Default::default()
},
);
add_module_to_app(
&mut app,
"test_module",
&serde_json::json!({
"server": "test_server"
}),
);
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _pool) = result.unwrap();
assert!(dsn.starts_with("postgresql://"));
assert!(dsn.contains('%'));
validate_dsn(&dsn).expect("DSN with Unicode characters should be valid");
}
#[test]
fn test_dsn_query_parameters_encoding() {
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let mut params = HashMap::new();
params.insert("ssl mode".to_owned(), "require & verify".to_owned());
params.insert("application_name".to_owned(), "my-app/v1.0".to_owned());
let mut app = create_app_with_server(
"test_server",
DbConnConfig {
host: Some("localhost".to_owned()),
user: Some("testuser".to_owned()),
dbname: Some("testdb".to_owned()),
params: Some(params),
..Default::default()
},
);
add_module_to_app(
&mut app,
"test_module",
&serde_json::json!({
"server": "test_server"
}),
);
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (dsn, _pool) = result.unwrap();
assert!(dsn.contains("ssl+mode=require+%26+verify"));
assert!(dsn.contains("application_name=my-app%2Fv1.0"));
validate_dsn(&dsn).expect("DSN with encoded query parameters should be valid");
}
#[test]
fn test_pool_config_merging() {
use std::time::Duration;
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let mut app = create_app_with_server(
"test_server",
DbConnConfig {
host: Some("localhost".to_owned()),
dbname: Some("testdb".to_owned()),
pool: Some(PoolCfg {
max_conns: Some(10),
min_conns: None,
acquire_timeout: Some(Duration::from_secs(5)),
idle_timeout: None,
max_lifetime: None,
test_before_acquire: None,
}),
..Default::default()
},
);
add_module_to_app(
&mut app,
"test_module",
&serde_json::json!({
"server": "test_server",
"pool": {
"max_conns": 20
}
}),
);
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (_dsn, pool) = result.unwrap();
assert_eq!(pool.max_conns, Some(20)); assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(5))); }
#[test]
fn test_pool_config_module_overrides_all() {
use std::time::Duration;
let tmp = tempdir().unwrap();
let home_dir = tmp.path();
let mut app = create_app_with_server(
"test_server",
DbConnConfig {
host: Some("localhost".to_owned()),
dbname: Some("testdb".to_owned()),
pool: Some(PoolCfg {
max_conns: Some(10),
min_conns: None,
acquire_timeout: Some(Duration::from_secs(5)),
idle_timeout: None,
max_lifetime: None,
test_before_acquire: None,
}),
..Default::default()
},
);
add_module_to_app(
&mut app,
"test_module",
&serde_json::json!({
"server": "test_server",
"pool": {
"max_conns": 30,
"acquire_timeout": "10s"
}
}),
);
let result = build_final_db_for_module(&app, "test_module", home_dir, false).unwrap();
assert!(result.is_some());
let (_dsn, pool) = result.unwrap();
assert_eq!(pool.max_conns, Some(30));
assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(10)));
}
#[test]
fn test_list_module_names() {
let mut app = create_minimal_app();
add_module_with_config(&mut app, "zebra_module", &serde_json::json!({}));
add_module_with_config(&mut app, "alpha_module", &serde_json::json!({}));
add_module_with_config(&mut app, "beta_module", &serde_json::json!({}));
let module_names = list_module_names(&app);
assert_eq!(module_names.len(), 3);
assert_eq!(module_names[0], "alpha_module");
assert_eq!(module_names[1], "beta_module");
assert_eq!(module_names[2], "zebra_module");
}
#[test]
fn test_list_module_names_empty() {
let app = create_minimal_app();
let module_names = list_module_names(&app);
assert_eq!(module_names.len(), 0);
}
#[test]
fn test_redact_dsn_password_postgres() {
let dsn = "postgres://user:secretpass@localhost:5432/mydb";
let redacted = redact_dsn_password(dsn).unwrap();
assert_eq!(
redacted,
"postgres://user:***REDACTED***@localhost:5432/mydb"
);
}
#[test]
fn test_redact_dsn_password_no_password() {
let dsn = "postgres://user@localhost:5432/mydb";
let redacted = redact_dsn_password(dsn).unwrap();
assert_eq!(redacted, "postgres://user@localhost:5432/mydb");
}
#[test]
fn test_redact_dsn_password_special_chars() {
let dsn = "postgres://user:p@ss%40word@localhost:5432/mydb";
let redacted = redact_dsn_password(dsn).unwrap();
assert_eq!(
redacted,
"postgres://user:***REDACTED***@localhost:5432/mydb"
);
}
#[test]
fn test_render_effective_modules_config() {
let mut app = create_minimal_app();
add_module_with_config(
&mut app,
"test_module",
&serde_json::json!({
"my_setting": "my_value",
"enabled": true
}),
);
let result = render_effective_modules_config(&app).unwrap();
assert!(result.is_object());
let modules = result.as_object().unwrap();
assert!(modules.contains_key("test_module"));
let test_module = modules.get("test_module").unwrap();
assert!(test_module.is_object());
let test_module_obj = test_module.as_object().unwrap();
assert!(test_module_obj.contains_key("config"));
let config = test_module_obj.get("config").unwrap();
assert_eq!(config.get("my_setting").unwrap(), "my_value");
assert_eq!(config.get("enabled").unwrap(), true);
}
#[test]
fn test_render_effective_modules_config_with_database() {
let mut app = create_app_with_server(
"test_server",
DbConnConfig {
host: Some("localhost".to_owned()),
port: Some(5432),
user: Some("user".to_owned()),
password: Some("pass".to_owned()),
dbname: Some("db".to_owned()),
..Default::default()
},
);
add_module_to_app(
&mut app,
"test_module",
&serde_json::json!({
"server": "test_server"
}),
);
let result = render_effective_modules_config(&app).unwrap();
let modules = result.as_object().unwrap();
let test_module = modules.get("test_module").unwrap().as_object().unwrap();
assert!(test_module.contains_key("database"));
let database = test_module.get("database").unwrap().as_object().unwrap();
assert!(database.contains_key("dsn"));
let dsn = database.get("dsn").unwrap().as_str().unwrap();
assert!(dsn.contains("***REDACTED***"));
assert!(!dsn.contains("pass"));
}
#[test]
fn test_render_effective_modules_config_minimal() {
let mut app = create_minimal_app();
app.modules
.insert("minimal_module".to_owned(), serde_json::json!({}));
let result = render_effective_modules_config(&app).unwrap();
assert!(result.is_object());
}
#[test]
fn test_dump_effective_modules_config_yaml() {
let mut app = create_minimal_app();
add_module_with_config(
&mut app,
"test_module",
&serde_json::json!({
"setting": "value"
}),
);
let yaml = dump_effective_modules_config_yaml(&app).unwrap();
assert!(yaml.contains("test_module:"));
assert!(yaml.contains("config:"));
assert!(yaml.contains("setting: value"));
}
#[test]
fn test_dump_effective_modules_config_json() {
let mut app = create_minimal_app();
add_module_with_config(
&mut app,
"test_module",
&serde_json::json!({
"setting": "value"
}),
);
let json = dump_effective_modules_config_json(&app).unwrap();
assert!(json.contains("\"test_module\""));
assert!(json.contains("\"config\""));
assert!(json.contains("\"setting\""));
assert!(json.contains("\"value\""));
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.is_object());
}
#[test]
fn test_render_multiple_modules() {
let mut app = create_minimal_app();
add_module_with_config(&mut app, "module_a", &serde_json::json!({"a": 1}));
add_module_with_config(&mut app, "module_b", &serde_json::json!({"b": 2}));
add_module_with_config(&mut app, "module_c", &serde_json::json!({"c": 3}));
let result = render_effective_modules_config(&app).unwrap();
let modules = result.as_object().unwrap();
assert_eq!(modules.len(), 3);
assert!(modules.contains_key("module_a"));
assert!(modules.contains_key("module_b"));
assert!(modules.contains_key("module_c"));
}
#[derive(Debug, Deserialize, Default, PartialEq)]
struct TestVendorConfig {
#[serde(default)]
api_token: String,
#[serde(default)]
api_url: String,
}
#[test]
fn test_vendor_section_parses_from_yaml() {
let yaml = r#"
server:
home_dir: "~/.test_vendor"
vendor:
acme:
api_token: "acme-token-123"
api_url: "https://acme.example.com"
other_corp:
api_token: "other-token-789"
api_url: "https://other.example.com"
"#;
let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(config.vendor.len(), 2);
assert!(config.vendor.contains_key("acme"));
assert!(config.vendor.contains_key("other_corp"));
let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
assert_eq!(acme.api_token, "acme-token-123");
assert_eq!(acme.api_url, "https://acme.example.com");
let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
assert_eq!(other.api_token, "other-token-789");
assert_eq!(other.api_url, "https://other.example.com");
}
#[test]
fn test_vendor_section_defaults_to_empty() {
let config = AppConfig::default();
assert!(config.vendor.is_empty());
}
#[test]
fn test_vendor_config_typed_access() {
let mut config = AppConfig::default();
config.vendor.insert(
"acme".to_owned(),
serde_json::json!({
"api_token": "acme-token-123",
"api_url": "https://acme.example.com"
}),
);
let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
assert_eq!(acme.api_token, "acme-token-123");
assert_eq!(acme.api_url, "https://acme.example.com");
}
#[test]
fn test_vendor_config_not_found() {
let config = AppConfig::default();
let result: Result<TestVendorConfig, _> = config.vendor_config("nonexistent");
assert!(matches!(
result,
Err(VendorConfigError::NotFound { ref vendor }) if vendor == "nonexistent"
));
}
#[test]
fn test_vendor_config_invalid_structure() {
let mut config = AppConfig::default();
config
.vendor
.insert("bad".to_owned(), serde_json::json!("not an object"));
let result: Result<TestVendorConfig, _> = config.vendor_config("bad");
assert!(matches!(
result,
Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
));
}
#[test]
fn test_vendor_config_or_default_missing() {
let config = AppConfig::default();
let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
assert_eq!(acme, TestVendorConfig::default());
}
#[test]
fn test_vendor_config_or_default_present() {
let mut config = AppConfig::default();
config.vendor.insert(
"acme".to_owned(),
serde_json::json!({ "api_token": "acme-token-123" }),
);
let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
assert_eq!(acme.api_token, "acme-token-123");
}
#[test]
fn test_vendor_config_env_override() {
let tmp = tempdir().unwrap();
let cfg_path = tmp.path().join("cfg.yaml");
let yaml = r#"
server:
home_dir: "~/.test_vendor"
vendor:
env_test_vendor:
api_token: "from_yaml"
"#;
fs::write(&cfg_path, yaml).unwrap();
with_var(
"APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
Some("from_env"),
|| {
let config = AppConfig::load_layered(&cfg_path).unwrap();
let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
assert_eq!(v.api_token, "from_env");
},
);
}
#[test]
fn test_vendor_multiple_vendors_typed_access() {
let mut config = AppConfig::default();
config.vendor.insert(
"acme".to_owned(),
serde_json::json!({ "api_token": "acme-token", "api_url": "https://acme.com" }),
);
config.vendor.insert(
"other_corp".to_owned(),
serde_json::json!({ "api_token": "other-token", "api_url": "https://other.com" }),
);
let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
assert_eq!(acme.api_token, "acme-token");
assert_eq!(other.api_token, "other-token");
assert_eq!(acme.api_url, "https://acme.com");
assert_eq!(other.api_url, "https://other.com");
}
#[test]
fn test_vendor_nested_config() {
#[derive(Debug, Deserialize, PartialEq)]
struct NestedVendorConfig {
api_url: String,
feature_flags: FeatureFlags,
}
#[derive(Debug, Deserialize, PartialEq)]
struct FeatureFlags {
beta_mode: bool,
max_retries: u32,
}
let mut config = AppConfig::default();
config.vendor.insert(
"acme".to_owned(),
serde_json::json!({
"api_url": "https://acme.com",
"feature_flags": {
"beta_mode": true,
"max_retries": 3
}
}),
);
let acme: NestedVendorConfig = config.vendor_config("acme").unwrap();
assert_eq!(acme.api_url, "https://acme.com");
assert!(acme.feature_flags.beta_mode);
assert_eq!(acme.feature_flags.max_retries, 3);
}
#[test]
fn test_vendor_config_or_default_invalid_returns_error() {
let mut config = AppConfig::default();
config
.vendor
.insert("bad".to_owned(), serde_json::json!("not an object"));
let result: Result<TestVendorConfig, _> = config.vendor_config_or_default("bad");
assert!(matches!(
result,
Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
));
}
#[test]
fn test_vendor_config_yaml_roundtrip() {
let mut config = AppConfig::default();
config.vendor.insert(
"acme".to_owned(),
serde_json::json!({ "api_token": "acme-token-123" }),
);
let yaml = config.to_yaml().unwrap();
assert!(yaml.contains("vendor"));
assert!(yaml.contains("acme"));
assert!(yaml.contains("acme-token-123"));
}
#[test]
fn test_vendor_coexists_with_modules() {
let mut config = AppConfig::default();
config.modules.insert(
"my_module".to_owned(),
serde_json::json!({ "config": { "some_setting": true } }),
);
config.vendor.insert(
"acme".to_owned(),
serde_json::json!({ "api_token": "acme-token-123" }),
);
assert!(config.modules.contains_key("my_module"));
assert!(config.vendor.contains_key("acme"));
let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
assert_eq!(acme.api_token, "acme-token-123");
}
#[test]
fn test_vendor_error_display_messages() {
let not_found = VendorConfigError::NotFound {
vendor: "acme".to_owned(),
};
assert_eq!(
not_found.to_string(),
"vendor 'acme' not found in configuration"
);
let invalid = VendorConfigError::InvalidConfig {
vendor: "bad".to_owned(),
source: serde_json::from_str::<TestVendorConfig>("invalid").unwrap_err(),
};
let msg = invalid.to_string();
assert!(msg.starts_with("invalid config for vendor 'bad':"));
}
#[test]
fn test_vendor_empty_object_in_yaml() {
let yaml = r#"
server:
home_dir: "~/.test_vendor"
vendor: {}
"#;
let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
assert!(config.vendor.is_empty());
}
#[test]
fn test_reject_duplicate_module_names() {
let tmp = tempdir().unwrap();
let cfg_path = tmp.path().join("cfg.yaml");
let yaml = r#"
server:
home_dir: "~/.test_dup"
modules:
module1:
config: {}
module2:
config: {}
module1:
config: {}
"#;
fs::write(&cfg_path, yaml).unwrap();
let result = AppConfig::load_layered(&cfg_path);
assert!(result.is_err(), "duplicate module names should be rejected");
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("duplicate") || msg.contains("Duplicate"),
"error should mention duplicates: {msg}"
);
}
#[test]
fn test_reject_duplicate_keys_in_module_file() {
let tmp = tempdir().unwrap();
let modules_dir = tmp.path().join("modules.d");
fs::create_dir_all(&modules_dir).unwrap();
let module_yaml = r#"
config:
key1: "value1"
config:
key2: "value2"
"#;
fs::write(modules_dir.join("bad_module.yaml"), module_yaml).unwrap();
let cfg_yaml = format!(
r#"
server:
home_dir: "~/.test_dup_modfile"
modules_dir: "{}"
"#,
normalize_path(&modules_dir)
);
let cfg_path = tmp.path().join("cfg.yaml");
fs::write(&cfg_path, cfg_yaml).unwrap();
let result = AppConfig::load_layered(&cfg_path);
assert!(
result.is_err(),
"duplicate keys in a module file should be rejected"
);
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("duplicate") || msg.contains("Duplicate"),
"error should mention duplicates: {msg}"
);
}
#[test]
fn test_no_false_positive_on_unique_modules() {
let tmp = tempdir().unwrap();
let cfg_path = tmp.path().join("cfg.yaml");
let yaml = r#"
server:
home_dir: "~/.test_ok"
modules:
module1:
config: {}
module2:
config: {}
module3:
config: {}
"#;
fs::write(&cfg_path, yaml).unwrap();
let result = AppConfig::load_layered(&cfg_path);
assert!(
result.is_ok(),
"unique module names should be accepted: {:?}",
result.unwrap_err()
);
}
}