Skip to main content

editor/
export.rs

1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use libdqg::scene_file::GameManifest;
6
7use crate::project::Project;
8
9/// Packages `project` into a standalone folder at `output_dir`: the prebuilt `runtime` export
10/// template (see [`find_template`]), renamed to the project's name, plus a `res/` folder holding
11/// the project's assets, scripts, and scenes — the exact layout `runtime`'s own `res_dir`/
12/// `load_manifest` expect. Pure file copying, no compilation — see the export/compile
13/// implementation plan for why.
14pub fn export_project(project: &Project, output_dir: &Path) -> anyhow::Result<()> {
15    let template = find_template()?;
16    package_into(&template, project, output_dir)
17}
18
19/// The actual file-copying steps, factored out of [`export_project`] so they can be exercised in
20/// a test without depending on [`find_template`]'s `env::current_exe`-based lookup.
21fn package_into(template: &Path, project: &Project, output_dir: &Path) -> anyhow::Result<()> {
22    let res_dir = output_dir.join("res");
23    // Wipe any previous export's `res/` first — `fs_extra`'s `overwrite` only overwrites files
24    // that still exist in the source, so a stale asset/script removed or renamed in the project
25    // since the last export would otherwise linger here and the exported game could still load
26    // it.
27    if res_dir.is_dir() {
28        fs::remove_dir_all(&res_dir)?;
29    }
30    fs::create_dir_all(&res_dir)?;
31
32    let exe_name = format!("{}{}", sanitize_file_name(&project.manifest.name), env::consts::EXE_SUFFIX);
33    fs::copy(template, output_dir.join(&exe_name))?;
34
35    let mut copy_options = fs_extra::dir::CopyOptions::new();
36    copy_options.overwrite = true;
37
38    // Stored asset/script paths (e.g. `assets/textures/foo.png`) are project-root-relative, so
39    // mirroring `assets/`'s and `scripts/`'s own layout under `res/` needs no path rewriting —
40    // `RenderableAsset::load`/`ScriptRuntime` resolve them against `res_dir` exactly as the
41    // editor resolves them against `project.root`.
42    let assets_dir = project.root.join("assets");
43    if assets_dir.is_dir() {
44        fs_extra::dir::copy(&assets_dir, &res_dir, &copy_options)?;
45    }
46    let scripts_dir = project.root.join("scripts");
47    if scripts_dir.is_dir() {
48        fs_extra::dir::copy(&scripts_dir, &res_dir, &copy_options)?;
49    }
50
51    let scenes_dir = project.root.join("scenes");
52    if scenes_dir.is_dir() {
53        fs_extra::dir::copy(&scenes_dir, &res_dir, &copy_options)?;
54    }
55
56    let manifest = GameManifest {
57        title: project.manifest.name.clone(),
58        width: 1280,
59        height: 720,
60        start_scene: project.manifest.start_scene.clone(),
61    };
62    fs::write(res_dir.join("game.ron"), ron::ser::to_string_pretty(&manifest, Default::default())?)?;
63
64    Ok(())
65}
66
67/// Locates the prebuilt `runtime` export template next to the running editor binary: first a
68/// `templates/` subfolder (how a packaged editor release ships it), then flat next to the editor
69/// exe (how a local `cargo build` — which puts every workspace binary in the same
70/// `target/<profile>/` directory — makes it available with no extra packaging step in dev).
71fn find_template() -> anyhow::Result<PathBuf> {
72    let exe_name = format!("runtime{}", env::consts::EXE_SUFFIX);
73    let editor_dir = env::current_exe()?
74        .parent()
75        .ok_or_else(|| anyhow::anyhow!("could not determine the editor's own directory"))?
76        .to_path_buf();
77
78    let candidates = [editor_dir.join("templates").join(&exe_name), editor_dir.join(&exe_name)];
79    candidates.into_iter().find(|path| path.is_file()).ok_or_else(|| {
80        anyhow::anyhow!(
81            "export template not found (looked for templates/{exe_name} and {exe_name} next to the editor) — \
82             build the `runtime` crate first (cargo build -p runtime)"
83        )
84    })
85}
86
87/// Strips characters Windows filenames disallow from a project name, for the exported exe's
88/// file name.
89fn sanitize_file_name(name: &str) -> String {
90    let cleaned: String = name.chars().map(|c| if r#"<>:"/\|?*"#.contains(c) { '_' } else { c }).collect();
91    let trimmed = cleaned.trim();
92    if trimmed.is_empty() { "Game".to_string() } else { trimmed.to_string() }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    fn temp_dir(name: &str) -> PathBuf {
100        let dir = env::temp_dir().join("libdqg_export_tests").join(name);
101        let _ = fs::remove_dir_all(&dir);
102        fs::create_dir_all(&dir).unwrap();
103        dir
104    }
105
106    #[test]
107    fn package_into_mirrors_project_layout_under_res() {
108        let project_root = temp_dir("project_export_layout");
109        let project = Project::create(project_root).expect("project should be created");
110
111        fs::create_dir_all(project.textures_dir()).unwrap();
112        fs::write(project.textures_dir().join("hero.png"), b"fake png bytes").unwrap();
113        fs::create_dir_all(project.scripts_dir()).unwrap();
114        fs::write(project.scripts_dir().join("move.rhai"), "let on_update = |dt, input| {};").unwrap();
115
116        let template = temp_dir("template_export_layout").join("runtime.exe");
117        fs::write(&template, b"fake exe bytes").unwrap();
118
119        let output_dir = temp_dir("output_export_layout");
120        package_into(&template, &project, &output_dir).expect("packaging should succeed");
121
122        let exe_name = format!("{}{}", sanitize_file_name(&project.manifest.name), env::consts::EXE_SUFFIX);
123        assert!(output_dir.join(&exe_name).is_file(), "template should be copied and renamed to the project name");
124        assert!(output_dir.join("res/assets/textures/hero.png").is_file());
125        assert!(output_dir.join("res/scripts/move.rhai").is_file());
126        assert!(output_dir.join("res/scenes/main.ron").is_file(), "an empty project still has scenes/main.ron to mirror");
127
128        let manifest_text = fs::read_to_string(output_dir.join("res/game.ron")).unwrap();
129        let manifest: GameManifest = ron::from_str(&manifest_text).unwrap();
130        assert_eq!(manifest.title, project.manifest.name);
131        assert_eq!(manifest.width, 1280);
132        assert_eq!(manifest.height, 720);
133        assert_eq!(manifest.start_scene, PathBuf::from("scenes/main.ron"));
134    }
135
136    #[test]
137    fn sanitize_file_name_strips_characters_windows_disallows() {
138        assert_eq!(sanitize_file_name("My:Game?"), "My_Game_");
139        assert_eq!(sanitize_file_name("  "), "Game");
140    }
141
142    /// Regression test: re-exporting to the same output folder after an asset was removed from
143    /// the project must not leave that asset behind in `res/` — see [`package_into`]'s
144    /// `remove_dir_all` doc comment for why `fs_extra`'s `overwrite` alone isn't enough.
145    #[test]
146    fn package_into_removes_stale_files_from_a_previous_export() {
147        let project_root = temp_dir("project_export_repeat");
148        let project = Project::create(project_root).expect("project should be created");
149
150        fs::create_dir_all(project.textures_dir()).unwrap();
151        fs::write(project.textures_dir().join("old.png"), b"fake png bytes").unwrap();
152
153        let template = temp_dir("template_export_repeat").join("runtime.exe");
154        fs::write(&template, b"fake exe bytes").unwrap();
155
156        let output_dir = temp_dir("output_export_repeat");
157        package_into(&template, &project, &output_dir).expect("first export should succeed");
158        assert!(output_dir.join("res/assets/textures/old.png").is_file());
159
160        fs::remove_file(project.textures_dir().join("old.png")).unwrap();
161        package_into(&template, &project, &output_dir).expect("second export should succeed");
162
163        assert!(
164            !output_dir.join("res/assets/textures/old.png").exists(),
165            "an asset removed from the project should not survive a re-export to the same folder"
166        );
167    }
168}