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).is_some_and(|rest| rest.starts_with('/'))
117            })
118            .cloned()
119            .collect();
120        out.sort();
121        out
122    }
123}
124
125/// Join project-relative POSIX path segments, stripping a leading `"./"`
126/// from `rel` (the editor's `"./data"` style). An empty `base` returns the
127/// normalized `rel`.
128pub fn join_path(base: &str, rel: &str) -> String {
129    let rel = rel.strip_prefix("./").unwrap_or(rel);
130    let rel = rel.strip_suffix('/').unwrap_or(rel);
131    if base.is_empty() {
132        rel.to_string()
133    } else if rel.is_empty() {
134        base.to_string()
135    } else {
136        format!("{base}/{rel}")
137    }
138}
139
140/// Recursive walk helper for [`DiskFiles::list`]: every regular file under
141/// `dir`, as a `root`-relative POSIX path.
142fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) {
143    let Ok(entries) = std::fs::read_dir(dir) else {
144        return;
145    };
146    for entry in entries.flatten() {
147        let path = entry.path();
148        if path.is_dir() {
149            walk(&path, root, out);
150        } else if path.is_file() {
151            if let Ok(rel) = path.strip_prefix(root) {
152                let posix = rel
153                    .components()
154                    .map(|c| c.as_os_str().to_string_lossy().into_owned())
155                    .collect::<Vec<_>>()
156                    .join("/");
157                out.push(posix);
158            }
159        }
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn mem() -> MemoryFiles {
168        MemoryFiles::new(HashMap::from([
169            ("data/maps/Town/map.tmx.json".to_string(), b"{}".to_vec()),
170            ("data/maps/Town/objects.json".to_string(), b"{}".to_vec()),
171            ("data/maps/Field/map.tmx.json".to_string(), b"{}".to_vec()),
172            ("data/rules.ron".to_string(), b"()".to_vec()),
173            (".dotzuki-editor.json".to_string(), b"{}".to_vec()),
174        ]))
175    }
176
177    #[test]
178    fn memory_list_is_recursive_sorted_and_prefix_bounded() {
179        let files = mem();
180        assert_eq!(
181            files.list("data/maps"),
182            vec![
183                "data/maps/Field/map.tmx.json".to_string(),
184                "data/maps/Town/map.tmx.json".to_string(),
185                "data/maps/Town/objects.json".to_string(),
186            ]
187        );
188        // "data/map" must NOT match prefix "data/maps" (boundary-aware).
189        assert_eq!(files.list("data/mapss"), Vec::<String>::new());
190        assert_eq!(files.list("").len(), 5);
191    }
192
193    #[test]
194    fn memory_read_and_exists() {
195        let files = mem();
196        assert_eq!(files.read("data/rules.ron").unwrap(), b"()".to_vec());
197        assert!(files.read("nope.json").is_err());
198        assert!(files.exists("data/rules.ron"));
199        assert!(!files.exists("nope.json"));
200    }
201
202    #[test]
203    fn join_path_normalizes() {
204        assert_eq!(join_path("", "data"), "data");
205        assert_eq!(join_path("", "./data"), "data");
206        assert_eq!(join_path("data", "maps"), "data/maps");
207        assert_eq!(join_path("data", "./maps"), "data/maps");
208        assert_eq!(join_path("data", ""), "data");
209    }
210}