1use 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
19static WATCHED: OnceLock<Bundle> = OnceLock::new();
21
22#[derive(Debug)]
24#[non_exhaustive]
25pub enum FolderError {
26 Read {
28 path: PathBuf,
30 error: io::Error,
32 },
33 TooBig {
35 path: PathBuf,
37 limit: u64,
39 },
40 NotUtf8 {
42 path: PathBuf,
44 },
45 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
65pub fn read_folder(path: &Path, name: &str, max_file_bytes: u64) -> Result<Layer, FolderError> {
80 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
120pub 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
147pub(crate) fn watched_file(path: &str) -> Option<&'static [u8]> {
151 WATCHED.get()?.bytes(path)
152}
153
154pub(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 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 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 #[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}