1use std::path::{Path, PathBuf};
2
3pub const FILE: &str = "project.toml";
4pub const SKIPPED: [&str; 2] = ["target", "tmp"];
5pub const CONTENT: [&str; 12] = [
6 "gltf", "glb", "bin", "png", "hdr", "wav", "ogg", "ttf", "otf", "svg", "ktx2", "exr",
7];
8pub const KINDS: [&str; 5] = [
9 ".scene.toml",
10 ".prefab.toml",
11 ".materials.toml",
12 ".proxies.toml",
13 "materials.toml",
14];
15
16#[derive(Clone, Debug, PartialEq)]
17pub struct Project {
18 root: PathBuf,
19 name: String,
20 scene: Option<PathBuf>,
21 play: Option<PathBuf>,
22 select: Option<String>,
23 ignore: Vec<PathBuf>,
24 screen: Option<toml::Table>,
25 modules: Vec<PathBuf>,
26 flat: bool,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct Module {
31 pub id: String,
32 pub path: PathBuf,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct Folder {
37 pub path: PathBuf,
38 pub files: Vec<PathBuf>,
39}
40
41fn absolute(path: &Path) -> PathBuf {
42 std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf())
43}
44
45fn path_key(
46 table: Option<&toml::Table>,
47 key: &str,
48 file: &Path,
49) -> Result<Option<PathBuf>, String> {
50 match table.and_then(|table| table.get(key)) {
51 None => Ok(None),
52 Some(toml::Value::String(text)) if !text.is_empty() => Ok(Some(PathBuf::from(text))),
53 Some(_) => Err(format!(
54 "{}: {key} is a path relative to the project's folder",
55 file.display()
56 )),
57 }
58}
59
60fn modules(table: Option<&toml::Table>, file: &Path) -> Result<Vec<PathBuf>, String> {
61 let refuse = || {
62 format!(
63 "{}: modules lists the built .wasm of each gameplay module, relative to the project's folder",
64 file.display()
65 )
66 };
67 match table.and_then(|table| table.get("modules")) {
68 None => Ok(Vec::new()),
69 Some(toml::Value::Array(paths)) => paths
70 .iter()
71 .map(|path| {
72 path.as_str()
73 .filter(|path| path.ends_with(".wasm") && path.len() > ".wasm".len())
74 .map(PathBuf::from)
75 .ok_or_else(refuse)
76 })
77 .collect(),
78 Some(_) => Err(refuse()),
79 }
80}
81
82fn scene_file(path: &Path) -> bool {
83 path.file_name()
84 .and_then(|name| name.to_str())
85 .is_some_and(|name| name.ends_with(".scene.toml"))
86}
87
88fn content_file(path: &Path) -> bool {
89 let name = path
90 .file_name()
91 .and_then(|name| name.to_str())
92 .unwrap_or_default();
93 if KINDS.iter().any(|kind| name.ends_with(kind)) {
94 return true;
95 }
96 path.extension()
97 .and_then(|extension| extension.to_str())
98 .is_some_and(|extension| CONTENT.contains(&extension.to_ascii_lowercase().as_str()))
99}
100
101impl Project {
102 pub fn open(folder: impl AsRef<Path>) -> Result<Project, String> {
103 let root = absolute(folder.as_ref());
104 let file = root.join(FILE);
105 let text = std::fs::read_to_string(&file)
106 .map_err(|error| format!("{}: {error}", file.display()))?;
107 Project::parse(&root, &text)
108 }
109
110 pub fn folder(folder: impl AsRef<Path>) -> Result<Project, String> {
111 let root = absolute(folder.as_ref());
112 if root.join(FILE).is_file() {
113 return Project::open(&root);
114 }
115 if !root.is_dir() {
116 return Err(format!("{}: no such folder", root.display()));
117 }
118 Ok(Project {
119 name: root
120 .file_name()
121 .map(|name| name.to_string_lossy().into_owned())
122 .unwrap_or_default(),
123 root,
124 scene: None,
125 play: None,
126 select: None,
127 ignore: Vec::new(),
128 screen: None,
129 modules: Vec::new(),
130 flat: false,
131 })
132 }
133
134 pub fn marked(&self) -> bool {
135 self.root.join(FILE).is_file()
136 }
137
138 pub fn parse(root: &Path, text: &str) -> Result<Project, String> {
139 let file = root.join(FILE);
140 let table: toml::Table = text
141 .parse()
142 .map_err(|error: toml::de::Error| format!("{}: {}", file.display(), error.message()))?;
143 let project = table
144 .get("project")
145 .and_then(toml::Value::as_table)
146 .ok_or_else(|| format!("{}: no [project] table", file.display()))?;
147 let name = project
148 .get("name")
149 .and_then(toml::Value::as_str)
150 .filter(|name| !name.trim().is_empty())
151 .ok_or_else(|| format!("{}: [project] has no name", file.display()))?
152 .to_string();
153 let pfx = table
154 .get("authoring")
155 .and_then(toml::Value::as_table)
156 .and_then(|authoring| authoring.get("pfx"))
157 .and_then(toml::Value::as_table);
158 Ok(Project {
159 root: root.to_path_buf(),
160 name,
161 scene: path_key(Some(project), "scene", &file)?,
162 play: path_key(pfx, "play", &file)?,
163 ignore: match project.get("ignore") {
164 None => Vec::new(),
165 Some(toml::Value::Array(paths)) => paths
166 .iter()
167 .map(|path| {
168 path.as_str().map(|path| root.join(path)).ok_or_else(|| {
169 format!(
170 "{}: ignore lists paths relative to the project's folder",
171 file.display()
172 )
173 })
174 })
175 .collect::<Result<_, _>>()?,
176 Some(_) => {
177 return Err(format!(
178 "{}: ignore lists paths relative to the project's folder",
179 file.display()
180 ));
181 }
182 },
183 screen: match pfx.and_then(|pfx| pfx.get("screen")) {
184 None => None,
185 Some(toml::Value::Table(table)) => Some(table.clone()),
186 Some(_) => {
187 return Err(format!(
188 "{}: [authoring.pfx.screen] is a table, the game's [screen] policy",
189 file.display()
190 ));
191 }
192 },
193 modules: modules(pfx, &file)?,
194 flat: match pfx.and_then(|pfx| pfx.get("flat")) {
195 None => false,
196 Some(toml::Value::Boolean(flat)) => *flat,
197 Some(_) => {
198 return Err(format!(
199 "{}: flat is true for a game with no 3D scene, or false",
200 file.display()
201 ));
202 }
203 },
204 select: match pfx.and_then(|pfx| pfx.get("select")) {
205 None => None,
206 Some(toml::Value::String(name)) if !name.is_empty() => Some(name.clone()),
207 Some(_) => {
208 return Err(format!(
209 "{}: select names the object the editor selects when it opens",
210 file.display()
211 ));
212 }
213 },
214 })
215 }
216
217 pub fn find(from: impl AsRef<Path>) -> Option<PathBuf> {
218 let from = absolute(from.as_ref());
219 let start = if from.is_dir() {
220 from.as_path()
221 } else {
222 from.parent()?
223 };
224 start
225 .ancestors()
226 .find(|folder| folder.join(FILE).is_file())
227 .map(Path::to_path_buf)
228 }
229
230 pub fn locate(manifest_dir: impl AsRef<Path>) -> Result<Project, String> {
231 let beside = std::env::current_exe()
232 .ok()
233 .and_then(|exe| exe.parent().map(Path::to_path_buf))
234 .filter(|folder| folder.join(FILE).is_file());
235 Project::open(beside.unwrap_or_else(|| manifest_dir.as_ref().to_path_buf()))
236 }
237
238 pub fn root(&self) -> &Path {
239 &self.root
240 }
241
242 pub fn name(&self) -> &str {
243 &self.name
244 }
245
246 pub fn version(&self) -> Option<String> {
247 let text = std::fs::read_to_string(self.root.join("Cargo.toml")).ok()?;
248 let table: toml::Table = text.parse().ok()?;
249 table
250 .get("package")?
251 .get("version")?
252 .as_str()
253 .map(str::to_string)
254 }
255
256 pub fn scene(&self) -> Option<PathBuf> {
257 self.scene.as_ref().map(|scene| self.root.join(scene))
258 }
259
260 pub fn screen(&self) -> Option<&toml::Table> {
261 self.screen.as_ref()
262 }
263
264 pub fn flat(&self) -> bool {
265 self.flat
266 }
267
268 pub fn select(&self) -> Option<&str> {
269 self.select.as_deref()
270 }
271
272 pub fn play(&self) -> PathBuf {
273 match (&self.play, &self.scene) {
274 (Some(play), _) | (None, Some(play)) => self.root.join(play),
275 (None, None) => self
276 .scenes()
277 .into_iter()
278 .next()
279 .unwrap_or_else(|| self.root.join("content")),
280 }
281 }
282
283 pub fn modules(&self) -> Vec<Module> {
284 self.modules
285 .iter()
286 .map(|path| Module {
287 id: path
288 .file_stem()
289 .map(|stem| stem.to_string_lossy().into_owned())
290 .unwrap_or_default(),
291 path: self.root.join(path),
292 })
293 .collect()
294 }
295
296 pub fn first(&self) -> Option<PathBuf> {
297 self.scene()
298 .or_else(|| self.play.as_ref().map(|play| self.root.join(play)))
299 .or_else(|| self.scenes().into_iter().next())
300 }
301
302 pub fn relative(&self, path: &Path) -> String {
303 path.strip_prefix(&self.root)
304 .unwrap_or(path)
305 .to_string_lossy()
306 .replace('\\', "/")
307 }
308
309 pub fn scenes(&self) -> Vec<PathBuf> {
310 self.folders()
311 .into_iter()
312 .flat_map(|folder| folder.files)
313 .filter(|file| scene_file(file))
314 .collect()
315 }
316
317 pub fn folders(&self) -> Vec<Folder> {
318 let mut out = Vec::new();
319 walk(&self.root, &self.root, &self.ignore, &mut out);
320 out
321 }
322}
323
324fn walk(root: &Path, folder: &Path, ignore: &[PathBuf], out: &mut Vec<Folder>) {
325 let Ok(entries) = std::fs::read_dir(folder) else {
326 return;
327 };
328 let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
329 entries.sort_by_key(|entry| entry.file_name());
330 let mut files = Vec::new();
331 let mut folders = Vec::new();
332 for entry in entries {
333 let name = entry.file_name();
334 let Some(name) = name.to_str() else { continue };
335 if name.starts_with('.') {
336 continue;
337 }
338 let Ok(kind) = entry.file_type() else {
339 continue;
340 };
341 let path = entry.path();
342 if ignore.contains(&path) {
343 continue;
344 }
345 if kind.is_dir() {
346 let skipped = folder == root && SKIPPED.contains(&name);
347 if !skipped && !path.join(FILE).is_file() {
348 folders.push(path);
349 }
350 } else if kind.is_file() && content_file(&path) {
351 files.push(path);
352 }
353 }
354 if !files.is_empty() {
355 out.push(Folder {
356 path: folder.to_path_buf(),
357 files,
358 });
359 }
360 for path in folders {
361 walk(root, &path, ignore, out);
362 }
363}