Skip to main content

philiprehberger_env_validator/
lib.rs

1//! Typed environment variable validation with batch error reporting.
2//!
3//! # Example
4//!
5//! ```rust,no_run
6//! use philiprehberger_env_validator::Schema;
7//!
8//! let config = Schema::new()
9//!     .string("DATABASE_URL").required(true).build()
10//!     .string("LOG_LEVEL").default_value("info").build()
11//!     .validate()
12//!     .expect("validation failed");
13//! ```
14
15use std::collections::HashMap;
16use std::env;
17use std::fmt;
18use std::str::FromStr;
19
20/// Error containing all validation failures.
21#[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/// Field type specification.
39#[derive(Debug, Clone)]
40pub enum FieldType {
41    Str,
42    Int,
43    Float,
44    Bool,
45    Url,
46}
47
48/// Configuration for a single environment variable.
49#[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/// Schema builder for environment variable validation.
59#[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    /// Validate environment variables and return a map of parsed values.
90    pub fn validate(&self) -> Result<HashMap<String, EnvValue>, ValidationError> {
91        self.validate_from(None)
92    }
93
94    /// Validate from a custom source map.
95    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
148/// Builder for field specifications.
149pub 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/// A parsed environment variable value.
190#[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}