use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct EnvironmentConfig {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub variables: HashMap<String, EnvValue>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env_files: Vec<EnvFileRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub passthrough_prefixes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub passthrough_vars: Vec<String>,
}
impl EnvironmentConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.variables
.insert(key.into(), EnvValue::Plain(value.into()));
self
}
pub fn with_reference(mut self, key: impl Into<String>, ref_var: impl Into<String>) -> Self {
self.variables
.insert(key.into(), EnvValue::Reference(ref_var.into()));
self
}
pub fn with_secret(mut self, key: impl Into<String>, secret_ref: SecretRef) -> Self {
self.variables
.insert(key.into(), EnvValue::Secret(secret_ref));
self
}
pub fn with_env_file(mut self, path: impl Into<String>) -> Self {
self.env_files.push(EnvFileRef {
path: path.into(),
required: true,
prefix: None,
});
self
}
pub fn with_optional_env_file(mut self, path: impl Into<String>) -> Self {
self.env_files.push(EnvFileRef {
path: path.into(),
required: false,
prefix: None,
});
self
}
pub fn with_passthrough_prefix(mut self, prefix: impl Into<String>) -> Self {
self.passthrough_prefixes.push(prefix.into());
self
}
pub fn with_passthrough_var(mut self, var: impl Into<String>) -> Self {
self.passthrough_vars.push(var.into());
self
}
pub fn variable_keys(&self) -> Vec<&str> {
self.variables.keys().map(|s| s.as_str()).collect()
}
pub fn is_secret(&self, key: &str) -> bool {
self.variables
.get(key)
.map(|v| matches!(v, EnvValue::Secret(_)))
.unwrap_or(false)
}
pub fn secret_refs(&self) -> Vec<(&str, &SecretRef)> {
self.variables
.iter()
.filter_map(|(k, v)| match v {
EnvValue::Secret(r) => Some((k.as_str(), r)),
_ => None,
})
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "type", content = "value")]
pub enum EnvValue {
Plain(String),
Reference(String),
Secret(SecretRef),
Generated(GeneratedValue),
FromFile(PathBuf),
}
impl EnvValue {
pub fn plain(value: impl Into<String>) -> Self {
Self::Plain(value.into())
}
pub fn reference(var_name: impl Into<String>) -> Self {
Self::Reference(var_name.into())
}
pub fn secret(context_id: impl Into<String>, key: impl Into<String>) -> Self {
Self::Secret(SecretRef::new(context_id, key))
}
pub fn uuid() -> Self {
Self::Generated(GeneratedValue::Uuid)
}
pub fn timestamp() -> Self {
Self::Generated(GeneratedValue::Timestamp)
}
pub fn from_file(path: impl Into<PathBuf>) -> Self {
Self::FromFile(path.into())
}
pub fn is_plain(&self) -> bool {
matches!(self, Self::Plain(_))
}
pub fn is_secret(&self) -> bool {
matches!(self, Self::Secret(_))
}
pub fn needs_resolution(&self) -> bool {
!matches!(self, Self::Plain(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct SecretRef {
pub context_id: String,
pub key: String,
}
impl SecretRef {
pub fn new(context_id: impl Into<String>, key: impl Into<String>) -> Self {
Self {
context_id: context_id.into(),
key: key.into(),
}
}
pub fn current(key: impl Into<String>) -> Self {
Self::new(".", key)
}
pub fn is_current_context(&self) -> bool {
self.context_id == "."
}
pub fn parse(s: &str) -> Option<Self> {
let s = s.strip_prefix("secret://")?;
let parts: Vec<&str> = s.splitn(2, '/').collect();
if parts.len() == 2 {
Some(Self::new(parts[0], parts[1]))
} else {
None
}
}
pub fn to_uri(&self) -> String {
format!("secret://{}/{}", self.context_id, self.key)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "generator")]
pub enum GeneratedValue {
Uuid,
Timestamp,
RandomString {
length: usize,
},
Hash {
algorithm: String,
of: String,
},
}
impl GeneratedValue {
pub fn random_string(length: usize) -> Self {
Self::RandomString { length }
}
pub fn hash(algorithm: impl Into<String>, of: impl Into<String>) -> Self {
Self::Hash {
algorithm: algorithm.into(),
of: of.into(),
}
}
pub fn generate(&self) -> String {
match self {
Self::Uuid => uuid::Uuid::new_v4().to_string(),
Self::Timestamp => chrono::Utc::now().to_rfc3339(),
Self::RandomString { length } => {
use std::iter;
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand_simple();
iter::repeat_with(|| CHARSET[rng.next() % CHARSET.len()])
.map(|c| c as char)
.take(*length)
.collect()
}
Self::Hash { algorithm, of } => {
format!("{}:{}", algorithm, of)
}
}
}
}
struct SimpleRng(u64);
impl SimpleRng {
fn next(&mut self) -> usize {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
(self.0 >> 33) as usize
}
}
fn rand_simple() -> SimpleRng {
use std::time::{SystemTime, UNIX_EPOCH};
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
SimpleRng(seed)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct EnvFileRef {
pub path: String,
#[serde(default = "default_true")]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
}
fn default_true() -> bool {
true
}
impl EnvFileRef {
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
required: true,
prefix: None,
}
}
pub fn optional(path: impl Into<String>) -> Self {
Self {
path: path.into(),
required: false,
prefix: None,
}
}
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = Some(prefix.into());
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_env_config_builder() {
let config = EnvironmentConfig::new()
.with_var("LOG_LEVEL", "debug")
.with_reference("API_URL", "PRODUCTION_API_URL")
.with_passthrough_prefix("AWS_")
.with_passthrough_var("PATH");
assert_eq!(config.variables.len(), 2);
assert!(config.passthrough_prefixes.contains(&"AWS_".to_string()));
assert!(config.passthrough_vars.contains(&"PATH".to_string()));
}
#[test]
fn test_env_value_types() {
let plain = EnvValue::plain("value");
let reference = EnvValue::reference("OTHER_VAR");
let secret = EnvValue::secret("my-context", "api-key");
let uuid = EnvValue::uuid();
assert!(plain.is_plain());
assert!(!plain.needs_resolution());
assert!(!reference.is_plain());
assert!(reference.needs_resolution());
assert!(secret.is_secret());
assert!(secret.needs_resolution());
assert!(uuid.needs_resolution());
}
#[test]
fn test_secret_ref_parsing() {
let ref1 = SecretRef::parse("secret://my-context/api-key").unwrap();
assert_eq!(ref1.context_id, "my-context");
assert_eq!(ref1.key, "api-key");
assert!(!ref1.is_current_context());
let ref2 = SecretRef::parse("secret://./local-key").unwrap();
assert!(ref2.is_current_context());
assert!(SecretRef::parse("invalid").is_none());
assert!(SecretRef::parse("other://scheme").is_none());
}
#[test]
fn test_secret_ref_uri() {
let secret_ref = SecretRef::new("ctx", "key");
assert_eq!(secret_ref.to_uri(), "secret://ctx/key");
}
#[test]
fn test_generated_value() {
let uuid = GeneratedValue::Uuid.generate();
assert_eq!(uuid.len(), 36);
let timestamp = GeneratedValue::Timestamp.generate();
assert!(timestamp.contains("T"));
let random = GeneratedValue::random_string(10).generate();
assert_eq!(random.len(), 10);
}
#[test]
fn test_env_config_serialization() {
let config = EnvironmentConfig::new()
.with_var("KEY", "value")
.with_secret("SECRET_KEY", SecretRef::current("my-secret"));
let json = serde_json::to_string(&config).unwrap();
let deserialized: EnvironmentConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config.variables.len(), deserialized.variables.len());
}
#[test]
fn test_env_file_ref() {
let required = EnvFileRef::new(".env.production");
assert!(required.required);
let optional = EnvFileRef::optional(".env.local").with_prefix("LOCAL_");
assert!(!optional.required);
assert_eq!(optional.prefix, Some("LOCAL_".to_string()));
}
}