dotenv_finder/
file.rs

1use std::{
2    collections::{BTreeMap, btree_map::IntoIter},
3    fmt, fs,
4    path::{Path, PathBuf},
5};
6
7use dotenv_core::{LineEntry, is_escaped};
8
9use crate::quote::get_quote;
10
11const PATTERN: &str = ".env";
12const EXCLUDED_FILES: &[&str] = &[".envrc"];
13const BACKUP_EXTENSION: &str = ".bak";
14pub const LF: &str = "\n";
15
16pub struct Files(BTreeMap<FileEntry, Vec<LineEntry>>);
17
18impl Files {
19    pub(crate) fn new(files: BTreeMap<FileEntry, Vec<LineEntry>>) -> Self {
20        Self(files)
21    }
22
23    pub fn is_empty(&self) -> bool {
24        self.0.is_empty()
25    }
26
27    pub fn len(&self) -> usize {
28        self.0.len()
29    }
30}
31
32impl IntoIterator for Files {
33    type Item = (FileEntry, Vec<LineEntry>);
34    type IntoIter = IntoIter<FileEntry, Vec<LineEntry>>;
35
36    fn into_iter(self) -> Self::IntoIter {
37        self.0.into_iter()
38    }
39}
40
41#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
42pub struct FileEntry {
43    pub path: PathBuf,
44    pub file_name: String,
45    pub total_lines: usize,
46}
47
48impl fmt::Display for FileEntry {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(f, "{}", self.path.display())
51    }
52}
53
54impl FileEntry {
55    /// Converts `PathBuf` to tuple of `(FileEntry, Vec<LineEntry>)`
56    pub(crate) fn from(path: PathBuf) -> Option<(Self, Vec<LineEntry>)> {
57        let file_name = get_file_name(&path)?.to_string();
58        let content = fs::read_to_string(&path).ok()?;
59
60        let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
61
62        // You must add a line, because [`Lines`] does not return the last empty row (excludes LF)
63        if content.ends_with(LF) {
64            lines.push(LF.to_string());
65        }
66
67        let lines = get_line_entries(lines);
68
69        Some((
70            FileEntry {
71                path,
72                file_name,
73                total_lines: lines.len(),
74            },
75            lines,
76        ))
77    }
78}
79
80/// Checks a file name with the `.env` pattern
81pub(crate) fn is_dotenv_file(path: &Path) -> bool {
82    get_file_name(path)
83        .filter(|file_name| !EXCLUDED_FILES.contains(file_name))
84        .filter(|file_name| file_name.starts_with(PATTERN) || file_name.ends_with(PATTERN))
85        .filter(|file_name| !file_name.ends_with(BACKUP_EXTENSION))
86        .is_some()
87}
88
89fn get_file_name(path: &Path) -> Option<&str> {
90    path.file_name().and_then(|file_name| file_name.to_str())
91}
92
93fn get_line_entries(lines: Vec<String>) -> Vec<LineEntry> {
94    let length = lines.len();
95
96    let mut lines: Vec<LineEntry> = lines
97        .into_iter()
98        .enumerate()
99        .map(|(index, line)| LineEntry::new(index + 1, line, length == (index + 1)))
100        .collect();
101
102    reduce_multiline_entries(&mut lines);
103    lines
104}
105
106fn reduce_multiline_entries(lines: &mut Vec<LineEntry>) {
107    let length = lines.len();
108    let multiline_ranges = find_multiline_ranges(lines);
109
110    // Replace multiline value to one line-entry for checking
111    let mut offset = 1; // index offset to account deleted lines (for access by index)
112    for (start, end) in multiline_ranges {
113        let result = lines
114            .drain(start - offset..end - offset + 1) // TODO: consider `drain_filter` (after stabilization in rust std)
115            .map(|entry| entry.raw_string)
116            .reduce(|result, line| result + "\n" + &line); // TODO: `intersperse` (after stabilization in rust std)
117
118        if let Some(value) = result {
119            lines.insert(start - offset, LineEntry::new(start, value, length == end));
120        }
121
122        offset += end - start;
123    }
124}
125
126fn find_multiline_ranges(lines: &[LineEntry]) -> Vec<(usize, usize)> {
127    let mut multiline_ranges: Vec<(usize, usize)> = Vec::new();
128    let mut start_number: Option<usize> = None;
129    let mut quote_char: Option<char> = None;
130
131    // here we find ranges of multi-line values
132    lines.iter().for_each(|entry| {
133        if let Some(start) = start_number {
134            if let Some(quote_char) = quote_char
135                && let Some(idx) = entry.raw_string.find(quote_char)
136                && !is_escaped(&entry.raw_string[..idx])
137            {
138                multiline_ranges.push((start, entry.number));
139                start_number = None;
140            }
141        } else if let Some(trimmed_value) = entry.get_value().map(|val| val.trim())
142            && let Some(quote) = get_quote(trimmed_value)
143        {
144            quote_char = Some(quote.as_char());
145            start_number = Some(entry.number);
146        }
147    });
148
149    multiline_ranges
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    mod from {
157        use super::*;
158
159        #[test]
160        fn path_without_file_test() {
161            let f = FileEntry::from(PathBuf::from("/"));
162            assert_eq!(None, f);
163        }
164
165        #[test]
166        fn path_with_file_test() {
167            let file_name = String::from(".env");
168            let dir = tempfile::tempdir().expect("create temp dir");
169            let path = dir.path().join(&file_name);
170            fs::File::create(&path).expect("create testfile");
171
172            let f = FileEntry::from(path.clone());
173            assert_eq!(
174                Some((
175                    FileEntry {
176                        path,
177                        file_name,
178                        total_lines: 0
179                    },
180                    vec![]
181                )),
182                f
183            );
184            dir.close().expect("temp dir deleted");
185        }
186    }
187
188    #[test]
189    fn is_env_file_test() {
190        let mut assertions = vec![
191            (".env", true),
192            ("foo.env", true),
193            (".env.foo", true),
194            (".env.foo.common", true),
195            ("env", false),
196            ("env.foo", false),
197            ("foo_env", false),
198            ("foo-env", false),
199            (".my-env-file", false),
200            ("dev.env.js", false),
201            (".env.bak", false),
202        ];
203
204        assertions.extend(EXCLUDED_FILES.iter().map(|file| (*file, false)));
205
206        for (file_name, expected) in assertions {
207            assert_eq!(
208                expected,
209                is_dotenv_file(&PathBuf::from(file_name)),
210                "Expected {expected} for the file name {file_name}"
211            )
212        }
213    }
214}