Skip to main content

shuyi_app/
folder.rs

1//! A folder on disk read into a bundle layer (ADR-0004 ยง1): a player's mod,
2//! a level they made, or a debug build's art read without a rebuild. The
3//! game says where the folder is and lays the layer over its own files;
4//! where mods live and in what order is the game's. Native only: the web
5//! has no folders.
6
7use std::collections::BTreeMap;
8use std::fs::{self, File};
9use std::io::{self, Read};
10use std::path::{Path, PathBuf};
11use std::sync::OnceLock;
12
13use shuyi_assets::bundle::{Bundle, Layer, LayerError};
14use shuyi_assets::format::DEFAULT_MAX_BYTES;
15use shuyi_platform::WATCH_VAR;
16
17use crate::watch;
18
19/// The layer [`watched_layer`] read first, for the backend's pictures.
20static WATCHED: OnceLock<Bundle> = OnceLock::new();
21
22/// Why a folder could not be read into a layer ([`read_folder`]).
23#[derive(Debug)]
24#[non_exhaustive]
25pub enum FolderError {
26    /// A file or folder that could not be read.
27    Read {
28        /// The file or folder.
29        path: PathBuf,
30        /// What the system said.
31        error: io::Error,
32    },
33    /// A file over the limit.
34    TooBig {
35        /// The file.
36        path: PathBuf,
37        /// The limit, in bytes.
38        limit: u64,
39    },
40    /// A file or folder whose name isn't UTF-8, which no read can name.
41    NotUtf8 {
42        /// The file or folder.
43        path: PathBuf,
44    },
45    /// A path the layer refused (a backslash in a name, where the system
46    /// allows one).
47    Layer(LayerError),
48}
49
50impl core::fmt::Display for FolderError {
51    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
52        match self {
53            Self::Read { path, error } => write!(f, "{}: {error}", path.display()),
54            Self::TooBig { path, limit } => {
55                write!(f, "{}: the file is over {limit} bytes", path.display())
56            }
57            Self::NotUtf8 { path } => write!(f, "{}: the name isn't UTF-8", path.display()),
58            Self::Layer(error) => error.fmt(f),
59        }
60    }
61}
62
63impl std::error::Error for FolderError {}
64
65/// Every file in the folder at `path` and the folders below it, as one
66/// layer named `name`, each at its path relative to the folder,
67/// `/`-separated (`data/crops.ron`). A link to a file is read; a link to
68/// a folder is not followed. Read it once: the layer's files are kept for
69/// the life of the program.
70///
71/// In a build with debug tools the folder is watched from then on: a file
72/// that changes takes its new bytes in the layer, and the game's modules
73/// hear of it (`Module::reloaded`).
74///
75/// # Errors
76///
77/// A file over `max_file_bytes`, a file or folder that can't be read or
78/// whose name isn't UTF-8, each naming it, and a path the layer refuses.
79pub fn read_folder(path: &Path, name: &str, max_file_bytes: u64) -> Result<Layer, FolderError> {
80    // Before the files are read: one that changes meanwhile is seen as
81    // changed, and read again.
82    let seen = shuyi_ui::DEBUG_TOOLS.then(|| watch::scan(path));
83    let mut files = BTreeMap::new();
84    let mut pending = vec![(path.to_path_buf(), String::new())];
85    while let Some((dir, prefix)) = pending.pop() {
86        let read = |error| FolderError::Read {
87            path: dir.clone(),
88            error,
89        };
90        for entry in fs::read_dir(&dir).map_err(read)? {
91            let entry = entry.map_err(read)?;
92            let at = entry.path();
93            let Some(file_name) = entry.file_name().to_str().map(str::to_owned) else {
94                return Err(FolderError::NotUtf8 { path: at });
95            };
96            let inner = if prefix.is_empty() {
97                file_name
98            } else {
99                format!("{prefix}/{file_name}")
100            };
101            let kind = entry.file_type().map_err(|error| FolderError::Read {
102                path: at.clone(),
103                error,
104            })?;
105            if kind.is_dir() {
106                pending.push((at, inner));
107            } else if fs::metadata(&at).is_ok_and(|meta| meta.is_file()) {
108                let bytes = read_file(&at, max_file_bytes)?;
109                files.insert(inner, bytes);
110            }
111        }
112    }
113    let layer = Layer::owned(name.to_owned(), files).map_err(FolderError::Layer)?;
114    if let Some(seen) = seen {
115        watch::note_folder(path, layer, max_file_bytes, seen);
116    }
117    Ok(layer)
118}
119
120/// The folder `cargo shuyi run --watch` named ([`WATCH_VAR`]) as a layer to
121/// lay over the game's files, watched like any folder [`read_folder`]
122/// reads and shown under the folder's name: in a build with debug tools,
123/// when it is set. A folder that can't be read logs why, and the game runs
124/// on its embedded files.
125pub fn watched_layer() -> Option<Layer> {
126    if !shuyi_ui::DEBUG_TOOLS {
127        return None;
128    }
129    let path = PathBuf::from(std::env::var_os(WATCH_VAR)?);
130    let name = path
131        .file_name()
132        .and_then(|name| name.to_str())
133        .unwrap_or("watched");
134    let limit = u64::try_from(DEFAULT_MAX_BYTES).unwrap_or(u64::MAX);
135    match read_folder(&path, name, limit) {
136        Ok(layer) => {
137            let _ = WATCHED.set(Bundle::new(vec![layer]));
138            Some(layer)
139        }
140        Err(error) => {
141            macroquad::logging::warn!("{}: {}", WATCH_VAR, error);
142            None
143        }
144    }
145}
146
147/// The file at `path` of the folder [`watched_layer`] read, with its last
148/// saved bytes: what the backend reads pictures through, since a game's
149/// `AppConfig::files` is a function over its embedded files.
150pub(crate) fn watched_file(path: &str) -> Option<&'static [u8]> {
151    WATCHED.get()?.bytes(path)
152}
153
154/// The bytes of the file at `path`, or an error if it is over `limit`
155/// bytes. It never reads more than one byte past the limit, also when the
156/// file grows while it is read.
157pub(crate) fn read_file(path: &Path, limit: u64) -> Result<Vec<u8>, FolderError> {
158    let read = |error| FolderError::Read {
159        path: path.to_path_buf(),
160        error,
161    };
162    let file = File::open(path).map_err(read)?;
163    let too_big = || FolderError::TooBig {
164        path: path.to_path_buf(),
165        limit,
166    };
167    if file.metadata().map_err(read)?.len() > limit {
168        return Err(too_big());
169    }
170    let mut bytes = Vec::new();
171    file.take(limit.saturating_add(1))
172        .read_to_end(&mut bytes)
173        .map_err(read)?;
174    if u64::try_from(bytes.len()).map_or(true, |len| len > limit) {
175        return Err(too_big());
176    }
177    Ok(bytes)
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    /// A temporary folder holding `files` (`/`-separated paths).
185    fn folder(files: &[(&str, &[u8])]) -> tempfile::TempDir {
186        let dir = tempfile::tempdir().unwrap();
187        for (path, bytes) in files {
188            let at = dir.path().join(path);
189            fs::create_dir_all(at.parent().unwrap()).unwrap();
190            fs::write(at, bytes).unwrap();
191        }
192        dir
193    }
194
195    /// The base layer the folder is laid over.
196    static BASE: &[(&str, &[u8])] = &[("data/crops.ron", b"base crops"), ("top.txt", b"top")];
197
198    #[test]
199    fn a_folder_is_read_into_a_layer_laid_over_the_games_files() {
200        let dir = folder(&[
201            ("data/crops.ron", b"mod crops"),
202            ("data/more/deep.ron", b"deep"),
203            ("new.png", &[0xff, 0x00]),
204        ]);
205        fs::create_dir(dir.path().join("empty")).unwrap();
206        let layer = read_folder(dir.path(), "mods/more", 16).unwrap();
207        let bundle = Bundle::new(vec![layer, Layer::table("base", BASE)]);
208        assert_eq!(bundle.file("data/crops.ron"), Some("mod crops"));
209        assert_eq!(
210            bundle.display_path("data/crops.ron"),
211            "mods/more/data/crops.ron"
212        );
213        assert_eq!(bundle.file("data/more/deep.ron"), Some("deep"));
214        assert_eq!(bundle.bytes("new.png"), Some(&[0xff, 0x00][..]));
215        assert_eq!(bundle.file("top.txt"), Some("top"));
216        assert_eq!(
217            bundle.files_under(""),
218            ["data/crops.ron", "data/more/deep.ron", "new.png", "top.txt"]
219        );
220    }
221
222    #[test]
223    fn a_file_at_the_limit_is_read_and_one_over_it_is_refused() {
224        let dir = folder(&[("a.txt", b"1234"), ("data/b.txt", b"12345")]);
225        let error = read_folder(dir.path(), "mods/big", 4).unwrap_err();
226        assert!(
227            matches!(&error, FolderError::TooBig { path, limit: 4 } if path.ends_with("b.txt")),
228            "{error:?}"
229        );
230        let message = error.to_string();
231        assert!(
232            message.contains("b.txt") && message.contains("over 4 bytes"),
233            "{message}"
234        );
235        let layer = read_folder(dir.path(), "mods/big", 5).unwrap();
236        assert_eq!(Bundle::new(vec![layer]).file("data/b.txt"), Some("12345"));
237    }
238
239    #[test]
240    fn a_missing_folder_is_an_error_naming_it() {
241        let dir = tempfile::tempdir().unwrap();
242        let missing = dir.path().join("nope");
243        let error = read_folder(&missing, "mods/none", 16).unwrap_err();
244        assert!(
245            matches!(&error, FolderError::Read { path, .. } if *path == missing),
246            "{error:?}"
247        );
248        assert!(error.to_string().contains("nope"), "{error}");
249    }
250
251    #[test]
252    fn an_empty_folder_is_an_empty_layer() {
253        let dir = tempfile::tempdir().unwrap();
254        let layer = read_folder(dir.path(), "mods/empty", 16).unwrap();
255        let listed = Bundle::new(vec![layer]).files_under("");
256        assert!(listed.is_empty(), "{listed:?}");
257    }
258
259    /// Where a name may hold a backslash, the layer refuses it.
260    #[cfg(unix)]
261    #[test]
262    fn a_name_with_a_backslash_is_refused() {
263        let dir = folder(&[("a\\b.txt", b"x")]);
264        let error = read_folder(dir.path(), "mods/odd", 16).unwrap_err();
265        assert!(
266            matches!(&error, FolderError::Layer(e) if e.problem == shuyi_assets::bundle::BadPath::Backslash),
267            "{error:?}"
268        );
269    }
270}