add el-style, el-layout, el-i18n, el-config, el-secrets: responsive by default, theme-driven, zero breakpoints

This commit is contained in:
Will Anderson
2026-04-27 20:18:47 -05:00
parent 76f419ad7a
commit ef841d8a01
43 changed files with 6384 additions and 0 deletions
+337
View File
@@ -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. el.toml [env.<current>] section
/// 4. el.toml [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 `el.toml` 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("el.toml", 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());
}
}
+114
View File
@@ -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");
}
}
+20
View File
@@ -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 },
}
+42
View File
@@ -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. `el.toml` `[env.<current>]` section
//! 4. `el.toml` `[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::*;
+248
View File
@@ -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");
}
}