darklua_core/frontend/
resources.rs

1use std::{
2    collections::HashMap,
3    ffi::OsStr,
4    fs::{self, File},
5    io::{self, BufWriter, ErrorKind as IOErrorKind, Write},
6    iter,
7    path::{Path, PathBuf},
8    sync::{Arc, Mutex},
9};
10
11use crate::utils::normalize_path;
12
13#[derive(Debug, Clone)]
14enum Source {
15    FileSystem,
16    Memory(Arc<Mutex<HashMap<PathBuf, String>>>),
17}
18
19impl Source {
20    pub fn exists(&self, location: &Path) -> ResourceResult<bool> {
21        match self {
22            Self::FileSystem => Ok(location.exists()),
23            Self::Memory(data) => Ok(data.lock().unwrap().contains_key(&normalize_path(location))),
24        }
25    }
26
27    pub fn is_directory(&self, location: &Path) -> ResourceResult<bool> {
28        let is_directory = match self {
29            Source::FileSystem => self.exists(location)? && location.is_dir(),
30            Source::Memory(data) => {
31                let data = data.lock().unwrap();
32                let location = normalize_path(location);
33
34                data.iter()
35                    .any(|(path, _content)| path != &location && path.starts_with(&location))
36            }
37        };
38        Ok(is_directory)
39    }
40
41    pub fn is_file(&self, location: &Path) -> ResourceResult<bool> {
42        let is_file = match self {
43            Source::FileSystem => self.exists(location)? && location.is_file(),
44            Source::Memory(data) => {
45                let data = data.lock().unwrap();
46                let location = normalize_path(location);
47
48                data.contains_key(&location)
49            }
50        };
51        Ok(is_file)
52    }
53
54    pub fn get(&self, location: &Path) -> ResourceResult<String> {
55        match self {
56            Self::FileSystem => fs::read_to_string(location).map_err(|err| match err.kind() {
57                IOErrorKind::NotFound => ResourceError::not_found(location),
58                _ => ResourceError::io_error(location, err),
59            }),
60            Self::Memory(data) => {
61                let data = data.lock().unwrap();
62                let location = normalize_path(location);
63
64                data.get(&location)
65                    .map(String::from)
66                    .ok_or_else(|| ResourceError::not_found(location))
67            }
68        }
69    }
70
71    pub fn write(&self, location: &Path, content: &str) -> ResourceResult<()> {
72        match self {
73            Self::FileSystem => {
74                if let Some(parent) = location.parent() {
75                    fs::create_dir_all(parent)
76                        .map_err(|err| ResourceError::io_error(parent, err))?;
77                };
78
79                let file =
80                    File::create(location).map_err(|err| ResourceError::io_error(location, err))?;
81
82                let mut file = BufWriter::new(file);
83                file.write_all(content.as_bytes())
84                    .map_err(|err| ResourceError::io_error(location, err))
85            }
86            Self::Memory(data) => {
87                let mut data = data.lock().unwrap();
88                data.insert(normalize_path(location), content.to_string());
89                Ok(())
90            }
91        }
92    }
93
94    pub fn walk(&self, location: &Path) -> impl Iterator<Item = PathBuf> {
95        match self {
96            Self::FileSystem => Box::new(walk_file_system(location.to_path_buf()))
97                as Box<dyn Iterator<Item = PathBuf>>,
98            Self::Memory(data) => {
99                let data = data.lock().unwrap();
100                let location = normalize_path(location);
101                let mut paths: Vec<_> = data.keys().map(normalize_path).collect();
102                paths.retain(|path| path.starts_with(&location));
103
104                Box::new(paths.into_iter())
105            }
106        }
107    }
108
109    fn remove(&self, location: &Path) -> Result<(), ResourceError> {
110        match self {
111            Self::FileSystem => {
112                if !self.exists(location)? {
113                    Ok(())
114                } else if self.is_file(location)? {
115                    fs::remove_file(location).map_err(|err| ResourceError::io_error(location, err))
116                } else if self.is_directory(location)? {
117                    fs::remove_dir_all(location)
118                        .map_err(|err| ResourceError::io_error(location, err))
119                } else {
120                    Ok(())
121                }
122            }
123            Self::Memory(data) => {
124                if self.is_file(location)? {
125                    let mut data = data.lock().unwrap();
126                    data.remove(&normalize_path(location));
127                } else if self.is_directory(location)? {
128                    let mut data = data.lock().unwrap();
129                    let location = normalize_path(location);
130                    data.retain(|path, _| !path.starts_with(&location));
131                }
132
133                Ok(())
134            }
135        }
136    }
137}
138
139fn walk_file_system(location: PathBuf) -> impl Iterator<Item = PathBuf> {
140    let mut unknown_paths = vec![location];
141    let mut file_paths = Vec::new();
142    let mut dir_entries = Vec::new();
143
144    iter::from_fn(move || loop {
145        if let Some(location) = unknown_paths.pop() {
146            match location.metadata() {
147                Ok(metadata) => {
148                    if metadata.is_file() {
149                        file_paths.push(location.to_path_buf());
150                    } else if metadata.is_dir() {
151                        dir_entries.push(location.to_path_buf());
152                    } else if metadata.is_symlink() {
153                        log::warn!("unexpected symlink `{}` not followed", location.display());
154                    } else {
155                        log::warn!(
156                            concat!(
157                                "path `{}` points to an unexpected location that is not a ",
158                                "file, not a directory and not a symlink"
159                            ),
160                            location.display()
161                        );
162                    };
163                }
164                Err(err) => {
165                    log::warn!(
166                        "unable to read metadata from file `{}`: {}",
167                        location.display(),
168                        err
169                    );
170                }
171            }
172        } else if let Some(dir_location) = dir_entries.pop() {
173            match dir_location.read_dir() {
174                Ok(read_dir) => {
175                    for entry in read_dir {
176                        match entry {
177                            Ok(entry) => {
178                                unknown_paths.push(entry.path());
179                            }
180                            Err(err) => {
181                                log::warn!(
182                                    "unable to read directory entry `{}`: {}",
183                                    dir_location.display(),
184                                    err
185                                );
186                            }
187                        }
188                    }
189                }
190                Err(err) => {
191                    log::warn!(
192                        "unable to read directory `{}`: {}",
193                        dir_location.display(),
194                        err
195                    );
196                }
197            }
198        } else if let Some(path) = file_paths.pop() {
199            break Some(path);
200        } else {
201            break None;
202        }
203    })
204}
205
206/// A resource manager for handling file operations.
207///
208/// This struct provides an abstraction over file system operations, allowing
209/// operations to be performed either on the actual file system or in memory.
210/// It handles reading, writing, and managing files and directories.
211#[derive(Debug, Clone)]
212pub struct Resources {
213    source: Source,
214}
215
216impl Resources {
217    /// Creates a new resource manager that operates on the file system.
218    pub fn from_file_system() -> Self {
219        Self {
220            source: Source::FileSystem,
221        }
222    }
223
224    /// Creates a new resource manager that operates in memory.
225    ///
226    /// This is useful for testing or when you want to process files without
227    /// writing to disk.
228    pub fn from_memory() -> Self {
229        Self {
230            source: Source::Memory(Arc::new(Mutex::new(HashMap::new()))),
231        }
232    }
233
234    /// Collects all Lua and Luau files in the specified location.
235    pub fn collect_work(&self, location: impl AsRef<Path>) -> impl Iterator<Item = PathBuf> {
236        self.source.walk(location.as_ref()).filter(|path| {
237            matches!(
238                path.extension().and_then(OsStr::to_str),
239                Some("lua") | Some("luau")
240            )
241        })
242    }
243
244    /// Checks if a path exists.
245    pub fn exists(&self, location: impl AsRef<Path>) -> ResourceResult<bool> {
246        self.source.exists(location.as_ref())
247    }
248
249    /// Checks if a path is a directory.
250    pub fn is_directory(&self, location: impl AsRef<Path>) -> ResourceResult<bool> {
251        self.source.is_directory(location.as_ref())
252    }
253
254    /// Checks if a path is a file.
255    pub fn is_file(&self, location: impl AsRef<Path>) -> ResourceResult<bool> {
256        self.source.is_file(location.as_ref())
257    }
258
259    /// Reads the contents of a file.
260    pub fn get(&self, location: impl AsRef<Path>) -> ResourceResult<String> {
261        self.source.get(location.as_ref())
262    }
263
264    /// Writes content to a file.
265    pub fn write(&self, location: impl AsRef<Path>, content: &str) -> ResourceResult<()> {
266        self.source.write(location.as_ref(), content)
267    }
268
269    /// Removes a file or directory.
270    pub fn remove(&self, location: impl AsRef<Path>) -> ResourceResult<()> {
271        self.source.remove(location.as_ref())
272    }
273
274    /// Walks through all files in a directory.
275    pub fn walk(&self, location: impl AsRef<Path>) -> impl Iterator<Item = PathBuf> {
276        self.source.walk(location.as_ref())
277    }
278}
279
280/// An error that can occur during operations on [`Resource`].
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub enum ResourceError {
283    /// The requested resource was not found.
284    NotFound(PathBuf),
285    /// An I/O error occurred while accessing the resource.
286    IO { path: PathBuf, error: String },
287}
288
289impl ResourceError {
290    pub(crate) fn not_found(path: impl Into<PathBuf>) -> Self {
291        Self::NotFound(path.into())
292    }
293
294    pub(crate) fn io_error(path: impl Into<PathBuf>, error: io::Error) -> Self {
295        Self::IO {
296            path: path.into(),
297            error: error.to_string(),
298        }
299    }
300}
301
302/// A type alias for `Result<T, ResourceError>`.
303type ResourceResult<T> = Result<T, ResourceError>;
304
305#[cfg(test)]
306mod test {
307    use super::*;
308
309    fn any_path() -> &'static Path {
310        Path::new("test.lua")
311    }
312
313    const ANY_CONTENT: &str = "return true";
314
315    mod memory {
316        use std::iter::FromIterator;
317
318        use super::*;
319
320        fn new() -> Resources {
321            Resources::from_memory()
322        }
323
324        #[test]
325        fn not_created_file_does_not_exist() {
326            assert_eq!(new().exists(any_path()), Ok(false));
327        }
328
329        #[test]
330        fn created_file_exists() {
331            let resources = new();
332            resources.write(any_path(), ANY_CONTENT).unwrap();
333
334            assert_eq!(resources.exists(any_path()), Ok(true));
335        }
336
337        #[test]
338        fn created_file_is_removed_exists() {
339            let resources = new();
340            resources.write(any_path(), ANY_CONTENT).unwrap();
341
342            resources.remove(any_path()).unwrap();
343
344            assert_eq!(resources.exists(any_path()), Ok(false));
345        }
346
347        #[test]
348        fn created_file_exists_is_a_file() {
349            let resources = new();
350            resources.write(any_path(), ANY_CONTENT).unwrap();
351
352            assert_eq!(resources.is_file(any_path()), Ok(true));
353        }
354
355        #[test]
356        fn created_file_exists_is_not_a_directory() {
357            let resources = new();
358            resources.write(any_path(), ANY_CONTENT).unwrap();
359
360            assert_eq!(resources.is_directory(any_path()), Ok(false));
361        }
362
363        #[test]
364        fn read_content_of_created_file() {
365            let resources = new();
366            resources.write(any_path(), ANY_CONTENT).unwrap();
367
368            assert_eq!(resources.get(any_path()), Ok(ANY_CONTENT.to_string()));
369        }
370
371        #[test]
372        fn collect_work_contains_created_files() {
373            let resources = new();
374            resources.write("src/test.lua", ANY_CONTENT).unwrap();
375
376            assert_eq!(
377                Vec::from_iter(resources.collect_work("src")),
378                vec![PathBuf::from("src/test.lua")]
379            );
380        }
381    }
382}