Skip to main content

pfx_project/
project.rs

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}