1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use libdqg::scene_file::GameManifest;
6
7use crate::project::Project;
8
9pub fn export_project(project: &Project, output_dir: &Path) -> anyhow::Result<()> {
15 let template = find_template()?;
16 package_into(&template, project, output_dir)
17}
18
19fn package_into(template: &Path, project: &Project, output_dir: &Path) -> anyhow::Result<()> {
22 let res_dir = output_dir.join("res");
23 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 let assets_dir = project.root.join("assets");
43 if assets_dir.is_dir() {
44 fs_extra::dir::copy(&assets_dir, &res_dir, ©_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, ©_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, ©_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
67fn 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
87fn 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 #[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}