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