1use std::borrow::Cow;
2use std::collections::{HashMap, HashSet};
3use std::io;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone)]
7pub struct DirEntry {
8 pub path: PathBuf,
9 pub name: String,
10 pub is_dir: bool,
11}
12
13pub trait FileSystem: Send + Sync {
14 fn read_file(&self, path: &Path) -> io::Result<Vec<u8>>;
15 fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>>;
16 fn exists(&self, path: &Path) -> bool;
17}
18
19pub struct LocalFs;
22
23impl FileSystem for LocalFs {
24 fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
25 std::fs::read(path)
26 }
27
28 fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
29 let mut entries = Vec::new();
30 for entry in std::fs::read_dir(path)? {
31 let entry = entry?;
32 let ft = entry.file_type()?;
33 entries.push(DirEntry {
34 path: entry.path(),
35 name: entry.file_name().to_string_lossy().into_owned(),
36 is_dir: ft.is_dir(),
37 });
38 }
39 Ok(entries)
40 }
41
42 fn exists(&self, path: &Path) -> bool {
43 path.exists()
44 }
45}
46
47pub struct RootedFs {
53 root: PathBuf,
54}
55
56impl RootedFs {
57 pub fn new(root: &Path) -> Self {
58 Self {
59 root: root.to_path_buf(),
60 }
61 }
62
63 fn resolve(&self, path: &Path) -> PathBuf {
64 if path.is_absolute() {
65 path.to_path_buf()
66 } else {
67 self.root.join(path)
68 }
69 }
70}
71
72impl FileSystem for RootedFs {
73 fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
74 std::fs::read(self.resolve(path))
75 }
76
77 fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
78 let resolved = self.resolve(path);
79 let mut entries = Vec::new();
80 for entry in std::fs::read_dir(&resolved)? {
81 let entry = entry?;
82 let ft = entry.file_type()?;
83 entries.push(DirEntry {
84 path: entry.path(),
85 name: entry.file_name().to_string_lossy().into_owned(),
86 is_dir: ft.is_dir(),
87 });
88 }
89 Ok(entries)
90 }
91
92 fn exists(&self, path: &Path) -> bool {
93 self.resolve(path).exists()
94 }
95}
96
97pub struct MemoryFs {
100 files: HashMap<PathBuf, Vec<u8>>,
101}
102
103impl MemoryFs {
104 pub fn new(entries: &[(&str, &str)]) -> Self {
105 let mut files = HashMap::new();
106 for (path, content) in entries {
107 files.insert(normalize(path), content.as_bytes().to_vec());
108 }
109 Self { files }
110 }
111
112 pub fn from_bytes(entries: &[(&str, &[u8])]) -> Self {
113 let mut files = HashMap::new();
114 for (path, content) in entries {
115 files.insert(normalize(path), content.to_vec());
116 }
117 Self { files }
118 }
119}
120
121impl FileSystem for MemoryFs {
122 fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
123 let key = normalize(&path.to_string_lossy());
124 self.files
125 .get(&key)
126 .cloned()
127 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("{}", path.display())))
128 }
129
130 fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
131 let dir = normalize(&path.to_string_lossy());
132 let prefix = if dir.as_os_str().is_empty() {
133 PathBuf::new()
134 } else {
135 dir.clone()
136 };
137
138 let mut seen = HashSet::new();
139 let mut entries = Vec::new();
140
141 for file_path in self.files.keys() {
142 let child_name = if prefix.as_os_str().is_empty() {
143 file_path.components().next()
145 } else if file_path.starts_with(&prefix) {
146 file_path
148 .strip_prefix(&prefix)
149 .ok()
150 .and_then(|rest| rest.components().next())
151 } else {
152 None
153 };
154
155 if let Some(component) = child_name {
156 let name = component.as_os_str().to_string_lossy().into_owned();
157 if seen.insert(name.clone()) {
158 let child_path = if prefix.as_os_str().is_empty() {
159 PathBuf::from(&name)
160 } else {
161 prefix.join(&name)
162 };
163 let is_dir = !self.files.contains_key(&child_path);
165 entries.push(DirEntry {
166 path: child_path,
167 name,
168 is_dir,
169 });
170 }
171 }
172 }
173
174 if entries.is_empty() && !self.exists(path) {
175 return Err(io::Error::new(
176 io::ErrorKind::NotFound,
177 format!("{}", path.display()),
178 ));
179 }
180
181 Ok(entries)
182 }
183
184 fn exists(&self, path: &Path) -> bool {
185 let key = normalize(&path.to_string_lossy());
186 if self.files.contains_key(&key) {
188 return true;
189 }
190 if key.as_os_str().is_empty() {
192 return true;
193 }
194 self.files.keys().any(|p| p.starts_with(&key))
196 }
197}
198
199pub(crate) fn normalize_dir<'a>(dir: &'a str) -> Cow<'a, str> {
203 let stripped_prefix = dir.strip_prefix("./");
204 let d = stripped_prefix.unwrap_or(dir);
205 let stripped_suffix = d.strip_suffix('/');
206 let d = stripped_suffix.unwrap_or(d);
207 if d.is_empty() {
208 Cow::Owned(".".to_string())
209 } else if stripped_prefix.is_some() || stripped_suffix.is_some() {
210 Cow::Owned(d.to_string())
211 } else {
212 Cow::Borrowed(dir)
213 }
214}
215
216fn normalize(s: &str) -> PathBuf {
218 let s = s.strip_prefix("./").unwrap_or(s);
219 let s = s.strip_suffix('/').unwrap_or(s);
220 if s == "." || s.is_empty() {
221 PathBuf::new()
222 } else {
223 PathBuf::from(s)
224 }
225}
226
227#[cfg(test)]
228#[allow(clippy::unwrap_used, clippy::expect_used)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn memory_fs_read_file() {
234 let fs = MemoryFs::new(&[("hello.txt", "world")]);
235 let content = fs.read_file(Path::new("hello.txt")).unwrap();
236 assert_eq!(content, b"world");
237 }
238
239 #[test]
240 fn memory_fs_read_file_not_found() {
241 let fs = MemoryFs::new(&[]);
242 assert!(fs.read_file(Path::new("missing.txt")).is_err());
243 }
244
245 #[test]
246 fn memory_fs_read_dir_root() {
247 let fs = MemoryFs::new(&[
248 ("package.json", "{}"),
249 ("src/main.rs", "fn main() {}"),
250 ("src/lib.rs", ""),
251 ]);
252 let entries = fs.read_dir(Path::new(".")).unwrap();
253 let names: HashSet<_> = entries.iter().map(|e| e.name.as_str()).collect();
254 assert_eq!(names, HashSet::from(["package.json", "src"]));
255 assert!(entries.iter().find(|e| e.name == "src").unwrap().is_dir);
256 assert!(
257 !entries
258 .iter()
259 .find(|e| e.name == "package.json")
260 .unwrap()
261 .is_dir
262 );
263 }
264
265 #[test]
266 fn memory_fs_read_dir_subdirectory() {
267 let fs = MemoryFs::new(&[("apps/api/package.json", "{}"), ("apps/web/index.ts", "")]);
268 let entries = fs.read_dir(Path::new("apps")).unwrap();
269 let names: HashSet<_> = entries.iter().map(|e| e.name.as_str()).collect();
270 assert_eq!(names, HashSet::from(["api", "web"]));
271 assert!(entries.iter().all(|e| e.is_dir));
272 }
273
274 #[test]
275 fn memory_fs_exists_file() {
276 let fs = MemoryFs::new(&[("a/b/c.txt", "content")]);
277 assert!(fs.exists(Path::new("a/b/c.txt")));
278 assert!(fs.exists(Path::new("a/b")));
279 assert!(fs.exists(Path::new("a")));
280 assert!(!fs.exists(Path::new("x")));
281 }
282
283 #[test]
284 fn memory_fs_exists_root() {
285 let fs = MemoryFs::new(&[("file.txt", "")]);
286 assert!(fs.exists(Path::new(".")));
287 assert!(fs.exists(Path::new("")));
288 }
289
290 #[test]
291 fn memory_fs_empty() {
292 let fs = MemoryFs::new(&[]);
293 assert!(!fs.exists(Path::new("anything")));
294 assert!(fs.exists(Path::new(".")));
296 assert!(fs.read_dir(Path::new(".")).unwrap().is_empty());
298 }
299
300 #[test]
301 fn memory_fs_normalized_paths() {
302 let fs = MemoryFs::new(&[("./src/main.rs", "fn main() {}")]);
303 assert!(fs.read_file(Path::new("src/main.rs")).is_ok());
304 assert!(fs.exists(Path::new("src")));
305 }
306}