1use std::collections::HashMap;
16use std::env;
17use std::fmt;
18use std::str::FromStr;
19
20#[derive(Debug, PartialEq)]
22pub struct ValidationError {
23 pub errors: Vec<String>,
24}
25
26impl fmt::Display for ValidationError {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 writeln!(f, "{} validation error(s):", self.errors.len())?;
29 for e in &self.errors {
30 writeln!(f, " - {}", e)?;
31 }
32 Ok(())
33 }
34}
35
36impl std::error::Error for ValidationError {}
37
38#[derive(Debug, Clone)]
40pub enum FieldType {
41 Str,
42 Int,
43 Float,
44 Bool,
45 Url,
46}
47
48#[derive(Debug, Clone)]
50pub struct FieldSpec {
51 pub name: String,
52 pub field_type: FieldType,
53 pub required: bool,
54 pub default: Option<String>,
55 pub choices: Option<Vec<String>>,
56}
57
58#[derive(Debug, Default, Clone)]
60pub struct Schema {
61 fields: Vec<FieldSpec>,
62}
63
64impl Schema {
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 pub fn string(self, name: &str) -> FieldSpecBuilder {
70 FieldSpecBuilder::new(self, name, FieldType::Str)
71 }
72
73 pub fn integer(self, name: &str) -> FieldSpecBuilder {
74 FieldSpecBuilder::new(self, name, FieldType::Int)
75 }
76
77 pub fn float(self, name: &str) -> FieldSpecBuilder {
78 FieldSpecBuilder::new(self, name, FieldType::Float)
79 }
80
81 pub fn boolean(self, name: &str) -> FieldSpecBuilder {
82 FieldSpecBuilder::new(self, name, FieldType::Bool)
83 }
84
85 pub fn url(self, name: &str) -> FieldSpecBuilder {
86 FieldSpecBuilder::new(self, name, FieldType::Url)
87 }
88
89 pub fn validate(&self) -> Result<HashMap<String, EnvValue>, ValidationError> {
91 self.validate_from(None)
92 }
93
94 pub fn validate_from(
96 &self,
97 source: Option<&HashMap<String, String>>,
98 ) -> Result<HashMap<String, EnvValue>, ValidationError> {
99 let mut errors = Vec::new();
100 let mut result = HashMap::new();
101
102 for spec in &self.fields {
103 let raw = match source {
104 Some(map) => map.get(&spec.name).cloned(),
105 None => env::var(&spec.name).ok(),
106 };
107
108 let raw = match raw {
109 Some(v) if !v.is_empty() => v,
110 _ => {
111 if let Some(ref default) = spec.default {
112 default.clone()
113 } else if spec.required {
114 errors.push(format!("missing required variable: {}", spec.name));
115 continue;
116 } else {
117 continue;
118 }
119 }
120 };
121
122 if let Some(ref choices) = spec.choices {
123 if !choices.contains(&raw) {
124 errors.push(format!(
125 "{} must be one of {:?}, got '{}'",
126 spec.name, choices, raw
127 ));
128 continue;
129 }
130 }
131
132 match parse_value(&raw, &spec.field_type) {
133 Ok(val) => {
134 result.insert(spec.name.clone(), val);
135 }
136 Err(msg) => errors.push(format!("{}: {}", spec.name, msg)),
137 }
138 }
139
140 if errors.is_empty() {
141 Ok(result)
142 } else {
143 Err(ValidationError { errors })
144 }
145 }
146}
147
148pub struct FieldSpecBuilder {
150 schema: Schema,
151 spec: FieldSpec,
152}
153
154impl FieldSpecBuilder {
155 fn new(schema: Schema, name: &str, field_type: FieldType) -> Self {
156 Self {
157 schema,
158 spec: FieldSpec {
159 name: name.to_string(),
160 field_type,
161 required: true,
162 default: None,
163 choices: None,
164 },
165 }
166 }
167
168 pub fn required(mut self, r: bool) -> Self {
169 self.spec.required = r;
170 self
171 }
172
173 pub fn default_value(mut self, v: &str) -> Self {
174 self.spec.default = Some(v.to_string());
175 self
176 }
177
178 pub fn choices(mut self, c: &[&str]) -> Self {
179 self.spec.choices = Some(c.iter().map(|s| s.to_string()).collect());
180 self
181 }
182
183 pub fn build(mut self) -> Schema {
184 self.schema.fields.push(self.spec);
185 self.schema
186 }
187}
188
189#[derive(Debug, Clone)]
191pub enum EnvValue {
192 Str(String),
193 Int(i64),
194 Float(f64),
195 Bool(bool),
196}
197
198impl EnvValue {
199 pub fn as_str(&self) -> Option<&str> {
200 match self {
201 EnvValue::Str(s) => Some(s),
202 _ => None,
203 }
204 }
205
206 pub fn as_int(&self) -> Option<i64> {
207 match self {
208 EnvValue::Int(n) => Some(*n),
209 _ => None,
210 }
211 }
212
213 pub fn as_float(&self) -> Option<f64> {
214 match self {
215 EnvValue::Float(f) => Some(*f),
216 _ => None,
217 }
218 }
219
220 pub fn as_bool(&self) -> Option<bool> {
221 match self {
222 EnvValue::Bool(b) => Some(*b),
223 _ => None,
224 }
225 }
226}
227
228impl fmt::Display for EnvValue {
229 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230 match self {
231 EnvValue::Str(s) => write!(f, "{}", s),
232 EnvValue::Int(n) => write!(f, "{}", n),
233 EnvValue::Float(v) => write!(f, "{}", v),
234 EnvValue::Bool(b) => write!(f, "{}", b),
235 }
236 }
237}
238
239impl PartialEq for EnvValue {
240 fn eq(&self, other: &Self) -> bool {
241 match (self, other) {
242 (EnvValue::Str(a), EnvValue::Str(b)) => a == b,
243 (EnvValue::Int(a), EnvValue::Int(b)) => a == b,
244 (EnvValue::Float(a), EnvValue::Float(b)) => a.to_bits() == b.to_bits(),
245 (EnvValue::Bool(a), EnvValue::Bool(b)) => a == b,
246 _ => false,
247 }
248 }
249}
250
251impl From<String> for EnvValue {
252 fn from(s: String) -> Self {
253 EnvValue::Str(s)
254 }
255}
256
257impl From<&str> for EnvValue {
258 fn from(s: &str) -> Self {
259 EnvValue::Str(s.to_string())
260 }
261}
262
263impl From<i64> for EnvValue {
264 fn from(n: i64) -> Self {
265 EnvValue::Int(n)
266 }
267}
268
269impl From<f64> for EnvValue {
270 fn from(v: f64) -> Self {
271 EnvValue::Float(v)
272 }
273}
274
275impl From<bool> for EnvValue {
276 fn from(b: bool) -> Self {
277 EnvValue::Bool(b)
278 }
279}
280
281fn parse_value(raw: &str, field_type: &FieldType) -> Result<EnvValue, String> {
282 match field_type {
283 FieldType::Str => Ok(EnvValue::Str(raw.to_string())),
284 FieldType::Int => i64::from_str(raw)
285 .map(EnvValue::Int)
286 .map_err(|_| format!("cannot convert '{}' to int", raw)),
287 FieldType::Float => f64::from_str(raw)
288 .map(EnvValue::Float)
289 .map_err(|_| format!("cannot convert '{}' to float", raw)),
290 FieldType::Bool => match raw.to_lowercase().as_str() {
291 "true" | "1" | "yes" | "on" => Ok(EnvValue::Bool(true)),
292 "false" | "0" | "no" | "off" => Ok(EnvValue::Bool(false)),
293 _ => Err(format!("cannot convert '{}' to bool", raw)),
294 },
295 FieldType::Url => {
296 if raw.starts_with("http://") || raw.starts_with("https://") {
297 Ok(EnvValue::Str(raw.to_string()))
298 } else {
299 Err(format!("'{}' is not a valid URL", raw))
300 }
301 }
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 fn source(pairs: &[(&str, &str)]) -> HashMap<String, String> {
310 pairs
311 .iter()
312 .map(|(k, v)| (k.to_string(), v.to_string()))
313 .collect()
314 }
315
316 #[test]
317 fn test_required_field_present() {
318 let src = source(&[("HOST", "localhost")]);
319 let result = Schema::new()
320 .string("HOST")
321 .build()
322 .validate_from(Some(&src))
323 .unwrap();
324 assert_eq!(result["HOST"].as_str().unwrap(), "localhost");
325 }
326
327 #[test]
328 fn test_required_field_missing() {
329 let src = source(&[]);
330 let err = Schema::new()
331 .string("HOST")
332 .build()
333 .validate_from(Some(&src))
334 .unwrap_err();
335 assert_eq!(err.errors.len(), 1);
336 assert!(err.errors[0].contains("missing required variable"));
337 }
338
339 #[test]
340 fn test_optional_field_missing() {
341 let src = source(&[]);
342 let result = Schema::new()
343 .string("HOST")
344 .required(false)
345 .build()
346 .validate_from(Some(&src))
347 .unwrap();
348 assert!(!result.contains_key("HOST"));
349 }
350
351 #[test]
352 fn test_default_value() {
353 let src = source(&[]);
354 let result = Schema::new()
355 .integer("PORT")
356 .default_value("3000")
357 .build()
358 .validate_from(Some(&src))
359 .unwrap();
360 assert_eq!(result["PORT"].as_int().unwrap(), 3000);
361 }
362
363 #[test]
364 fn test_integer_parsing() {
365 let src = source(&[("PORT", "8080")]);
366 let result = Schema::new()
367 .integer("PORT")
368 .build()
369 .validate_from(Some(&src))
370 .unwrap();
371 assert_eq!(result["PORT"].as_int().unwrap(), 8080);
372 }
373
374 #[test]
375 fn test_integer_invalid() {
376 let src = source(&[("PORT", "abc")]);
377 let err = Schema::new()
378 .integer("PORT")
379 .build()
380 .validate_from(Some(&src))
381 .unwrap_err();
382 assert!(err.errors[0].contains("cannot convert"));
383 }
384
385 #[test]
386 fn test_float_parsing() {
387 let src = source(&[("RATE", "3.14")]);
388 let result = Schema::new()
389 .float("RATE")
390 .build()
391 .validate_from(Some(&src))
392 .unwrap();
393 assert!((result["RATE"].as_float().unwrap() - 3.14).abs() < f64::EPSILON);
394 }
395
396 #[test]
397 fn test_boolean_variants() {
398 for (input, expected) in &[
399 ("true", true),
400 ("1", true),
401 ("yes", true),
402 ("on", true),
403 ("false", false),
404 ("0", false),
405 ("no", false),
406 ("off", false),
407 ] {
408 let src = source(&[("FLAG", input)]);
409 let result = Schema::new()
410 .boolean("FLAG")
411 .build()
412 .validate_from(Some(&src))
413 .unwrap();
414 assert_eq!(result["FLAG"].as_bool().unwrap(), *expected);
415 }
416 }
417
418 #[test]
419 fn test_boolean_invalid() {
420 let src = source(&[("FLAG", "maybe")]);
421 let err = Schema::new()
422 .boolean("FLAG")
423 .build()
424 .validate_from(Some(&src))
425 .unwrap_err();
426 assert!(err.errors[0].contains("cannot convert"));
427 }
428
429 #[test]
430 fn test_url_valid() {
431 let src = source(&[("API", "https://example.com")]);
432 let result = Schema::new()
433 .url("API")
434 .build()
435 .validate_from(Some(&src))
436 .unwrap();
437 assert_eq!(result["API"].as_str().unwrap(), "https://example.com");
438 }
439
440 #[test]
441 fn test_url_invalid() {
442 let src = source(&[("API", "not-a-url")]);
443 let err = Schema::new()
444 .url("API")
445 .build()
446 .validate_from(Some(&src))
447 .unwrap_err();
448 assert!(err.errors[0].contains("not a valid URL"));
449 }
450
451 #[test]
452 fn test_choices_valid() {
453 let src = source(&[("ENV", "production")]);
454 let result = Schema::new()
455 .string("ENV")
456 .choices(&["development", "staging", "production"])
457 .build()
458 .validate_from(Some(&src))
459 .unwrap();
460 assert_eq!(result["ENV"].as_str().unwrap(), "production");
461 }
462
463 #[test]
464 fn test_choices_invalid() {
465 let src = source(&[("ENV", "testing")]);
466 let err = Schema::new()
467 .string("ENV")
468 .choices(&["development", "staging", "production"])
469 .build()
470 .validate_from(Some(&src))
471 .unwrap_err();
472 assert!(err.errors[0].contains("must be one of"));
473 }
474
475 #[test]
476 fn test_multiple_errors() {
477 let src = source(&[]);
478 let err = Schema::new()
479 .string("A")
480 .build()
481 .string("B")
482 .build()
483 .string("C")
484 .build()
485 .validate_from(Some(&src))
486 .unwrap_err();
487 assert_eq!(err.errors.len(), 3);
488 }
489
490 #[test]
491 fn test_multiple_fields_valid() {
492 let src = source(&[("HOST", "localhost"), ("PORT", "8080"), ("DEBUG", "true")]);
493 let result = Schema::new()
494 .string("HOST")
495 .build()
496 .integer("PORT")
497 .build()
498 .boolean("DEBUG")
499 .build()
500 .validate_from(Some(&src))
501 .unwrap();
502 assert_eq!(result["HOST"].as_str().unwrap(), "localhost");
503 assert_eq!(result["PORT"].as_int().unwrap(), 8080);
504 assert_eq!(result["DEBUG"].as_bool().unwrap(), true);
505 }
506
507 #[test]
508 fn test_empty_value_treated_as_missing() {
509 let src = source(&[("HOST", "")]);
510 let err = Schema::new()
511 .string("HOST")
512 .build()
513 .validate_from(Some(&src))
514 .unwrap_err();
515 assert!(err.errors[0].contains("missing required variable"));
516 }
517
518 #[test]
519 fn test_display_validation_error() {
520 let err = ValidationError {
521 errors: vec!["error one".to_string(), "error two".to_string()],
522 };
523 let display = format!("{}", err);
524 assert!(display.contains("2 validation error(s)"));
525 assert!(display.contains("error one"));
526 assert!(display.contains("error two"));
527 }
528
529 #[test]
530 fn test_env_value_display() {
531 assert_eq!(format!("{}", EnvValue::Str("hello".into())), "hello");
532 assert_eq!(format!("{}", EnvValue::Int(42)), "42");
533 assert_eq!(format!("{}", EnvValue::Float(3.14)), "3.14");
534 assert_eq!(format!("{}", EnvValue::Bool(true)), "true");
535 }
536
537 #[test]
538 fn test_env_value_partial_eq() {
539 assert_eq!(EnvValue::Str("a".into()), EnvValue::Str("a".into()));
540 assert_ne!(EnvValue::Str("a".into()), EnvValue::Str("b".into()));
541 assert_eq!(EnvValue::Int(1), EnvValue::Int(1));
542 assert_ne!(EnvValue::Int(1), EnvValue::Int(2));
543 assert_eq!(EnvValue::Float(1.5), EnvValue::Float(1.5));
544 assert_ne!(EnvValue::Float(1.5), EnvValue::Float(2.5));
545 assert_eq!(EnvValue::Bool(true), EnvValue::Bool(true));
546 assert_ne!(EnvValue::Bool(true), EnvValue::Bool(false));
547 assert_ne!(EnvValue::Int(1), EnvValue::Str("1".into()));
548 }
549
550 #[test]
551 fn test_env_value_from_impls() {
552 assert_eq!(EnvValue::from("hello"), EnvValue::Str("hello".into()));
553 assert_eq!(EnvValue::from("hello".to_string()), EnvValue::Str("hello".into()));
554 assert_eq!(EnvValue::from(42i64), EnvValue::Int(42));
555 assert_eq!(EnvValue::from(3.14f64), EnvValue::Float(3.14));
556 assert_eq!(EnvValue::from(true), EnvValue::Bool(true));
557 }
558
559 #[test]
560 fn test_schema_clone() {
561 let src = source(&[("HOST", "localhost")]);
562 let schema = Schema::new().string("HOST").build();
563 let schema2 = schema.clone();
564 let r1 = schema.validate_from(Some(&src)).unwrap();
565 let r2 = schema2.validate_from(Some(&src)).unwrap();
566 assert_eq!(r1["HOST"], r2["HOST"]);
567 }
568
569 #[test]
570 fn test_validation_error_partial_eq() {
571 let e1 = ValidationError { errors: vec!["a".into()] };
572 let e2 = ValidationError { errors: vec!["a".into()] };
573 let e3 = ValidationError { errors: vec!["b".into()] };
574 assert_eq!(e1, e2);
575 assert_ne!(e1, e3);
576 }
577}