feat: port el-ui vessels — rename crates→vessels, add El source + manifests
This commit is contained in:
@@ -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