1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub struct EnvironmentConfig {
14 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
16 pub variables: HashMap<String, EnvValue>,
17
18 #[serde(default, skip_serializing_if = "Vec::is_empty")]
20 pub env_files: Vec<EnvFileRef>,
21
22 #[serde(default, skip_serializing_if = "Vec::is_empty")]
24 pub passthrough_prefixes: Vec<String>,
25
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
28 pub passthrough_vars: Vec<String>,
29}
30
31impl EnvironmentConfig {
32 pub fn new() -> Self {
34 Self::default()
35 }
36
37 pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
39 self.variables
40 .insert(key.into(), EnvValue::Plain(value.into()));
41 self
42 }
43
44 pub fn with_reference(mut self, key: impl Into<String>, ref_var: impl Into<String>) -> Self {
46 self.variables
47 .insert(key.into(), EnvValue::Reference(ref_var.into()));
48 self
49 }
50
51 pub fn with_secret(mut self, key: impl Into<String>, secret_ref: SecretRef) -> Self {
53 self.variables
54 .insert(key.into(), EnvValue::Secret(secret_ref));
55 self
56 }
57
58 pub fn with_env_file(mut self, path: impl Into<String>) -> Self {
60 self.env_files.push(EnvFileRef {
61 path: path.into(),
62 required: true,
63 prefix: None,
64 });
65 self
66 }
67
68 pub fn with_optional_env_file(mut self, path: impl Into<String>) -> Self {
70 self.env_files.push(EnvFileRef {
71 path: path.into(),
72 required: false,
73 prefix: None,
74 });
75 self
76 }
77
78 pub fn with_passthrough_prefix(mut self, prefix: impl Into<String>) -> Self {
80 self.passthrough_prefixes.push(prefix.into());
81 self
82 }
83
84 pub fn with_passthrough_var(mut self, var: impl Into<String>) -> Self {
86 self.passthrough_vars.push(var.into());
87 self
88 }
89
90 pub fn variable_keys(&self) -> Vec<&str> {
92 self.variables.keys().map(|s| s.as_str()).collect()
93 }
94
95 pub fn is_secret(&self, key: &str) -> bool {
97 self.variables
98 .get(key)
99 .map(|v| matches!(v, EnvValue::Secret(_)))
100 .unwrap_or(false)
101 }
102
103 pub fn secret_refs(&self) -> Vec<(&str, &SecretRef)> {
105 self.variables
106 .iter()
107 .filter_map(|(k, v)| match v {
108 EnvValue::Secret(r) => Some((k.as_str(), r)),
109 _ => None,
110 })
111 .collect()
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
117#[serde(rename_all = "snake_case", tag = "type", content = "value")]
118pub enum EnvValue {
119 Plain(String),
121
122 Reference(String),
124
125 Secret(SecretRef),
127
128 Generated(GeneratedValue),
130
131 FromFile(PathBuf),
133}
134
135impl EnvValue {
136 pub fn plain(value: impl Into<String>) -> Self {
138 Self::Plain(value.into())
139 }
140
141 pub fn reference(var_name: impl Into<String>) -> Self {
143 Self::Reference(var_name.into())
144 }
145
146 pub fn secret(context_id: impl Into<String>, key: impl Into<String>) -> Self {
148 Self::Secret(SecretRef::new(context_id, key))
149 }
150
151 pub fn uuid() -> Self {
153 Self::Generated(GeneratedValue::Uuid)
154 }
155
156 pub fn timestamp() -> Self {
158 Self::Generated(GeneratedValue::Timestamp)
159 }
160
161 pub fn from_file(path: impl Into<PathBuf>) -> Self {
163 Self::FromFile(path.into())
164 }
165
166 pub fn is_plain(&self) -> bool {
168 matches!(self, Self::Plain(_))
169 }
170
171 pub fn is_secret(&self) -> bool {
173 matches!(self, Self::Secret(_))
174 }
175
176 pub fn needs_resolution(&self) -> bool {
178 !matches!(self, Self::Plain(_))
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
184#[serde(rename_all = "snake_case")]
185pub struct SecretRef {
186 pub context_id: String,
188
189 pub key: String,
191}
192
193impl SecretRef {
194 pub fn new(context_id: impl Into<String>, key: impl Into<String>) -> Self {
196 Self {
197 context_id: context_id.into(),
198 key: key.into(),
199 }
200 }
201
202 pub fn current(key: impl Into<String>) -> Self {
204 Self::new(".", key)
205 }
206
207 pub fn is_current_context(&self) -> bool {
209 self.context_id == "."
210 }
211
212 pub fn parse(s: &str) -> Option<Self> {
214 let s = s.strip_prefix("secret://")?;
215 let parts: Vec<&str> = s.splitn(2, '/').collect();
216 if parts.len() == 2 {
217 Some(Self::new(parts[0], parts[1]))
218 } else {
219 None
220 }
221 }
222
223 pub fn to_uri(&self) -> String {
225 format!("secret://{}/{}", self.context_id, self.key)
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
231#[serde(rename_all = "snake_case", tag = "generator")]
232pub enum GeneratedValue {
233 Uuid,
235
236 Timestamp,
238
239 RandomString {
241 length: usize,
243 },
244
245 Hash {
247 algorithm: String,
249 of: String,
251 },
252}
253
254impl GeneratedValue {
255 pub fn random_string(length: usize) -> Self {
257 Self::RandomString { length }
258 }
259
260 pub fn hash(algorithm: impl Into<String>, of: impl Into<String>) -> Self {
262 Self::Hash {
263 algorithm: algorithm.into(),
264 of: of.into(),
265 }
266 }
267
268 pub fn generate(&self) -> String {
270 match self {
271 Self::Uuid => uuid::Uuid::new_v4().to_string(),
272 Self::Timestamp => chrono::Utc::now().to_rfc3339(),
273 Self::RandomString { length } => {
274 use std::iter;
275 const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
276 let mut rng = rand_simple();
277 iter::repeat_with(|| CHARSET[rng.next() % CHARSET.len()])
278 .map(|c| c as char)
279 .take(*length)
280 .collect()
281 }
282 Self::Hash { algorithm, of } => {
283 format!("{}:{}", algorithm, of)
286 }
287 }
288 }
289}
290
291struct SimpleRng(u64);
293
294impl SimpleRng {
295 fn next(&mut self) -> usize {
296 self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
297 (self.0 >> 33) as usize
298 }
299}
300
301fn rand_simple() -> SimpleRng {
302 use std::time::{SystemTime, UNIX_EPOCH};
303 let seed = SystemTime::now()
304 .duration_since(UNIX_EPOCH)
305 .map(|d| d.as_nanos() as u64)
306 .unwrap_or(0);
307 SimpleRng(seed)
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
312#[serde(rename_all = "snake_case")]
313pub struct EnvFileRef {
314 pub path: String,
316
317 #[serde(default = "default_true")]
319 pub required: bool,
320
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub prefix: Option<String>,
324}
325
326fn default_true() -> bool {
327 true
328}
329
330impl EnvFileRef {
331 pub fn new(path: impl Into<String>) -> Self {
333 Self {
334 path: path.into(),
335 required: true,
336 prefix: None,
337 }
338 }
339
340 pub fn optional(path: impl Into<String>) -> Self {
342 Self {
343 path: path.into(),
344 required: false,
345 prefix: None,
346 }
347 }
348
349 pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
351 self.prefix = Some(prefix.into());
352 self
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn test_env_config_builder() {
362 let config = EnvironmentConfig::new()
363 .with_var("LOG_LEVEL", "debug")
364 .with_reference("API_URL", "PRODUCTION_API_URL")
365 .with_passthrough_prefix("AWS_")
366 .with_passthrough_var("PATH");
367
368 assert_eq!(config.variables.len(), 2);
369 assert!(config.passthrough_prefixes.contains(&"AWS_".to_string()));
370 assert!(config.passthrough_vars.contains(&"PATH".to_string()));
371 }
372
373 #[test]
374 fn test_env_value_types() {
375 let plain = EnvValue::plain("value");
376 let reference = EnvValue::reference("OTHER_VAR");
377 let secret = EnvValue::secret("my-context", "api-key");
378 let uuid = EnvValue::uuid();
379
380 assert!(plain.is_plain());
381 assert!(!plain.needs_resolution());
382
383 assert!(!reference.is_plain());
384 assert!(reference.needs_resolution());
385
386 assert!(secret.is_secret());
387 assert!(secret.needs_resolution());
388
389 assert!(uuid.needs_resolution());
390 }
391
392 #[test]
393 fn test_secret_ref_parsing() {
394 let ref1 = SecretRef::parse("secret://my-context/api-key").unwrap();
395 assert_eq!(ref1.context_id, "my-context");
396 assert_eq!(ref1.key, "api-key");
397 assert!(!ref1.is_current_context());
398
399 let ref2 = SecretRef::parse("secret://./local-key").unwrap();
400 assert!(ref2.is_current_context());
401
402 assert!(SecretRef::parse("invalid").is_none());
403 assert!(SecretRef::parse("other://scheme").is_none());
404 }
405
406 #[test]
407 fn test_secret_ref_uri() {
408 let secret_ref = SecretRef::new("ctx", "key");
409 assert_eq!(secret_ref.to_uri(), "secret://ctx/key");
410 }
411
412 #[test]
413 fn test_generated_value() {
414 let uuid = GeneratedValue::Uuid.generate();
415 assert_eq!(uuid.len(), 36); let timestamp = GeneratedValue::Timestamp.generate();
418 assert!(timestamp.contains("T")); let random = GeneratedValue::random_string(10).generate();
421 assert_eq!(random.len(), 10);
422 }
423
424 #[test]
425 fn test_env_config_serialization() {
426 let config = EnvironmentConfig::new()
427 .with_var("KEY", "value")
428 .with_secret("SECRET_KEY", SecretRef::current("my-secret"));
429
430 let json = serde_json::to_string(&config).unwrap();
431 let deserialized: EnvironmentConfig = serde_json::from_str(&json).unwrap();
432
433 assert_eq!(config.variables.len(), deserialized.variables.len());
434 }
435
436 #[test]
437 fn test_env_file_ref() {
438 let required = EnvFileRef::new(".env.production");
439 assert!(required.required);
440
441 let optional = EnvFileRef::optional(".env.local").with_prefix("LOCAL_");
442 assert!(!optional.required);
443 assert_eq!(optional.prefix, Some("LOCAL_".to_string()));
444 }
445}