Skip to main content

launch/
fs.rs

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
19// -- LocalFs --
20
21pub 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
47// -- RootedFs --
48
49/// Wraps LocalFs to root all relative paths to a base directory.
50/// Solves the CWD mismatch: walk_local strips paths to relative,
51/// but generate needs to read files relative to the scanned root, not CWD.
52pub 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
97// -- MemoryFs --
98
99pub 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                // Root directory: take the first component
144                file_path.components().next()
145            } else if file_path.starts_with(&prefix) {
146                // Subdirectory: strip prefix and take the first remaining component
147                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                    // It's a file if the exact path exists in our map, otherwise it's a directory
164                    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        // Exact file match
187        if self.files.contains_key(&key) {
188            return true;
189        }
190        // Root dir always exists (even if empty)
191        if key.as_os_str().is_empty() {
192            return true;
193        }
194        // Directory inference: exists if any file has this as a prefix
195        self.files.keys().any(|p| p.starts_with(&key))
196    }
197}
198
199/// Normalize a dir string for use as a HashMap key in the signal pipeline.
200/// Strips leading "./", trailing "/", normalizes empty to ".".
201/// Returns a borrow when no transformation is needed.
202pub(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
216/// Normalize a path string: strip leading "./", trailing "/"
217fn 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        // Root always exists (even if empty) — matches real filesystem behavior
295        assert!(fs.exists(Path::new(".")));
296        // read_dir returns empty vec, not an error
297        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}