Skip to main content

shuyi_assets/
bundle.rs

1//! The asset bundle: the files a game embeds in its binary, read from
2//! layers the game gives, in order. A file is read from the first layer
3//! that has it, so a layer laid over another replaces its files at the same
4//! paths and adds its own; a listing merges every layer's files.
5//!
6//! A layer may also come from outside the binary: [`Layer::owned`] takes
7//! files handed in at run time (a player's mod, a level they made), kept
8//! for the life of the program, so a game's loaders read them as they read
9//! its own. `shuyi-app`'s `read_folder` reads a folder on disk into one.
10//!
11//! Two flags shape a layer. A **private** layer holds files that may not be
12//! handed out (bought art): [`Bundle::is_private`] tells a tool which shown
13//! names come from one. A **test** layer holds the files of the tests and
14//! debug tools, which a build that ships leaves out: test layers are read
15//! after every other layer, and [`story_only`] hides them from a thread.
16//!
17//! A layer may also name the files it **leaves out** on purpose
18//! ([`Layer::leaving_out`]): a smaller build (a handheld's) that keeps only
19//! what it uses. [`Bundle::left_out`] lets a loader tell such a file from
20//! one that is missing.
21//!
22//! A layer may be **for a target** (ADR-0008 §4): `assets@playdate` is
23//! `assets`' layer for the Playdate, used only when baking or running
24//! for it. [`Bundle::for_target`] lays it right above `assets`, so any
25//! file there (a hand-drawn one-bit `hero.png`, a settings file) replaces
26//! the one at the same path, and leaves out every other target's.
27//!
28//! A bundle's [`fingerprint`](Bundle::fingerprint) stands for the content
29//! that ships, the same on every platform: what a record or a save is
30//! stamped with (`shuyi_core::made`).
31//!
32//! The game registers its bundle once ([`install`] or [`get_or_install`]),
33//! and its loaders read files as `&'static` data from it. A front end can
34//! [`watch`] every file read, to log or time what it loads; with `std`,
35//! [`reading`] makes one thread read another bundle (a test of a smaller
36//! build's bundle), and [`recording`] lists the files a stretch of code
37//! read on its thread.
38//!
39//! In a debug build a file of a run-time layer may change on disk:
40//! [`Bundle::replace`] takes its new bytes, and [`hot`](crate::hot) values
41//! decode again.
42use alloc::collections::BTreeMap;
43#[cfg(feature = "std")]
44use alloc::collections::BTreeSet;
45
46use shuyi_core::ContentHash;
47
48use crate::error::ContentError;
49use crate::prelude::*;
50
51mod fingerprint;
52/// The fixed bundle whose fingerprint `formats.ron` pins (`format::golden_tests`).
53#[cfg(test)]
54pub(crate) use fingerprint::tests::fixed as fixed_fingerprint_bundle;
55
56#[cfg(feature = "std")]
57use include_dir::Dir;
58use once_cell::race::OnceBox;
59
60/// Where a layer's files are.
61#[derive(Debug, Clone, Copy)]
62#[non_exhaustive]
63pub enum Files {
64    /// A directory embedded by `include_dir!`.
65    #[cfg(feature = "std")]
66    Dir(&'static Dir<'static>),
67    /// A table of `(path, contents)` sorted by path (relative to the
68    /// layer's root, `/`-separated): what a build script writes where
69    /// `include_dir` does not build (without `std`).
70    Table(&'static [(&'static str, &'static [u8])]),
71    /// Files handed in at run time ([`Layer::owned`]): a player's mod, a
72    /// folder read from disk.
73    Owned(&'static OwnedFiles),
74}
75
76/// One file of a layer's table: its path and its contents.
77type TableFile = (&'static str, &'static [u8]);
78
79/// The files of a layer handed in at run time ([`Layer::owned`]), with
80/// checked paths, kept for the life of the program so every read of them
81/// is `&'static`. With `std` a file can be replaced ([`Layer::replace`]);
82/// its old bytes stay, so what was read of them stays valid.
83#[derive(Debug)]
84pub struct OwnedFiles {
85    /// `(path, contents)`, sorted by path.
86    #[cfg(feature = "std")]
87    table: std::sync::RwLock<Vec<TableFile>>,
88    /// `(path, contents)`, sorted by path.
89    #[cfg(not(feature = "std"))]
90    table: Vec<TableFile>,
91}
92
93impl OwnedFiles {
94    /// The files of `table`, sorted by path.
95    fn new(table: Vec<TableFile>) -> Self {
96        Self {
97            #[cfg(feature = "std")]
98            table: std::sync::RwLock::new(table),
99            #[cfg(not(feature = "std"))]
100            table,
101        }
102    }
103
104    /// What `read` gives on the table as it is now.
105    fn with<T>(&self, read: impl FnOnce(&[TableFile]) -> T) -> T {
106        #[cfg(feature = "std")]
107        let guard = self
108            .table
109            .read()
110            .unwrap_or_else(std::sync::PoisonError::into_inner);
111        #[cfg(feature = "std")]
112        let table: &[TableFile] = &guard;
113        #[cfg(not(feature = "std"))]
114        let table: &[TableFile] = &self.table;
115        read(table)
116    }
117
118    /// Puts `bytes` at `path`, in place of what is there. The old bytes are
119    /// leaked, as every file of the layer is.
120    #[cfg(feature = "std")]
121    fn replace(&self, path: &str, bytes: Vec<u8>) {
122        let bytes: &'static [u8] = Box::leak(bytes.into_boxed_slice());
123        let mut table = self
124            .table
125            .write()
126            .unwrap_or_else(std::sync::PoisonError::into_inner);
127        match table.binary_search_by(|(p, _)| (*p).cmp(path)) {
128            Ok(at) => table[at].1 = bytes,
129            Err(at) => {
130                let path: &'static str = Box::leak(path.to_owned().into_boxed_str());
131                table.insert(at, (path, bytes));
132            }
133        }
134    }
135}
136
137impl Files {
138    /// The contents of the file at `path`.
139    fn get(self, path: &str) -> Option<&'static [u8]> {
140        match self {
141            #[cfg(feature = "std")]
142            Self::Dir(dir) => dir.get_file(path).map(include_dir::File::contents),
143            Self::Table(files) => table_get(files, path),
144            Self::Owned(files) => files.with(|table| table_get(table, path)),
145        }
146    }
147
148    /// Adds the paths of the files inside `dir` to `paths`: directly inside
149    /// it, or with `deep` also in any directory below it.
150    fn list(self, dir: &str, deep: bool, paths: &mut Vec<&'static str>) {
151        match self {
152            #[cfg(feature = "std")]
153            Self::Dir(root) => {
154                let root = if dir.is_empty() {
155                    Some(root)
156                } else {
157                    root.get_dir(dir)
158                };
159                let mut pending: Vec<&'static Dir<'static>> = root.into_iter().collect();
160                while let Some(dir) = pending.pop() {
161                    paths.extend(dir.files().filter_map(|f| f.path().to_str()));
162                    if deep {
163                        pending.extend(dir.dirs());
164                    }
165                }
166            }
167            Self::Table(files) => table_list(files, dir, deep, paths),
168            Self::Owned(files) => files.with(|table| table_list(table, dir, deep, paths)),
169        }
170    }
171}
172
173/// The contents of the file at `path` in `files` (sorted by path).
174fn table_get(files: &[TableFile], path: &str) -> Option<&'static [u8]> {
175    let at = files.binary_search_by(|(p, _)| (*p).cmp(path)).ok()?;
176    Some(files[at].1)
177}
178
179/// Adds the paths of the files of `files` inside `dir` to `paths`, as
180/// [`Files::list`] does.
181fn table_list(files: &[TableFile], dir: &str, deep: bool, paths: &mut Vec<&'static str>) {
182    paths.extend(files.iter().map(|(path, _)| *path).filter(|path| {
183        let rest = if dir.is_empty() {
184            Some(*path)
185        } else {
186            path.strip_prefix(dir)
187                .and_then(|rest| rest.strip_prefix('/'))
188        };
189        rest.is_some_and(|rest| deep || !rest.contains('/'))
190    }));
191}
192
193/// Why a path handed to [`Layer::owned`] was refused: a layer's paths are
194/// relative to its root and `/`-separated, as every read names them.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
196#[non_exhaustive]
197pub enum BadPath {
198    /// The path is empty.
199    #[error("the path is empty")]
200    Empty,
201    /// The path starts at a root (`/data/x.ron`, `C:/data/x.ron`).
202    #[error("the path is absolute; it must be relative to the layer's root")]
203    Absolute,
204    /// The path has a backslash: paths are `/`-separated.
205    #[error("the path has a backslash; paths are `/`-separated")]
206    Backslash,
207    /// The path goes up a folder (`..`).
208    #[error("the path goes up a folder (`..`)")]
209    Parent,
210    /// The path has an empty or `.` part (`data//x.ron`, `./x.ron`,
211    /// `data/`), which no read names.
212    #[error("the path has an empty or `.` part")]
213    NotNormal,
214}
215
216/// A layer that could not be made ([`Layer::owned`]): one of its paths was
217/// refused.
218#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
219#[error("layer `{layer}`: `{path}`: {problem}")]
220#[non_exhaustive]
221pub struct LayerError {
222    /// The layer's name.
223    pub layer: String,
224    /// The path refused.
225    pub path: String,
226    /// What is wrong with it.
227    pub problem: BadPath,
228}
229
230/// What is wrong with `path` as a path of a layer's file, if anything.
231fn check_path(path: &str) -> Result<(), BadPath> {
232    if path.is_empty() {
233        return Err(BadPath::Empty);
234    }
235    if path.contains('\\') {
236        return Err(BadPath::Backslash);
237    }
238    let first = path.split('/').next().unwrap_or(path);
239    let drive = matches!(first.as_bytes(), [letter, b':'] if letter.is_ascii_alphabetic());
240    if path.starts_with('/') || drive {
241        return Err(BadPath::Absolute);
242    }
243    for part in path.split('/') {
244        match part {
245            ".." => return Err(BadPath::Parent),
246            "" | "." => return Err(BadPath::NotNormal),
247            _ => {}
248        }
249    }
250    Ok(())
251}
252
253/// One layer of the bundle.
254#[derive(Debug, Clone, Copy)]
255pub struct Layer {
256    /// The prefix its files are shown with (`base` shows `data/x.ron` as
257    /// `base/data/x.ron`).
258    pub name: &'static str,
259    /// Its files.
260    pub files: Files,
261    /// Whether its files may not be handed out (bought art).
262    pub private: bool,
263    /// Whether it holds test files, which a build that ships leaves out.
264    pub test: bool,
265    /// The paths (sorted) of the files this layer leaves out on purpose.
266    pub left_out: &'static [&'static str],
267    /// The fingerprint of the content this layer stands for, if it holds
268    /// every file of a bundle (a pack's layer, [`Layer::standing_for`]):
269    /// [`Bundle::fingerprint`] then takes that, and not its files.
270    pub stands_for: Option<ContentHash>,
271}
272
273impl Layer {
274    /// A layer of the directory `dir`, shown as `name`.
275    #[cfg(feature = "std")]
276    pub const fn dir(name: &'static str, dir: &'static Dir<'static>) -> Self {
277        Self::of(name, Files::Dir(dir))
278    }
279
280    /// A layer of the table `files` (sorted by path), shown as `name`.
281    pub const fn table(
282        name: &'static str,
283        files: &'static [(&'static str, &'static [u8])],
284    ) -> Self {
285        Self::of(name, Files::Table(files))
286    }
287
288    /// A layer of `files` handed in at run time (a mod, a folder read from
289    /// disk), each at its path (relative to the layer's root,
290    /// `/`-separated), shown as `name`. The name, paths and bytes are kept
291    /// for the life of the program (leaked once), so reads of them are
292    /// `&'static` like the embedded layers'. Make each layer once.
293    ///
294    /// ```
295    /// use std::collections::BTreeMap;
296    /// use shuyi_assets::bundle::{Bundle, Layer};
297    ///
298    /// static BASE: &[(&str, &[u8])] = &[("data/a.ron", b"base a")];
299    /// let files = BTreeMap::from([("data/a.ron".to_owned(), b"mod a".to_vec())]);
300    /// let layer = Layer::owned("mods/more".to_owned(), files).unwrap();
301    /// let bundle = Bundle::new(vec![layer, Layer::table("base", BASE)]);
302    /// assert_eq!(bundle.file("data/a.ron"), Some("mod a"));
303    /// assert_eq!(bundle.display_path("data/a.ron"), "mods/more/data/a.ron");
304    /// ```
305    ///
306    /// # Errors
307    ///
308    /// A path that is empty, absolute, has a backslash, goes up a folder
309    /// (`..`) or has an empty or `.` part, naming the layer and the path.
310    /// Nothing is kept then.
311    pub fn owned(name: String, files: BTreeMap<String, Vec<u8>>) -> Result<Self, LayerError> {
312        for path in files.keys() {
313            check_path(path).map_err(|problem| LayerError {
314                layer: name.clone(),
315                path: path.clone(),
316                problem,
317            })?;
318        }
319        // A `BTreeMap` is sorted by path, as the table's search needs.
320        let table: Vec<TableFile> = files
321            .into_iter()
322            .map(|(path, bytes)| {
323                let path: &'static str = Box::leak(path.into_boxed_str());
324                let bytes: &'static [u8] = Box::leak(bytes.into_boxed_slice());
325                (path, bytes)
326            })
327            .collect();
328        let files: &'static OwnedFiles = Box::leak(Box::new(OwnedFiles::new(table)));
329        Ok(Self::of(
330            Box::leak(name.into_boxed_str()),
331            Files::Owned(files),
332        ))
333    }
334
335    /// Puts `bytes` at `path` in this layer (a file changed on disk), for
336    /// every bundle that has the layer, and says whether it did: only a
337    /// layer handed in at run time ([`Layer::owned`]) changes, and only at a
338    /// path it could have been made with.
339    ///
340    /// A hot reload's way in, in debug builds: the old bytes are leaked, as
341    /// the layer's files are, so every `&'static` read of them stays valid.
342    #[cfg(feature = "std")]
343    #[must_use]
344    pub fn replace(&self, path: &str, bytes: Vec<u8>) -> bool {
345        let Files::Owned(files) = self.files else {
346            return false;
347        };
348        if check_path(path).is_err() {
349            return false;
350        }
351        files.replace(path, bytes);
352        true
353    }
354
355    /// A layer of `files`, shown as `name`.
356    const fn of(name: &'static str, files: Files) -> Self {
357        Self {
358            name,
359            files,
360            private: false,
361            test: false,
362            left_out: &[],
363            stands_for: None,
364        }
365    }
366
367    /// This layer, marked private.
368    #[must_use]
369    pub const fn private(self) -> Self {
370        Self {
371            private: true,
372            ..self
373        }
374    }
375
376    /// This layer, marked as test files.
377    #[must_use]
378    pub const fn test(self) -> Self {
379        Self { test: true, ..self }
380    }
381
382    /// This layer, leaving out the files at `paths` (sorted, relative to
383    /// its root) on purpose: they are the game's, but this build does
384    /// without them.
385    #[must_use]
386    pub const fn leaving_out(self, paths: &'static [&'static str]) -> Self {
387        Self {
388            left_out: paths,
389            ..self
390        }
391    }
392
393    /// This layer, standing for the bundle whose fingerprint is `content`
394    /// ([`Bundle::fingerprint`]): it holds every file of that bundle, as a
395    /// pack does of the bundle it was baked from, so a bundle with it has
396    /// that bundle's fingerprint.
397    #[must_use]
398    pub const fn standing_for(self, content: ContentHash) -> Self {
399        Self {
400            stands_for: Some(content),
401            ..self
402        }
403    }
404
405    /// The target this layer is for, as its name says after the last
406    /// [`TARGET_MARK`]: `playdate` for `assets@playdate`. `None` for a
407    /// layer for every target (`assets`).
408    pub fn target(&self) -> Option<&'static str> {
409        let (base, target) = self.name.rsplit_once(TARGET_MARK)?;
410        (!base.is_empty() && !target.is_empty()).then_some(target)
411    }
412}
413
414/// What a layer's name puts between the name of the layer it is laid over
415/// and the target it is for: `assets@playdate` ([`Bundle::for_target`]).
416pub const TARGET_MARK: char = '@';
417
418/// The name of `layer`'s layer for `target`: `assets@playdate` for
419/// `assets` and `playdate`.
420pub fn target_layer_name(layer: &str, target: &str) -> String {
421    format!("{layer}{TARGET_MARK}{target}")
422}
423
424/// The layers a game reads its files from.
425#[derive(Debug, Clone)]
426pub struct Bundle {
427    /// Every layer: those that ship first, then the test layers.
428    layers: Vec<Layer>,
429    /// How many layers ship (they come first).
430    shipped: usize,
431}
432
433impl Bundle {
434    /// The bundle of `layers`, read in the order given, except that the
435    /// test layers are read after all the others.
436    pub fn new(mut layers: Vec<Layer>) -> Self {
437        // A stable sort: each group keeps the order it was given in.
438        layers.sort_by_key(|layer| layer.test);
439        let shipped = layers.iter().filter(|layer| !layer.test).count();
440        Self { layers, shipped }
441    }
442
443    /// The bundle of `layers` for `target` (ADR-0008 §4): each of
444    /// `layers` with its layer for `target` from `per_target` laid right
445    /// above it, the one named `<name>@<target>`
446    /// ([`target_layer_name`]), so a file there replaces the one at the
447    /// same path. Every other layer of `per_target` (another target's, or
448    /// one over no layer of `layers`) is left out, and with no target all
449    /// of them are: the bundle of `layers` alone ([`Bundle::new`]).
450    ///
451    /// A game embeds its target layers beside its files, and picks the
452    /// bundle by the target it bakes for (`GameTools::bake`) or runs on
453    /// (`PlatformFacts::target`):
454    ///
455    /// ```
456    /// use shuyi_assets::bundle::{Bundle, Layer};
457    ///
458    /// static ASSETS: &[(&str, &[u8])] = &[("hero.png", b"colour"), ("map.ron", b"map")];
459    /// static PLAYDATE: &[(&str, &[u8])] = &[("hero.png", b"drawn in black and white")];
460    /// let layers = || vec![Layer::table("assets", ASSETS)];
461    /// let targets = [Layer::table("assets@playdate", PLAYDATE)];
462    ///
463    /// let bundle = Bundle::for_target(layers(), &targets, Some("playdate"));
464    /// assert_eq!(bundle.file("hero.png"), Some("drawn in black and white"));
465    /// assert_eq!(bundle.display_path("hero.png"), "assets@playdate/hero.png");
466    /// assert_eq!(bundle.file("map.ron"), Some("map"));
467    /// let web = Bundle::for_target(layers(), &targets, Some("web"));
468    /// assert_eq!(web.file("hero.png"), Some("colour"));
469    /// ```
470    pub fn for_target(layers: Vec<Layer>, per_target: &[Layer], target: Option<&str>) -> Self {
471        let Some(target) = target else {
472            return Self::new(layers);
473        };
474        let mut laid = Vec::with_capacity(layers.len() + per_target.len());
475        for layer in layers {
476            let name = target_layer_name(layer.name, target);
477            laid.extend(per_target.iter().filter(|over| over.name == name));
478            laid.push(layer);
479        }
480        Self::new(laid)
481    }
482
483    /// The layer this thread reads the file at `path` from, if a layer has
484    /// it: whether it is the game's own, a mod's, or a target's
485    /// ([`Layer::target`]).
486    pub fn layer_of(&self, path: &str) -> Option<&Layer> {
487        find(self.read(), path).map(|(layer, _)| layer)
488    }
489
490    /// Every layer, in the order files are read from them.
491    pub fn layers(&self) -> &[Layer] {
492        &self.layers
493    }
494
495    /// The layers that ship: all but the test layers.
496    fn shipped(&self) -> &[Layer] {
497        &self.layers[..self.shipped]
498    }
499
500    /// The test layers.
501    fn tests(&self) -> &[Layer] {
502        &self.layers[self.shipped..]
503    }
504
505    /// The layers this thread reads: all of them, or inside [`story_only`]
506    /// those that ship.
507    fn read(&self) -> &[Layer] {
508        if story_only_now() {
509            self.shipped()
510        } else {
511            &self.layers
512        }
513    }
514
515    /// The layer files are shown under when no layer has them: the first
516    /// that is neither private nor test files.
517    fn home(&self) -> Option<&Layer> {
518        self.layers
519            .iter()
520            .find(|layer| !layer.private && !layer.test)
521    }
522
523    /// Returns the UTF-8 contents of the file at `path` (relative to a
524    /// layer's root, `/`-separated), or `None` if it is missing or not
525    /// valid UTF-8.
526    pub fn file(&self, path: &str) -> Option<&'static str> {
527        core::str::from_utf8(self.bytes(path)?).ok()
528    }
529
530    /// Returns the raw bytes of the file at `path` (relative to a layer's
531    /// root, `/`-separated), e.g. an image, or `None` if it is missing.
532    pub fn bytes(&self, path: &str) -> Option<&'static [u8]> {
533        let bytes = find(self.read(), path).map(|(_, bytes)| bytes);
534        if let Some(watcher) = WATCHER.get() {
535            watcher(path, bytes.is_some());
536        }
537        #[cfg(feature = "std")]
538        note_read(path, bytes.is_some());
539        bytes
540    }
541
542    /// Whether a layer this thread reads has the file at `path`. Unlike
543    /// [`Bundle::bytes`], this reads nothing: the [`watch`]er isn't told,
544    /// and [`recording`] doesn't hear of it.
545    pub fn has(&self, path: &str) -> bool {
546        find(self.read(), path).is_some()
547    }
548
549    /// Puts `bytes` at `path` in the first layer handed in at run time that
550    /// has the file, or the first of them if none has it, and says whether
551    /// one took it ([`Layer::replace`]). A file of an embedded layer stays
552    /// as it is, so a layer above one still wins over it.
553    ///
554    /// Debug builds only, for a hot reload: the old bytes are leaked, so
555    /// every `&'static` read of them stays valid.
556    ///
557    /// ```
558    /// use std::collections::BTreeMap;
559    /// use shuyi_assets::bundle::{Bundle, Layer};
560    ///
561    /// let files = BTreeMap::from([("a.txt".to_owned(), b"old".to_vec())]);
562    /// let layer = Layer::owned("disk".to_owned(), files).unwrap();
563    /// let bundle = Bundle::new(vec![layer]);
564    /// let old = bundle.file("a.txt");
565    /// assert!(bundle.replace("a.txt", b"new".to_vec()));
566    /// assert_eq!(bundle.file("a.txt"), Some("new"));
567    /// assert_eq!(old, Some("old"));
568    /// ```
569    #[cfg(feature = "std")]
570    #[must_use]
571    pub fn replace(&self, path: &str, bytes: Vec<u8>) -> bool {
572        let mut disk = self
573            .layers
574            .iter()
575            .filter(|layer| matches!(layer.files, Files::Owned(_)));
576        let first = disk.clone().next();
577        disk.find(|layer| layer.files.get(path).is_some())
578            .or(first)
579            .is_some_and(|layer| layer.replace(path, bytes))
580    }
581
582    /// Whether a layer this thread reads leaves the file at `path` out on
583    /// purpose ([`Layer::leaving_out`]): a loader then does without it
584    /// instead of reporting it missing.
585    pub fn left_out(&self, path: &str) -> bool {
586        self.read()
587            .iter()
588            .any(|layer| layer.left_out.binary_search(&path).is_ok())
589    }
590
591    /// The test layers' own file at `path`, with its human-facing name,
592    /// when a layer that ships has a file there too: the entries a data
593    /// table gains in a build with the test files. [`Bundle::file`] reads
594    /// the shipped one. `None` without test layers, inside [`story_only`],
595    /// and for a path only one of the two has.
596    pub fn test_part(&self, path: &str) -> Option<(String, &'static str)> {
597        if story_only_now() {
598            return None;
599        }
600        find(self.shipped(), path)?;
601        let (layer, bytes) = find(self.tests(), path)?;
602        let display = format!("{}/{path}", layer.name);
603        Some((display, core::str::from_utf8(bytes).ok()?))
604    }
605
606    /// Lists the paths (relative to a layer's root, `/`-separated, sorted,
607    /// each once) of every file directly inside directory `dir`, in any
608    /// layer. A missing directory yields an empty list. Use `""` for the
609    /// root.
610    pub fn files_in(&self, dir: &str) -> Vec<&'static str> {
611        list(self.read(), dir, false)
612    }
613
614    /// Lists the paths (relative to a layer's root, `/`-separated, sorted,
615    /// each once) of every file inside directory `dir` or any directory
616    /// below it, in any layer. A missing directory yields an empty list.
617    /// Use `""` for the whole bundle.
618    pub fn files_under(&self, dir: &str) -> Vec<&'static str> {
619        list(self.read(), dir, true)
620    }
621
622    /// Parses every file of extension `ext` (without the dot) directly inside
623    /// `dir` with `parse`, into a map by file name without the extension.
624    /// A file that is not valid UTF-8 is an error of `E` ([`NotText`]); every
625    /// broken file is reported, not only the first: the result is either the
626    /// whole map or all the errors, in file order.
627    ///
628    /// ```
629    /// use shuyi_assets::bundle::{Bundle, Layer};
630    /// use shuyi_assets::ContentError;
631    ///
632    /// static FILES: &[(&str, &[u8])] = &[("crops/a.txt", b"1"), ("crops/b.txt", b"2")];
633    /// let bundle = Bundle::new(vec![Layer::table("base", FILES)]);
634    /// let crops = bundle.load_each("crops", "txt", |file| {
635    ///     file.text
636    ///         .parse::<u32>()
637    ///         .map_err(|e| vec![ContentError::new(file.path, e.to_string())])
638    /// });
639    /// assert_eq!(crops.unwrap().get("b"), Some(&2));
640    /// ```
641    ///
642    /// # Errors
643    ///
644    /// Every error `parse` returned, and a [`NotText`] for each file that
645    /// is not UTF-8.
646    pub fn load_each<T, E: From<NotText>>(
647        &self,
648        dir: &str,
649        ext: &str,
650        mut parse: impl FnMut(&LoadFile<'_>) -> Result<T, Vec<E>>,
651    ) -> Result<BTreeMap<String, T>, Vec<E>> {
652        let mut loaded = BTreeMap::new();
653        let mut errors = Vec::new();
654        for path in self.files_in(dir) {
655            let name = path.rsplit('/').next().unwrap_or(path);
656            let Some(stem) = name
657                .strip_suffix(ext)
658                .and_then(|stem| stem.strip_suffix('.'))
659            else {
660                continue;
661            };
662            let shown = self.display_path(path);
663            let Some(text) = self.file(path) else {
664                errors.push(NotText { path: shown }.into());
665                continue;
666            };
667            match parse(&LoadFile {
668                stem,
669                path: &shown,
670                text,
671            }) {
672                Ok(value) => {
673                    loaded.insert(stem.to_owned(), value);
674                }
675                Err(more) => errors.extend(more),
676            }
677        }
678        if errors.is_empty() {
679            Ok(loaded)
680        } else {
681            Err(errors)
682        }
683    }
684
685    /// The human-facing name of `path`: under the name of the layer it is
686    /// read from (`base/data/x.ron`), or of the first layer that is neither
687    /// private nor test files when no layer has it.
688    pub fn display_path(&self, path: &str) -> String {
689        let layer = find(self.read(), path).map(|(layer, _)| layer);
690        match layer.or_else(|| self.home()) {
691            Some(layer) => format!("{}/{path}", layer.name),
692            None => path.to_owned(),
693        }
694    }
695
696    /// Whether `shown` (a human-facing name, from [`Bundle::display_path`])
697    /// names a file of a private layer.
698    pub fn is_private(&self, shown: &str) -> bool {
699        self.layers
700            .iter()
701            .any(|layer| layer.private && shown_under(shown, layer.name))
702    }
703}
704
705/// One file of a folder, as [`Bundle::load_each`] hands it to a parser.
706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
707pub struct LoadFile<'a> {
708    /// The file's name without its folder and extension (`data/a.ron` is `a`).
709    pub stem: &'a str,
710    /// The file's human-facing name ([`Bundle::display_path`]), for errors.
711    pub path: &'a str,
712    /// The file's text.
713    pub text: &'static str,
714}
715
716/// A file that is not valid UTF-8: what [`Bundle::load_each`] turns into
717/// the loader's own error type.
718#[derive(Debug, Clone, PartialEq, Eq)]
719pub struct NotText {
720    /// The file's human-facing name.
721    pub path: String,
722}
723
724impl From<NotText> for ContentError {
725    fn from(not_text: NotText) -> Self {
726        Self::new(not_text.path, "file is not valid UTF-8").with_code(crate::error::codes::NOT_TEXT)
727    }
728}
729
730/// Whether `shown` (a human-facing name) names a file under the layer
731/// named `name`: `base/x.png` is under `base`, `bases/x.png` and `base`
732/// are not.
733pub fn shown_under(shown: &str, name: &str) -> bool {
734    shown
735        .strip_prefix(name)
736        .is_some_and(|rest| rest.starts_with('/'))
737}
738
739/// The file at `path` and the layer it is read from: the first of `layers`
740/// that has it.
741fn find<'a>(layers: &'a [Layer], path: &str) -> Option<(&'a Layer, &'static [u8])> {
742    layers
743        .iter()
744        .find_map(|layer| Some((layer, layer.files.get(path)?)))
745}
746
747/// The paths (sorted, each once) of the files of every layer inside `dir`:
748/// directly inside it, or with `deep` also in any directory below it.
749fn list(layers: &[Layer], dir: &str, deep: bool) -> Vec<&'static str> {
750    let mut paths = Vec::new();
751    for layer in layers {
752        layer.files.list(dir, deep, &mut paths);
753    }
754    paths.sort_unstable();
755    paths.dedup();
756    paths
757}
758
759/// The bundle the game installed.
760static INSTALLED: OnceBox<Bundle> = OnceBox::new();
761
762/// Makes `bundle` the process-wide bundle. Done once: a second call gives
763/// its bundle back and changes nothing.
764pub fn install(bundle: Bundle) -> Result<(), Bundle> {
765    INSTALLED.set(Box::new(bundle)).map_err(|bundle| *bundle)
766}
767
768/// The process-wide bundle, once installed; inside [`reading`], the
769/// bundle this thread reads instead.
770pub fn installed() -> Option<&'static Bundle> {
771    #[cfg(feature = "std")]
772    if let Some(bundle) = READING.get() {
773        return Some(bundle);
774    }
775    INSTALLED.get()
776}
777
778/// The process-wide bundle, installing the one `make` builds if none is.
779/// When two threads race to install, one bundle wins and both get it.
780/// Inside [`reading`], the bundle this thread reads instead, and nothing is
781/// installed.
782pub fn get_or_install(make: impl FnOnce() -> Bundle) -> &'static Bundle {
783    #[cfg(feature = "std")]
784    if let Some(bundle) = READING.get() {
785        return bundle;
786    }
787    INSTALLED.get_or_init(|| Box::new(make()))
788}
789
790/// What is told of every file read ([`watch`]): its path, and whether the
791/// bundle has it.
792pub type Watcher = fn(path: &str, found: bool);
793
794/// The watcher of every file read, once one is set.
795static WATCHER: OnceBox<Watcher> = OnceBox::new();
796
797/// Tells `watcher` of every file any bundle reads from now on
798/// ([`Bundle::bytes`] and [`Bundle::file`]), on the thread that reads it:
799/// for a front end that logs or times what it loads. Set once: a second
800/// call gives its watcher back and changes nothing.
801pub fn watch(watcher: Watcher) -> Result<(), Watcher> {
802    WATCHER.set(Box::new(watcher)).map_err(|watcher| *watcher)
803}
804
805/// The files a stretch of code asked for ([`reads_of`]).
806#[cfg(feature = "std")]
807#[derive(Debug, Default)]
808pub(crate) struct Reads {
809    /// The paths [`Bundle::bytes`] found.
810    pub(crate) found: BTreeSet<String>,
811    /// The paths it was asked for, found or not.
812    pub(crate) asked: BTreeSet<String>,
813}
814
815#[cfg(feature = "std")]
816std::thread_local! {
817    /// What this thread has read inside [`reads_of`].
818    static READS: core::cell::RefCell<Option<Reads>> = const { core::cell::RefCell::new(None) };
819}
820
821/// Notes a read for [`reads_of`], on this thread.
822#[cfg(feature = "std")]
823fn note_read(path: &str, found: bool) {
824    READS.with_borrow_mut(|reads| {
825        if let Some(reads) = reads {
826            reads.asked.insert(path.to_owned());
827            if found {
828                reads.found.insert(path.to_owned());
829            }
830        }
831    });
832}
833
834/// What `f` gives, and the files it asked any bundle for on this thread.
835/// Nested, the outer call sees the inner one's reads too.
836#[cfg(feature = "std")]
837pub(crate) fn reads_of<T>(f: impl FnOnce() -> T) -> (T, Reads) {
838    /// Puts the outer log back, also when `f` panics.
839    struct Restore(Option<Reads>);
840    impl Drop for Restore {
841        fn drop(&mut self) {
842            READS.set(self.0.take());
843        }
844    }
845    let mut outer = Restore(READS.replace(Some(Reads::default())));
846    let value = f();
847    let reads = READS.take().unwrap_or_default();
848    if let Some(outer) = &mut outer.0 {
849        outer.found.extend(reads.found.iter().cloned());
850        outer.asked.extend(reads.asked.iter().cloned());
851    }
852    (value, reads)
853}
854
855/// What `f` gives, and the paths [`Bundle::bytes`] found while it ran on
856/// this thread (also through [`Bundle::file`] and the formats that read a
857/// bundle). Unlike [`watch`], it is scoped, and more than one tool can use
858/// it at once. Nested, the outer call sees the inner one's reads too.
859/// [`Bundle::has`] records nothing.
860#[cfg(feature = "std")]
861pub fn recording<T>(f: impl FnOnce() -> T) -> (T, BTreeSet<String>) {
862    let (value, reads) = reads_of(f);
863    (value, reads.found)
864}
865
866#[cfg(feature = "std")]
867std::thread_local! {
868    /// The bundle this thread reads inside [`reading`].
869    static READING: core::cell::Cell<Option<&'static Bundle>> =
870        const { core::cell::Cell::new(None) };
871}
872
873/// Runs `f` with this thread reading `bundle` instead of the installed one
874/// ([`installed`], [`get_or_install`]): for a test of another build's
875/// bundle (a handheld's smaller one) in a process that installed the
876/// whole. Other threads are never affected.
877#[cfg(feature = "std")]
878pub fn reading<T>(bundle: &'static Bundle, f: impl FnOnce() -> T) -> T {
879    /// Puts the old bundle back, also when `f` panics.
880    struct Restore(Option<&'static Bundle>);
881    impl Drop for Restore {
882        fn drop(&mut self) {
883            READING.set(self.0);
884        }
885    }
886    let _restore = Restore(READING.replace(Some(bundle)));
887    f()
888}
889
890#[cfg(feature = "std")]
891std::thread_local! {
892    /// Whether this thread is inside [`story_only`].
893    static STORY_ONLY: core::cell::Cell<bool> = const { core::cell::Cell::new(false) };
894}
895
896/// Whether [`story_only`] is running. Without `std` there is one thread.
897#[cfg(not(feature = "std"))]
898static STORY_ONLY: Flag = Flag::new();
899
900/// A `bool` a `static` can hold, with the `get` and `replace` of the
901/// thread-local `Cell` the `std` build uses. Built for the tests too, so
902/// they check the `no_std` build's flag.
903#[cfg(any(not(feature = "std"), test))]
904struct Flag(core::sync::atomic::AtomicBool);
905
906#[cfg(any(not(feature = "std"), test))]
907impl Flag {
908    /// A flag that is off.
909    const fn new() -> Self {
910        Self(core::sync::atomic::AtomicBool::new(false))
911    }
912
913    /// Whether it is on.
914    fn get(&self) -> bool {
915        self.0.load(core::sync::atomic::Ordering::Relaxed)
916    }
917
918    /// Sets it to `on`; returns the old value.
919    fn replace(&self, on: bool) -> bool {
920        self.0.swap(on, core::sync::atomic::Ordering::Relaxed)
921    }
922}
923
924/// Whether this thread is inside [`story_only`].
925fn story_only_now() -> bool {
926    STORY_ONLY.get()
927}
928
929/// Sets whether this thread is inside [`story_only`]; returns the old value.
930fn set_story_only(on: bool) -> bool {
931    STORY_ONLY.replace(on)
932}
933
934/// Runs `f` with the test layers of every bundle hidden from this thread:
935/// each answers as it does in a build that ships. For the tests that check
936/// what such a build holds.
937pub fn story_only<T>(f: impl FnOnce() -> T) -> T {
938    /// Puts the old value back, also when `f` panics.
939    struct Restore(bool);
940    impl Drop for Restore {
941        fn drop(&mut self) {
942            set_story_only(self.0);
943        }
944    }
945    let _restore = Restore(set_story_only(true));
946    f()
947}
948
949#[cfg(test)]
950mod tests {
951    use include_dir::{DirEntry, File};
952
953    use super::*;
954
955    const fn entry(path: &'static str, contents: &'static str) -> DirEntry<'static> {
956        DirEntry::File(File::new(path, contents.as_bytes()))
957    }
958
959    /// A private layer laid over the base one.
960    static PRIVATE_FIXTURE: Dir<'static> = Dir::new(
961        "",
962        &[
963            DirEntry::Dir(Dir::new("data", &[entry("data/a.ron", "private a")])),
964            DirEntry::Dir(Dir::new(
965                "portraits",
966                &[entry("portraits/new.portrait", "private only")],
967            )),
968            DirEntry::Dir(Dir::new(
969                "deep",
970                &[DirEntry::Dir(Dir::new(
971                    "deep/x",
972                    &[entry("deep/x/y.txt", "private y")],
973                ))],
974            )),
975        ],
976    );
977
978    /// The base layer.
979    static PUBLIC_FIXTURE: Dir<'static> = Dir::new(
980        "",
981        &[
982            entry("top.txt", "public top"),
983            DirEntry::Dir(Dir::new(
984                "data",
985                &[
986                    entry("data/a.ron", "public a"),
987                    entry("data/b.ron", "public b"),
988                ],
989            )),
990            DirEntry::Dir(Dir::new(
991                "deep",
992                &[DirEntry::Dir(Dir::new(
993                    "deep/x",
994                    &[entry("deep/x/z.txt", "public z")],
995                ))],
996            )),
997        ],
998    );
999
1000    /// [`PUBLIC_FIXTURE`] as a table.
1001    static PUBLIC_TABLE: &[(&str, &[u8])] = &[
1002        ("data/a.ron", b"public a"),
1003        ("data/b.ron", b"public b"),
1004        ("deep/x/z.txt", b"public z"),
1005        ("top.txt", b"public top"),
1006    ];
1007
1008    /// The private test files, laid over the test files.
1009    static PRIVATE_TEST_FIXTURE: Dir<'static> = Dir::new(
1010        "",
1011        &[DirEntry::Dir(Dir::new(
1012            "portraits",
1013            &[entry("portraits/t.png", "bought t")],
1014        ))],
1015    );
1016
1017    /// The test files.
1018    static TEST_FIXTURE: Dir<'static> = Dir::new(
1019        "",
1020        &[
1021            DirEntry::Dir(Dir::new(
1022                "data",
1023                &[entry("data/b.ron", "test b"), entry("data/t.ron", "test t")],
1024            )),
1025            DirEntry::Dir(Dir::new(
1026                "portraits",
1027                &[entry("portraits/t.png", "placeholder t")],
1028            )),
1029        ],
1030    );
1031
1032    const PRIVATE: Layer = Layer::dir("bought", &PRIVATE_FIXTURE).private();
1033    const PUBLIC: Layer = Layer::dir("base", &PUBLIC_FIXTURE);
1034    const PRIVATE_TEST: Layer = Layer::dir("bought-tests", &PRIVATE_TEST_FIXTURE)
1035        .private()
1036        .test();
1037    const TEST: Layer = Layer::dir("tests", &TEST_FIXTURE).test();
1038
1039    /// The private layer laid over the public one.
1040    fn merged() -> Bundle {
1041        Bundle::new(vec![PRIVATE, PUBLIC])
1042    }
1043
1044    /// Every layer.
1045    fn with_tests() -> Bundle {
1046        Bundle::new(vec![PRIVATE, PUBLIC, PRIVATE_TEST, TEST])
1047    }
1048
1049    fn names(layers: &[Layer]) -> Vec<&'static str> {
1050        layers.iter().map(|layer| layer.name).collect()
1051    }
1052
1053    #[test]
1054    fn the_test_layers_are_the_last_ones() {
1055        let bundle = with_tests();
1056        assert_eq!(names(bundle.shipped()), ["bought", "base"]);
1057        assert_eq!(names(bundle.tests()), ["bought-tests", "tests"]);
1058        // Given first, they are still read last, in the order given.
1059        let given = Bundle::new(vec![PRIVATE_TEST, PRIVATE, TEST, PUBLIC]);
1060        assert_eq!(
1061            names(given.layers()),
1062            ["bought", "base", "bought-tests", "tests"]
1063        );
1064        assert_eq!(names(merged().shipped()), names(merged().layers()));
1065        let bundle = merged();
1066        let got = bundle.tests();
1067        assert!(got.is_empty(), "{got:?}");
1068    }
1069
1070    /// A game's files, its mod layer's, and its layers for two targets.
1071    static GAME: &[(&str, &[u8])] = &[("a.png", b"game a"), ("b.png", b"game b")];
1072    static MODDED: &[(&str, &[u8])] = &[("a.png", b"mod a")];
1073    static PLAYDATE: &[(&str, &[u8])] = &[("b.png", b"playdate b"), ("c.png", b"playdate c")];
1074    static GBA: &[(&str, &[u8])] = &[("b.png", b"gba b")];
1075
1076    fn per_target() -> [Layer; 4] {
1077        [
1078            Layer::table("assets@gba", GBA),
1079            Layer::table("assets@playdate", PLAYDATE),
1080            Layer::table("mods@playdate", MODDED),
1081            Layer::table("nothing@playdate", MODDED),
1082        ]
1083    }
1084
1085    #[test]
1086    fn a_targets_layer_is_laid_right_above_its_own() {
1087        let layers = || vec![Layer::table("mods", MODDED), Layer::table("assets", GAME)];
1088        let bundle = Bundle::for_target(layers(), &per_target(), Some("playdate"));
1089        assert_eq!(
1090            names(bundle.layers()),
1091            ["mods@playdate", "mods", "assets@playdate", "assets"]
1092        );
1093        assert_eq!(bundle.file("b.png"), Some("playdate b"));
1094        assert_eq!(bundle.display_path("b.png"), "assets@playdate/b.png");
1095        assert_eq!(bundle.file("c.png"), Some("playdate c"));
1096        // A layer above the game's still wins over the game's target layer.
1097        assert_eq!(bundle.display_path("a.png"), "mods@playdate/a.png");
1098        let layer = |path| bundle.layer_of(path).map(Layer::target);
1099        assert_eq!(layer("b.png"), Some(Some("playdate")));
1100        assert_eq!(layer("x.png"), None);
1101        // Another target's layer is left out, and so is every one with no
1102        // target.
1103        let gba = Bundle::for_target(layers(), &per_target(), Some("gba"));
1104        assert_eq!(names(gba.layers()), ["mods", "assets@gba", "assets"]);
1105        assert_eq!(gba.file("b.png"), Some("gba b"));
1106        let none = Bundle::for_target(layers(), &per_target(), None);
1107        assert_eq!(names(none.layers()), ["mods", "assets"]);
1108        let web = Bundle::for_target(layers(), &per_target(), Some("web"));
1109        assert_eq!(names(web.layers()), ["mods", "assets"]);
1110        assert_eq!(web.fingerprint(), none.fingerprint());
1111        assert_ne!(bundle.fingerprint(), none.fingerprint());
1112    }
1113
1114    #[test]
1115    fn a_layer_is_for_the_target_after_its_last_mark() {
1116        let target = |name| Layer::table(name, GAME).target();
1117        assert_eq!(target("assets@playdate"), Some("playdate"));
1118        assert_eq!(target("a@b@steam-deck"), Some("steam-deck"));
1119        assert_eq!(target("assets"), None);
1120        assert_eq!(target("@playdate"), None);
1121        assert_eq!(target("assets@"), None);
1122        assert_eq!(target_layer_name("files", "playdate"), "files@playdate");
1123    }
1124
1125    #[test]
1126    fn a_test_file_is_added_after_the_shipped_files() {
1127        let bundle = with_tests();
1128        assert_eq!(bundle.file("data/t.ron"), Some("test t"));
1129        assert_eq!(bundle.display_path("data/t.ron"), "tests/data/t.ron");
1130        assert_eq!(story_only(|| bundle.file("data/t.ron")), None);
1131        // The shipped file is the one read where both have one.
1132        assert_eq!(bundle.file("data/b.ron"), Some("public b"));
1133        assert_eq!(
1134            bundle.files_in("data"),
1135            ["data/a.ron", "data/b.ron", "data/t.ron"]
1136        );
1137    }
1138
1139    #[test]
1140    fn a_test_part_is_the_test_layers_own_file_where_a_shipped_one_has_one_too() {
1141        let bundle = with_tests();
1142        assert_eq!(
1143            bundle.test_part("data/b.ron"),
1144            Some(("tests/data/b.ron".to_owned(), "test b"))
1145        );
1146        // Only the test files have it: an ordinary file, read whole.
1147        assert_eq!(bundle.test_part("data/t.ron"), None);
1148        // Only a shipped layer has it.
1149        assert_eq!(bundle.test_part("data/a.ron"), None);
1150        assert_eq!(bundle.test_part("data/nope.ron"), None);
1151        // No test layers.
1152        assert_eq!(merged().test_part("data/b.ron"), None);
1153        assert_eq!(story_only(|| bundle.test_part("data/b.ron")), None);
1154    }
1155
1156    #[test]
1157    fn a_private_test_file_replaces_the_placeholder_and_is_private() {
1158        let bundle = with_tests();
1159        assert_eq!(bundle.file("portraits/t.png"), Some("bought t"));
1160        let shown = |path| bundle.display_path(path);
1161        assert_eq!(shown("portraits/t.png"), "bought-tests/portraits/t.png");
1162        assert!(bundle.is_private(&shown("portraits/t.png")));
1163        assert!(bundle.is_private(&shown("data/a.ron")));
1164        assert!(bundle.is_private(&shown("portraits/new.portrait")));
1165        assert!(!bundle.is_private(&shown("data/b.ron")));
1166        assert!(!bundle.is_private(&shown("data/t.ron")));
1167        assert!(!bundle.is_private(&shown("data/nope.ron")));
1168        // Without the private test files the placeholder is back.
1169        let placeholders = Bundle::new(vec![PRIVATE, PUBLIC, TEST]);
1170        assert_eq!(placeholders.file("portraits/t.png"), Some("placeholder t"));
1171        let shown = placeholders.display_path("portraits/t.png");
1172        assert!(!placeholders.is_private(&shown));
1173        // Only a name under one of the private layers.
1174        assert!(bundle.is_private("bought/x.png"));
1175        assert!(bundle.is_private("bought-tests/x.png"));
1176        assert!(!bundle.is_private("boughts/x.png"));
1177        assert!(!bundle.is_private("bought"));
1178        assert!(!bundle.is_private("base/x.png"));
1179        assert!(!bundle.is_private("x/bought/x.png"));
1180    }
1181
1182    #[test]
1183    fn a_private_file_replaces_the_public_file_at_the_same_path() {
1184        assert_eq!(merged().file("data/a.ron"), Some("private a"));
1185        assert_eq!(merged().display_path("data/a.ron"), "bought/data/a.ron");
1186        // Without the private layer the public file is back.
1187        let public = Bundle::new(vec![PUBLIC]);
1188        assert_eq!(public.file("data/a.ron"), Some("public a"));
1189    }
1190
1191    #[test]
1192    fn a_private_only_file_is_added() {
1193        let path = "portraits/new.portrait";
1194        assert_eq!(merged().file(path), Some("private only"));
1195        assert_eq!(merged().files_in("portraits"), [path]);
1196        let public = Bundle::new(vec![PUBLIC]);
1197        assert_eq!(public.file(path), None);
1198        let listed = public.files_in("portraits");
1199        assert!(listed.is_empty(), "{listed:?}");
1200    }
1201
1202    #[test]
1203    fn a_public_only_file_is_still_read() {
1204        assert_eq!(merged().file("data/b.ron"), Some("public b"));
1205        assert_eq!(merged().display_path("data/b.ron"), "base/data/b.ron");
1206    }
1207
1208    #[test]
1209    fn a_file_in_no_layer_is_missing_and_shown_under_the_first_public_layer() {
1210        assert_eq!(merged().bytes("data/nope.ron"), None);
1211        assert_eq!(merged().display_path("data/nope.ron"), "base/data/nope.ron");
1212        // With no such layer, the path alone.
1213        let private = Bundle::new(vec![PRIVATE, TEST]);
1214        assert_eq!(private.display_path("data/nope.ron"), "data/nope.ron");
1215        assert_eq!(Bundle::new(vec![]).display_path("x"), "x");
1216    }
1217
1218    #[test]
1219    fn lists_merge_the_layers_sorted_with_each_path_once() {
1220        let bundle = merged();
1221        assert_eq!(bundle.files_in("data"), ["data/a.ron", "data/b.ron"]);
1222        assert_eq!(bundle.files_in(""), ["top.txt"]);
1223        // `deep` has no files of its own, only in the directory below.
1224        let listed = bundle.files_in("deep");
1225        assert!(listed.is_empty(), "{listed:?}");
1226        assert_eq!(bundle.files_under("deep"), ["deep/x/y.txt", "deep/x/z.txt"]);
1227        assert_eq!(
1228            bundle.files_under(""),
1229            [
1230                "data/a.ron",
1231                "data/b.ron",
1232                "deep/x/y.txt",
1233                "deep/x/z.txt",
1234                "portraits/new.portrait",
1235                "top.txt",
1236            ]
1237        );
1238        let listed = bundle.files_under("no_such_dir");
1239        assert!(listed.is_empty(), "{listed:?}");
1240    }
1241
1242    /// A folder of two good files, a bad one, a binary one and another kind.
1243    static FOLDER: &[(&str, &[u8])] = &[
1244        ("n/a.num", b"1"),
1245        ("n/b.num", b"2"),
1246        ("n/bad.num", b"x"),
1247        ("n/bin.num", &[0xff]),
1248        ("n/other.txt", b"9"),
1249    ];
1250
1251    fn parse_num(file: &LoadFile<'_>) -> Result<u32, Vec<ContentError>> {
1252        file.text
1253            .parse()
1254            .map_err(|_| vec![ContentError::new(file.path, "not a number")])
1255    }
1256
1257    #[test]
1258    fn load_each_gathers_a_map_by_name_or_every_error() {
1259        let bundle = Bundle::new(vec![Layer::table("base", FOLDER)]);
1260        let errors = bundle.load_each("n", "num", parse_num).unwrap_err();
1261        let files: Vec<_> = errors.iter().map(ToString::to_string).collect();
1262        assert_eq!(files.len(), 2);
1263        assert!(files[0].contains("base/n/bad.num") && files[0].contains("not a number"));
1264        assert!(files[1].contains("base/n/bin.num") && files[1].contains("not valid UTF-8"));
1265        let good = Bundle::new(vec![Layer::table("base", &FOLDER[..2])]);
1266        let map = good.load_each("n", "num", parse_num).unwrap();
1267        assert_eq!(
1268            map.into_iter().collect::<Vec<_>>(),
1269            [("a".to_owned(), 1), ("b".to_owned(), 2)]
1270        );
1271        let none = good.load_each("nope", "num", parse_num).unwrap();
1272        assert!(none.is_empty(), "{none:?}");
1273    }
1274
1275    /// A file that isn't UTF-8.
1276    static BINARY: &[(&str, &[u8])] = &[("x.bin", &[0xff, 0xfe])];
1277
1278    /// A table laid over [`PUBLIC_FIXTURE`].
1279    static OVER: &[(&str, &[u8])] = &[("data/a.ron", b"table a")];
1280
1281    #[test]
1282    fn reads_bytes_and_text() {
1283        let bundle = merged();
1284        assert_eq!(bundle.bytes("top.txt"), Some(&b"public top"[..]));
1285        assert_eq!(bundle.file("top.txt"), Some("public top"));
1286        let binary = Bundle::new(vec![Layer::table("base", BINARY)]);
1287        assert_eq!(binary.bytes("x.bin"), Some(&[0xff, 0xfe][..]));
1288        assert_eq!(binary.file("x.bin"), None);
1289    }
1290
1291    /// A table layer answers every question as the directory of the same
1292    /// files does: the build without `std` reads the same bundle.
1293    #[test]
1294    fn a_table_layer_answers_as_the_same_directory() {
1295        let dir = Bundle::new(vec![PUBLIC]);
1296        let table = Bundle::new(vec![Layer::table("base", PUBLIC_TABLE)]);
1297        let all = dir.files_under("");
1298        assert_eq!(table.files_under(""), all);
1299        for path in all.iter().copied().chain(["nope", "data/nope.ron", ""]) {
1300            assert_eq!(table.bytes(path), dir.bytes(path), "{path}");
1301            assert_eq!(table.display_path(path), dir.display_path(path));
1302        }
1303        for dir_name in ["", "data", "deep", "deep/x", "dat", "no_such_dir"] {
1304            assert_eq!(
1305                table.files_in(dir_name),
1306                dir.files_in(dir_name),
1307                "{dir_name}"
1308            );
1309            assert_eq!(
1310                table.files_under(dir_name),
1311                dir.files_under(dir_name),
1312                "{dir_name}"
1313            );
1314        }
1315        // Over a directory layer, a table layer replaces its files too.
1316        let laid = Bundle::new(vec![Layer::table("over", OVER), PUBLIC]);
1317        assert_eq!(laid.file("data/a.ron"), Some("table a"));
1318        assert_eq!(laid.display_path("data/a.ron"), "over/data/a.ron");
1319        assert_eq!(laid.files_in("data"), ["data/a.ron", "data/b.ron"]);
1320    }
1321
1322    /// A layer of `files` handed in at run time, shown as `name`.
1323    fn owned(name: &str, files: &[(&str, &str)]) -> Layer {
1324        let files = files
1325            .iter()
1326            .map(|(path, text)| ((*path).to_owned(), text.as_bytes().to_vec()))
1327            .collect();
1328        Layer::owned(name.to_owned(), files).unwrap()
1329    }
1330
1331    #[test]
1332    fn a_run_time_file_replaces_an_embedded_one_and_a_new_one_is_added() {
1333        let layer = owned(
1334            "mods/more",
1335            &[
1336                ("data/a.ron", "mod a"),
1337                ("data/c.ron", "mod c"),
1338                ("x/y.txt", "mod y"),
1339            ],
1340        );
1341        let over_table = Bundle::new(vec![layer, Layer::table("base", PUBLIC_TABLE)]);
1342        for bundle in [&over_table, &Bundle::new(vec![layer, PUBLIC])] {
1343            assert_eq!(bundle.file("data/a.ron"), Some("mod a"));
1344            assert_eq!(bundle.file("data/c.ron"), Some("mod c"));
1345            assert_eq!(bundle.file("data/b.ron"), Some("public b"));
1346            assert_eq!(
1347                bundle.files_in("data"),
1348                ["data/a.ron", "data/b.ron", "data/c.ron"]
1349            );
1350            assert_eq!(bundle.files_in("x"), ["x/y.txt"]);
1351            assert_eq!(
1352                bundle.files_under(""),
1353                [
1354                    "data/a.ron",
1355                    "data/b.ron",
1356                    "data/c.ron",
1357                    "deep/x/z.txt",
1358                    "top.txt",
1359                    "x/y.txt",
1360                ]
1361            );
1362            assert!(bundle.has("x/y.txt"));
1363        }
1364        // Under the embedded layer, the embedded file is read.
1365        let under = Bundle::new(vec![PUBLIC, layer]);
1366        assert_eq!(under.file("data/a.ron"), Some("public a"));
1367        assert_eq!(under.file("data/c.ron"), Some("mod c"));
1368    }
1369
1370    #[test]
1371    fn of_two_run_time_layers_the_first_wins() {
1372        let first = owned("first", &[("a.txt", "first a"), ("b.txt", "first b")]);
1373        let second = owned("second", &[("a.txt", "second a"), ("c.txt", "second c")]);
1374        let bundle = Bundle::new(vec![first, second]);
1375        assert_eq!(bundle.file("a.txt"), Some("first a"));
1376        assert_eq!(bundle.file("c.txt"), Some("second c"));
1377        assert_eq!(bundle.files_in(""), ["a.txt", "b.txt", "c.txt"]);
1378        let swapped = Bundle::new(vec![second, first]);
1379        assert_eq!(swapped.file("a.txt"), Some("second a"));
1380    }
1381
1382    #[test]
1383    fn a_run_time_file_is_shown_under_its_layers_name() {
1384        let layer = owned("mods/more", &[("data/a.ron", "mod a")]);
1385        let bundle = Bundle::new(vec![layer, PUBLIC]);
1386        assert_eq!(bundle.display_path("data/a.ron"), "mods/more/data/a.ron");
1387        assert_eq!(bundle.display_path("data/b.ron"), "base/data/b.ron");
1388        // A missing file is shown under the first public layer: this one.
1389        assert_eq!(bundle.display_path("nope"), "mods/more/nope");
1390        assert_eq!(names(bundle.layers()), ["mods/more", "base"]);
1391    }
1392
1393    #[test]
1394    fn a_run_time_layer_takes_the_flags_of_any_layer() {
1395        let bought = owned("bought", &[("data/a.ron", "bought a")]).private();
1396        let tests = owned("tests", &[("data/t.ron", "test t"), ("data/a.ron", "t")]).test();
1397        let bundle = Bundle::new(vec![tests, bought, PUBLIC]);
1398        assert_eq!(names(bundle.layers()), ["bought", "base", "tests"]);
1399        assert_eq!(bundle.file("data/a.ron"), Some("bought a"));
1400        assert!(bundle.is_private(&bundle.display_path("data/a.ron")));
1401        assert!(!bundle.is_private(&bundle.display_path("data/b.ron")));
1402        assert_eq!(bundle.file("data/t.ron"), Some("test t"));
1403        assert_eq!(story_only(|| bundle.file("data/t.ron")), None);
1404        let trimmed = owned("base", &[("a.txt", "a")]).leaving_out(&["b.txt"]);
1405        let bundle = Bundle::new(vec![trimmed]);
1406        assert!(bundle.left_out("b.txt"));
1407        assert!(!bundle.left_out("a.txt"));
1408    }
1409
1410    /// A run-time layer answers every question as the table of the same
1411    /// files does.
1412    #[test]
1413    fn a_run_time_layer_answers_as_the_same_table() {
1414        let table = Bundle::new(vec![Layer::table("base", PUBLIC_TABLE)]);
1415        let files: Vec<(&str, &str)> = PUBLIC_TABLE
1416            .iter()
1417            .map(|(path, bytes)| (*path, core::str::from_utf8(bytes).unwrap_or_default()))
1418            .collect();
1419        let run_time = Bundle::new(vec![owned("base", &files)]);
1420        let all = table.files_under("");
1421        assert_eq!(run_time.files_under(""), all);
1422        for path in all.iter().copied().chain(["nope", "data/nope.ron", ""]) {
1423            assert_eq!(run_time.bytes(path), table.bytes(path), "{path}");
1424            assert_eq!(run_time.display_path(path), table.display_path(path));
1425        }
1426        for dir in ["", "data", "deep", "deep/x", "dat", "no_such_dir"] {
1427            assert_eq!(run_time.files_in(dir), table.files_in(dir), "{dir}");
1428            assert_eq!(run_time.files_under(dir), table.files_under(dir), "{dir}");
1429        }
1430        // No files at all: an empty layer.
1431        let empty = Layer::owned("empty".to_owned(), BTreeMap::new()).unwrap();
1432        let listed = Bundle::new(vec![empty]).files_under("");
1433        assert!(listed.is_empty(), "{listed:?}");
1434    }
1435
1436    #[test]
1437    fn a_bad_path_is_refused_with_the_layers_name_and_the_path() {
1438        let cases = [
1439            ("", BadPath::Empty),
1440            ("/data/a.ron", BadPath::Absolute),
1441            ("C:/data/a.ron", BadPath::Absolute),
1442            ("c:", BadPath::Absolute),
1443            ("data\\a.ron", BadPath::Backslash),
1444            ("..", BadPath::Parent),
1445            ("../a.ron", BadPath::Parent),
1446            ("data/../../a.ron", BadPath::Parent),
1447            ("data//a.ron", BadPath::NotNormal),
1448            ("./a.ron", BadPath::NotNormal),
1449            ("data/./a.ron", BadPath::NotNormal),
1450            ("data/", BadPath::NotNormal),
1451        ];
1452        for (path, problem) in cases {
1453            let files = BTreeMap::from([
1454                ("good.txt".to_owned(), Vec::new()),
1455                (path.to_owned(), b"x".to_vec()),
1456            ]);
1457            let error = Layer::owned("mods/bad".to_owned(), files).unwrap_err();
1458            let expected = LayerError {
1459                layer: "mods/bad".to_owned(),
1460                path: path.to_owned(),
1461                problem,
1462            };
1463            assert_eq!(error, expected, "{path}");
1464            let message = error.to_string();
1465            assert!(
1466                message.contains("mods/bad") && message.contains(&format!("`{path}`")),
1467                "{message}"
1468            );
1469        }
1470        // Names that only look like those are fine.
1471        for path in [
1472            "a..b", "..a/b", "data/a.", "ab:/c", "data/c:", "-/x", "a b.txt",
1473        ] {
1474            let files = BTreeMap::from([(path.to_owned(), Vec::new())]);
1475            assert!(Layer::owned("ok".to_owned(), files).is_ok(), "{path}");
1476        }
1477    }
1478
1479    proptest::proptest! {
1480        /// For any layers of any kind, a listing is sorted and once each,
1481        /// holds every layer's files in the folder, and a read of each path
1482        /// it lists agrees with it: the first layer that has the path.
1483        #[test]
1484        fn a_listing_is_sorted_once_each_and_reads_agree_with_it(
1485            layers in proptest::collection::vec(
1486                (
1487                    proptest::collection::btree_map("[ab]{1,2}(/[ab]{1,2}){0,2}", "[xyz]{0,3}", 0..6),
1488                    proptest::bool::ANY,
1489                    proptest::bool::ANY,
1490                ),
1491                0..5,
1492            ),
1493            dir in "|[ab]{1,2}|[ab]{1,2}/[ab]{1,2}",
1494            deep in proptest::bool::ANY,
1495        ) {
1496            let layers: Vec<Layer> = layers
1497                .into_iter()
1498                .enumerate()
1499                .map(|(at, (files, run_time, test))| {
1500                    let name = format!("layer{at}");
1501                    let layer = if run_time {
1502                        let files = files.into_iter().map(|(p, t)| (p, t.into_bytes())).collect();
1503                        Layer::owned(name, files).unwrap()
1504                    } else {
1505                        let table: Vec<(&'static str, &'static [u8])> = files
1506                            .into_iter()
1507                            .map(|(p, t)| {
1508                                let p: &'static str = Box::leak(p.into_boxed_str());
1509                                let t: &'static [u8] = Box::leak(t.into_bytes().into_boxed_slice());
1510                                (p, t)
1511                            })
1512                            .collect();
1513                        Layer::table(Box::leak(name.into_boxed_str()), Box::leak(table.into_boxed_slice()))
1514                    };
1515                    if test { layer.test() } else { layer }
1516                })
1517                .collect();
1518            let bundle = Bundle::new(layers);
1519            let listed = if deep { bundle.files_under(&dir) } else { bundle.files_in(&dir) };
1520            proptest::prop_assert!(listed.windows(2).all(|w| w[0] < w[1]), "{listed:?}");
1521            for path in &listed {
1522                let first = bundle.layers().iter().find_map(|l| Some((l.name, l.files.get(path)?)));
1523                proptest::prop_assert!(first.is_some(), "{path}");
1524                let (name, bytes) = first.unwrap_or_default();
1525                proptest::prop_assert_eq!(bundle.bytes(path), Some(bytes));
1526                proptest::prop_assert_eq!(bundle.display_path(path), format!("{name}/{path}"));
1527            }
1528            // Every layer's file in the folder is listed.
1529            for layer in bundle.layers() {
1530                let mut own = Vec::new();
1531                layer.files.list(&dir, deep, &mut own);
1532                for path in own {
1533                    proptest::prop_assert!(listed.binary_search(&path).is_ok(), "{path}");
1534                }
1535            }
1536        }
1537    }
1538
1539    #[test]
1540    #[cfg_attr(
1541        target_os = "wasi",
1542        ignore = "needs threads and unwinding, which WASI preview 1 lacks"
1543    )]
1544    fn story_only_hides_the_test_layers_from_this_thread_until_it_returns() {
1545        let bundle = with_tests();
1546        let path = "data/t.ron";
1547        let inside = story_only(|| {
1548            // A nested call leaves them hidden when it returns.
1549            story_only(|| ());
1550            (
1551                bundle.file(path),
1552                bundle.bytes(path),
1553                bundle.files_in("data").contains(&path),
1554                bundle.files_under("").contains(&path),
1555                bundle.display_path(path),
1556                bundle.file("data/b.ron").is_some(),
1557            )
1558        });
1559        assert_eq!(
1560            inside,
1561            (None, None, false, false, "base/data/t.ron".to_owned(), true)
1562        );
1563        assert!(bundle.file(path).is_some());
1564        // Also after a panic inside it.
1565        assert!(std::panic::catch_unwind(|| story_only(|| panic!("inside"))).is_err());
1566        assert!(bundle.file(path).is_some());
1567        // Another thread is never affected.
1568        let elsewhere = story_only(|| {
1569            std::thread::spawn(move || with_tests().file("data/t.ron").is_some()).join()
1570        });
1571        assert!(matches!(elsewhere, Ok(true)));
1572    }
1573
1574    #[test]
1575    fn a_bundle_has_the_files_a_layer_this_thread_reads_has() {
1576        let bundle = with_tests();
1577        assert!(bundle.has("top.txt"));
1578        assert!(bundle.has("portraits/new.portrait"));
1579        assert!(bundle.has("data/t.ron"));
1580        assert!(!bundle.has("data/nope.ron"));
1581        assert!(!bundle.has("data"));
1582        // Inside `story_only`, the test layers' files are not there.
1583        assert!(!story_only(|| bundle.has("data/t.ron")));
1584        assert!(story_only(|| bundle.has("data/b.ron")));
1585    }
1586
1587    /// [`PUBLIC_TABLE`] without `data/b.ron`, which it leaves out.
1588    static TRIMMED: &[(&str, &[u8])] = &[
1589        ("data/a.ron", b"public a"),
1590        ("deep/x/z.txt", b"public z"),
1591        ("top.txt", b"public top"),
1592    ];
1593
1594    #[test]
1595    fn a_file_a_layer_leaves_out_is_missing_and_left_out() {
1596        let trimmed = Layer::table("base", TRIMMED).leaving_out(&["data/b.ron"]);
1597        let bundle = Bundle::new(vec![trimmed]);
1598        assert_eq!(bundle.file("data/b.ron"), None);
1599        assert!(bundle.left_out("data/b.ron"));
1600        // A missing file it does not name, and a file it has, are not.
1601        assert!(!bundle.left_out("data/nope.ron"));
1602        assert!(!bundle.left_out("data/a.ron"));
1603        assert!(!bundle.left_out("data"));
1604        assert_eq!(bundle.files_in("data"), ["data/a.ron"]);
1605        // Nothing is left out of a layer that does not say so.
1606        assert!(!merged().left_out("data/b.ron"));
1607        // Nor out of a test layer, inside `story_only`.
1608        let test = Layer::table("tests", TRIMMED).leaving_out(&["x"]).test();
1609        let with_test = Bundle::new(vec![PUBLIC, test]);
1610        assert!(with_test.left_out("x"));
1611        assert!(!story_only(|| with_test.left_out("x")));
1612    }
1613
1614    std::thread_local! {
1615        /// The files this thread read inside [`watched`].
1616        static SEEN: core::cell::RefCell<Option<Vec<(String, bool)>>> =
1617            const { core::cell::RefCell::new(None) };
1618    }
1619
1620    /// The test's watcher: notes the reads of a thread inside [`watched`].
1621    fn note(path: &str, found: bool) {
1622        SEEN.with_borrow_mut(|seen| {
1623            if let Some(seen) = seen {
1624                seen.push((path.to_owned(), found));
1625            }
1626        });
1627    }
1628
1629    /// The files this thread reads in `f`, and whether each was found.
1630    fn watched(f: impl FnOnce()) -> Vec<(String, bool)> {
1631        SEEN.set(Some(Vec::new()));
1632        f();
1633        SEEN.take().unwrap_or_default()
1634    }
1635
1636    /// A section of import settings, for the watcher's test.
1637    #[derive(Debug, Default, PartialEq, Eq, serde::Deserialize)]
1638    #[serde(default, deny_unknown_fields)]
1639    struct Flip {
1640        flip: bool,
1641    }
1642
1643    impl crate::import_settings::ImportSettings for Flip {
1644        const SECTION: &'static str = "flip";
1645    }
1646
1647    /// Settings in a folder, none beside the file.
1648    static SETTINGS: &[(&str, &[u8])] = &[("images/_folder.meta", b"(flip: (flip: true))")];
1649
1650    /// The only test that sets the watcher: it is process-wide.
1651    #[test]
1652    fn the_watcher_is_told_of_every_file_read() {
1653        assert!(watch(note).is_ok());
1654        let refused: Watcher = |_, _| {};
1655        assert!(watch(refused).is_err());
1656        let bundle = merged();
1657        let seen = watched(|| {
1658            let _ = bundle.file("data/a.ron");
1659            let _ = bundle.bytes("data/nope.ron");
1660            // Listing and naming files reads none.
1661            let _ = bundle.files_under("");
1662            let _ = bundle.display_path("top.txt");
1663            let _ = bundle.left_out("top.txt");
1664            let _ = bundle.has("top.txt");
1665            // Nor does a fingerprint, though it takes every byte.
1666            let _ = bundle.fingerprint();
1667        });
1668        assert_eq!(
1669            seen,
1670            [
1671                ("data/a.ron".to_owned(), true),
1672                ("data/nope.ron".to_owned(), false)
1673            ]
1674        );
1675        // Each settings file looked for is heard of, found or not, up to
1676        // the first that has the section.
1677        let settings = Bundle::new(vec![Layer::table("base", SETTINGS)]);
1678        let hero = crate::format::AssetPath::new("images/hero.png");
1679        let mut read = None;
1680        let seen = watched(|| {
1681            read =
1682                Some(crate::format::DecodeCx::in_bundle(&settings).import_settings::<Flip>(&hero));
1683        });
1684        assert_eq!(read, Some(Ok(Flip { flip: true })));
1685        assert_eq!(
1686            seen,
1687            [
1688                ("images/hero.png.meta".to_owned(), false),
1689                ("images/_folder.meta".to_owned(), true)
1690            ]
1691        );
1692    }
1693
1694    #[test]
1695    #[cfg_attr(
1696        target_os = "wasi",
1697        ignore = "needs threads and unwinding, which WASI preview 1 lacks"
1698    )]
1699    fn reading_makes_this_thread_read_another_bundle_until_it_returns() {
1700        let other: &'static Bundle =
1701            Box::leak(Box::new(Bundle::new(vec![Layer::table("other", OVER)])));
1702        let is_other = move |bundle: &Bundle| core::ptr::eq(bundle, other);
1703        let inside = reading(other, || {
1704            // A nested call puts the outer bundle back when it returns.
1705            reading(other, || ());
1706            (
1707                installed().is_some_and(is_other),
1708                is_other(get_or_install(merged)),
1709            )
1710        });
1711        assert_eq!(inside, (true, true));
1712        // Nothing was installed by it.
1713        assert!(!installed().is_some_and(is_other));
1714        // Also after a panic inside it.
1715        assert!(std::panic::catch_unwind(|| reading(other, || panic!("inside"))).is_err());
1716        assert!(!installed().is_some_and(is_other));
1717        // Another thread is never affected.
1718        let elsewhere = reading(other, || {
1719            std::thread::spawn(move || installed().is_some_and(is_other)).join()
1720        });
1721        assert!(matches!(elsewhere, Ok(false)));
1722    }
1723
1724    /// The `no_std` build's flag.
1725    #[test]
1726    fn a_flag_starts_off_and_gives_back_its_old_value() {
1727        let flag = Flag::new();
1728        assert!(!flag.get());
1729        assert!(!flag.replace(true));
1730        assert!(flag.get());
1731        assert!(flag.replace(false));
1732        assert!(!flag.get());
1733    }
1734
1735    #[test]
1736    fn a_name_is_shown_under_a_layer_after_its_name_and_a_slash() {
1737        assert!(shown_under("base/x.png", "base"));
1738        assert!(shown_under("bought-tests/a/b", "bought-tests"));
1739        assert!(!shown_under("bases/x.png", "base"));
1740        assert!(!shown_under("base", "base"));
1741        assert!(!shown_under("x/base/x.png", "base"));
1742    }
1743
1744    /// The only test that installs: the bundle is process-wide.
1745    #[test]
1746    fn a_bundle_is_installed_once() {
1747        assert!(installed().is_none());
1748        let first = get_or_install(merged);
1749        assert_eq!(first.file("data/a.ron"), Some("private a"));
1750        // A second install changes nothing.
1751        let refused = install(Bundle::new(vec![PUBLIC]));
1752        assert!(refused.is_err_and(|bundle| names(bundle.layers()) == ["base"]));
1753        let again = get_or_install(|| Bundle::new(vec![PUBLIC]));
1754        assert!(core::ptr::eq(first, again));
1755        assert!(installed().is_some_and(|bundle| core::ptr::eq(bundle, first)));
1756    }
1757}