Skip to main content

figue/layers/
env.rs

1//! Schema-driven environment variable parser that outputs ConfigValue with provenance.
2//!
3//!
4//! This parser:
5//! - Uses the pre-built Schema to know the config field structure
6//! - Outputs LayerOutput (ConfigValue + diagnostics), not a Partial
7//! - Does NOT validate types (that's the driver's job)
8//! - Reports malformed env var names as diagnostics
9//! - Tracks unused keys (env vars that don't match schema fields)
10//!
11//! # Naming Convention
12//!
13//! Given a prefix like `"REEF"` and a config struct:
14//!
15//! ```rust,ignore
16//! struct ServerConfig {
17//!     port: u16,
18//!     smtp: SmtpConfig,
19//! }
20//!
21//! struct SmtpConfig {
22//!     host: String,
23//!     connection_timeout: u64,
24//! }
25//! ```
26//!
27//! The corresponding environment variable names are:
28//! - `REEF__PORT` → config.port
29//! - `REEF__SMTP__HOST` → config.smtp.host
30//! - `REEF__SMTP__CONNECTION_TIMEOUT` → config.smtp.connection_timeout
31//!
32//! Rules:
33//! - Prefix implies the config field (env vars only set config, not CLI args)
34//! - All SCREAMING_SNAKE_CASE
35//! - Double underscore (`__`) as separator (to allow single `_` in field names)
36
37use std::string::{String, ToString};
38use std::vec::Vec;
39
40use facet_reflect::Span;
41use indexmap::IndexMap;
42
43use crate::config_value::{ConfigValue, ConfigValueVisitorMut};
44use crate::driver::LayerOutput;
45use crate::path::Path;
46use crate::provenance::Provenance;
47use crate::schema::{ConfigStructSchema, ConfigValueSchema, Schema};
48use crate::value_builder::{LeafValue, ValueBuilder};
49
50// ============================================================================
51// EnvSource trait
52// ============================================================================
53
54/// Trait for abstracting over environment variable sources.
55///
56/// This allows testing without modifying the actual environment.
57pub trait EnvSource {
58    /// Get the value of an environment variable by name.
59    fn get(&self, name: &str) -> Option<String>;
60
61    /// Iterate over all environment variables.
62    fn vars(&self) -> Box<dyn Iterator<Item = (String, String)> + '_>;
63}
64
65/// Environment source that reads from the actual process environment.
66#[derive(Debug, Clone, Copy, Default)]
67pub struct StdEnv;
68
69impl EnvSource for StdEnv {
70    fn get(&self, name: &str) -> Option<String> {
71        std::env::var(name).ok()
72    }
73
74    fn vars(&self) -> Box<dyn Iterator<Item = (String, String)> + '_> {
75        Box::new(std::env::vars())
76    }
77}
78
79/// Environment source backed by a map (for testing).
80#[derive(Debug, Clone, Default)]
81pub struct MockEnv {
82    vars: IndexMap<String, String, std::hash::RandomState>,
83}
84
85impl MockEnv {
86    /// Create a new empty mock environment.
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Create a mock environment from an iterator of key-value pairs.
92    pub fn from_pairs<I, K, V>(iter: I) -> Self
93    where
94        I: IntoIterator<Item = (K, V)>,
95        K: Into<String>,
96        V: Into<String>,
97    {
98        Self {
99            vars: iter
100                .into_iter()
101                .map(|(k, v)| (k.into(), v.into()))
102                .collect(),
103        }
104    }
105
106    /// Set an environment variable.
107    pub fn set(&mut self, name: impl Into<String>, value: impl Into<String>) {
108        self.vars.insert(name.into(), value.into());
109    }
110}
111
112impl EnvSource for MockEnv {
113    fn get(&self, name: &str) -> Option<String> {
114        self.vars.get(name).cloned()
115    }
116
117    fn vars(&self) -> Box<dyn Iterator<Item = (String, String)> + '_> {
118        Box::new(self.vars.iter().map(|(k, v)| (k.clone(), v.clone())))
119    }
120}
121
122// ============================================================================
123// EnvConfig
124// ============================================================================
125
126/// Configuration for environment variable parsing.
127pub struct EnvConfig {
128    /// The prefix to look for (e.g., `MYAPP`). For example, configuration variable
129    /// foo.bar will be overrideable via `MYAPP__FOO__BAR`.
130    pub prefix: String,
131
132    /// Whether to error out if any env vars that start with `MYAPP__` should be reported
133    /// as errors and stop the program entirely (to try and catch typos)
134    pub strict: bool,
135
136    /// Custom environment source (for testing). If None, uses StdEnv.
137    pub source: Option<Box<dyn EnvSource>>,
138}
139
140impl EnvConfig {
141    /// Create a new EnvConfig with the given prefix.
142    pub fn new(prefix: impl Into<String>) -> Self {
143        Self {
144            prefix: prefix.into(),
145            strict: false,
146            source: None,
147        }
148    }
149
150    /// Enable strict mode.
151    pub fn strict(mut self) -> Self {
152        self.strict = true;
153        self
154    }
155
156    /// Get the env source, or StdEnv if none set.
157    pub fn source(&self) -> &dyn EnvSource {
158        self.source.as_ref().map(|s| s.as_ref()).unwrap_or(&StdEnv)
159    }
160}
161
162/// Builder for environment variable configuration.
163#[derive(Default)]
164pub struct EnvConfigBuilder {
165    prefix: String,
166    strict: bool,
167    source: Option<Box<dyn EnvSource>>,
168}
169
170impl EnvConfigBuilder {
171    /// Create a new env config builder.
172    pub fn new() -> Self {
173        Self::default()
174    }
175
176    /// Set the environment variable prefix.
177    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
178        self.prefix = prefix.into();
179        self
180    }
181
182    /// Enable strict mode - error on unknown env vars with the prefix.
183    pub fn strict(mut self) -> Self {
184        self.strict = true;
185        self
186    }
187
188    /// Use a custom environment source (for testing).
189    pub fn source(mut self, source: impl EnvSource + 'static) -> Self {
190        self.source = Some(Box::new(source));
191        self
192    }
193
194    /// Build the env configuration.
195    pub fn build(self) -> EnvConfig {
196        let mut config = EnvConfig::new(self.prefix);
197        if self.strict {
198            config = config.strict();
199        }
200        config.source = self.source;
201        config
202    }
203}
204
205/// Parse environment variables using the schema, returning a LayerOutput.
206///
207/// This reads env vars with the configured prefix and builds a ConfigValue tree
208/// under the schema's config field.
209pub fn parse_env(schema: &Schema, env_config: &EnvConfig, source: &dyn EnvSource) -> LayerOutput {
210    // Get the config schema - if there's no config field, we can't parse env vars
211    let Some(config_schema) = schema.config() else {
212        return parse_env_no_config(env_config, source);
213    };
214
215    // Use explicit prefix from config, or fall back to schema's env_prefix
216    let prefix = if env_config.prefix.is_empty() {
217        config_schema.env_prefix().unwrap_or("")
218    } else {
219        &env_config.prefix
220    };
221
222    let prefix_with_sep = format!("{}__", prefix);
223
224    // Create a ValueBuilder
225    let mut builder = ValueBuilder::new(config_schema);
226
227    // Track which paths were set by prefixed vars (so aliases don't override them)
228    let mut prefixed_paths: Vec<Vec<String>> = Vec::new();
229
230    // First pass: collect all prefixed env vars (higher priority)
231    for (name, value) in source.vars() {
232        // Check if this var matches our prefix
233        if !name.starts_with(&prefix_with_sep) {
234            continue;
235        }
236
237        // Extract the path after the prefix
238        let rest = &name[prefix_with_sep.len()..];
239        if rest.is_empty() {
240            builder.warn(format!(
241                "invalid environment variable name: {} (empty after prefix)",
242                name
243            ));
244            continue;
245        }
246
247        // Parse the path segments
248        let segments: Vec<&str> = rest.split("__").collect();
249
250        // Check for empty segments
251        if segments.iter().any(|s| s.is_empty()) {
252            builder.warn(format!(
253                "invalid environment variable name: {} (contains empty segment)",
254                name
255            ));
256            continue;
257        }
258
259        // Convert to lowercase for field matching
260        let path: Vec<String> = segments.iter().map(|s| s.to_lowercase()).collect();
261
262        // Create provenance for this env var
263        let prov = Provenance::env(&name, &value);
264
265        // Validate enum values if the target is an enum
266        validate_enum_value_if_applicable(&mut builder, config_schema, &path, &value, &name);
267
268        // Parse the value (handle comma-separated lists)
269        let leaf_value = parse_env_value(&value);
270
271        // Set the value with its provenance
272        // Unknown keys are tracked in unused_keys by the builder.
273        // In strict mode, they'll be reported by the driver alongside the config dump.
274        if builder.set(&path, leaf_value, None, prov) {
275            prefixed_paths.push(path);
276        }
277    }
278
279    // Second pass: check env aliases (lower priority than prefixed vars)
280    check_env_aliases(&mut builder, config_schema, source, &[], &prefixed_paths);
281
282    let mut output = builder.into_output(config_schema.field_name());
283
284    // Assign spans to env-sourced values and build the virtual source document
285    if let Some(ref mut value) = output.value {
286        let source_text = assign_env_spans(value);
287        if !source_text.is_empty() {
288            output.source_text = Some(source_text);
289        }
290    }
291
292    output
293}
294
295/// Recursively check env aliases for all fields in a config struct.
296fn check_env_aliases(
297    builder: &mut ValueBuilder,
298    schema: &ConfigStructSchema,
299    source: &dyn EnvSource,
300    parent_path: &[String],
301    prefixed_paths: &[Vec<String>],
302) {
303    for (field_name, field_schema) in schema.fields() {
304        let mut field_path = parent_path.to_vec();
305        field_path.push(field_name.clone());
306
307        // Check if this field has aliases and wasn't already set by a prefixed var
308        let already_set = prefixed_paths.contains(&field_path);
309        if !already_set {
310            for alias in field_schema.env_aliases() {
311                if let Some(value) = source.get(alias) {
312                    let prov = Provenance::env(alias, &value);
313                    let leaf_value = parse_env_value(&value);
314                    builder.set(&field_path, leaf_value, None, prov);
315                    // Only use the first matching alias
316                    break;
317                }
318            }
319        }
320
321        // Recurse into nested structs
322        match field_schema.value() {
323            ConfigValueSchema::Struct(nested) => {
324                check_env_aliases(builder, nested, source, &field_path, prefixed_paths);
325            }
326            ConfigValueSchema::Option { value, .. } => {
327                if let ConfigValueSchema::Struct(nested) = value.as_ref() {
328                    check_env_aliases(builder, nested, source, &field_path, prefixed_paths);
329                }
330            }
331            _ => {}
332        }
333    }
334}
335
336/// Handle the case where there's no config field in the schema.
337fn parse_env_no_config(env_config: &EnvConfig, source: &dyn EnvSource) -> LayerOutput {
338    use crate::config_value::{ConfigValue, Sourced};
339    use crate::driver::UnusedKey;
340
341    let prefix = &env_config.prefix;
342    let prefix_with_sep = format!("{}__", prefix);
343
344    let mut unused_keys = Vec::new();
345
346    for (name, _value) in source.vars() {
347        if name.starts_with(&prefix_with_sep) {
348            let rest = &name[prefix_with_sep.len()..];
349            if !rest.is_empty() {
350                let segments: Vec<&str> = rest.split("__").collect();
351                if !segments.iter().any(|s| s.is_empty()) {
352                    let path: Vec<String> = segments.iter().map(|s| s.to_lowercase()).collect();
353                    unused_keys.push(UnusedKey {
354                        key: path,
355                        provenance: Provenance::env(&name, ""),
356                    });
357                }
358            }
359        }
360    }
361
362    LayerOutput {
363        value: Some(ConfigValue::Object(Sourced::new(IndexMap::default()))),
364        unused_keys,
365        diagnostics: Vec::new(),
366        source_text: None,
367        config_file_path: None,
368        help_list_mode: None,
369    }
370}
371
372/// Parse an env var value, handling comma-separated lists.
373fn parse_env_value(value: &str) -> LeafValue {
374    if value.contains(',') {
375        let elements = parse_comma_separated(value);
376        if elements.len() > 1 {
377            return LeafValue::StringArray(elements);
378        } else if elements.len() == 1 {
379            return LeafValue::String(elements.into_iter().next().unwrap());
380        }
381    }
382    LeafValue::String(value.to_string())
383}
384
385/// Validate enum value if the target path points to an enum field.
386fn validate_enum_value_if_applicable(
387    builder: &mut ValueBuilder,
388    schema: &ConfigStructSchema,
389    path: &[String],
390    value: &str,
391    var_name: &str,
392) {
393    if let Some(value_schema) = schema.get_by_path(&path.to_vec()) {
394        // Unwrap Option wrapper if present
395        let inner_schema = match value_schema {
396            ConfigValueSchema::Option { value: inner, .. } => inner.as_ref(),
397            other => other,
398        };
399
400        // For enum fields, validate the value is a known variant
401        if let ConfigValueSchema::Enum(enum_schema) = inner_schema {
402            let variants = enum_schema.variants();
403            if !variants.contains_key(value) {
404                let valid_variants: Vec<&str> = variants.keys().map(|s| s.as_str()).collect();
405
406                // Try to find a similar variant
407                let suggestion =
408                    crate::suggest::format_suggestion(value, valid_variants.iter().copied());
409
410                builder.warn(format!(
411                    "{}: unknown variant '{}' for {}{} Valid variants are: {}",
412                    var_name,
413                    value,
414                    path.join("."),
415                    suggestion,
416                    valid_variants
417                        .iter()
418                        .map(|v| format!("'{}'", v))
419                        .collect::<Vec<_>>()
420                        .join(", ")
421                ));
422            }
423        }
424    }
425}
426
427/// Parse a comma-separated string, handling escaping.
428fn parse_comma_separated(input: &str) -> Vec<String> {
429    let mut result = Vec::new();
430    let mut current = String::new();
431    let mut chars = input.chars().peekable();
432
433    while let Some(ch) = chars.next() {
434        if ch == '\\' {
435            if let Some(&next) = chars.peek() {
436                if next == ',' {
437                    chars.next();
438                    current.push(',');
439                } else {
440                    current.push(ch);
441                }
442            } else {
443                current.push(ch);
444            }
445        } else if ch == ',' {
446            let trimmed = current.trim().to_string();
447            if !trimmed.is_empty() {
448                result.push(trimmed);
449            }
450            current.clear();
451        } else {
452            current.push(ch);
453        }
454    }
455
456    let trimmed = current.trim().to_string();
457    if !trimmed.is_empty() {
458        result.push(trimmed);
459    }
460
461    if result.is_empty() {
462        result.push(input.to_string());
463    }
464
465    result
466}
467
468// ============================================================================
469// Virtual env document for span tracking
470// ============================================================================
471
472/// Result of assigning env spans to a ConfigValue tree.
473/// Assign spans to all env-sourced values in a ConfigValue tree.
474///
475/// This walks the tree, collects all unique env vars, builds a virtual document
476/// like:
477/// ```text
478/// REEF__PORT="8080"
479/// DATABASE_URL="postgres://localhost/db"
480/// ```
481///
482/// And updates each value's span to point to its position in this document.
483/// Returns the virtual document for error reporting.
484pub fn assign_env_spans(value: &mut ConfigValue) -> String {
485    let mut visitor = EnvSpanVisitor::new();
486    let mut path = Path::new();
487    value.visit_mut(&mut visitor, &mut path);
488    visitor.document
489}
490
491/// Visitor that assigns spans to env-sourced values.
492struct EnvSpanVisitor {
493    /// The virtual document being built.
494    document: String,
495    /// Map from var name to (offset, len) in the document.
496    var_spans: IndexMap<String, (usize, usize), std::hash::RandomState>,
497}
498
499impl EnvSpanVisitor {
500    fn new() -> Self {
501        Self {
502            document: String::new(),
503            var_spans: IndexMap::default(),
504        }
505    }
506
507    /// Ensure an env var is in the document and return its value span.
508    fn ensure_var(&mut self, var: &str, env_value: &str) -> Span {
509        if let Some(&(offset, len)) = self.var_spans.get(var) {
510            return Span::new(offset, len);
511        }
512
513        // Add to document: VAR="value"\n
514        // The span points to just the value part (inside quotes)
515        self.document.push_str(var);
516        self.document.push_str("=\"");
517        let value_offset = self.document.len();
518        self.document.push_str(env_value);
519        let value_len = env_value.len();
520        self.document.push_str("\"\n");
521
522        self.var_spans
523            .insert(var.to_string(), (value_offset, value_len));
524
525        Span::new(value_offset, value_len)
526    }
527}
528
529impl ConfigValueVisitorMut for EnvSpanVisitor {
530    fn visit_value(&mut self, _path: &Path, value: &mut ConfigValue) {
531        if let Some(Provenance::Env {
532            var,
533            value: env_value,
534        }) = value.provenance().cloned()
535        {
536            *value.span_mut() = Some(self.ensure_var(&var, &env_value));
537        }
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use facet::Facet;
544    use figue_attrs as args;
545
546    use crate::config_value::ConfigValue;
547    use crate::driver::Severity;
548    use crate::schema::Schema;
549
550    use super::*;
551
552    // ========================================================================
553    // Test schemas
554    // ========================================================================
555
556    #[derive(Facet)]
557    struct ArgsWithConfig {
558        #[facet(args::named)]
559        verbose: bool,
560
561        #[facet(args::config)]
562        config: ServerConfig,
563    }
564
565    #[derive(Facet)]
566    struct ServerConfig {
567        port: u16,
568        host: String,
569    }
570
571    #[derive(Facet)]
572    struct ArgsWithNestedConfig {
573        #[facet(args::config)]
574        settings: AppSettings,
575    }
576
577    #[derive(Facet)]
578    struct AppSettings {
579        port: u16,
580        smtp: SmtpConfig,
581    }
582
583    #[derive(Facet)]
584    struct SmtpConfig {
585        host: String,
586        connection_timeout: u64,
587    }
588
589    #[derive(Facet)]
590    struct ArgsWithListConfig {
591        #[facet(args::config)]
592        config: ListConfig,
593    }
594
595    #[derive(Facet)]
596    struct ListConfig {
597        ports: Vec<u16>,
598        allowed_hosts: Vec<String>,
599    }
600
601    // ========================================================================
602    // Helper functions
603    // ========================================================================
604
605    fn env_config(prefix: &str) -> EnvConfig {
606        EnvConfigBuilder::new().prefix(prefix).build()
607    }
608
609    fn env_config_strict(prefix: &str) -> EnvConfig {
610        EnvConfigBuilder::new().prefix(prefix).strict().build()
611    }
612
613    fn get_nested<'a>(cv: &'a ConfigValue, path: &[&str]) -> Option<&'a ConfigValue> {
614        let mut current = cv;
615        for key in path {
616            match current {
617                ConfigValue::Object(obj) => {
618                    current = obj.value.get(*key)?;
619                }
620                _ => return None,
621            }
622        }
623        Some(current)
624    }
625
626    fn get_string(cv: &ConfigValue) -> Option<&str> {
627        match cv {
628            ConfigValue::String(s) => Some(&s.value),
629            _ => None,
630        }
631    }
632
633    fn get_array_len(cv: &ConfigValue) -> Option<usize> {
634        match cv {
635            ConfigValue::Array(arr) => Some(arr.value.len()),
636            _ => None,
637        }
638    }
639
640    // ========================================================================
641    // Tests: Basic parsing
642    // ========================================================================
643
644    #[test]
645    fn test_empty_env() {
646        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
647        let env = MockEnv::new();
648        let config = env_config("REEF");
649
650        let output = parse_env(&schema, &config, &env);
651
652        assert!(output.diagnostics.is_empty());
653        assert!(output.unused_keys.is_empty());
654        // Empty env should produce an empty object (or None?)
655        // Let's say it produces an object with just the config field as empty object
656    }
657
658    #[test]
659    fn test_single_flat_field() {
660        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
661        let env = MockEnv::from_pairs([("REEF__PORT", "8080")]);
662        let config = env_config("REEF");
663
664        let output = parse_env(&schema, &config, &env);
665
666        assert!(output.diagnostics.is_empty());
667        let value = output.value.expect("should have value");
668
669        // Should be {config: {port: "8080"}}
670        let port = get_nested(&value, &["config", "port"]).expect("should have config.port");
671        assert_eq!(get_string(port), Some("8080"));
672    }
673
674    #[test]
675    fn test_multiple_flat_fields() {
676        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
677        let env = MockEnv::from_pairs([("REEF__PORT", "8080"), ("REEF__HOST", "localhost")]);
678        let config = env_config("REEF");
679
680        let output = parse_env(&schema, &config, &env);
681
682        assert!(output.diagnostics.is_empty());
683        let value = output.value.expect("should have value");
684
685        let port = get_nested(&value, &["config", "port"]).expect("should have config.port");
686        assert_eq!(get_string(port), Some("8080"));
687
688        let host = get_nested(&value, &["config", "host"]).expect("should have config.host");
689        assert_eq!(get_string(host), Some("localhost"));
690    }
691
692    #[test]
693    fn test_nested_field() {
694        let schema = Schema::from_shape(ArgsWithNestedConfig::SHAPE).unwrap();
695        let env = MockEnv::from_pairs([("REEF__SMTP__HOST", "mail.example.com")]);
696        let config = env_config("REEF");
697
698        let output = parse_env(&schema, &config, &env);
699
700        assert!(output.diagnostics.is_empty());
701        let value = output.value.expect("should have value");
702
703        // Config field is named "settings" in this schema
704        let host = get_nested(&value, &["settings", "smtp", "host"])
705            .expect("should have settings.smtp.host");
706        assert_eq!(get_string(host), Some("mail.example.com"));
707    }
708
709    #[test]
710    fn test_deeply_nested() {
711        let schema = Schema::from_shape(ArgsWithNestedConfig::SHAPE).unwrap();
712        let env = MockEnv::from_pairs([
713            ("REEF__PORT", "8080"),
714            ("REEF__SMTP__HOST", "mail.example.com"),
715            ("REEF__SMTP__CONNECTION_TIMEOUT", "30"),
716        ]);
717        let config = env_config("REEF");
718
719        let output = parse_env(&schema, &config, &env);
720
721        assert!(output.diagnostics.is_empty());
722        let value = output.value.expect("should have value");
723
724        let port = get_nested(&value, &["settings", "port"]).expect("port");
725        assert_eq!(get_string(port), Some("8080"));
726
727        let host = get_nested(&value, &["settings", "smtp", "host"]).expect("smtp.host");
728        assert_eq!(get_string(host), Some("mail.example.com"));
729
730        let timeout = get_nested(&value, &["settings", "smtp", "connection_timeout"])
731            .expect("smtp.connection_timeout");
732        assert_eq!(get_string(timeout), Some("30"));
733    }
734
735    // ========================================================================
736    // Tests: Value handling
737    // ========================================================================
738
739    #[test]
740    fn test_comma_separated_list() {
741        let schema = Schema::from_shape(ArgsWithListConfig::SHAPE).unwrap();
742        let env = MockEnv::from_pairs([("REEF__PORTS", "8080,8081,8082")]);
743        let config = env_config("REEF");
744
745        let output = parse_env(&schema, &config, &env);
746
747        assert!(output.diagnostics.is_empty());
748        let value = output.value.expect("should have value");
749
750        let ports = get_nested(&value, &["config", "ports"]).expect("config.ports");
751        assert_eq!(get_array_len(ports), Some(3));
752    }
753
754    #[test]
755    fn test_escaped_comma() {
756        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
757        let env = MockEnv::from_pairs([("REEF__HOST", r"hello\, world")]);
758        let config = env_config("REEF");
759
760        let output = parse_env(&schema, &config, &env);
761
762        assert!(output.diagnostics.is_empty());
763        let value = output.value.expect("should have value");
764
765        let host = get_nested(&value, &["config", "host"]).expect("config.host");
766        assert_eq!(get_string(host), Some("hello, world"));
767    }
768
769    #[test]
770    fn test_values_stay_as_strings() {
771        // We don't parse "8080" into an integer - that's the driver's job
772        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
773        let env = MockEnv::from_pairs([("REEF__PORT", "8080")]);
774        let config = env_config("REEF");
775
776        let output = parse_env(&schema, &config, &env);
777
778        let value = output.value.expect("should have value");
779        let port = get_nested(&value, &["config", "port"]).expect("config.port");
780
781        // Should be a string, not an integer
782        assert!(matches!(port, ConfigValue::String(_)));
783    }
784
785    // ========================================================================
786    // Tests: Provenance
787    // ========================================================================
788
789    #[test]
790    fn test_provenance_is_set() {
791        use crate::provenance::Provenance;
792
793        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
794        let env = MockEnv::from_pairs([("REEF__PORT", "8080")]);
795        let config = env_config("REEF");
796
797        let output = parse_env(&schema, &config, &env);
798        let value = output.value.expect("should have value");
799
800        let port = get_nested(&value, &["config", "port"]).expect("config.port");
801        if let ConfigValue::String(s) = port {
802            let prov = s.provenance.as_ref().expect("should have provenance");
803            assert!(matches!(prov, Provenance::Env { .. }));
804            if let Provenance::Env { var, value } = prov {
805                assert_eq!(var, "REEF__PORT");
806                assert_eq!(value, "8080");
807            }
808        } else {
809            panic!("expected string");
810        }
811    }
812
813    // ========================================================================
814    // Tests: Malformed names (diagnostics)
815    // ========================================================================
816
817    #[test]
818    fn test_empty_segment_diagnostic() {
819        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
820        let env = MockEnv::from_pairs([("REEF__FOO____BAR", "x")]);
821        let config = env_config("REEF");
822
823        let output = parse_env(&schema, &config, &env);
824
825        // Should have a diagnostic about invalid env var name
826        assert!(!output.diagnostics.is_empty());
827        assert!(
828            output
829                .diagnostics
830                .iter()
831                .any(|d| d.message.contains("empty segment") || d.message.contains("invalid"))
832        );
833    }
834
835    #[test]
836    fn test_just_prefix_diagnostic() {
837        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
838        let env = MockEnv::from_pairs([("REEF__", "x")]);
839        let config = env_config("REEF");
840
841        let output = parse_env(&schema, &config, &env);
842
843        // Should have a diagnostic
844        assert!(!output.diagnostics.is_empty());
845    }
846
847    #[test]
848    fn test_wrong_prefix_ignored() {
849        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
850        let env = MockEnv::from_pairs([("OTHER__PORT", "8080")]);
851        let config = env_config("REEF");
852
853        let output = parse_env(&schema, &config, &env);
854
855        // No diagnostics - it's just a different prefix
856        assert!(output.diagnostics.is_empty());
857        assert!(output.unused_keys.is_empty());
858        // No value or empty object
859    }
860
861    #[test]
862    fn test_single_underscore_ignored() {
863        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
864        let env = MockEnv::from_pairs([("REEF_PORT", "8080")]);
865        let config = env_config("REEF");
866
867        let output = parse_env(&schema, &config, &env);
868
869        // Single underscore doesn't match PREFIX__ pattern
870        assert!(output.diagnostics.is_empty());
871        assert!(output.unused_keys.is_empty());
872    }
873
874    // ========================================================================
875    // Tests: Unused keys (schema-aware)
876    // ========================================================================
877
878    #[test]
879    fn test_unknown_field_unused_key() {
880        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
881        // Typo: PORTT instead of PORT
882        let env = MockEnv::from_pairs([("REEF__PORTT", "8080")]);
883        let config = env_config("REEF");
884
885        let output = parse_env(&schema, &config, &env);
886
887        // Should be in unused_keys
888        assert!(!output.unused_keys.is_empty());
889        assert!(output.unused_keys.iter().any(|k| {
890            // The key path should contain "portt"
891            k.key.iter().any(|s| s == "portt")
892        }));
893    }
894
895    #[test]
896    fn test_unknown_nested_field_unused_key() {
897        let schema = Schema::from_shape(ArgsWithNestedConfig::SHAPE).unwrap();
898        // Typo: HOSTT instead of HOST
899        let env = MockEnv::from_pairs([("REEF__SMTP__HOSTT", "x")]);
900        let config = env_config("REEF");
901
902        let output = parse_env(&schema, &config, &env);
903
904        assert!(!output.unused_keys.is_empty());
905    }
906
907    #[test]
908    fn test_strict_mode_tracks_unknown_keys() {
909        // In strict mode, unknown keys are tracked in unused_keys.
910        // The driver will report them alongside the config dump (not as early errors).
911        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
912        let env = MockEnv::from_pairs([("REEF__PORTT", "8080")]);
913        let config = env_config_strict("REEF");
914
915        let output = parse_env(&schema, &config, &env);
916
917        // Unknown keys should be tracked in unused_keys
918        assert!(
919            !output.unused_keys.is_empty(),
920            "should track unknown key in unused_keys"
921        );
922        assert!(
923            output
924                .unused_keys
925                .iter()
926                .any(|uk| uk.key.join(".") == "portt"),
927            "unused_keys should contain 'portt': {:?}",
928            output.unused_keys
929        );
930
931        // No error diagnostics at parse time - driver handles reporting with dump
932        let errors: Vec<_> = output
933            .diagnostics
934            .iter()
935            .filter(|d| d.severity == Severity::Error)
936            .collect();
937        assert!(
938            errors.is_empty(),
939            "should not have error diagnostics at parse time, got: {:?}",
940            errors
941        );
942    }
943
944    // ========================================================================
945    // Tests: Edge cases
946    // ========================================================================
947
948    #[test]
949    fn test_case_matching() {
950        // Env vars are SCREAMING_SNAKE, schema fields are snake_case
951        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
952        let env = MockEnv::from_pairs([("REEF__PORT", "8080")]);
953        let config = env_config("REEF");
954
955        let output = parse_env(&schema, &config, &env);
956
957        assert!(output.diagnostics.is_empty());
958        let value = output.value.expect("should have value");
959        // "PORT" should match "port"
960        assert!(get_nested(&value, &["config", "port"]).is_some());
961    }
962
963    #[test]
964    fn test_field_with_underscore() {
965        // connection_timeout in schema should match CONNECTION_TIMEOUT in env
966        let schema = Schema::from_shape(ArgsWithNestedConfig::SHAPE).unwrap();
967        let env = MockEnv::from_pairs([("REEF__SMTP__CONNECTION_TIMEOUT", "30")]);
968        let config = env_config("REEF");
969
970        let output = parse_env(&schema, &config, &env);
971
972        assert!(output.diagnostics.is_empty());
973        let value = output.value.expect("should have value");
974        assert!(get_nested(&value, &["settings", "smtp", "connection_timeout"]).is_some());
975    }
976
977    #[test]
978    fn test_empty_value() {
979        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
980        let env = MockEnv::from_pairs([("REEF__PORT", "")]);
981        let config = env_config("REEF");
982
983        let output = parse_env(&schema, &config, &env);
984
985        // Empty value should still be set (as empty string), not skipped
986        assert!(output.diagnostics.is_empty());
987        let value = output.value.expect("should have value");
988        let port = get_nested(&value, &["config", "port"]).expect("config.port");
989        assert_eq!(get_string(port), Some(""));
990    }
991
992    // ========================================================================
993    // Tests: No config field in schema
994    // ========================================================================
995
996    #[derive(Facet)]
997    struct ArgsWithoutConfig {
998        #[facet(args::named)]
999        verbose: bool,
1000    }
1001
1002    #[test]
1003    fn test_no_config_field_in_schema() {
1004        // If the schema has no config field, env vars matching the prefix
1005        // should all be unused keys (or we could emit a warning?)
1006        let schema = Schema::from_shape(ArgsWithoutConfig::SHAPE).unwrap();
1007        let env = MockEnv::from_pairs([("REEF__PORT", "8080")]);
1008        let config = env_config("REEF");
1009
1010        let output = parse_env(&schema, &config, &env);
1011
1012        // No config field means all env vars are unused
1013        assert!(!output.unused_keys.is_empty());
1014    }
1015
1016    // ========================================================================
1017    // Tests: Flattened config fields
1018    // ========================================================================
1019
1020    #[derive(Facet)]
1021    struct CommonConfig {
1022        log_level: String,
1023        debug: bool,
1024    }
1025
1026    #[derive(Facet)]
1027    struct ServerConfigWithFlatten {
1028        port: u16,
1029        #[facet(flatten)]
1030        common: CommonConfig,
1031    }
1032
1033    #[derive(Facet)]
1034    struct ArgsWithFlattenConfig {
1035        #[facet(args::named)]
1036        verbose: bool,
1037
1038        #[facet(args::config)]
1039        config: ServerConfigWithFlatten,
1040    }
1041
1042    #[test]
1043    fn test_flatten_config_parses_flattened_field() {
1044        // With flatten, REEF__LOG_LEVEL (not REEF__COMMON__LOG_LEVEL) sets log_level
1045        // The schema hoists flattened fields to the parent level
1046        let schema = Schema::from_shape(ArgsWithFlattenConfig::SHAPE).unwrap();
1047        let env = MockEnv::from_pairs([("REEF__LOG_LEVEL", "debug")]);
1048        let config = env_config("REEF");
1049
1050        let output = parse_env(&schema, &config, &env);
1051
1052        assert!(
1053            output.diagnostics.is_empty(),
1054            "diagnostics: {:?}",
1055            output.diagnostics
1056        );
1057        assert!(
1058            output.unused_keys.is_empty(),
1059            "unused keys: {:?}",
1060            output.unused_keys
1061        );
1062
1063        let value = output.value.expect("should have value");
1064        // Flattened field appears at the flattened level, not nested
1065        let log_level = get_nested(&value, &["config", "log_level"]).expect("config.log_level");
1066        assert_eq!(get_string(log_level), Some("debug"));
1067    }
1068
1069    #[test]
1070    fn test_flatten_config_top_level_and_flattened() {
1071        // Mix of top-level (port) and flattened (debug from common) config fields
1072        let schema = Schema::from_shape(ArgsWithFlattenConfig::SHAPE).unwrap();
1073        // PORT is not flattened, DEBUG is flattened from common
1074        let env = MockEnv::from_pairs([("REEF__PORT", "8080"), ("REEF__DEBUG", "true")]);
1075        let config = env_config("REEF");
1076
1077        let output = parse_env(&schema, &config, &env);
1078
1079        assert!(
1080            output.diagnostics.is_empty(),
1081            "diagnostics: {:?}",
1082            output.diagnostics
1083        );
1084        assert!(
1085            output.unused_keys.is_empty(),
1086            "unused keys: {:?}",
1087            output.unused_keys
1088        );
1089
1090        let value = output.value.expect("should have value");
1091        let port = get_nested(&value, &["config", "port"]).expect("config.port");
1092        assert_eq!(get_string(port), Some("8080"));
1093
1094        // Flattened field appears at the flattened level
1095        let debug = get_nested(&value, &["config", "debug"]).expect("config.debug");
1096        assert_eq!(get_string(debug), Some("true"));
1097    }
1098
1099    // ------------------------------------------------------------------------
1100    // Two-level flatten tests
1101    // ------------------------------------------------------------------------
1102
1103    #[derive(Facet)]
1104    struct DeepConfig {
1105        trace: bool,
1106    }
1107
1108    #[derive(Facet)]
1109    struct MiddleConfig {
1110        #[facet(flatten)]
1111        deep: DeepConfig,
1112        verbose: bool,
1113    }
1114
1115    #[derive(Facet)]
1116    struct OuterConfigWithDeepFlatten {
1117        name: String,
1118        #[facet(flatten)]
1119        middle: MiddleConfig,
1120    }
1121
1122    #[derive(Facet)]
1123    struct ArgsWithDeepFlattenConfig {
1124        #[facet(args::config)]
1125        config: OuterConfigWithDeepFlatten,
1126    }
1127
1128    #[test]
1129    fn test_two_level_flatten_config() {
1130        // trace is flattened from deep -> middle -> outer
1131        // So REEF__TRACE should work (not REEF__MIDDLE__DEEP__TRACE)
1132        let schema = Schema::from_shape(ArgsWithDeepFlattenConfig::SHAPE).unwrap();
1133        let env = MockEnv::from_pairs([
1134            ("REEF__NAME", "myapp"),
1135            ("REEF__VERBOSE", "true"),
1136            ("REEF__TRACE", "true"),
1137        ]);
1138        let config = env_config("REEF");
1139
1140        let output = parse_env(&schema, &config, &env);
1141
1142        assert!(
1143            output.diagnostics.is_empty(),
1144            "diagnostics: {:?}",
1145            output.diagnostics
1146        );
1147        assert!(
1148            output.unused_keys.is_empty(),
1149            "unused keys: {:?}",
1150            output.unused_keys
1151        );
1152
1153        let value = output.value.expect("should have value");
1154
1155        // All fields appear at the top config level due to flattening
1156        let name = get_nested(&value, &["config", "name"]).expect("config.name");
1157        assert_eq!(get_string(name), Some("myapp"));
1158
1159        let verbose = get_nested(&value, &["config", "verbose"]).expect("config.verbose");
1160        assert_eq!(get_string(verbose), Some("true"));
1161
1162        let trace = get_nested(&value, &["config", "trace"]).expect("config.trace");
1163        assert_eq!(get_string(trace), Some("true"));
1164    }
1165
1166    #[test]
1167    fn test_nested_path_rejected_for_flattened_field() {
1168        // REEF__COMMON__LOG_LEVEL should be rejected because "common" doesn't exist
1169        // in the flattened schema (log_level is hoisted to the parent)
1170        let schema = Schema::from_shape(ArgsWithFlattenConfig::SHAPE).unwrap();
1171        let env = MockEnv::from_pairs([("REEF__COMMON__LOG_LEVEL", "debug")]);
1172        let config = env_config("REEF");
1173
1174        let output = parse_env(&schema, &config, &env);
1175
1176        // Should have an unused key because "common" doesn't exist in schema
1177        assert!(
1178            !output.unused_keys.is_empty(),
1179            "should reject nested path for flattened field"
1180        );
1181        assert!(
1182            output
1183                .unused_keys
1184                .iter()
1185                .any(|k| k.key.contains(&"common".to_string())),
1186            "unused key should contain 'common': {:?}",
1187            output.unused_keys
1188        );
1189    }
1190
1191    // ========================================================================
1192    // Tests: Env aliases
1193    // ========================================================================
1194
1195    #[derive(Facet)]
1196    struct ConfigWithAlias {
1197        /// Database URL with standard alias
1198        #[facet(args::env_alias = "DATABASE_URL")]
1199        database_url: String,
1200
1201        /// Port without alias
1202        port: u16,
1203    }
1204
1205    #[derive(Facet)]
1206    struct ArgsWithAliasConfig {
1207        #[facet(args::config)]
1208        config: ConfigWithAlias,
1209    }
1210
1211    #[test]
1212    fn test_env_alias_basic() {
1213        // DATABASE_URL should be read via the alias
1214        let schema = Schema::from_shape(ArgsWithAliasConfig::SHAPE).unwrap();
1215        let env = MockEnv::from_pairs([("DATABASE_URL", "postgres://localhost/mydb")]);
1216        let config = env_config("REEF");
1217
1218        let output = parse_env(&schema, &config, &env);
1219
1220        assert!(
1221            output.diagnostics.is_empty(),
1222            "diagnostics: {:?}",
1223            output.diagnostics
1224        );
1225        let value = output.value.expect("should have value");
1226
1227        let db_url = get_nested(&value, &["config", "database_url"]).expect("config.database_url");
1228        assert_eq!(get_string(db_url), Some("postgres://localhost/mydb"));
1229    }
1230
1231    #[test]
1232    fn test_env_alias_prefixed_wins() {
1233        // When both prefixed and alias are set, prefixed wins
1234        let schema = Schema::from_shape(ArgsWithAliasConfig::SHAPE).unwrap();
1235        let env = MockEnv::from_pairs([
1236            ("DATABASE_URL", "alias_value"),
1237            ("REEF__DATABASE_URL", "prefixed_value"),
1238        ]);
1239        let config = env_config("REEF");
1240
1241        let output = parse_env(&schema, &config, &env);
1242
1243        assert!(
1244            output.diagnostics.is_empty(),
1245            "diagnostics: {:?}",
1246            output.diagnostics
1247        );
1248        let value = output.value.expect("should have value");
1249
1250        let db_url = get_nested(&value, &["config", "database_url"]).expect("config.database_url");
1251        // Prefixed var should win over alias
1252        assert_eq!(get_string(db_url), Some("prefixed_value"));
1253    }
1254
1255    #[test]
1256    fn test_env_alias_only_alias_set() {
1257        // Only alias set, no prefixed - alias should be used
1258        let schema = Schema::from_shape(ArgsWithAliasConfig::SHAPE).unwrap();
1259        let env = MockEnv::from_pairs([("DATABASE_URL", "alias_value"), ("REEF__PORT", "8080")]);
1260        let config = env_config("REEF");
1261
1262        let output = parse_env(&schema, &config, &env);
1263
1264        assert!(
1265            output.diagnostics.is_empty(),
1266            "diagnostics: {:?}",
1267            output.diagnostics
1268        );
1269        let value = output.value.expect("should have value");
1270
1271        let db_url = get_nested(&value, &["config", "database_url"]).expect("config.database_url");
1272        assert_eq!(get_string(db_url), Some("alias_value"));
1273
1274        let port = get_nested(&value, &["config", "port"]).expect("config.port");
1275        assert_eq!(get_string(port), Some("8080"));
1276    }
1277
1278    #[derive(Facet)]
1279    struct ConfigWithMultipleAliases {
1280        /// Database URL with multiple aliases
1281        #[facet(args::env_alias = "DATABASE_URL", args::env_alias = "DB_URL")]
1282        database_url: String,
1283    }
1284
1285    #[derive(Facet)]
1286    struct ArgsWithMultipleAliasConfig {
1287        #[facet(args::config)]
1288        config: ConfigWithMultipleAliases,
1289    }
1290
1291    #[test]
1292    fn test_env_alias_multiple_aliases_first_wins() {
1293        // When multiple aliases exist, the first one found is used
1294        let schema = Schema::from_shape(ArgsWithMultipleAliasConfig::SHAPE).unwrap();
1295        // Only the second alias is set
1296        let env = MockEnv::from_pairs([("DB_URL", "second_alias_value")]);
1297        let config = env_config("REEF");
1298
1299        let output = parse_env(&schema, &config, &env);
1300
1301        assert!(
1302            output.diagnostics.is_empty(),
1303            "diagnostics: {:?}",
1304            output.diagnostics
1305        );
1306        let value = output.value.expect("should have value");
1307
1308        let db_url = get_nested(&value, &["config", "database_url"]).expect("config.database_url");
1309        assert_eq!(get_string(db_url), Some("second_alias_value"));
1310    }
1311
1312    #[test]
1313    fn test_env_alias_provenance() {
1314        // Provenance should show the actual alias var that was used
1315        use crate::provenance::Provenance;
1316
1317        let schema = Schema::from_shape(ArgsWithAliasConfig::SHAPE).unwrap();
1318        let env = MockEnv::from_pairs([("DATABASE_URL", "postgres://localhost/mydb")]);
1319        let config = env_config("REEF");
1320
1321        let output = parse_env(&schema, &config, &env);
1322
1323        let value = output.value.expect("should have value");
1324        let db_url = get_nested(&value, &["config", "database_url"]).expect("config.database_url");
1325
1326        if let ConfigValue::String(s) = db_url {
1327            let prov = s.provenance.as_ref().expect("should have provenance");
1328            if let Provenance::Env { var, value } = prov {
1329                // Should show DATABASE_URL, not REEF__DATABASE_URL
1330                assert_eq!(var, "DATABASE_URL");
1331                assert_eq!(value, "postgres://localhost/mydb");
1332            } else {
1333                panic!("expected Env provenance");
1334            }
1335        } else {
1336            panic!("expected string");
1337        }
1338    }
1339
1340    #[derive(Facet)]
1341    struct NestedConfigWithAlias {
1342        db: DbConfig,
1343    }
1344
1345    #[derive(Facet)]
1346    struct DbConfig {
1347        #[facet(args::env_alias = "DATABASE_URL")]
1348        url: String,
1349    }
1350
1351    #[derive(Facet)]
1352    struct ArgsWithNestedAliasConfig {
1353        #[facet(args::config)]
1354        config: NestedConfigWithAlias,
1355    }
1356
1357    #[test]
1358    fn test_env_alias_in_nested_struct() {
1359        // Alias should work even when field is in a nested struct
1360        let schema = Schema::from_shape(ArgsWithNestedAliasConfig::SHAPE).unwrap();
1361        let env = MockEnv::from_pairs([("DATABASE_URL", "postgres://localhost/mydb")]);
1362        let config = env_config("REEF");
1363
1364        let output = parse_env(&schema, &config, &env);
1365
1366        assert!(
1367            output.diagnostics.is_empty(),
1368            "diagnostics: {:?}",
1369            output.diagnostics
1370        );
1371        let value = output.value.expect("should have value");
1372
1373        let url = get_nested(&value, &["config", "db", "url"]).expect("config.db.url");
1374        assert_eq!(get_string(url), Some("postgres://localhost/mydb"));
1375    }
1376
1377    #[test]
1378    fn test_env_alias_nested_prefixed_wins() {
1379        // Prefixed var should win over alias even in nested struct
1380        let schema = Schema::from_shape(ArgsWithNestedAliasConfig::SHAPE).unwrap();
1381        let env = MockEnv::from_pairs([
1382            ("DATABASE_URL", "alias_value"),
1383            ("REEF__DB__URL", "prefixed_value"),
1384        ]);
1385        let config = env_config("REEF");
1386
1387        let output = parse_env(&schema, &config, &env);
1388
1389        let value = output.value.expect("should have value");
1390        let url = get_nested(&value, &["config", "db", "url"]).expect("config.db.url");
1391        assert_eq!(get_string(url), Some("prefixed_value"));
1392    }
1393
1394    // ========================================================================
1395    // Tests: Schema-guided enum validation
1396    // ========================================================================
1397
1398    /// Log level enum for testing enum validation
1399    #[derive(Facet)]
1400    #[repr(u8)]
1401    #[allow(dead_code)]
1402    enum LogLevel {
1403        Debug,
1404        Info,
1405        Warn,
1406        Error,
1407    }
1408
1409    #[derive(Facet)]
1410    struct ConfigWithEnum {
1411        log_level: LogLevel,
1412        port: u16,
1413    }
1414
1415    #[derive(Facet)]
1416    struct ArgsWithEnumConfig {
1417        #[facet(args::config)]
1418        config: ConfigWithEnum,
1419    }
1420
1421    #[test]
1422    fn test_enum_valid_variant_no_warning() {
1423        // Valid variant should not produce a warning
1424        let schema = Schema::from_shape(ArgsWithEnumConfig::SHAPE).unwrap();
1425        let env = MockEnv::from_pairs([("REEF__LOG_LEVEL", "Debug")]);
1426        let config = env_config("REEF");
1427
1428        let output = parse_env(&schema, &config, &env);
1429
1430        assert!(
1431            output.diagnostics.is_empty(),
1432            "valid enum variant should not produce warnings: {:?}",
1433            output.diagnostics
1434        );
1435
1436        let value = output.value.expect("should have value");
1437        let log_level = get_nested(&value, &["config", "log_level"]).expect("config.log_level");
1438        assert_eq!(get_string(log_level), Some("Debug"));
1439    }
1440
1441    #[test]
1442    fn test_enum_invalid_variant_produces_warning() {
1443        // Invalid variant should produce a warning with helpful message
1444        let schema = Schema::from_shape(ArgsWithEnumConfig::SHAPE).unwrap();
1445        let env = MockEnv::from_pairs([("REEF__LOG_LEVEL", "Debugg")]); // typo
1446        let config = env_config("REEF");
1447
1448        let output = parse_env(&schema, &config, &env);
1449
1450        // Should have a warning
1451        assert!(
1452            !output.diagnostics.is_empty(),
1453            "invalid enum variant should produce a warning"
1454        );
1455
1456        // Warning should mention the invalid value and valid variants
1457        let warning = &output.diagnostics[0];
1458        assert!(
1459            warning.message.contains("Debugg"),
1460            "warning should mention the invalid value: {}",
1461            warning.message
1462        );
1463        assert!(
1464            warning.message.contains("Debug")
1465                && warning.message.contains("Info")
1466                && warning.message.contains("Warn")
1467                && warning.message.contains("Error"),
1468            "warning should list valid variants: {}",
1469            warning.message
1470        );
1471        // Should include a "did you mean" suggestion since "Debugg" is similar to "Debug"
1472        assert!(
1473            warning.message.contains("Did you mean 'Debug'?"),
1474            "warning should suggest similar variant: {}",
1475            warning.message
1476        );
1477
1478        // Value should still be set (the driver will do the actual parsing/rejection)
1479        let value = output.value.expect("should have value");
1480        let log_level = get_nested(&value, &["config", "log_level"]).expect("config.log_level");
1481        assert_eq!(get_string(log_level), Some("Debugg"));
1482    }
1483
1484    #[derive(Facet)]
1485    struct ConfigWithOptionalEnum {
1486        log_level: Option<LogLevel>,
1487    }
1488
1489    #[derive(Facet)]
1490    struct ArgsWithOptionalEnumConfig {
1491        #[facet(args::config)]
1492        config: ConfigWithOptionalEnum,
1493    }
1494
1495    #[test]
1496    fn test_optional_enum_validation() {
1497        // Optional enum should also be validated
1498        let schema = Schema::from_shape(ArgsWithOptionalEnumConfig::SHAPE).unwrap();
1499        let env = MockEnv::from_pairs([("REEF__LOG_LEVEL", "invalid")]);
1500        let config = env_config("REEF");
1501
1502        let output = parse_env(&schema, &config, &env);
1503
1504        // Should have a warning even for optional enum
1505        assert!(
1506            !output.diagnostics.is_empty(),
1507            "invalid optional enum variant should produce a warning"
1508        );
1509    }
1510
1511    #[derive(Facet)]
1512    struct NestedConfigWithEnum {
1513        logging: LoggingConfig,
1514    }
1515
1516    #[derive(Facet)]
1517    struct LoggingConfig {
1518        level: LogLevel,
1519    }
1520
1521    #[derive(Facet)]
1522    struct ArgsWithNestedEnumConfig {
1523        #[facet(args::config)]
1524        config: NestedConfigWithEnum,
1525    }
1526
1527    #[test]
1528    fn test_nested_enum_validation() {
1529        // Enum in nested struct should be validated
1530        let schema = Schema::from_shape(ArgsWithNestedEnumConfig::SHAPE).unwrap();
1531        let env = MockEnv::from_pairs([("REEF__LOGGING__LEVEL", "unknown")]);
1532        let config = env_config("REEF");
1533
1534        let output = parse_env(&schema, &config, &env);
1535
1536        // Should have a warning
1537        assert!(
1538            !output.diagnostics.is_empty(),
1539            "invalid nested enum variant should produce a warning"
1540        );
1541    }
1542
1543    // ========================================================================
1544    // Tests: Enum variant field setting (issue #37)
1545    // ========================================================================
1546
1547    /// Storage enum with struct variants for testing enum variant field paths
1548    #[derive(Facet)]
1549    #[repr(u8)]
1550    #[allow(dead_code)]
1551    enum Storage {
1552        S3 { bucket: String, region: String },
1553        Gcp { project: String, zone: String },
1554        Local { path: String },
1555    }
1556
1557    #[derive(Facet)]
1558    struct ConfigWithEnumVariants {
1559        storage: Storage,
1560        port: u16,
1561    }
1562
1563    #[derive(Facet)]
1564    struct ArgsWithEnumVariantConfig {
1565        #[facet(args::config)]
1566        config: ConfigWithEnumVariants,
1567    }
1568
1569    #[test]
1570    fn test_enum_variant_field_single() {
1571        // Setting a single field within an enum variant: REEF__STORAGE__S3__BUCKET
1572        let schema = Schema::from_shape(ArgsWithEnumVariantConfig::SHAPE).unwrap();
1573        let env = MockEnv::from_pairs([("REEF__STORAGE__S3__BUCKET", "my-bucket")]);
1574        let config = env_config("REEF");
1575
1576        let output = parse_env(&schema, &config, &env);
1577
1578        assert!(
1579            output.diagnostics.is_empty(),
1580            "should not have diagnostics: {:?}",
1581            output.diagnostics
1582        );
1583        assert!(
1584            output.unused_keys.is_empty(),
1585            "should not have unused keys: {:?}",
1586            output.unused_keys
1587        );
1588
1589        let value = output.value.expect("should have value");
1590        // Should produce: {config: {storage: {s3: {bucket: "my-bucket"}}}}
1591        let bucket = get_nested(&value, &["config", "storage", "S3", "bucket"])
1592            .expect("should have config.storage.S3.bucket");
1593        assert_eq!(get_string(bucket), Some("my-bucket"));
1594    }
1595
1596    #[test]
1597    fn test_enum_variant_field_multiple() {
1598        // Setting multiple fields within the same enum variant
1599        let schema = Schema::from_shape(ArgsWithEnumVariantConfig::SHAPE).unwrap();
1600        let env = MockEnv::from_pairs([
1601            ("REEF__STORAGE__S3__BUCKET", "my-bucket"),
1602            ("REEF__STORAGE__S3__REGION", "us-east-1"),
1603        ]);
1604        let config = env_config("REEF");
1605
1606        let output = parse_env(&schema, &config, &env);
1607
1608        assert!(
1609            output.diagnostics.is_empty(),
1610            "should not have diagnostics: {:?}",
1611            output.diagnostics
1612        );
1613        assert!(
1614            output.unused_keys.is_empty(),
1615            "should not have unused keys: {:?}",
1616            output.unused_keys
1617        );
1618
1619        let value = output.value.expect("should have value");
1620        let bucket = get_nested(&value, &["config", "storage", "S3", "bucket"])
1621            .expect("should have config.storage.S3.bucket");
1622        assert_eq!(get_string(bucket), Some("my-bucket"));
1623
1624        let region = get_nested(&value, &["config", "storage", "S3", "region"])
1625            .expect("should have config.storage.S3.region");
1626        assert_eq!(get_string(region), Some("us-east-1"));
1627    }
1628
1629    #[test]
1630    fn test_enum_variant_field_different_variant() {
1631        // Setting fields in a different variant (Gcp instead of S3)
1632        let schema = Schema::from_shape(ArgsWithEnumVariantConfig::SHAPE).unwrap();
1633        let env = MockEnv::from_pairs([("REEF__STORAGE__GCP__PROJECT", "my-project")]);
1634        let config = env_config("REEF");
1635
1636        let output = parse_env(&schema, &config, &env);
1637
1638        assert!(
1639            output.diagnostics.is_empty(),
1640            "should not have diagnostics: {:?}",
1641            output.diagnostics
1642        );
1643        assert!(
1644            output.unused_keys.is_empty(),
1645            "should not have unused keys: {:?}",
1646            output.unused_keys
1647        );
1648
1649        let value = output.value.expect("should have value");
1650        let project = get_nested(&value, &["config", "storage", "Gcp", "project"])
1651            .expect("should have config.storage.Gcp.project");
1652        assert_eq!(get_string(project), Some("my-project"));
1653    }
1654
1655    #[test]
1656    fn test_enum_variant_field_with_regular_field() {
1657        // Mix of enum variant field and regular config field
1658        let schema = Schema::from_shape(ArgsWithEnumVariantConfig::SHAPE).unwrap();
1659        let env = MockEnv::from_pairs([
1660            ("REEF__STORAGE__S3__BUCKET", "my-bucket"),
1661            ("REEF__PORT", "8080"),
1662        ]);
1663        let config = env_config("REEF");
1664
1665        let output = parse_env(&schema, &config, &env);
1666
1667        assert!(
1668            output.diagnostics.is_empty(),
1669            "should not have diagnostics: {:?}",
1670            output.diagnostics
1671        );
1672        assert!(
1673            output.unused_keys.is_empty(),
1674            "should not have unused keys: {:?}",
1675            output.unused_keys
1676        );
1677
1678        let value = output.value.expect("should have value");
1679        let bucket = get_nested(&value, &["config", "storage", "S3", "bucket"])
1680            .expect("should have config.storage.S3.bucket");
1681        assert_eq!(get_string(bucket), Some("my-bucket"));
1682
1683        let port = get_nested(&value, &["config", "port"]).expect("should have config.port");
1684        assert_eq!(get_string(port), Some("8080"));
1685    }
1686
1687    #[test]
1688    fn test_enum_variant_unknown_variant_rejected() {
1689        // Unknown variant name should be rejected
1690        let schema = Schema::from_shape(ArgsWithEnumVariantConfig::SHAPE).unwrap();
1691        let env = MockEnv::from_pairs([("REEF__STORAGE__AZURE__CONTAINER", "my-container")]);
1692        let config = env_config("REEF");
1693
1694        let output = parse_env(&schema, &config, &env);
1695
1696        // Should have an unused key
1697        assert!(
1698            !output.unused_keys.is_empty(),
1699            "unknown variant should produce unused key"
1700        );
1701        assert!(
1702            output
1703                .unused_keys
1704                .iter()
1705                .any(|k| k.key.iter().any(|s| s == "azure")),
1706            "unused key should mention azure: {:?}",
1707            output.unused_keys
1708        );
1709    }
1710
1711    #[test]
1712    fn test_enum_variant_unknown_field_rejected() {
1713        // Unknown field within a valid variant should be rejected
1714        let schema = Schema::from_shape(ArgsWithEnumVariantConfig::SHAPE).unwrap();
1715        let env = MockEnv::from_pairs([("REEF__STORAGE__S3__UNKNOWN_FIELD", "value")]);
1716        let config = env_config("REEF");
1717
1718        let output = parse_env(&schema, &config, &env);
1719
1720        // Should have an unused key
1721        assert!(
1722            !output.unused_keys.is_empty(),
1723            "unknown field in variant should produce unused key"
1724        );
1725    }
1726
1727    #[derive(Facet)]
1728    struct ConfigWithOptionalEnumVariants {
1729        storage: Option<Storage>,
1730    }
1731
1732    #[derive(Facet)]
1733    struct ArgsWithOptionalEnumVariantConfig {
1734        #[facet(args::config)]
1735        config: ConfigWithOptionalEnumVariants,
1736    }
1737
1738    #[test]
1739    fn test_optional_enum_variant_field() {
1740        // Setting field within optional enum variant
1741        let schema = Schema::from_shape(ArgsWithOptionalEnumVariantConfig::SHAPE).unwrap();
1742        let env = MockEnv::from_pairs([("REEF__STORAGE__LOCAL__PATH", "/data")]);
1743        let config = env_config("REEF");
1744
1745        let output = parse_env(&schema, &config, &env);
1746
1747        assert!(
1748            output.diagnostics.is_empty(),
1749            "should not have diagnostics: {:?}",
1750            output.diagnostics
1751        );
1752        assert!(
1753            output.unused_keys.is_empty(),
1754            "should not have unused keys: {:?}",
1755            output.unused_keys
1756        );
1757
1758        let value = output.value.expect("should have value");
1759        let path = get_nested(&value, &["config", "storage", "Local", "path"])
1760            .expect("should have config.storage.Local.path");
1761        assert_eq!(get_string(path), Some("/data"));
1762    }
1763
1764    // ========================================================================
1765    // Tests: Virtual env document and span assignment
1766    // ========================================================================
1767
1768    #[test]
1769    fn test_env_spans_are_assigned() {
1770        // Env vars should have spans pointing into a virtual document
1771        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
1772        let env = MockEnv::from_pairs([("REEF__PORT", "8080"), ("REEF__HOST", "localhost")]);
1773        let config = env_config("REEF");
1774
1775        let output = parse_env(&schema, &config, &env);
1776
1777        // Should have source_text set
1778        assert!(
1779            output.source_text.is_some(),
1780            "source_text should be set when env vars are parsed"
1781        );
1782
1783        let source_text = output.source_text.as_ref().unwrap();
1784
1785        // The source_text should contain both env vars
1786        assert!(
1787            source_text.contains("REEF__PORT=\"8080\""),
1788            "source_text should contain REEF__PORT: {}",
1789            source_text
1790        );
1791        assert!(
1792            source_text.contains("REEF__HOST=\"localhost\""),
1793            "source_text should contain REEF__HOST: {}",
1794            source_text
1795        );
1796
1797        // Check that spans are set on the values
1798        let value = output.value.expect("should have value");
1799        let port = get_nested(&value, &["config", "port"]).expect("config.port");
1800
1801        // The port value should have a span
1802        assert!(port.span().is_some(), "port should have a span");
1803
1804        // The span should point to the value "8080" in the source_text
1805        let span = port.span().unwrap();
1806        let offset = span.offset as usize;
1807        let len = span.len as usize;
1808        let pointed_text = &source_text[offset..offset + len];
1809        assert_eq!(
1810            pointed_text, "8080",
1811            "span should point to the value in source_text"
1812        );
1813    }
1814
1815    #[test]
1816    fn test_env_spans_with_alias() {
1817        // Env aliases should also get spans
1818        let schema = Schema::from_shape(ArgsWithAliasConfig::SHAPE).unwrap();
1819        let env = MockEnv::from_pairs([("DATABASE_URL", "postgres://localhost/db")]);
1820        let config = env_config("REEF");
1821
1822        let output = parse_env(&schema, &config, &env);
1823
1824        assert!(
1825            output.source_text.is_some(),
1826            "source_text should be set for aliased env vars"
1827        );
1828
1829        let source_text = output.source_text.as_ref().unwrap();
1830        assert!(
1831            source_text.contains("DATABASE_URL=\"postgres://localhost/db\""),
1832            "source_text should contain DATABASE_URL: {}",
1833            source_text
1834        );
1835
1836        let value = output.value.expect("should have value");
1837        let db_url = get_nested(&value, &["config", "database_url"]).expect("config.database_url");
1838
1839        let span = db_url.span().expect("db_url should have a span");
1840        let offset = span.offset as usize;
1841        let len = span.len as usize;
1842        let pointed_text = &source_text[offset..offset + len];
1843        assert_eq!(
1844            pointed_text, "postgres://localhost/db",
1845            "span should point to the value in source_text"
1846        );
1847    }
1848
1849    #[test]
1850    fn test_env_spans_no_env_vars_no_source_text() {
1851        // If no env vars are set, source_text should be None (or empty)
1852        let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
1853        let env = MockEnv::new();
1854        let config = env_config("REEF");
1855
1856        let output = parse_env(&schema, &config, &env);
1857
1858        // No env vars means no source_text
1859        assert!(
1860            output.source_text.is_none(),
1861            "source_text should be None when no env vars are parsed"
1862        );
1863    }
1864}