Skip to main content

dotzuki_runner/
vfs.rs

1//! Virtual file system for project content: [`ProjectFiles`].
2//!
3//! Every project file the runner reads (manifest, DSL sources, maps,
4//! tilesets, data-table records, audio tracks, sprites) goes through this
5//! trait instead of `std::fs`, so the same loading code runs on native disk
6//! projects ([`DiskFiles`]) and on in-memory projects ([`MemoryFiles`]) —
7//! the browser/WASM shell fetches the project into a `MemoryFiles` and boots
8//! it with [`crate::project::LoadedProject::load_with_files`].
9//!
10//! Paths are **project-relative POSIX strings** (`'/'`-separated, no leading
11//! slash, `"./"` prefixes tolerated and stripped): `"data/maps/Town/map.tmx.json"`.
12
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16use anyhow::{Context, Result};
17
18/// Read/list access to a game project's files.
19pub trait ProjectFiles {
20    /// Read the file at project-relative POSIX `path`.
21    ///
22    /// # Errors
23    ///
24    /// Fails when the file does not exist or cannot be read.
25    fn read(&self, path: &str) -> Result<Vec<u8>>;
26
27    /// Recursively list every file under `prefix` (a project-relative POSIX
28    /// directory; `""` lists the whole project), as project-relative POSIX
29    /// paths, sorted lexicographically (stable). An absent `prefix` yields an
30    /// empty list, not an error.
31    fn list(&self, prefix: &str) -> Vec<String>;
32
33    /// Whether a readable file exists at `path`.
34    fn exists(&self, path: &str) -> bool {
35        self.read(path).is_ok()
36    }
37
38    /// The on-disk project root, when backed by a real directory. Native
39    /// conveniences (the save-file default location, hot-reload watching)
40    /// use this; in-memory projects return `None`.
41    fn root(&self) -> Option<&Path> {
42        None
43    }
44}
45
46/// A project backed by a real directory tree (the native case).
47pub struct DiskFiles {
48    root: PathBuf,
49}
50
51impl DiskFiles {
52    /// A view over the directory `root` (the project dir).
53    pub fn new(root: impl Into<PathBuf>) -> Self {
54        Self { root: root.into() }
55    }
56}
57
58impl ProjectFiles for DiskFiles {
59    fn read(&self, path: &str) -> Result<Vec<u8>> {
60        let full = self.root.join(path);
61        std::fs::read(&full).with_context(|| format!("failed to read {}", full.display()))
62    }
63
64    fn list(&self, prefix: &str) -> Vec<String> {
65        let base = if prefix.is_empty() {
66            self.root.clone()
67        } else {
68            self.root.join(prefix)
69        };
70        let mut out = Vec::new();
71        if base.is_dir() {
72            walk(&base, &self.root, &mut out);
73        }
74        out.sort();
75        out
76    }
77
78    fn root(&self) -> Option<&Path> {
79        Some(&self.root)
80    }
81}
82
83/// A project held fully in memory (WASM shell, tests).
84pub struct MemoryFiles {
85    map: HashMap<String, Vec<u8>>,
86}
87
88impl MemoryFiles {
89    /// A view over an in-memory `path → content` map.
90    pub fn new(map: HashMap<String, Vec<u8>>) -> Self {
91        Self { map }
92    }
93}
94
95impl From<HashMap<String, Vec<u8>>> for MemoryFiles {
96    fn from(map: HashMap<String, Vec<u8>>) -> Self {
97        Self::new(map)
98    }
99}
100
101impl ProjectFiles for MemoryFiles {
102    fn read(&self, path: &str) -> Result<Vec<u8>> {
103        self.map
104            .get(path)
105            .cloned()
106            .with_context(|| format!("no such file '{path}'"))
107    }
108
109    fn list(&self, prefix: &str) -> Vec<String> {
110        let mut out: Vec<String> = self
111            .map
112            .keys()
113            .filter(|k| {
114                prefix.is_empty()
115                    || k.as_str() == prefix
116                    || k.strip_prefix(prefix)
117                        .is_some_and(|rest| rest.starts_with('/'))
118            })
119            .cloned()
120            .collect();
121        out.sort();
122        out
123    }
124}
125
126/// Join project-relative POSIX path segments, stripping a leading `"./"`
127/// from `rel` (the editor's `"./data"` style). An empty `base` returns the
128/// normalized `rel`.
129pub fn join_path(base: &str, rel: &str) -> String {
130    let rel = rel.strip_prefix("./").unwrap_or(rel);
131    let rel = rel.strip_suffix('/').unwrap_or(rel);
132    if base.is_empty() {
133        rel.to_string()
134    } else if rel.is_empty() {
135        base.to_string()
136    } else {
137        format!("{base}/{rel}")
138    }
139}
140
141/// Recursive walk helper for [`DiskFiles::list`]: every regular file under
142/// `dir`, as a `root`-relative POSIX path.
143fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) {
144    let Ok(entries) = std::fs::read_dir(dir) else {
145        return;
146    };
147    for entry in entries.flatten() {
148        let path = entry.path();
149        if path.is_dir() {
150            walk(&path, root, out);
151        } else if path.is_file() {
152            if let Ok(rel) = path.strip_prefix(root) {
153                let posix = rel
154                    .components()
155                    .map(|c| c.as_os_str().to_string_lossy().into_owned())
156                    .collect::<Vec<_>>()
157                    .join("/");
158                out.push(posix);
159            }
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn mem() -> MemoryFiles {
169        MemoryFiles::new(HashMap::from([
170            ("data/maps/Town/map.tmx.json".to_string(), b"{}".to_vec()),
171            ("data/maps/Town/objects.json".to_string(), b"{}".to_vec()),
172            ("data/maps/Field/map.tmx.json".to_string(), b"{}".to_vec()),
173            ("data/rules.ron".to_string(), b"()".to_vec()),
174            (".dotzuki-editor.json".to_string(), b"{}".to_vec()),
175        ]))
176    }
177
178    #[test]
179    fn memory_list_is_recursive_sorted_and_prefix_bounded() {
180        let files = mem();
181        assert_eq!(
182            files.list("data/maps"),
183            vec![
184                "data/maps/Field/map.tmx.json".to_string(),
185                "data/maps/Town/map.tmx.json".to_string(),
186                "data/maps/Town/objects.json".to_string(),
187            ]
188        );
189        // "data/map" must NOT match prefix "data/maps" (boundary-aware).
190        assert_eq!(files.list("data/mapss"), Vec::<String>::new());
191        assert_eq!(files.list("").len(), 5);
192    }
193
194    #[test]
195    fn memory_read_and_exists() {
196        let files = mem();
197        assert_eq!(files.read("data/rules.ron").unwrap(), b"()".to_vec());
198        assert!(files.read("nope.json").is_err());
199        assert!(files.exists("data/rules.ron"));
200        assert!(!files.exists("nope.json"));
201    }
202
203    #[test]
204    fn join_path_normalizes() {
205        assert_eq!(join_path("", "data"), "data");
206        assert_eq!(join_path("", "./data"), "data");
207        assert_eq!(join_path("data", "maps"), "data/maps");
208        assert_eq!(join_path("data", "./maps"), "data/maps");
209        assert_eq!(join_path("data", ""), "data");
210    }
211}