Skip to main content

tanzim_parse/
env.rs

1//! Dotenv / env-file parser (`env` feature).
2//!
3//! **Format:** `env`
4//!
5//! # Behaviour
6//!
7//! - Splits the UTF-8 input into lines; blank lines are ignored. Full-line `#` comments and
8//!   inline `#` comments after a value are preserved on each entry via [`tanzim_value::Comment`].
9//!   An optional leading `export ` is stripped.
10//! - Each remaining `KEY=VALUE` line becomes a string entry. Values may be double-quoted (with
11//!   `\n`, `\r`, `\t`, `\"`, `\\` escapes), single-quoted (taken literally), or unquoted (used
12//!   verbatim). The result is always a [`Value::Map`] of [`Value::String`]s.
13//! - When the source carries a `separator` option, keys are split on that separator and nested
14//!   into sub-maps (e.g. `BAR__BAZ=val` with `separator=__` becomes `{bar: {baz: "val"}}`).
15//!   For non-`env` sources (e.g. file-loaded `.env` payloads), the parser inherits `separator`
16//!   and `lowercase` from a sibling `env(...)` source in `other_source_list` when present.
17//! - Each key carries its line/column [`Location`]; for single-line input the line/column are
18//!   omitted. The root map has no line.
19//! - Non-UTF-8 input fails with [`Error::InvalidUtf8`]; there are
20//!   no syntax errors otherwise. [`is_format_supported`](crate::Parse::is_format_supported)
21//!   returns `Some(true)` when any non-comment line contains `=`, else `Some(false)`.
22//!
23//! # Example
24//!
25//! ```
26//! use tanzim_parse::{Parse, env::Env};
27//! use tanzim_source::SourceBuilder;
28//!
29//! let source = SourceBuilder::new()
30//!     .with_source("file")
31//!     .with_resource(".env")
32//!     .build()
33//!     .unwrap();
34//! let value = Env::new()
35//!     .parse(&source, b"SERVER_HOST=\"127.0.0.1\"\n", &[])
36//!     .unwrap();
37//! assert_eq!(
38//!     value.value().as_map().unwrap().get("server_host").unwrap().value().as_string().unwrap(),
39//!     "127.0.0.1"
40//! );
41//! ```
42
43use crate::span::{is_single_line, line_column_from_line};
44use crate::{Parse, Source};
45use cfg_if::cfg_if;
46use tanzim_value::{Comment, Error, LocatedValue, Location, Map, Value};
47
48/// Parser for the `env` format: dotenv / env-file `KEY=VALUE` lines into a string map.
49///
50/// Skips blank lines, preserves `#` comments on each entry, supports quoted values, and records each key's line number
51/// as a [`Location`]. When the source carries a `separator` option, keys are nested into
52/// sub-maps. Stateless — construct with [`Env::new`].
53///
54/// ```
55/// use tanzim_parse::{Parse, env::Env};
56/// use tanzim_source::SourceBuilder;
57///
58/// let source = SourceBuilder::new()
59///     .with_source("file")
60///     .with_resource(".env")
61///     .build()
62///     .unwrap();
63/// let value = Env::new()
64///     .parse(&source, b"# comment\nPORT=8080\n", &[])
65///     .unwrap();
66/// let port = value.value().as_map().unwrap().get("port").unwrap();
67/// assert_eq!(port.value().as_string().unwrap(), "8080");
68/// ```
69#[derive(Clone, Copy, Default)]
70pub struct Env;
71
72impl Env {
73    /// Create an env-format parser.
74    pub fn new() -> Self {
75        Self
76    }
77}
78
79fn hash_comment_body(line: &str) -> Option<String> {
80    let trimmed = line.trim();
81    if !trimmed.starts_with('#') {
82        return None;
83    }
84    Some(trimmed[1..].trim_start().to_string())
85}
86
87fn parse_double_quoted_value(input: &str) -> Option<(String, &str)> {
88    let mut out = String::new();
89    let mut index = 1usize;
90    while index < input.len() {
91        let ch = input[index..].chars().next()?;
92        let ch_len = ch.len_utf8();
93        if ch == '"' {
94            return Some((out, &input[index + ch_len..]));
95        }
96        if ch == '\\' {
97            index += ch_len;
98            if index >= input.len() {
99                out.push('\\');
100                break;
101            }
102            let next = input[index..].chars().next()?;
103            let next_len = next.len_utf8();
104            match next {
105                'n' => out.push('\n'),
106                'r' => out.push('\r'),
107                't' => out.push('\t'),
108                '"' => out.push('"'),
109                '\\' => out.push('\\'),
110                other => {
111                    out.push('\\');
112                    out.push(other);
113                }
114            }
115            index += next_len;
116        } else {
117            out.push(ch);
118            index += ch_len;
119        }
120    }
121    None
122}
123
124fn parse_single_quoted_value(input: &str) -> Option<(String, &str)> {
125    let mut index = 1usize;
126    while index < input.len() {
127        let ch = input[index..].chars().next()?;
128        if ch == '\'' {
129            return Some((input[1..index].to_string(), &input[index + ch.len_utf8()..]));
130        }
131        index += ch.len_utf8();
132    }
133    None
134}
135
136fn parse_env_value_and_comment(value_part: &str) -> (String, Option<String>) {
137    let trimmed = value_part.trim_start();
138    if trimmed.starts_with('"')
139        && let Some((value, rest)) = parse_double_quoted_value(trimmed)
140    {
141        return (value, hash_comment_body(rest));
142    } else if trimmed.starts_with('\'')
143        && let Some((value, rest)) = parse_single_quoted_value(trimmed)
144    {
145        return (value, hash_comment_body(rest));
146    }
147    if let Some(space_index) = trimmed.find(" #") {
148        let value = trimmed[..space_index].trim_end();
149        let comment = trimmed[space_index + 1..].trim();
150        if comment.starts_with('#') {
151            return (value.to_string(), hash_comment_body(comment));
152        }
153    }
154    (trimmed.to_string(), None)
155}
156
157impl Parse for Env {
158    fn name(&self) -> &str {
159        "Environment-Variables"
160    }
161
162    fn supported_format_list(&self) -> Vec<String> {
163        vec!["env".into()]
164    }
165
166    fn parse(
167        &self,
168        source: &Source,
169        bytes: &[u8],
170        other_source_list: &[Source],
171    ) -> Result<LocatedValue, Error> {
172        fn insert_nested(map: &mut Map, parts: &[String], value: LocatedValue) {
173            if parts.is_empty() {
174                return;
175            }
176            if parts.len() == 1 {
177                map.insert(parts[0].clone(), value);
178                return;
179            }
180            let head = parts[0].clone();
181            let rest = &parts[1..];
182            match map.get_mut(&head) {
183                Some(existing) => {
184                    if let Value::Map(inner) = existing.value_mut() {
185                        insert_nested(inner, rest, value);
186                        return;
187                    }
188                    let loc = value.location().clone();
189                    let mut inner = Map::new();
190                    insert_nested(&mut inner, rest, value);
191                    existing.set_value(Value::Map(inner));
192                    existing.set_location(loc);
193                }
194                None => {
195                    let loc = value.location().clone();
196                    let mut inner = Map::new();
197                    insert_nested(&mut inner, rest, value);
198                    map.insert(head, LocatedValue::new(Value::Map(inner), loc));
199                }
200            }
201        }
202
203        #[cfg(any(feature = "tracing", feature = "logging"))]
204        let source_name = source.source();
205        #[cfg(any(feature = "tracing", feature = "logging"))]
206        let resource = source.resource();
207        cfg_if! {
208            if #[cfg(feature = "tracing")] {
209                tracing::debug!(msg = "Parsing env-format configuration", source = source_name, resource = resource, bytes = bytes.len());
210            } else if #[cfg(feature = "logging")] {
211                log::debug!("msg=\"Parsing env-format configuration\" source={source_name} resource={resource} bytes={}", bytes.len());
212            }
213        }
214
215        let (separator, lowercase) = if source.source() == "env" {
216            let separator = match source.options().get("separator") {
217                None => None,
218                Some(value) => value.as_string().cloned(),
219            };
220            let lowercase = match source.options().get("lowercase") {
221                None => true,
222                Some(value) => value.as_bool().unwrap_or(true),
223            };
224            (separator, lowercase)
225        } else {
226            let mut env_sources: Vec<&Source> = Vec::new();
227            for other in other_source_list {
228                if other.source() == "env" {
229                    env_sources.push(other);
230                }
231            }
232            if env_sources.is_empty() {
233                let separator = match source.options().get("separator") {
234                    None => None,
235                    Some(value) => value.as_string().cloned(),
236                };
237                let lowercase = match source.options().get("lowercase") {
238                    None => true,
239                    Some(value) => value.as_bool().unwrap_or(true),
240                };
241                (separator, lowercase)
242            } else {
243                let mut first_separator: Option<Option<String>> = None;
244                for env_source in &env_sources {
245                    let sep = match env_source.options().get("separator") {
246                        None => None,
247                        Some(value) => value.as_string().cloned(),
248                    };
249                    match &first_separator {
250                        None => first_separator = Some(sep),
251                        Some(expected) => {
252                            if *expected != sep {
253                                return Err(Error::Parse {
254                                    text: String::new(),
255                                    location: Some(Box::new(Location::in_source(
256                                        source.clone(),
257                                        None,
258                                        None,
259                                        None,
260                                    ))),
261                                    message: "cannot determine env separator: multiple env sources with different separator options".to_string(),
262                                });
263                            }
264                        }
265                    }
266                }
267                let separator = first_separator.unwrap_or(None);
268                let lowercase = match env_sources[0].options().get("lowercase") {
269                    None => true,
270                    Some(value) => value.as_bool().unwrap_or(true),
271                };
272                (separator, lowercase)
273            }
274        };
275
276        let text = match std::str::from_utf8(bytes) {
277            Ok(value) => value,
278            Err(_) => {
279                return Err(Error::InvalidUtf8 {
280                    location: Box::new(Location::in_source(source.clone(), None, None, None)),
281                });
282            }
283        };
284        let single_line = is_single_line(bytes);
285        let mut map = Map::new();
286        let mut pending_before: Vec<String> = Vec::new();
287        let mut line_number = 0usize;
288        let mut offset = 0usize;
289        while offset < text.len() {
290            let rest = &text[offset..];
291            let line_end = match rest.find('\n') {
292                Some(index) => index,
293                None => rest.len(),
294            };
295            let line = &rest[..line_end];
296            line_number += 1;
297            let trimmed = line.trim();
298            if trimmed.is_empty() {
299                offset += line_end;
300                if offset < text.len() {
301                    offset += 1;
302                }
303                continue;
304            }
305            if let Some(body) = hash_comment_body(trimmed) {
306                pending_before.push(body);
307            } else {
308                let mut line_body = trimmed;
309                if line_body.starts_with("export ") {
310                    line_body = line_body["export ".len()..].trim_start();
311                }
312                if let Some(equal_index) = line_body.find('=') {
313                    let key = line_body[..equal_index].trim();
314                    let value_part = line_body[equal_index + 1..].trim();
315                    if !key.is_empty() {
316                        let key_start = line.find(key).unwrap_or(0);
317                        let column = line_column_from_line(line, 1, key_start);
318                        let (value, after_comment) = parse_env_value_and_comment(value_part);
319                        let location = if single_line {
320                            Location::in_source(source.clone(), None, None, None)
321                        } else {
322                            Location::in_source(
323                                source.clone(),
324                                Some(line_number),
325                                Some(column),
326                                None,
327                            )
328                        };
329                        let final_key = if lowercase {
330                            key.to_lowercase()
331                        } else {
332                            key.to_string()
333                        };
334                        let mut located_value = LocatedValue::new(Value::String(value), location);
335                        let mut comment = Comment::new();
336                        if !pending_before.is_empty() {
337                            comment.set_before(pending_before.clone());
338                            pending_before.clear();
339                        }
340                        if let Some(after) = after_comment {
341                            comment.set_after(Some(after));
342                        }
343                        if !comment.before().is_empty() || comment.after().is_some() {
344                            located_value = located_value.with_comment(comment);
345                        }
346                        match &separator {
347                            None => {
348                                map.insert(final_key, located_value);
349                            }
350                            Some(sep) => {
351                                let mut part_list: Vec<String> = Vec::new();
352                                let mut remaining = final_key.as_str();
353                                loop {
354                                    if let Some(index) = remaining.find(sep.as_str()) {
355                                        part_list.push(remaining[..index].to_string());
356                                        remaining = &remaining[index + sep.len()..];
357                                    } else {
358                                        part_list.push(remaining.to_string());
359                                        break;
360                                    }
361                                }
362                                if part_list.len() == 1 {
363                                    map.insert(part_list[0].clone(), located_value);
364                                } else {
365                                    insert_nested(&mut map, &part_list, located_value);
366                                }
367                            }
368                        }
369                    }
370                }
371            }
372            offset += line_end;
373            if offset < text.len() {
374                offset += 1;
375            }
376        }
377        cfg_if! {
378            if #[cfg(feature = "tracing")] {
379                tracing::trace!(msg = "Parsed env-format configuration", source = source_name, resource = resource, key_count = map.len());
380            } else if #[cfg(feature = "logging")] {
381                log::trace!("msg=\"Parsed env-format configuration\" source={source_name} resource={resource} key_count={}", map.len());
382            }
383        }
384        Ok(LocatedValue::new(
385            Value::Map(map),
386            Location::in_source(source.clone(), None, None, None),
387        ))
388    }
389
390    fn is_format_supported(&self, bytes: &[u8]) -> Option<bool> {
391        let text = std::str::from_utf8(bytes).ok()?;
392        for line in text.split('\n') {
393            let line = line.trim();
394            if !line.is_empty() && !line.starts_with('#') && line.contains('=') {
395                return Some(true);
396            }
397        }
398        Some(false)
399    }
400}
401
402/// Serialize a [`Value`] map into dotenv / env-file `KEY=VALUE` lines.
403///
404/// Accepts a [`Value`], `&Value`, [`LocatedValue`], or `&LocatedValue`; the root must be
405/// a [`Value::Map`]. Nested maps are flattened using the `separator` option carried by
406/// `source` (the same option [`Env::parse`] reads); a nested map with no separator
407/// configured is an error, as are lists (env has no list representation).
408///
409/// ```
410/// use tanzim_parse::env::unparse;
411/// use tanzim_source::SourceBuilder;
412/// use tanzim_value::{Map, LocatedValue, Location, Value};
413///
414/// let source = SourceBuilder::new().with_source("env").build().unwrap();
415/// let mut map = Map::new();
416/// map.insert("port".into(), LocatedValue::new(
417///     Value::String("8080".into()),
418///     Location::at("env", "", None, None, None),
419/// ));
420/// assert_eq!(unparse(&source, Value::Map(map)).unwrap(), "port=8080\n");
421/// ```
422pub fn unparse<V: AsRef<Value>>(
423    source: &Source,
424    value: V,
425) -> Result<String, Box<dyn std::error::Error + Send + Sync + 'static>> {
426    let value = value.as_ref();
427    let map = match value.as_map() {
428        Some(map) => map,
429        None => {
430            return Err(format!("env root must be a map, found {}", value.type_name()).into());
431        }
432    };
433    let separator = source
434        .options()
435        .get("separator")
436        .and_then(|value| value.as_string().cloned());
437    let mut out = String::new();
438    write_env(&mut out, map, "", separator.as_deref())?;
439    Ok(out)
440}
441
442fn write_env(
443    out: &mut String,
444    map: &Map,
445    prefix: &str,
446    separator: Option<&str>,
447) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
448    for (key, item) in map.entries() {
449        if matches!(item.value(), Value::Null) {
450            continue;
451        }
452        let full_key = format!("{prefix}{key}");
453        match item.value() {
454            Value::Map(inner) => {
455                let separator = match separator {
456                    Some(separator) => separator,
457                    None => {
458                        return Err(format!(
459                            "cannot serialize nested map at key {full_key:?} to env without a separator option"
460                        )
461                        .into());
462                    }
463                };
464                write_env(
465                    out,
466                    inner,
467                    &format!("{full_key}{separator}"),
468                    Some(separator),
469                )?;
470            }
471            Value::List(_) => {
472                return Err(format!(
473                    "cannot serialize list at key {full_key:?} to env: env has no list representation"
474                )
475                .into());
476            }
477            scalar => {
478                for before in item.comment().before() {
479                    out.push_str("# ");
480                    out.push_str(before);
481                    if !before.ends_with('\n') {
482                        out.push('\n');
483                    }
484                }
485                out.push_str(&full_key);
486                out.push('=');
487                match scalar {
488                    Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
489                    Value::Int(value) => out.push_str(&value.to_string()),
490                    Value::Float(value) => out.push_str(&format!("{value:?}")),
491                    Value::String(value) => {
492                        let needs_quote = value.is_empty()
493                            || value.contains(|ch: char| {
494                                ch.is_whitespace() || matches!(ch, '"' | '\'' | '#' | '=')
495                            });
496                        if needs_quote {
497                            out.push('"');
498                            for ch in value.chars() {
499                                match ch {
500                                    '"' => out.push_str("\\\""),
501                                    '\\' => out.push_str("\\\\"),
502                                    '\n' => out.push_str("\\n"),
503                                    '\r' => out.push_str("\\r"),
504                                    '\t' => out.push_str("\\t"),
505                                    other => out.push(other),
506                                }
507                            }
508                            out.push('"');
509                        } else {
510                            out.push_str(value);
511                        }
512                    }
513                    Value::Null => {}
514                    // Maps and lists are handled by the arms above.
515                    Value::List(_) | Value::Map(_) => {}
516                }
517                if let Some(after) = item.comment().after() {
518                    out.push_str(" # ");
519                    out.push_str(after);
520                }
521                out.push('\n');
522            }
523        }
524    }
525    Ok(())
526}
527
528#[cfg(all(test, feature = "env"))]
529mod tests {
530    use super::*;
531    use std::path::PathBuf;
532    use tanzim_source::{OptionValue, SourceBuilder};
533
534    fn file_source(resource: &str) -> Source {
535        SourceBuilder::new()
536            .with_source("file")
537            .with_resource(resource)
538            .build()
539            .unwrap()
540    }
541
542    fn loc(value: Value) -> LocatedValue {
543        LocatedValue::new(value, Location::at("env", "test", None, None, None))
544    }
545
546    #[test]
547    fn unparses_complex_env() {
548        let source = SourceBuilder::new()
549            .with_source("env")
550            .with_option("separator", OptionValue::String("__".into()))
551            .build()
552            .unwrap();
553        let mut database = Map::new();
554        database.insert("host".into(), loc(Value::String("localhost".into())));
555        database.insert("port".into(), loc(Value::Int(5432)));
556        let mut map = Map::new();
557        map.insert("database".into(), loc(Value::Map(database)));
558        map.insert("debug".into(), loc(Value::Bool(true)));
559        map.insert("note".into(), loc(Value::String("has space".into())));
560
561        let text = unparse(&source, Value::Map(map)).unwrap();
562        assert_eq!(
563            text,
564            "database__host=localhost\ndatabase__port=5432\ndebug=true\nnote=\"has space\"\n"
565        );
566    }
567
568    #[test]
569    fn unparse_list_is_error() {
570        let source = file_source(".env");
571        let mut map = Map::new();
572        map.insert("items".into(), loc(Value::List(vec![loc(Value::Int(1))])));
573        assert!(unparse(&source, Value::Map(map)).is_err());
574    }
575
576    #[test]
577    fn parses_dotenv_contents() {
578        let source = file_source(".env");
579        let parsed = Env::new()
580            .parse(&source, b"FOO=bar\nBAZ=qux\n", &[])
581            .unwrap();
582        let map = parsed.value().as_map().unwrap();
583        assert_eq!(map.get("foo").unwrap().value().as_string().unwrap(), "bar");
584        assert_eq!(map.get("baz").unwrap().value().as_string().unwrap(), "qux");
585    }
586
587    #[test]
588    fn parses_env_with_line_numbers() {
589        let source = file_source(".env");
590        let root = Env::new()
591            .parse(&source, b"FOO=bar\nBAZ=qux\n", &[])
592            .unwrap();
593        let map = root.value().as_map().unwrap();
594        let foo = map.get("foo").unwrap();
595        assert_eq!(foo.value().as_string().unwrap(), "bar");
596        assert_eq!(foo.location().line, std::num::NonZeroU32::new(1));
597        let baz = map.get("baz").unwrap();
598        assert_eq!(baz.location().line, std::num::NonZeroU32::new(2));
599    }
600
601    #[test]
602    fn parses_nested_keys_with_separator() {
603        let source = SourceBuilder::new()
604            .with_source("env")
605            .with_option("separator", OptionValue::String("__".into()))
606            .build()
607            .unwrap();
608        let parsed = Env::new().parse(&source, b"BAR__BAZ=val\n", &[]).unwrap();
609        let map = parsed.value().as_map().unwrap();
610        let bar = map.get("bar").unwrap();
611        let nested = bar.value().as_map().unwrap();
612        assert_eq!(
613            nested.get("baz").unwrap().value().as_string().unwrap(),
614            "val"
615        );
616    }
617
618    #[test]
619    fn parses_prefix_and_suffix_comments() {
620        let text = b"# top comment\n# second line\nPORT=8080 # listen port\n";
621        let parsed = Env::new().parse(&file_source(".env"), text, &[]).unwrap();
622        let port = parsed.value().as_map().unwrap().get("port").unwrap();
623        assert_eq!(port.comment().before(), &["top comment", "second line"]);
624        assert_eq!(port.comment().after(), Some("listen port"));
625        assert_eq!(port.value().as_string().unwrap(), "8080");
626    }
627
628    #[test]
629    fn parses_quoted_value_with_suffix_comment() {
630        let source = SourceBuilder::new()
631            .with_source("env")
632            .with_option("separator", OptionValue::String("__".into()))
633            .build()
634            .unwrap();
635        let parsed = Env::new()
636            .parse(&source, b"SERVER__PORT=\"8080\" # listen port\n", &[])
637            .unwrap();
638        let server = parsed.value().as_map().unwrap().get("server").unwrap();
639        let port = server.value().as_map().unwrap().get("port").unwrap();
640        assert_eq!(port.value().as_string().unwrap(), "8080");
641        assert_eq!(port.comment().after(), Some("listen port"));
642    }
643
644    #[test]
645    fn unparses_prefix_and_suffix_comments() {
646        let source = file_source(".env");
647        let mut map = Map::new();
648        map.insert(
649            "port".into(),
650            loc(Value::String("8080".into())).with_comment(
651                Comment::new()
652                    .with_before(["top comment"])
653                    .with_after(Some("listen port")),
654            ),
655        );
656        let text = unparse(&source, Value::Map(map)).unwrap();
657        assert_eq!(text, "# top comment\nport=8080 # listen port\n");
658    }
659
660    #[test]
661    fn parses_and_unparses_foo_env_comments() {
662        let path =
663            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/full/etc/foo.env");
664        let text = std::fs::read_to_string(&path).unwrap();
665        let source = SourceBuilder::new()
666            .with_source("env")
667            .with_option("separator", OptionValue::String(".".into()))
668            .build()
669            .unwrap();
670        let parsed = Env::new().parse(&source, text.as_bytes(), &[]).unwrap();
671        let port = parsed
672            .value()
673            .as_map()
674            .unwrap()
675            .get("server")
676            .unwrap()
677            .value()
678            .as_map()
679            .unwrap()
680            .get("port")
681            .unwrap();
682        assert_eq!(port.value().as_string().unwrap(), "8080");
683        assert_eq!(port.comment().after(), Some("listen port"));
684
685        let reparsed = unparse(&source, parsed.into_value()).unwrap();
686        assert_eq!(reparsed, "server.port=8080 # listen port\n");
687
688        let reparsed_again = Env::new().parse(&source, reparsed.as_bytes(), &[]).unwrap();
689        let port_again = reparsed_again
690            .value()
691            .as_map()
692            .unwrap()
693            .get("server")
694            .unwrap()
695            .value()
696            .as_map()
697            .unwrap()
698            .get("port")
699            .unwrap();
700        assert_eq!(port_again.value().as_string().unwrap(), "8080");
701        assert_eq!(port_again.comment().after(), Some("listen port"));
702    }
703
704    #[test]
705    fn parses_file_env_inheriting_separator() {
706        let env_source = SourceBuilder::new()
707            .with_source("env")
708            .with_option("separator", OptionValue::String(".".into()))
709            .build()
710            .unwrap();
711        let file_source = file_source("foo.env");
712        let other_sources = [env_source];
713        let parsed = Env::new()
714            .parse(
715                &file_source,
716                b"SERVER.PORT=8080\n",
717                other_sources.as_slice(),
718            )
719            .unwrap();
720        let server = parsed.value().as_map().unwrap().get("server").unwrap();
721        let port = server.value().as_map().unwrap().get("port").unwrap();
722        assert_eq!(port.value().as_string().unwrap(), "8080");
723    }
724
725    #[test]
726    fn rejects_conflicting_env_separators() {
727        let env_dot = SourceBuilder::new()
728            .with_source("env")
729            .with_option("separator", OptionValue::String(".".into()))
730            .build()
731            .unwrap();
732        let env_underscore = SourceBuilder::new()
733            .with_source("env")
734            .with_option("separator", OptionValue::String("__".into()))
735            .build()
736            .unwrap();
737        let other_sources = [env_dot, env_underscore];
738        let error = Env::new()
739            .parse(
740                &file_source("foo.env"),
741                b"SERVER.PORT=8080\n",
742                other_sources.as_slice(),
743            )
744            .unwrap_err();
745        assert!(matches!(error, Error::Parse { .. }));
746        assert!(error.to_string().contains("separator"));
747    }
748}