1use crate::span::{is_single_line, line_column_from_line};
41use crate::{Parse, Source};
42use cfg_if::cfg_if;
43use tanzim_value::{Error, LocatedValue, Location, Map, Value};
44
45#[derive(Clone, Copy, Default)]
67pub struct Env;
68
69impl Env {
70 pub fn new() -> Self {
72 Self
73 }
74}
75
76impl Parse for Env {
77 fn name(&self) -> &str {
78 "Environment-Variables"
79 }
80
81 fn supported_format_list(&self) -> Vec<String> {
82 vec!["env".into()]
83 }
84
85 fn parse(&self, source: &Source, bytes: &[u8]) -> Result<LocatedValue, Error> {
86 fn insert_nested(map: &mut Map, parts: &[String], value: LocatedValue) {
87 if parts.is_empty() {
88 return;
89 }
90 if parts.len() == 1 {
91 map.insert(parts[0].clone(), value);
92 return;
93 }
94 let head = parts[0].clone();
95 let rest = &parts[1..];
96 match map.get_mut(&head) {
97 Some(existing) => {
98 if let Value::Map(ref mut inner) = existing.value {
99 insert_nested(inner, rest, value);
100 return;
101 }
102 let loc = value.location.clone();
103 let mut inner = Map::new();
104 insert_nested(&mut inner, rest, value);
105 existing.value = Value::Map(inner);
106 existing.location = loc;
107 }
108 None => {
109 let loc = value.location.clone();
110 let mut inner = Map::new();
111 insert_nested(&mut inner, rest, value);
112 map.insert(
113 head,
114 LocatedValue {
115 value: Value::Map(inner),
116 location: loc,
117 },
118 );
119 }
120 }
121 }
122
123 #[cfg(any(feature = "tracing", feature = "logging"))]
124 let source_name = source.source();
125 #[cfg(any(feature = "tracing", feature = "logging"))]
126 let resource = source.resource();
127 cfg_if! {
128 if #[cfg(feature = "tracing")] {
129 tracing::debug!(msg = "Parsing env-format configuration", source = source_name, resource = resource, bytes = bytes.len());
130 } else if #[cfg(feature = "logging")] {
131 log::debug!("msg=\"Parsing env-format configuration\" source={source_name} resource={resource} bytes={}", bytes.len());
132 }
133 }
134
135 let separator = match source.options().get("separator") {
136 None => None,
137 Some(value) => value.as_string().cloned(),
138 };
139
140 let lowercase = match source.options().get("lowercase") {
141 None => true,
142 Some(value) => value.as_bool().unwrap_or(true),
143 };
144
145 let text = match std::str::from_utf8(bytes) {
146 Ok(value) => value,
147 Err(_) => {
148 return Err(Error::InvalidUtf8 {
149 location: Box::new(Location::in_source(source.clone(), None, None, None)),
150 });
151 }
152 };
153 let single_line = is_single_line(bytes);
154 let mut map = Map::new();
155 let mut line_number = 0usize;
156 let mut offset = 0usize;
157 while offset < text.len() {
158 let rest = &text[offset..];
159 let line_end = match rest.find('\n') {
160 Some(index) => index,
161 None => rest.len(),
162 };
163 let line = &rest[..line_end];
164 line_number += 1;
165 let trimmed = line.trim();
166 if !trimmed.is_empty() && !trimmed.starts_with('#') {
167 let mut line_body = trimmed;
168 if line_body.starts_with("export ") {
169 line_body = line_body["export ".len()..].trim_start();
170 }
171 if let Some(equal_index) = line_body.find('=') {
172 let key = line_body[..equal_index].trim();
173 let value_part = line_body[equal_index + 1..].trim();
174 if !key.is_empty() {
175 let key_start = line.find(key).unwrap_or(0);
176 let column = line_column_from_line(line, 1, key_start);
177 let value = if value_part.starts_with('"')
178 && value_part.ends_with('"')
179 && value_part.len() >= 2
180 {
181 let inner = &value_part[1..value_part.len() - 1];
182 let mut out = String::new();
183 let mut index = 0usize;
184 while index < inner.len() {
185 let ch = inner[index..].chars().next().expect("valid utf-8");
186 let ch_len = ch.len_utf8();
187 if ch == '\\' {
188 index += ch_len;
189 if index < inner.len() {
190 let next =
191 inner[index..].chars().next().expect("valid utf-8");
192 let next_len = next.len_utf8();
193 match next {
194 'n' => out.push('\n'),
195 'r' => out.push('\r'),
196 't' => out.push('\t'),
197 '"' => out.push('"'),
198 '\\' => out.push('\\'),
199 other => {
200 out.push('\\');
201 out.push(other);
202 }
203 }
204 index += next_len;
205 } else {
206 out.push('\\');
207 }
208 } else {
209 out.push(ch);
210 index += ch_len;
211 }
212 }
213 out
214 } else if value_part.starts_with('\'')
215 && value_part.ends_with('\'')
216 && value_part.len() >= 2
217 {
218 value_part[1..value_part.len() - 1].to_string()
219 } else {
220 value_part.to_string()
221 };
222 let location = if single_line {
223 Location::in_source(source.clone(), None, None, None)
224 } else {
225 Location::in_source(
226 source.clone(),
227 Some(line_number),
228 Some(column),
229 None,
230 )
231 };
232 let final_key = if lowercase {
233 key.to_lowercase()
234 } else {
235 key.to_string()
236 };
237 let located_value = LocatedValue {
238 value: Value::String(value),
239 location,
240 };
241 match &separator {
242 None => {
243 map.insert(final_key, located_value);
244 }
245 Some(sep) => {
246 let mut part_list: Vec<String> = Vec::new();
247 let mut remaining = final_key.as_str();
248 loop {
249 if let Some(index) = remaining.find(sep.as_str()) {
250 part_list.push(remaining[..index].to_string());
251 remaining = &remaining[index + sep.len()..];
252 } else {
253 part_list.push(remaining.to_string());
254 break;
255 }
256 }
257 if part_list.len() == 1 {
258 map.insert(part_list[0].clone(), located_value);
259 } else {
260 insert_nested(&mut map, &part_list, located_value);
261 }
262 }
263 }
264 }
265 }
266 }
267 offset += line_end;
268 if offset < text.len() {
269 offset += 1;
270 }
271 }
272 cfg_if! {
273 if #[cfg(feature = "tracing")] {
274 tracing::trace!(msg = "Parsed env-format configuration", source = source_name, resource = resource, key_count = map.len());
275 } else if #[cfg(feature = "logging")] {
276 log::trace!("msg=\"Parsed env-format configuration\" source={source_name} resource={resource} key_count={}", map.len());
277 }
278 }
279 Ok(LocatedValue {
280 value: Value::Map(map),
281 location: Location::in_source(source.clone(), None, None, None),
282 })
283 }
284
285 fn is_format_supported(&self, bytes: &[u8]) -> Option<bool> {
286 let text = std::str::from_utf8(bytes).ok()?;
287 for line in text.split('\n') {
288 let line = line.trim();
289 if !line.is_empty() && !line.starts_with('#') && line.contains('=') {
290 return Some(true);
291 }
292 }
293 Some(false)
294 }
295}
296
297pub fn unparse<V: AsRef<Value>>(
318 source: &Source,
319 value: V,
320) -> Result<String, Box<dyn std::error::Error + Send + Sync + 'static>> {
321 let value = value.as_ref();
322 let map = match value.as_map() {
323 Some(map) => map,
324 None => {
325 return Err(format!("env root must be a map, found {}", value.type_name()).into());
326 }
327 };
328 let separator = source
329 .options()
330 .get("separator")
331 .and_then(|value| value.as_string().cloned());
332 let mut out = String::new();
333 write_env(&mut out, map, "", separator.as_deref())?;
334 Ok(out)
335}
336
337fn write_env(
338 out: &mut String,
339 map: &Map,
340 prefix: &str,
341 separator: Option<&str>,
342) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
343 for (key, item) in map.entries() {
344 let full_key = format!("{prefix}{key}");
345 match &item.value {
346 Value::Map(inner) => {
347 let separator = match separator {
348 Some(separator) => separator,
349 None => {
350 return Err(format!(
351 "cannot serialize nested map at key {full_key:?} to env without a separator option"
352 )
353 .into());
354 }
355 };
356 write_env(
357 out,
358 inner,
359 &format!("{full_key}{separator}"),
360 Some(separator),
361 )?;
362 }
363 Value::List(_) => {
364 return Err(format!(
365 "cannot serialize list at key {full_key:?} to env: env has no list representation"
366 )
367 .into());
368 }
369 scalar => {
370 out.push_str(&full_key);
371 out.push('=');
372 match scalar {
373 Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
374 Value::Int(value) => out.push_str(&value.to_string()),
375 Value::Float(value) => out.push_str(&format!("{value:?}")),
376 Value::String(value) => {
377 let needs_quote = value.is_empty()
378 || value.contains(|ch: char| {
379 ch.is_whitespace() || matches!(ch, '"' | '\'' | '#' | '=')
380 });
381 if needs_quote {
382 out.push('"');
383 for ch in value.chars() {
384 match ch {
385 '"' => out.push_str("\\\""),
386 '\\' => out.push_str("\\\\"),
387 '\n' => out.push_str("\\n"),
388 '\r' => out.push_str("\\r"),
389 '\t' => out.push_str("\\t"),
390 other => out.push(other),
391 }
392 }
393 out.push('"');
394 } else {
395 out.push_str(value);
396 }
397 }
398 Value::List(_) | Value::Map(_) => {}
400 }
401 out.push('\n');
402 }
403 }
404 }
405 Ok(())
406}
407
408#[cfg(all(test, feature = "env"))]
409mod tests {
410 use super::*;
411 use tanzim_source::{OptionValue, SourceBuilder};
412
413 fn file_source(resource: &str) -> Source {
414 SourceBuilder::new()
415 .with_source("file")
416 .with_resource(resource)
417 .build()
418 .unwrap()
419 }
420
421 fn loc(value: Value) -> LocatedValue {
422 LocatedValue {
423 value,
424 location: Location::at("env", "test", None, None, None),
425 }
426 }
427
428 #[test]
429 fn unparses_complex_env() {
430 let source = SourceBuilder::new()
431 .with_source("env")
432 .with_option("separator", OptionValue::String("__".into()))
433 .build()
434 .unwrap();
435 let mut database = Map::new();
436 database.insert("host".into(), loc(Value::String("localhost".into())));
437 database.insert("port".into(), loc(Value::Int(5432)));
438 let mut map = Map::new();
439 map.insert("database".into(), loc(Value::Map(database)));
440 map.insert("debug".into(), loc(Value::Bool(true)));
441 map.insert("note".into(), loc(Value::String("has space".into())));
442
443 let text = unparse(&source, Value::Map(map)).unwrap();
444 assert_eq!(
445 text,
446 "database__host=localhost\ndatabase__port=5432\ndebug=true\nnote=\"has space\"\n"
447 );
448 }
449
450 #[test]
451 fn unparse_list_is_error() {
452 let source = file_source(".env");
453 let mut map = Map::new();
454 map.insert("items".into(), loc(Value::List(vec![loc(Value::Int(1))])));
455 assert!(unparse(&source, Value::Map(map)).is_err());
456 }
457
458 #[test]
459 fn parses_dotenv_contents() {
460 let source = file_source(".env");
461 let parsed = Env::new().parse(&source, b"FOO=bar\nBAZ=qux\n").unwrap();
462 let map = parsed.value.as_map().unwrap();
463 assert_eq!(map.get("foo").unwrap().value.as_string().unwrap(), "bar");
464 assert_eq!(map.get("baz").unwrap().value.as_string().unwrap(), "qux");
465 }
466
467 #[test]
468 fn parses_env_with_line_numbers() {
469 let source = file_source(".env");
470 let root = Env::new().parse(&source, b"FOO=bar\nBAZ=qux\n").unwrap();
471 let map = root.value.as_map().unwrap();
472 let foo = map.get("foo").unwrap();
473 assert_eq!(foo.value.as_string().unwrap(), "bar");
474 assert_eq!(foo.location.line, std::num::NonZeroU32::new(1));
475 let baz = map.get("baz").unwrap();
476 assert_eq!(baz.location.line, std::num::NonZeroU32::new(2));
477 }
478
479 #[test]
480 fn parses_nested_keys_with_separator() {
481 let source = SourceBuilder::new()
482 .with_source("env")
483 .with_option("separator", OptionValue::String("__".into()))
484 .build()
485 .unwrap();
486 let parsed = Env::new().parse(&source, b"BAR__BAZ=val\n").unwrap();
487 let map = parsed.value.as_map().unwrap();
488 let bar = map.get("bar").unwrap();
489 let nested = bar.value.as_map().unwrap();
490 assert_eq!(nested.get("baz").unwrap().value.as_string().unwrap(), "val");
491 }
492}