Archived
feat: rename crates/ → vessels/ + add El ports per sub-vessel
Belated rename commit for foundation/el-ui — was missed in the workspace-wide crates→vessels pass earlier today. Same structural intent as the rename in the other repos: 'crates' is the Rust word, 'vessel' is El's, and the directory rename is the marker that this slot holds an El buildable unit even if its current contents are still Rust pending port. Plus the El ports themselves — manifest.el + src/main.el per sub- vessel (el-aop, el-auth, el-config, el-i18n, el-identity, el-layout, el-platform, el-publish, el-secrets, el-services, el-style, el-ui- compiler). The ui-compiler is a stub: elc only emits C right now; generating browser-target JS/Wasm is the biggest open language gap and gets its own project. Until then, el-ui-compiler emits a JS module that throws elc.backend_missing so callers fail loudly. Cross-repo path dependencies in Cargo.toml updated to vessels/.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "el-config"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui configuration system — layered, typed, environment-aware"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_config"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,24 @@
|
||||
// el-config — Layered, typed configuration for el-ui applications.
|
||||
//
|
||||
// Resolution order (highest priority wins):
|
||||
// 1. Environment variables (EL_APP_NAME=...)
|
||||
// 2. .env file (development only)
|
||||
// 3. manifest.el [env.<current>] section
|
||||
// 4. manifest.el [config] base section
|
||||
// 5. Defaults defined in code
|
||||
|
||||
vessel "el-config" {
|
||||
version "0.1.0"
|
||||
description "Layered, typed configuration with manifest + env + defaults"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/// Config — layered configuration with typed access.
|
||||
///
|
||||
/// Sources are stacked in priority order. The first source to provide a value
|
||||
/// for a key wins. Resolution order:
|
||||
/// 1. Environment variables (highest)
|
||||
/// 2. .env file (dev only)
|
||||
/// 3. manifest.el [env.<current>] section
|
||||
/// 4. manifest.el [config] section (base)
|
||||
/// 5. Defaults defined in code (lowest)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use crate::error::ConfigError;
|
||||
use crate::source::{ConfigSource, FromConfigStr, MapSource};
|
||||
use crate::env::Environment;
|
||||
|
||||
/// The main configuration object.
|
||||
///
|
||||
/// Holds a stack of sources and resolves keys through them in order.
|
||||
pub struct Config {
|
||||
/// Sources in descending priority order (index 0 = highest priority).
|
||||
sources: Vec<Box<dyn ConfigSource>>,
|
||||
/// Environment in effect.
|
||||
pub environment: Environment,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Create an empty config with no sources.
|
||||
pub fn new(env: Environment) -> Self {
|
||||
Self {
|
||||
sources: Vec::new(),
|
||||
environment: env,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build with the default source stack for an application:
|
||||
/// env vars > defaults map.
|
||||
pub fn default_stack() -> Self {
|
||||
use crate::source::EnvVarSource;
|
||||
let env = Environment::current();
|
||||
let mut config = Self::new(env);
|
||||
config.push_source(Box::new(EnvVarSource::new()));
|
||||
config
|
||||
}
|
||||
|
||||
/// Add a source at the lowest priority (end of the stack).
|
||||
pub fn push_source(&mut self, source: Box<dyn ConfigSource>) {
|
||||
self.sources.push(source);
|
||||
}
|
||||
|
||||
/// Add a source at the highest priority (beginning of the stack).
|
||||
pub fn prepend_source(&mut self, source: Box<dyn ConfigSource>) {
|
||||
self.sources.insert(0, source);
|
||||
}
|
||||
|
||||
/// Add defaults as the lowest-priority source.
|
||||
pub fn set_defaults(&mut self, defaults: HashMap<String, String>) {
|
||||
let src = MapSource::from_map("defaults", defaults);
|
||||
self.sources.push(Box::new(src));
|
||||
}
|
||||
|
||||
/// Get a raw string value for a key.
|
||||
pub fn get_raw(&self, key: &str) -> Option<String> {
|
||||
for source in &self.sources {
|
||||
if let Some(val) = source.get_raw(key) {
|
||||
return Some(val);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get a typed value for a key.
|
||||
///
|
||||
/// Returns an error if the key is not found or can't be parsed.
|
||||
pub fn get<T: FromConfigStr>(&self, key: &str) -> Result<T, ConfigError> {
|
||||
let raw = self.get_raw(key).ok_or_else(|| ConfigError::NotFound {
|
||||
key: key.to_string(),
|
||||
})?;
|
||||
|
||||
T::from_config_str(&raw).map_err(|e| {
|
||||
// Inject the key into TypeMismatch errors
|
||||
match e {
|
||||
ConfigError::TypeMismatch { expected, got, .. } => {
|
||||
ConfigError::TypeMismatch {
|
||||
key: key.to_string(),
|
||||
expected,
|
||||
got,
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a typed value with a fallback default.
|
||||
pub fn get_or<T: FromConfigStr>(&self, key: &str, default: T) -> T {
|
||||
self.get(key).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Get an optional typed value. Returns None if not set (not an error).
|
||||
pub fn get_opt<T: FromConfigStr>(&self, key: &str) -> Result<Option<T>, ConfigError> {
|
||||
match self.get_raw(key) {
|
||||
None => Ok(None),
|
||||
Some(raw) => T::from_config_str(&raw)
|
||||
.map(Some)
|
||||
.map_err(|e| match e {
|
||||
ConfigError::TypeMismatch { expected, got, .. } => {
|
||||
ConfigError::TypeMismatch {
|
||||
key: key.to_string(),
|
||||
expected,
|
||||
got,
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// All key→value pairs from all sources (merged, highest-priority wins).
|
||||
pub fn all(&self) -> HashMap<String, String> {
|
||||
let mut result = HashMap::new();
|
||||
// Iterate in reverse order (lowest priority first) so higher-priority
|
||||
// sources overwrite lower-priority ones.
|
||||
for source in self.sources.iter().rev() {
|
||||
for (k, v) in source.all() {
|
||||
result.insert(k, v);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Load config from an `manifest.el` string.
|
||||
///
|
||||
/// Reads `[config]` as the base, then overlays `[env.<environment>]`.
|
||||
pub fn load_from_toml(toml_str: &str, env: &Environment) -> Result<MapSource, ConfigError> {
|
||||
let value: toml::Value = toml::from_str(toml_str)
|
||||
.map_err(|e| ConfigError::ParseError(e.to_string()))?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
|
||||
// Load base [config] section
|
||||
if let Some(config_section) = value.get("config") {
|
||||
if let Some(table) = config_section.as_table() {
|
||||
flatten_toml_table(table, "", &mut map);
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay [env.<name>] section
|
||||
let env_key = env.name();
|
||||
if let Some(env_sections) = value.get("env") {
|
||||
if let Some(env_table) = env_sections.get(env_key) {
|
||||
if let Some(table) = env_table.as_table() {
|
||||
flatten_toml_table(table, "", &mut map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MapSource::from_map("manifest.el", map))
|
||||
}
|
||||
|
||||
fn flatten_toml_table(
|
||||
table: &toml::value::Table,
|
||||
prefix: &str,
|
||||
out: &mut HashMap<String, String>,
|
||||
) {
|
||||
for (key, value) in table {
|
||||
let full_key = if prefix.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{}.{}", prefix, key)
|
||||
};
|
||||
|
||||
match value {
|
||||
toml::Value::String(s) => {
|
||||
out.insert(full_key, s.clone());
|
||||
}
|
||||
toml::Value::Integer(i) => {
|
||||
out.insert(full_key, i.to_string());
|
||||
}
|
||||
toml::Value::Float(f) => {
|
||||
out.insert(full_key, f.to_string());
|
||||
}
|
||||
toml::Value::Boolean(b) => {
|
||||
out.insert(full_key, b.to_string());
|
||||
}
|
||||
toml::Value::Table(t) => {
|
||||
flatten_toml_table(t, &full_key, out);
|
||||
}
|
||||
_ => {} // Arrays, datetimes: skip for now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Macro for typed config access on a global/injected Config.
|
||||
///
|
||||
/// ```ignore
|
||||
/// let name = config!(cfg, "app.name", String);
|
||||
/// let port = config!(cfg, "server.port", u32, 8080);
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! config {
|
||||
($cfg:expr, $key:expr, $type:ty) => {
|
||||
$cfg.get::<$type>($key)
|
||||
};
|
||||
($cfg:expr, $key:expr, $type:ty, $default:expr) => {
|
||||
$cfg.get_or::<$type>($key, $default)
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::source::MapSource;
|
||||
|
||||
fn make_config(pairs: &[(&str, &str)]) -> Config {
|
||||
let mut src = MapSource::new("test");
|
||||
for (k, v) in pairs {
|
||||
src.insert(*k, *v);
|
||||
}
|
||||
let mut cfg = Config::new(Environment::Development);
|
||||
cfg.push_source(Box::new(src));
|
||||
cfg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_string() {
|
||||
let cfg = make_config(&[("app.name", "TestApp")]);
|
||||
assert_eq!(cfg.get::<String>("app.name").unwrap(), "TestApp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_u32() {
|
||||
let cfg = make_config(&[("server.port", "8080")]);
|
||||
assert_eq!(cfg.get::<u32>("server.port").unwrap(), 8080u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_bool() {
|
||||
let cfg = make_config(&[("feature.enabled", "true")]);
|
||||
assert_eq!(cfg.get::<bool>("feature.enabled").unwrap(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_missing_returns_error() {
|
||||
let cfg = Config::new(Environment::Development);
|
||||
assert!(cfg.get::<String>("missing.key").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_or_default() {
|
||||
let cfg = Config::new(Environment::Development);
|
||||
assert_eq!(cfg.get_or("timeout", 30u32), 30u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_or_prefers_source() {
|
||||
let cfg = make_config(&[("timeout", "60")]);
|
||||
assert_eq!(cfg.get_or("timeout", 30u32), 60u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_priority_source_wins() {
|
||||
let mut cfg = Config::new(Environment::Development);
|
||||
let mut low = MapSource::new("low");
|
||||
low.insert("key", "low-value");
|
||||
let mut high = MapSource::new("high");
|
||||
high.insert("key", "high-value");
|
||||
cfg.push_source(Box::new(high));
|
||||
cfg.push_source(Box::new(low));
|
||||
// First source (index 0) is highest priority
|
||||
assert_eq!(cfg.get::<String>("key").unwrap(), "high-value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_opt_missing_is_none() {
|
||||
let cfg = Config::new(Environment::Development);
|
||||
assert_eq!(cfg.get_opt::<String>("missing").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_opt_present_is_some() {
|
||||
let cfg = make_config(&[("key", "value")]);
|
||||
assert_eq!(
|
||||
cfg.get_opt::<String>("key").unwrap(),
|
||||
Some("value".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_toml_base() {
|
||||
let toml = r#"
|
||||
[config]
|
||||
app.name = "MyApp"
|
||||
app.version = "1.0.0"
|
||||
"#;
|
||||
let src = load_from_toml(toml, &Environment::Development).unwrap();
|
||||
assert_eq!(src.get_raw("app.name"), Some("MyApp".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_toml_env_overlay() {
|
||||
let toml = r#"
|
||||
[config]
|
||||
api.base_url = "https://api.example.com"
|
||||
|
||||
[env.development]
|
||||
api.base_url = "http://localhost:8080"
|
||||
"#;
|
||||
let src = load_from_toml(toml, &Environment::Development).unwrap();
|
||||
assert_eq!(
|
||||
src.get_raw("api.base_url"),
|
||||
Some("http://localhost:8080".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_toml_env_does_not_override_in_prod() {
|
||||
let toml = r#"
|
||||
[config]
|
||||
api.base_url = "https://api.example.com"
|
||||
|
||||
[env.development]
|
||||
api.base_url = "http://localhost:8080"
|
||||
"#;
|
||||
let src = load_from_toml(toml, &Environment::Production).unwrap();
|
||||
assert_eq!(
|
||||
src.get_raw("api.base_url"),
|
||||
Some("https://api.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_toml_invalid() {
|
||||
let result = load_from_toml("not valid toml %%%", &Environment::Development);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/// Environment detection — which deployment context are we in?
|
||||
|
||||
/// The current deployment environment.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Environment {
|
||||
/// Local developer machine. Verbose errors, hot reload, relaxed auth.
|
||||
Development,
|
||||
/// Pre-production environment. Production build, test data.
|
||||
Staging,
|
||||
/// Live production. Minimal logging, strict auth, performance mode.
|
||||
Production,
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
/// Detect from the `EL_ENV` environment variable (or `APP_ENV`, `RUST_ENV`).
|
||||
///
|
||||
/// Falls back to Development if unset or unrecognized.
|
||||
pub fn current() -> Self {
|
||||
let val = std::env::var("EL_ENV")
|
||||
.or_else(|_| std::env::var("APP_ENV"))
|
||||
.or_else(|_| std::env::var("RUST_ENV"))
|
||||
.unwrap_or_default();
|
||||
|
||||
Self::from_str(&val)
|
||||
}
|
||||
|
||||
/// Parse from a string.
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"production" | "prod" => Environment::Production,
|
||||
"staging" | "stage" => Environment::Staging,
|
||||
_ => Environment::Development,
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical name for this environment.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Environment::Development => "development",
|
||||
Environment::Staging => "staging",
|
||||
Environment::Production => "production",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a production environment.
|
||||
pub fn is_production(&self) -> bool {
|
||||
matches!(self, Environment::Production)
|
||||
}
|
||||
|
||||
/// Whether this is a development environment.
|
||||
pub fn is_development(&self) -> bool {
|
||||
matches!(self, Environment::Development)
|
||||
}
|
||||
|
||||
/// Whether debug features should be enabled.
|
||||
pub fn debug_enabled(&self) -> bool {
|
||||
!self.is_production()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Environment {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.name())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_production() {
|
||||
assert_eq!(Environment::from_str("production"), Environment::Production);
|
||||
assert_eq!(Environment::from_str("prod"), Environment::Production);
|
||||
assert_eq!(Environment::from_str("PRODUCTION"), Environment::Production);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_staging() {
|
||||
assert_eq!(Environment::from_str("staging"), Environment::Staging);
|
||||
assert_eq!(Environment::from_str("stage"), Environment::Staging);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_development_fallback() {
|
||||
assert_eq!(Environment::from_str("dev"), Environment::Development);
|
||||
assert_eq!(Environment::from_str(""), Environment::Development);
|
||||
assert_eq!(Environment::from_str("unknown"), Environment::Development);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_is_production() {
|
||||
assert!(Environment::Production.is_production());
|
||||
assert!(!Environment::Development.is_production());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development_debug_enabled() {
|
||||
assert!(Environment::Development.debug_enabled());
|
||||
assert!(!Environment::Production.debug_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_name() {
|
||||
assert_eq!(Environment::Production.name(), "production");
|
||||
assert_eq!(Environment::Staging.name(), "staging");
|
||||
assert_eq!(Environment::Development.name(), "development");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display() {
|
||||
assert_eq!(format!("{}", Environment::Production), "production");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("config key '{key}' not found")]
|
||||
NotFound { key: String },
|
||||
|
||||
#[error("config key '{key}': expected {expected}, got '{got}'")]
|
||||
TypeMismatch {
|
||||
key: String,
|
||||
expected: String,
|
||||
got: String,
|
||||
},
|
||||
|
||||
#[error("config parse error: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("config source error '{source_name}': {message}")]
|
||||
SourceError { source_name: String, message: String },
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! el-config — Layered, typed configuration for el-ui applications.
|
||||
//!
|
||||
//! ## Resolution order (highest priority wins)
|
||||
//!
|
||||
//! 1. Environment variables (`EL_APP_NAME=...`)
|
||||
//! 2. `.env` file (development only)
|
||||
//! 3. `manifest.el` `[env.<current>]` section
|
||||
//! 4. `manifest.el` `[config]` base section
|
||||
//! 5. Defaults defined in code
|
||||
//!
|
||||
//! ## Quick start
|
||||
//!
|
||||
//! ```
|
||||
//! use el_config::prelude::*;
|
||||
//!
|
||||
//! let mut cfg = Config::new(Environment::Development);
|
||||
//! let mut defaults = std::collections::HashMap::new();
|
||||
//! defaults.insert("app.name".to_string(), "MyApp".to_string());
|
||||
//! defaults.insert("server.port".to_string(), "8080".to_string());
|
||||
//! cfg.set_defaults(defaults);
|
||||
//!
|
||||
//! let name = cfg.get::<String>("app.name").unwrap();
|
||||
//! let port = cfg.get::<u32>("server.port").unwrap();
|
||||
//! assert_eq!(name, "MyApp");
|
||||
//! assert_eq!(port, 8080);
|
||||
//! ```
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
pub mod config;
|
||||
pub mod env;
|
||||
pub mod error;
|
||||
pub mod source;
|
||||
|
||||
pub mod prelude {
|
||||
pub use crate::config::{load_from_toml, Config};
|
||||
pub use crate::env::Environment;
|
||||
pub use crate::error::ConfigError;
|
||||
pub use crate::source::{ConfigSource, EnvVarSource, FromConfigStr, MapSource};
|
||||
}
|
||||
|
||||
pub use prelude::*;
|
||||
@@ -0,0 +1,177 @@
|
||||
// el-config — Layered, typed configuration for el-ui.
|
||||
//
|
||||
// Each layer is a key->value map. `Config::get(key)` walks layers from highest
|
||||
// to lowest priority and returns the first hit. Values are parsed into the
|
||||
// caller-requested type via `parse_<type>`.
|
||||
|
||||
// ── Environment ──────────────────────────────────────────────────────────────
|
||||
|
||||
let ENV_DEVELOPMENT: String = "development"
|
||||
let ENV_STAGING: String = "staging"
|
||||
let ENV_PRODUCTION: String = "production"
|
||||
let ENV_TEST: String = "test"
|
||||
|
||||
fn env_current() -> String {
|
||||
let raw: String = env("EL_ENV")
|
||||
if str_eq(raw, "") { return ENV_DEVELOPMENT }
|
||||
raw
|
||||
}
|
||||
|
||||
fn env_is_production(name: String) -> Bool {
|
||||
str_eq(name, "production")
|
||||
}
|
||||
|
||||
// ── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let CFG_ERR_MISSING: String = "config.missing"
|
||||
let CFG_ERR_PARSE: String = "config.parse_error"
|
||||
let CFG_ERR_TYPE: String = "config.type_error"
|
||||
|
||||
// ── Sources ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let SRC_ENV: String = "env"
|
||||
let SRC_DOTENV: String = "dotenv"
|
||||
let SRC_MANIFEST: String = "manifest"
|
||||
let SRC_DEFAULTS: String = "defaults"
|
||||
let SRC_MAP: String = "map"
|
||||
|
||||
type ConfigSource {
|
||||
kind: String
|
||||
priority: Int // higher = wins
|
||||
map_json: String // JSON: key -> string value
|
||||
}
|
||||
|
||||
fn source_env_vars(prefix: String) -> ConfigSource {
|
||||
// Snapshot env at construction (the runtime exposes env_keys()).
|
||||
let map: String = "{}"
|
||||
let keys: String = env_keys_with_prefix(prefix)
|
||||
let n: Int = json_array_len(keys)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let raw_key: String = json_array_get(keys, i)
|
||||
let value: String = env(raw_key)
|
||||
let logical_key: String = str_to_lower(str_replace(str_slice(raw_key, str_len(prefix), str_len(raw_key)), "_", "."))
|
||||
let map = json_set(map, logical_key, "\"" + value + "\"")
|
||||
let i = i + 1
|
||||
}
|
||||
{ "kind": "env", "priority": 100, "map_json": map }
|
||||
}
|
||||
|
||||
fn source_dotenv(path: String) -> ConfigSource {
|
||||
let raw: String = ""
|
||||
if fs_exists(path) { let raw = fs_read(path) }
|
||||
let map: String = dotenv_parse(raw)
|
||||
{ "kind": "dotenv", "priority": 80, "map_json": map }
|
||||
}
|
||||
|
||||
fn source_manifest(manifest_path: String, current_env: String) -> ConfigSource {
|
||||
let raw: String = fs_read(manifest_path)
|
||||
let base: String = manifest_section(raw, "config")
|
||||
let env_key: String = "env." + current_env
|
||||
let env_overrides: String = manifest_section(raw, env_key)
|
||||
let map: String = json_merge(base, env_overrides)
|
||||
{ "kind": "manifest", "priority": 60, "map_json": map }
|
||||
}
|
||||
|
||||
fn source_defaults(map_json: String) -> ConfigSource {
|
||||
{ "kind": "defaults", "priority": 0, "map_json": map_json }
|
||||
}
|
||||
|
||||
// ── Config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Config {
|
||||
environment: String // development | staging | production | test
|
||||
sources_json: String // JSON array of ConfigSource
|
||||
}
|
||||
|
||||
fn config_new(env_name: String) -> Config {
|
||||
{ "environment": env_name, "sources_json": "[]" }
|
||||
}
|
||||
|
||||
fn config_add_source(c: Config, src: ConfigSource) -> Config {
|
||||
let updated: String = json_array_push_sorted(c.sources_json, json_encode(src), "priority", true)
|
||||
{ "environment": c.environment, "sources_json": updated }
|
||||
}
|
||||
|
||||
fn config_set_defaults(c: Config, defaults_map: String) -> Config {
|
||||
config_add_source(c, source_defaults(defaults_map))
|
||||
}
|
||||
|
||||
fn config_get_string(c: Config, key: String) -> String {
|
||||
let n: Int = json_array_len(c.sources_json)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let src_json: String = json_array_get(c.sources_json, i)
|
||||
let map: String = json_get(src_json, "map_json")
|
||||
let v: String = json_get(map, key)
|
||||
if !str_eq(v, "") { return v }
|
||||
let i = i + 1
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
fn config_get_int(c: Config, key: String) -> Int {
|
||||
let raw: String = config_get_string(c, key)
|
||||
if str_eq(raw, "") { return 0 }
|
||||
str_to_int(raw)
|
||||
}
|
||||
|
||||
fn config_get_bool(c: Config, key: String) -> Bool {
|
||||
let raw: String = str_to_lower(config_get_string(c, key))
|
||||
if str_eq(raw, "true") { return true }
|
||||
if str_eq(raw, "1") { return true }
|
||||
if str_eq(raw, "yes") { return true }
|
||||
false
|
||||
}
|
||||
|
||||
// Strict variants — non-empty required.
|
||||
fn config_require_string(c: Config, key: String) -> String {
|
||||
let v: String = config_get_string(c, key)
|
||||
if str_eq(v, "") { panic(CFG_ERR_MISSING + ":" + key) }
|
||||
v
|
||||
}
|
||||
|
||||
// ── load_from_toml — convenience for TOML config files ──────────────────────
|
||||
|
||||
fn config_load_from_toml(path: String, env_name: String) -> Config {
|
||||
let cfg: Config = config_new(env_name)
|
||||
let raw: String = fs_read(path)
|
||||
let map: String = toml_to_json_flat(raw) // dotted keys
|
||||
let cfg = config_add_source(cfg, source_defaults(map))
|
||||
cfg
|
||||
}
|
||||
|
||||
// ── .env parser (minimal) ───────────────────────────────────────────────────
|
||||
|
||||
fn dotenv_parse(raw: String) -> String {
|
||||
let map: String = "{}"
|
||||
let lines: String = str_split(raw, "\n")
|
||||
let n: Int = json_array_len(lines)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let line: String = str_trim(json_array_get(lines, i))
|
||||
if str_eq(line, "") {
|
||||
let i = i + 1
|
||||
}
|
||||
if !str_eq(line, "") {
|
||||
if !str_starts_with(line, "#") {
|
||||
let eq: Int = str_index_of(line, "=")
|
||||
if eq > 0 {
|
||||
let k: String = str_trim(str_slice(line, 0, eq))
|
||||
let v: String = str_trim(str_slice(line, eq + 1, str_len(line)))
|
||||
let v = str_strip_quotes(v)
|
||||
let map = json_set(map, str_to_lower(str_replace(k, "_", ".")), "\"" + v + "\"")
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let cfg: Config = config_new(env_current())
|
||||
let defaults: String = "{\"app.name\":\"MyApp\",\"server.port\":\"8080\"}"
|
||||
let cfg = config_set_defaults(cfg, defaults)
|
||||
println("[el-config] env=" + cfg.environment + " app=" + config_get_string(cfg, "app.name"))
|
||||
@@ -0,0 +1,248 @@
|
||||
/// ConfigSource trait and implementations.
|
||||
///
|
||||
/// Each source provides key→value pairs. Sources are stacked in priority order;
|
||||
/// the Config struct resolves by asking each source in turn.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use crate::error::ConfigError;
|
||||
|
||||
/// A source of configuration values.
|
||||
pub trait ConfigSource: Send + Sync {
|
||||
/// The name of this source (for debugging/error messages).
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Get a raw string value for a key.
|
||||
/// Returns None if this source doesn't have the key.
|
||||
fn get_raw(&self, key: &str) -> Option<String>;
|
||||
|
||||
/// All key→value pairs from this source.
|
||||
fn all(&self) -> HashMap<String, String>;
|
||||
}
|
||||
|
||||
/// Reads from environment variables.
|
||||
///
|
||||
/// Keys are mapped: `app.name` → `EL_APP_NAME` (uppercased, dots → underscores).
|
||||
pub struct EnvVarSource {
|
||||
/// Optional prefix. Default: "EL".
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
impl EnvVarSource {
|
||||
pub fn new() -> Self {
|
||||
Self { prefix: "EL".to_string() }
|
||||
}
|
||||
|
||||
pub fn with_prefix(prefix: impl Into<String>) -> Self {
|
||||
Self { prefix: prefix.into() }
|
||||
}
|
||||
|
||||
fn env_key(&self, key: &str) -> String {
|
||||
let normalized = key.replace('.', "_").replace('-', "_").to_uppercase();
|
||||
format!("{}_{}", self.prefix, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EnvVarSource {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigSource for EnvVarSource {
|
||||
fn name(&self) -> &str {
|
||||
"environment"
|
||||
}
|
||||
|
||||
fn get_raw(&self, key: &str) -> Option<String> {
|
||||
std::env::var(self.env_key(key)).ok()
|
||||
}
|
||||
|
||||
fn all(&self) -> HashMap<String, String> {
|
||||
let prefix = format!("{}_", self.prefix);
|
||||
std::env::vars()
|
||||
.filter(|(k, _)| k.starts_with(&prefix))
|
||||
.map(|(k, v)| {
|
||||
let stripped = k.strip_prefix(&prefix).unwrap_or(&k);
|
||||
let config_key = stripped.to_lowercase().replace('_', ".");
|
||||
(config_key, v)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds an in-memory map of config values.
|
||||
///
|
||||
/// Used for defaults defined in code, or for config loaded from a parsed
|
||||
/// TOML/JSON file section.
|
||||
pub struct MapSource {
|
||||
name: String,
|
||||
values: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl MapSource {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
values: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
|
||||
self.values.insert(key.into(), value.into());
|
||||
}
|
||||
|
||||
pub fn from_map(name: impl Into<String>, map: HashMap<String, String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
values: map,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigSource for MapSource {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn get_raw(&self, key: &str) -> Option<String> {
|
||||
self.values.get(key).cloned()
|
||||
}
|
||||
|
||||
fn all(&self) -> HashMap<String, String> {
|
||||
self.values.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed config value extractor.
|
||||
pub trait FromConfigStr: Sized {
|
||||
fn from_config_str(s: &str) -> Result<Self, ConfigError>;
|
||||
}
|
||||
|
||||
impl FromConfigStr for String {
|
||||
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
|
||||
Ok(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromConfigStr for u32 {
|
||||
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
|
||||
s.parse().map_err(|_| ConfigError::TypeMismatch {
|
||||
key: String::new(),
|
||||
expected: "u32".to_string(),
|
||||
got: s.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromConfigStr for u64 {
|
||||
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
|
||||
s.parse().map_err(|_| ConfigError::TypeMismatch {
|
||||
key: String::new(),
|
||||
expected: "u64".to_string(),
|
||||
got: s.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromConfigStr for i32 {
|
||||
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
|
||||
s.parse().map_err(|_| ConfigError::TypeMismatch {
|
||||
key: String::new(),
|
||||
expected: "i32".to_string(),
|
||||
got: s.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromConfigStr for i64 {
|
||||
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
|
||||
s.parse().map_err(|_| ConfigError::TypeMismatch {
|
||||
key: String::new(),
|
||||
expected: "i64".to_string(),
|
||||
got: s.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromConfigStr for f32 {
|
||||
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
|
||||
s.parse().map_err(|_| ConfigError::TypeMismatch {
|
||||
key: String::new(),
|
||||
expected: "f32".to_string(),
|
||||
got: s.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromConfigStr for f64 {
|
||||
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
|
||||
s.parse().map_err(|_| ConfigError::TypeMismatch {
|
||||
key: String::new(),
|
||||
expected: "f64".to_string(),
|
||||
got: s.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromConfigStr for bool {
|
||||
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"true" | "1" | "yes" | "on" => Ok(true),
|
||||
"false" | "0" | "no" | "off" => Ok(false),
|
||||
_ => Err(ConfigError::TypeMismatch {
|
||||
key: String::new(),
|
||||
expected: "bool".to_string(),
|
||||
got: s.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn map_source_get() {
|
||||
let mut src = MapSource::new("test");
|
||||
src.insert("app.name", "TestApp");
|
||||
assert_eq!(src.get_raw("app.name"), Some("TestApp".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_source_missing() {
|
||||
let src = MapSource::new("test");
|
||||
assert_eq!(src.get_raw("no.key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bool_from_config_str() {
|
||||
assert_eq!(bool::from_config_str("true").unwrap(), true);
|
||||
assert_eq!(bool::from_config_str("1").unwrap(), true);
|
||||
assert_eq!(bool::from_config_str("false").unwrap(), false);
|
||||
assert_eq!(bool::from_config_str("0").unwrap(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u32_from_config_str() {
|
||||
assert_eq!(u32::from_config_str("42").unwrap(), 42u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u32_type_mismatch() {
|
||||
assert!(u32::from_config_str("not-a-number").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_key_mapping() {
|
||||
let src = EnvVarSource::new();
|
||||
// app.name → EL_APP_NAME
|
||||
assert_eq!(src.env_key("app.name"), "EL_APP_NAME");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_source_name() {
|
||||
let src = EnvVarSource::new();
|
||||
assert_eq!(src.name(), "environment");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user