1use crate::project_vfs::ProjectVfs;
60use anyhow::{anyhow, Context, Result};
61use khora_sdk::khora_core::asset::CompressionKind;
62use khora_sdk::PackBuilder;
63use serde::Serialize;
64use std::path::{Path, PathBuf};
65
66#[allow(dead_code)]
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum BuildPreset {
75 Debug,
78 Release,
81 Shipping,
85}
86
87impl BuildPreset {
88 pub fn label(self) -> &'static str {
89 match self {
90 Self::Debug => "debug",
91 Self::Release => "release",
92 Self::Shipping => "shipping",
93 }
94 }
95
96 pub fn compression(self) -> CompressionKind {
98 match self {
99 Self::Debug => CompressionKind::None,
100 Self::Release | Self::Shipping => CompressionKind::Lz4,
101 }
102 }
103
104 pub fn emit_manifest(self) -> bool {
106 match self {
107 Self::Debug => false,
108 Self::Release | Self::Shipping => true,
109 }
110 }
111
112 pub fn verify_integrity(self) -> bool {
115 match self {
116 Self::Debug | Self::Release => true,
117 Self::Shipping => false,
118 }
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum BuildTarget {
125 Windows,
126 Linux,
127 Macos,
128}
129
130impl BuildTarget {
131 pub fn host() -> Self {
134 if cfg!(target_os = "windows") {
135 Self::Windows
136 } else if cfg!(target_os = "macos") {
137 Self::Macos
138 } else {
139 Self::Linux
142 }
143 }
144
145 pub fn dir_name(self) -> &'static str {
148 match self {
149 Self::Windows => "windows",
150 Self::Linux => "linux",
151 Self::Macos => "macos",
152 }
153 }
154
155 pub fn exe_suffix(self) -> &'static str {
157 match self {
158 Self::Windows => ".exe",
159 Self::Linux | Self::Macos => "",
160 }
161 }
162}
163
164#[derive(Debug, Serialize)]
167struct RuntimeConfig<'a> {
168 project_name: &'a str,
169 default_scene: &'a str,
170 preset: &'a str,
173 verify_integrity: bool,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum BuildStrategy {
182 RuntimeStamp,
185 CargoBuild,
189}
190
191impl BuildStrategy {
192 pub fn label(self) -> &'static str {
193 match self {
194 Self::RuntimeStamp => "runtime stamp",
195 Self::CargoBuild => "cargo build",
196 }
197 }
198}
199
200#[derive(Debug, Clone)]
203pub struct BuildOutcome {
204 pub strategy: BuildStrategy,
205 pub output_dir: PathBuf,
206 #[allow(dead_code)]
210 pub binary_path: PathBuf,
211 pub asset_count: usize,
212 pub pack_bytes: u64,
213}
214
215pub fn build_for_host(pvfs: &ProjectVfs, project_name: &str) -> Result<BuildOutcome> {
218 let target = BuildTarget::host();
219 build_for_target(pvfs, project_name, target, BuildPreset::Release)
220}
221
222pub fn build_for_target(
227 pvfs: &ProjectVfs,
228 project_name: &str,
229 target: BuildTarget,
230 preset: BuildPreset,
231) -> Result<BuildOutcome> {
232 if project_name.trim().is_empty() {
233 anyhow::bail!("Build Game: project_name is empty");
234 }
235
236 let output_dir = pvfs.root.join("dist").join(target.dir_name());
237 std::fs::create_dir_all(&output_dir).with_context(|| {
238 format!(
239 "Failed to create build output directory {}",
240 output_dir.display()
241 )
242 })?;
243
244 let has_cargo = pvfs.root.join("Cargo.toml").is_file();
245 let strategy = if has_cargo {
246 BuildStrategy::CargoBuild
247 } else {
248 BuildStrategy::RuntimeStamp
249 };
250
251 log::info!(
252 "Build Game: target={:?}, preset={}, strategy={}, output={}",
253 target,
254 preset.label(),
255 strategy.label(),
256 output_dir.display()
257 );
258
259 let pack_out = PackBuilder::new(&pvfs.assets_root, &output_dir)
264 .with_compression(preset.compression())
265 .with_manifest(preset.emit_manifest())
266 .build()
267 .context("PackBuilder failed")?;
268
269 let bin_dst = match strategy {
270 BuildStrategy::RuntimeStamp => stage_with_runtime_stamp(&output_dir, project_name, target)?,
271 BuildStrategy::CargoBuild => {
272 stage_with_cargo_build(&pvfs.root, &output_dir, project_name, target)?
273 }
274 };
275
276 write_runtime_config(&output_dir, project_name, preset)?;
277
278 log::info!(
279 "Build Game: staged {} ({} assets, {} bytes packed) via {}",
280 bin_dst.display(),
281 pack_out.asset_count,
282 pack_out.pack_bytes(),
283 strategy.label()
284 );
285
286 Ok(BuildOutcome {
287 strategy,
288 output_dir,
289 binary_path: bin_dst,
290 asset_count: pack_out.asset_count,
291 pack_bytes: pack_out.pack_bytes(),
292 })
293}
294
295fn stage_with_runtime_stamp(
298 output_dir: &Path,
299 project_name: &str,
300 target: BuildTarget,
301) -> Result<PathBuf> {
302 let runtime_src = locate_runtime_binary(target)
303 .with_context(|| format!("Could not locate khora-runtime for {:?}", target))?;
304 let safe_name = sanitize_binary_name(project_name);
305 let bin_filename = format!("{}{}", safe_name, target.exe_suffix());
306 let bin_dst = output_dir.join(&bin_filename);
307 std::fs::copy(&runtime_src, &bin_dst).with_context(|| {
308 format!(
309 "Failed to copy runtime {} → {}",
310 runtime_src.display(),
311 bin_dst.display()
312 )
313 })?;
314 set_executable_bit(&bin_dst);
315 Ok(bin_dst)
316}
317
318fn stage_with_cargo_build(
325 project_root: &Path,
326 output_dir: &Path,
327 project_name: &str,
328 target: BuildTarget,
329) -> Result<PathBuf> {
330 if target != BuildTarget::host() {
331 anyhow::bail!(
332 "Build Game (cargo strategy): target {:?} is not the host \
333 ({:?}). Native-Rust projects can only be built for the host \
334 OS in v1 — run the editor on the desired target.",
335 target,
336 BuildTarget::host()
337 );
338 }
339
340 let manifest = project_root.join("Cargo.toml");
341 if !manifest.is_file() {
342 anyhow::bail!(
343 "Build Game (cargo strategy): no Cargo.toml at {}",
344 manifest.display()
345 );
346 }
347
348 log::info!(
349 "Build Game: invoking `cargo build --release --manifest-path {}`",
350 manifest.display()
351 );
352
353 let output = std::process::Command::new("cargo")
354 .arg("build")
355 .arg("--release")
356 .arg("--manifest-path")
357 .arg(&manifest)
358 .output()
359 .with_context(|| {
360 format!(
361 "Failed to launch cargo (is it installed and on PATH?). \
362 Manifest: {}",
363 manifest.display()
364 )
365 })?;
366
367 if !output.stdout.is_empty() {
370 log::info!("cargo stdout:\n{}", String::from_utf8_lossy(&output.stdout));
371 }
372 if !output.stderr.is_empty() {
373 log::info!("cargo stderr:\n{}", String::from_utf8_lossy(&output.stderr));
376 }
377 if !output.status.success() {
378 anyhow::bail!(
379 "cargo build failed with exit code {:?}",
380 output.status.code()
381 );
382 }
383
384 let target_release = project_root.join("target").join("release");
389 let pkg_bin = find_compiled_binary(&target_release, target).with_context(|| {
390 format!(
391 "Could not find a compiled executable in {} (cargo build \
392 succeeded but the binary is missing — is `[[bin]]` set?)",
393 target_release.display()
394 )
395 })?;
396
397 let safe_name = sanitize_binary_name(project_name);
398 let bin_filename = format!("{}{}", safe_name, target.exe_suffix());
399 let bin_dst = output_dir.join(&bin_filename);
400 std::fs::copy(&pkg_bin, &bin_dst).with_context(|| {
401 format!(
402 "Failed to copy compiled binary {} → {}",
403 pkg_bin.display(),
404 bin_dst.display()
405 )
406 })?;
407 set_executable_bit(&bin_dst);
408 Ok(bin_dst)
409}
410
411fn find_compiled_binary(target_release: &Path, target: BuildTarget) -> Result<PathBuf> {
414 let suffix = target.exe_suffix();
415 let entries = std::fs::read_dir(target_release).with_context(|| {
416 format!(
417 "Failed to read target/release directory {}",
418 target_release.display()
419 )
420 })?;
421 for entry in entries.flatten() {
422 let path = entry.path();
423 if !path.is_file() {
424 continue;
425 }
426 let name = path
427 .file_name()
428 .and_then(|s| s.to_str())
429 .unwrap_or_default();
430 if name.ends_with(".d") || name.ends_with(".rlib") || name.ends_with(".pdb") {
432 continue;
433 }
434 if !suffix.is_empty() {
437 if path.extension().and_then(|s| s.to_str()) == Some(suffix.trim_start_matches('.')) {
438 return Ok(path);
439 }
440 } else if path.extension().is_none() {
441 return Ok(path);
442 }
443 }
444 Err(anyhow!(
445 "no compiled executable found in {}",
446 target_release.display()
447 ))
448}
449
450fn write_runtime_config(output_dir: &Path, project_name: &str, preset: BuildPreset) -> Result<()> {
451 let cfg = RuntimeConfig {
452 project_name,
453 default_scene: crate::scene_io::DEFAULT_SCENE_REL,
454 preset: preset.label(),
455 verify_integrity: preset.verify_integrity(),
456 };
457 let cfg_text = serde_json::to_string_pretty(&cfg).context("serialize runtime.json")?;
458 std::fs::write(output_dir.join("runtime.json"), cfg_text)
459 .context("Failed to write runtime.json")
460}
461
462#[cfg_attr(not(unix), allow(unused_variables))]
463fn set_executable_bit(path: &Path) {
464 #[cfg(unix)]
465 {
466 use std::os::unix::fs::PermissionsExt;
467 if let Ok(meta) = std::fs::metadata(path) {
468 let mut perms = meta.permissions();
469 perms.set_mode(0o755);
470 let _ = std::fs::set_permissions(path, perms);
471 }
472 }
473}
474
475fn sanitize_binary_name(name: &str) -> String {
478 let cleaned: String = name
479 .chars()
480 .map(|c| {
481 if c.is_alphanumeric() || c == '-' || c == '_' {
482 c
483 } else {
484 '_'
485 }
486 })
487 .collect();
488 if cleaned.is_empty() {
489 "game".to_owned()
490 } else {
491 cleaned
492 }
493}
494
495fn locate_runtime_binary(target: BuildTarget) -> Result<PathBuf> {
515 let bin_name = format!("khora-runtime{}", target.exe_suffix());
516
517 let sibling_dir = if target == BuildTarget::host() {
520 let editor_exe =
521 std::env::current_exe().context("locate_runtime_binary: current_exe failed")?;
522 editor_exe.parent().map(|p| p.to_path_buf())
523 } else {
524 None
525 };
526 if let Some(dir) = sibling_dir.as_ref() {
527 let candidate = dir.join(&bin_name);
528 if candidate.is_file() {
529 return Ok(candidate);
530 }
531 }
532
533 if let Some(dir) = sibling_dir.as_ref() {
538 if let Some(workspace_root) = workspace_root_from_target_dir(dir) {
539 for profile in ["release", "debug"] {
540 let candidate = workspace_root.join("target").join(profile).join(&bin_name);
541 if candidate.is_file() {
542 return Ok(candidate);
543 }
544 }
545 }
546 }
547
548 if target == BuildTarget::host() {
551 if let Some(cache) = engine_cache_dir() {
552 if let Ok(entries) = std::fs::read_dir(&cache) {
553 let mut versions: Vec<PathBuf> = entries
554 .flatten()
555 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
556 .map(|e| e.path())
557 .collect();
558 versions.sort();
559 for ver in versions.iter().rev() {
560 let canonical = ver.join("runtime").join(&bin_name);
561 if canonical.is_file() {
562 return Ok(canonical);
563 }
564 if let Some(found) = find_file_recursive(ver, &bin_name) {
565 return Ok(found);
566 }
567 }
568 }
569 }
570 }
571
572 let workspace_hint = sibling_dir
576 .as_ref()
577 .and_then(|d| workspace_root_from_target_dir(d))
578 .map(|w| {
579 format!(
580 " Run `cargo hub-dev` (or `cargo build -p khora-runtime`) from {}.",
581 w.display()
582 )
583 })
584 .unwrap_or_default();
585 Err(anyhow!(
586 "khora-runtime binary not found for {:?}. \
587 Expected '{}' next to the editor binary or under \
588 '~/.khora/engines/<version>/runtime/'.{}",
589 target,
590 bin_name,
591 workspace_hint
592 ))
593}
594
595fn workspace_root_from_target_dir(target_dir: &Path) -> Option<PathBuf> {
599 let target_dir_name = target_dir.parent()?.file_name()?.to_str()?;
600 if target_dir_name != "target" {
601 return None;
602 }
603 let candidate = target_dir.parent()?.parent()?;
604 if candidate.join("Cargo.toml").is_file() {
605 Some(candidate.to_path_buf())
606 } else {
607 None
608 }
609}
610
611fn engine_cache_dir() -> Option<PathBuf> {
614 let home = dirs_home()?;
615 let p = home.join(".khora").join("engines");
616 if p.is_dir() {
617 Some(p)
618 } else {
619 None
620 }
621}
622
623fn find_file_recursive(dir: &Path, filename: &str) -> Option<PathBuf> {
625 let direct = dir.join(filename);
626 if direct.is_file() {
627 return Some(direct);
628 }
629 let entries = std::fs::read_dir(dir).ok()?;
630 for entry in entries.flatten() {
631 let p = entry.path();
632 if p.is_dir() {
633 if let Some(found) = find_file_recursive(&p, filename) {
634 return Some(found);
635 }
636 } else if p.file_name().and_then(|n| n.to_str()) == Some(filename) {
637 return Some(p);
638 }
639 }
640 None
641}
642
643fn dirs_home() -> Option<PathBuf> {
644 if let Some(h) = std::env::var_os("HOME") {
646 return Some(PathBuf::from(h));
647 }
648 if let Some(p) = std::env::var_os("USERPROFILE") {
649 return Some(PathBuf::from(p));
650 }
651 None
652}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657
658 #[test]
659 fn host_target_matches_cfg() {
660 let host = BuildTarget::host();
661 if cfg!(target_os = "windows") {
662 assert_eq!(host, BuildTarget::Windows);
663 } else if cfg!(target_os = "macos") {
664 assert_eq!(host, BuildTarget::Macos);
665 } else {
666 assert_eq!(host, BuildTarget::Linux);
667 }
668 }
669
670 #[test]
671 fn target_suffixes_are_correct() {
672 assert_eq!(BuildTarget::Windows.exe_suffix(), ".exe");
673 assert_eq!(BuildTarget::Linux.exe_suffix(), "");
674 assert_eq!(BuildTarget::Macos.exe_suffix(), "");
675 }
676
677 #[test]
678 fn sanitize_binary_name_strips_unsafe_chars() {
679 assert_eq!(sanitize_binary_name("My Game!"), "My_Game_");
680 assert_eq!(sanitize_binary_name("ok-name_1"), "ok-name_1");
681 assert_eq!(sanitize_binary_name(""), "game");
682 assert_eq!(sanitize_binary_name("/etc/passwd"), "_etc_passwd");
683 }
684}