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 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 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
80pub(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 let mut offset = 1; for (start, end) in multiline_ranges {
113 let result = lines
114 .drain(start - offset..end - offset + 1) .map(|entry| entry.raw_string)
116 .reduce(|result, line| result + "\n" + &line); 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 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}