This repository has no description
3.5 kB
114 lines
1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, thiserror::Error)]
5pub enum EnvFileError {
6 #[error("read env file {path}: {source}")]
7 Io {
8 path: PathBuf,
9 source: std::io::Error,
10 },
11}
12
13#[derive(Debug, Default)]
14pub struct EnvFile {
15 values: BTreeMap<String, String>,
16}
17
18impl EnvFile {
19 pub fn read(path: &Path) -> Result<Self, EnvFileError> {
20 let body = std::fs::read_to_string(path).map_err(|source| EnvFileError::Io {
21 path: path.to_path_buf(),
22 source,
23 })?;
24 Ok(Self::parse(&body))
25 }
26
27 pub fn parse(body: &str) -> Self {
28 let values = body
29 .lines()
30 .map(str::trim)
31 .filter(|line| !line.is_empty() && !line.starts_with('#'))
32 .map(|line| line.strip_prefix("export ").unwrap_or(line))
33 .filter_map(|line| line.split_once('='))
34 .filter_map(|(key, value)| unquote(value).map(|value| (key.trim().to_string(), value)))
35 .collect();
36 Self { values }
37 }
38
39 pub fn get(&self, key: &str) -> Option<&str> {
40 self.values.get(key).map(String::as_str)
41 }
42}
43
44fn unquote(raw: &str) -> Option<String> {
45 let trimmed = raw.trim();
46 ['"', '\'']
47 .into_iter()
48 .find_map(|quote| trimmed.strip_prefix(quote).map(|rest| (quote, rest)))
49 .map_or_else(
50 || Some(strip_comment(trimmed).to_string()),
51 |(quote, rest)| rest.split_once(quote).map(|(inner, _)| inner.to_string()),
52 )
53}
54
55fn strip_comment(value: &str) -> &str {
56 value
57 .match_indices('#')
58 .find(|(index, _)| {
59 value[..*index]
60 .chars()
61 .next_back()
62 .is_some_and(char::is_whitespace)
63 })
64 .map_or(value, |(index, _)| &value[..index])
65 .trim_end()
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71
72 #[test]
73 fn parsing_keeps_assignments_and_drops_every_other_line() {
74 let file = EnvFile::parse(
75 r#"# knot1
76KNOT_SERVER_HOSTNAME=oyster.cafe
77export KNOT_SERVER_OWNER="did:plc:nel"
78
79KNOT_SERVER_PLC_URL='https://plc.oyster.cafe'
80broken line
81KNOT_REPO_SCAN_PATH=/data/repos # tangled default
82KNOT_QUOTED_HOSTNAME="nel.pet" # quoted
83KNOT_SERVER_SECRET='a # b'
84KNOT_DEV_FLAGS=#literal
85KNOT_APPVIEW_URL=https://tangled.test/#frag
86KNOT_UNTERMINATED="oyster.cafe
87KNOT_ALSO_UNTERMINATED='did:plc:nel
88KNOT_LAST=kept
89"#,
90 );
91 assert_eq!(file.get("KNOT_SERVER_HOSTNAME"), Some("oyster.cafe"));
92 assert_eq!(file.get("KNOT_SERVER_OWNER"), Some("did:plc:nel"));
93 assert_eq!(
94 file.get("KNOT_SERVER_PLC_URL"),
95 Some("https://plc.oyster.cafe")
96 );
97 assert_eq!(file.get("broken"), None);
98 assert_eq!(file.get("KNOT_REPO_SCAN_PATH"), Some("/data/repos"));
99 assert_eq!(file.get("KNOT_QUOTED_HOSTNAME"), Some("nel.pet"));
100 assert_eq!(file.get("KNOT_SERVER_SECRET"), Some("a # b"));
101 assert_eq!(file.get("KNOT_DEV_FLAGS"), Some("#literal"));
102 assert_eq!(
103 file.get("KNOT_APPVIEW_URL"),
104 Some("https://tangled.test/#frag")
105 );
106 assert_eq!(file.get("KNOT_UNTERMINATED"), None);
107 assert_eq!(file.get("KNOT_ALSO_UNTERMINATED"), None);
108 assert_eq!(
109 file.get("KNOT_LAST"),
110 Some("kept"),
111 "an unterminated quote drops its own line and nothing after it"
112 );
113 }
114}