1use std::fs;
17use std::io;
18use std::path::{Path, PathBuf};
19
20use concinnity_cook::authoring::world::WorldJsonlAsset;
21use concinnity_cook::build_from_path;
22use concinnity_cook::build_only::prepare_world;
23
24use crate::command::resolve_world_path;
25
26struct AppMeta {
28 display_name: String,
30 identifier: String,
32 version: String,
34 icon: Option<PathBuf>,
36}
37
38pub fn export(
42 json_path: Option<&str>,
43 name: Option<&str>,
44 version: Option<&str>,
45 platform: Option<&str>,
46 out: &str,
47 format: &str,
48 dmg: bool,
49) -> io::Result<()> {
50 let make_zip = match format {
51 "zip" => true,
52 "dir" => false,
53 other => {
54 return Err(io::Error::new(
55 io::ErrorKind::InvalidInput,
56 format!("unknown --format '{other}' (expected 'zip' or 'dir')"),
57 ));
58 }
59 };
60 if dmg && !cfg!(target_os = "macos") {
61 return Err(io::Error::new(
62 io::ErrorKind::Unsupported,
63 "--dmg is only available when exporting on macOS",
64 ));
65 }
66
67 check_target_platform(platform)?;
71 let runtime = runtime_binary_path()?;
72 let runtime_platform = read_runtime_platform(&runtime)?;
73 verify_runtime_backend(runtime_platform.as_deref(), crate::cook_platform())?;
74
75 let world_path = resolve_world_path(json_path)?;
78 build_from_path(&world_path, crate::cook_platform())?;
79
80 let content = fs::read_to_string(&world_path)?;
83 let loaded = prepare_world(
84 &content,
85 concinnity_cook::paths::assets_dir().as_deref(),
86 crate::cook_platform(),
87 )
88 .map_err(|errs| io::Error::new(io::ErrorKind::InvalidData, errs.join("\n")))?;
89 let meta = read_app_meta(name, version, &loaded.assets);
90
91 let out_dir = Path::new(out);
92 fs::create_dir_all(out_dir)?;
93 let data_dir = concinnity_cook::paths::data_dir().ok_or_else(|| {
94 io::Error::new(
95 io::ErrorKind::NotFound,
96 "no project state directory to read the built blobs from",
97 )
98 })?;
99
100 if cfg!(target_os = "macos") {
101 export_macos(&meta, &runtime, out_dir, &data_dir, make_zip, dmg)
102 } else {
103 export_portable(
104 &meta,
105 &runtime,
106 runtime_platform.as_deref(),
107 out_dir,
108 &data_dir,
109 make_zip,
110 )
111 }
112}
113
114fn export_portable(
119 meta: &AppMeta,
120 runtime: &Path,
121 runtime_platform: Option<&str>,
122 out_dir: &Path,
123 data_dir: &Path,
124 make_zip: bool,
125) -> io::Result<()> {
126 let slug = slug(&meta.display_name);
127 let bundle_dir = out_dir.join(&slug);
128 reset_dir(&bundle_dir)?;
129
130 let exe_name = exe_file_name(&slug);
131 let exe_dst = bundle_dir.join(&exe_name);
132 fs::copy(runtime, &exe_dst)?;
133 make_executable(&exe_dst)?;
134 copy_runtime_sidecars(runtime, runtime_platform, &bundle_dir)?;
135
136 let blobs = copy_blobs(data_dir, &bundle_dir.join("data"))?;
137 precompile_shaders(&bundle_dir);
141 report_export(&meta.display_name, &bundle_dir, blobs);
142
143 if make_zip {
144 let stem = artifact_stem(meta, platform_tag(std::env::consts::OS));
145 let zip_path = out_dir.join(format!("{stem}.zip"));
146 zip_tree(&bundle_dir, &slug, &exe_name, &zip_path)?;
147 println!("Wrote {}", zip_path.display());
148 }
149 Ok(())
150}
151
152fn export_macos(
157 meta: &AppMeta,
158 runtime: &Path,
159 out_dir: &Path,
160 data_dir: &Path,
161 make_zip: bool,
162 make_dmg: bool,
163) -> io::Result<()> {
164 let slug = slug(&meta.display_name);
165 let app_dir = out_dir.join(format!("{slug}.app"));
166 reset_dir(&app_dir)?;
167
168 let contents = app_dir.join("Contents");
169 let macos_dir = contents.join("MacOS");
170 let resources = contents.join("Resources");
171 fs::create_dir_all(&macos_dir)?;
172 fs::create_dir_all(&resources)?;
173
174 let exe_dst = macos_dir.join(&slug);
175 fs::copy(runtime, &exe_dst)?;
176 make_executable(&exe_dst)?;
177
178 let blobs = copy_blobs(data_dir, &resources.join("data"))?;
179 precompile_shaders(&resources);
182
183 let icon_file = Some(match &meta.icon {
186 Some(src) => build_icns(src, &resources, &slug)?,
187 None => build_default_icns(&resources, &slug)?,
188 });
189
190 fs::write(
191 contents.join("Info.plist"),
192 info_plist(meta, &slug, icon_file.as_deref()),
193 )?;
194
195 report_export(&meta.display_name, &app_dir, blobs);
196
197 let stem = artifact_stem(meta, platform_tag(std::env::consts::OS));
198 if make_zip {
199 let zip_path = out_dir.join(format!("{stem}.zip"));
200 let exe_rel = format!("Contents/MacOS/{slug}");
201 zip_tree(&app_dir, &format!("{slug}.app"), &exe_rel, &zip_path)?;
202 println!("Wrote {}", zip_path.display());
203 }
204 if make_dmg {
205 let dmg_path = out_dir.join(format!("{stem}.dmg"));
206 build_dmg(&app_dir, &slug, &meta.display_name, &dmg_path)?;
207 println!("Wrote {}", dmg_path.display());
208 }
209 Ok(())
210}
211
212#[cfg(any(backend_dx, backend_vk))]
225fn precompile_shaders(state_dir: &Path) {
226 if cfg!(test) {
232 return;
233 }
234 println!("Compiling built-in shaders...");
235 let report = concinnity_engine::precompile_builtin_shaders(state_dir);
236 println!(
237 " cached {} shader binaries ({} compiled, {} reused)",
238 report.cached(),
239 report.compiled,
240 report.reused
241 );
242 for failure in &report.failed {
243 eprintln!(
244 "warning: shader precompile failed ({failure}); it will compile on \
245 the bundle's first launch"
246 );
247 }
248}
249
250#[cfg(not(any(backend_dx, backend_vk)))]
252fn precompile_shaders(_state_dir: &Path) {}
253
254fn report_export(display_name: &str, bundle: &Path, blobs: usize) {
257 println!(
258 "Exported \"{}\" -> {} ({} blob{})",
259 display_name,
260 bundle.display(),
261 blobs,
262 if blobs == 1 { "" } else { "s" },
263 );
264}
265
266fn reset_dir(dir: &Path) -> io::Result<()> {
268 if dir.exists() {
269 fs::remove_dir_all(dir)?;
270 }
271 fs::create_dir_all(dir)
272}
273
274fn copy_blobs(data_src: &Path, data_dst: &Path) -> io::Result<usize> {
286 let blobs = blobs_in(data_src)?;
287 if blobs.is_empty() {
288 return Err(io::Error::new(
289 io::ErrorKind::NotFound,
290 format!("no compiled blobs in {}", data_src.display()),
291 ));
292 }
293 let _ = fs::remove_file(data_dst);
296 let _ = fs::remove_dir_all(data_dst);
297
298 if let [(_, only)] = blobs.as_slice() {
299 if let Some(parent) = data_dst.parent() {
300 fs::create_dir_all(parent)?;
301 }
302 fs::copy(only, data_dst)?;
303 return Ok(1);
304 }
305
306 fs::create_dir_all(data_dst)?;
307 for (index, path) in &blobs {
308 fs::copy(path, data_dst.join(index.to_string()))?;
309 }
310 Ok(blobs.len())
311}
312
313fn blobs_in(dir: &Path) -> io::Result<Vec<(u32, PathBuf)>> {
315 let mut blobs = Vec::new();
316 for entry in fs::read_dir(dir)? {
317 let entry = entry?;
318 let file_name = entry.file_name();
319 let name = file_name.to_string_lossy();
320 if let Some(index) = blob_index(&name) {
321 blobs.push((index, entry.path()));
322 }
323 }
324 blobs.sort_by_key(|(index, _)| *index);
325 Ok(blobs)
326}
327
328fn blob_index(name: &str) -> Option<u32> {
331 if name.is_empty() || !name.bytes().all(|b| b.is_ascii_digit()) {
332 return None;
333 }
334 name.parse().ok()
335}
336
337fn check_target_platform(platform: Option<&str>) -> io::Result<()> {
342 let Some(requested) = platform else {
343 return Ok(());
344 };
345 let host = std::env::consts::OS;
346 if normalize_platform(requested) == host {
347 return Ok(());
348 }
349 Err(io::Error::new(
350 io::ErrorKind::Unsupported,
351 format!(
352 "cross-platform export is not supported yet: this `cn` targets '{host}'. \
353 Run `cn export` on a {requested} machine to produce a {requested} build."
354 ),
355 ))
356}
357
358fn normalize_platform(p: &str) -> &str {
359 match p.to_lowercase().as_str() {
360 "mac" | "macos" | "osx" | "darwin" => "macos",
361 "win" | "windows" => "windows",
362 "linux" => "linux",
363 _ => "",
365 }
366}
367
368fn runtime_binary_path() -> io::Result<PathBuf> {
370 let cn = std::env::current_exe()?;
371 let dir = cn
372 .parent()
373 .ok_or_else(|| io::Error::other("cannot locate the cn executable's directory"))?;
374 let path = dir.join(exe_file_name("concinnity-run"));
375 if path.exists() {
376 Ok(path)
377 } else {
378 Err(io::Error::new(
379 io::ErrorKind::NotFound,
380 format!(
381 "runtime player not found at {} -- the `concinnity-run` binary must sit \
382 beside the `cn` executable (in a dev checkout, build it with \
383 `cargo build --features player`)",
384 path.display()
385 ),
386 ))
387 }
388}
389
390const RUNTIME_PLATFORM_MARKER: &[u8] = b"cn-runtime-platform:";
393
394fn read_runtime_platform(runtime: &Path) -> io::Result<Option<String>> {
398 let bytes = fs::read(runtime)?;
399 let found = find_platform_stamp(&bytes);
400 if found.is_none() {
401 eprintln!(
402 "warning: no backend stamp found in {}; skipping the runtime/cook \
403 backend check (rebuild `concinnity-run` to enable it)",
404 runtime.display()
405 );
406 }
407 Ok(found)
408}
409
410fn verify_runtime_backend(
418 found: Option<&str>,
419 cooked: concinnity_cook::platform::Platform,
420) -> io::Result<()> {
421 let expected = cooked.key();
422 match found {
423 None => Ok(()),
426 Some(found) if found == expected => Ok(()),
427 Some(found) => Err(io::Error::new(
428 io::ErrorKind::InvalidData,
429 format!(
430 "runtime/cook backend mismatch: this `cn` cooks {} blobs, but the \
431 `concinnity-run` player beside it was built for {}. Rebuild the \
432 runtime for the same backend (`cargo build --features player{}`) \
433 before exporting.",
434 backend_label(expected),
435 backend_label(found),
436 feature_hint(expected),
437 ),
438 )),
439 }
440}
441
442fn find_platform_stamp(bytes: &[u8]) -> Option<String> {
446 let start = find_subslice(bytes, RUNTIME_PLATFORM_MARKER)? + RUNTIME_PLATFORM_MARKER.len();
447 let rest = &bytes[start..];
448 let end = rest
449 .iter()
450 .position(|b| !b.is_ascii_lowercase())
451 .unwrap_or(rest.len());
452 let token = &rest[..end];
453 if token.is_empty() {
454 None
455 } else {
456 std::str::from_utf8(token).ok().map(str::to_string)
457 }
458}
459
460fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
462 if needle.is_empty() || haystack.len() < needle.len() {
463 return None;
464 }
465 haystack.windows(needle.len()).position(|w| w == needle)
466}
467
468fn backend_label(platform_key: &str) -> &str {
470 match platform_key {
471 "metal" => "Metal (metallib)",
472 "hlsl" => "DirectX (DXBC)",
473 "glsl" => "Vulkan (SPIR-V)",
474 other => other,
475 }
476}
477
478fn feature_hint(platform_key: &str) -> &str {
483 match platform_key {
484 "glsl" => ",vulkan",
485 _ => "",
486 }
487}
488
489fn exe_file_name(stem: &str) -> String {
491 if cfg!(windows) {
492 format!("{stem}.exe")
493 } else {
494 stem.to_string()
495 }
496}
497
498#[cfg(unix)]
499fn make_executable(path: &Path) -> io::Result<()> {
500 use std::os::unix::fs::PermissionsExt;
501 let mut perms = fs::metadata(path)?.permissions();
502 perms.set_mode(0o755);
503 fs::set_permissions(path, perms)
504}
505
506#[cfg(not(unix))]
507fn make_executable(_path: &Path) -> io::Result<()> {
508 Ok(())
509}
510
511fn read_app_meta(
514 cli_name: Option<&str>,
515 cli_version: Option<&str>,
516 assets: &[WorldJsonlAsset],
517) -> AppMeta {
518 let display_name = resolve_display_name(cli_name, assets);
519 let identifier =
520 string_arg(assets, "appconfig", "id").unwrap_or_else(|| derive_identifier(&display_name));
521 let version = cli_version
522 .map(str::trim)
523 .filter(|s| !s.is_empty())
524 .map(str::to_string)
525 .or_else(|| string_arg(assets, "appconfig", "version"))
526 .unwrap_or_else(|| "0.1.0".to_string());
527 let icon = string_arg(assets, "appconfig", "icon").map(PathBuf::from);
528 AppMeta {
529 display_name,
530 identifier,
531 version,
532 icon,
533 }
534}
535
536fn resolve_display_name(cli_name: Option<&str>, assets: &[WorldJsonlAsset]) -> String {
539 if let Some(n) = cli_name.map(str::trim).filter(|s| !s.is_empty()) {
540 return n.to_string();
541 }
542 if let Some(n) = string_arg(assets, "appconfig", "name") {
543 return n;
544 }
545 if let Some(n) = string_arg(assets, "mainmenu", "title") {
546 return n;
547 }
548 "Concinnity".to_string()
549}
550
551fn derive_identifier(name: &str) -> String {
555 let mut comp = String::new();
556 let mut pending_dash = false;
557 for c in name.chars() {
558 if c.is_ascii_alphanumeric() {
559 if pending_dash && !comp.is_empty() {
560 comp.push('-');
561 }
562 pending_dash = false;
563 comp.push(c.to_ascii_lowercase());
564 } else {
565 pending_dash = true;
566 }
567 }
568 let comp = comp.trim_matches('-');
569 if comp.is_empty() {
570 "gg.concinnity.app".to_string()
571 } else {
572 format!("gg.concinnity.{comp}")
573 }
574}
575
576fn string_arg(assets: &[WorldJsonlAsset], type_norm: &str, key: &str) -> Option<String> {
579 assets
580 .iter()
581 .find(|a| normalize_type(&a.asset_type) == type_norm)
582 .and_then(|a| a.args.get(key))
583 .and_then(|v| v.as_str())
584 .map(str::trim)
585 .filter(|s| !s.is_empty())
586 .map(str::to_string)
587}
588
589fn normalize_type(t: &str) -> String {
590 t.to_lowercase().replace('_', "")
591}
592
593fn slug(name: &str) -> String {
597 let mut out = String::new();
598 let mut pending_dash = false;
599 for c in name.chars() {
600 if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
601 if pending_dash && !out.is_empty() {
602 out.push('-');
603 }
604 pending_dash = false;
605 out.push(c);
606 } else {
607 pending_dash = true;
608 }
609 }
610 let trimmed = out.trim_matches('-');
611 if trimmed.is_empty() {
612 "app".to_string()
613 } else {
614 trimmed.to_string()
615 }
616}
617
618fn platform_tag(os: &str) -> &str {
622 match os {
623 "macos" => "mac",
624 "windows" => "win",
625 "linux" => "linux",
626 other => other,
627 }
628}
629
630fn artifact_stem(meta: &AppMeta, platform: &str) -> String {
635 format!(
636 "{}-{}-{}",
637 slug(&meta.display_name),
638 slug(&meta.version),
639 platform
640 )
641}
642
643fn info_plist(meta: &AppMeta, exe_name: &str, icon_file: Option<&str>) -> String {
647 let icon_entry = match icon_file {
648 Some(icon) => format!(
649 "\t<key>CFBundleIconFile</key>\n\t<string>{}</string>\n",
650 xml_escape(icon)
651 ),
652 None => String::new(),
653 };
654 format!(
655 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
656 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
657 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
658 <plist version=\"1.0\">\n\
659 <dict>\n\
660 \t<key>CFBundleName</key>\n\t<string>{name}</string>\n\
661 \t<key>CFBundleDisplayName</key>\n\t<string>{name}</string>\n\
662 \t<key>CFBundleExecutable</key>\n\t<string>{exe}</string>\n\
663 \t<key>CFBundleIdentifier</key>\n\t<string>{id}</string>\n\
664 \t<key>CFBundleVersion</key>\n\t<string>{ver}</string>\n\
665 \t<key>CFBundleShortVersionString</key>\n\t<string>{ver}</string>\n\
666 \t<key>CFBundlePackageType</key>\n\t<string>APPL</string>\n\
667 \t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\
668 {icon}\
669 \t<key>LSMinimumSystemVersion</key>\n\t<string>11.0</string>\n\
670 \t<key>NSHighResolutionCapable</key>\n\t<true/>\n\
671 \t<key>NSPrincipalClass</key>\n\t<string>NSApplication</string>\n\
672 </dict>\n\
673 </plist>\n",
674 name = xml_escape(&meta.display_name),
675 exe = xml_escape(exe_name),
676 id = xml_escape(&meta.identifier),
677 ver = xml_escape(&meta.version),
678 icon = icon_entry,
679 )
680}
681
682fn xml_escape(s: &str) -> String {
683 s.replace('&', "&")
684 .replace('<', "<")
685 .replace('>', ">")
686 .replace('"', """)
687 .replace('\'', "'")
688}
689
690fn build_icns(src: &Path, resources: &Path, slug: &str) -> io::Result<String> {
694 if !src.exists() {
695 return Err(io::Error::new(
696 io::ErrorKind::NotFound,
697 format!("AppConfig icon not found: {}", src.display()),
698 ));
699 }
700 let iconset = std::env::temp_dir().join(format!("cn-export-{slug}.iconset"));
701 reset_dir(&iconset)?;
702
703 for size in [16u32, 32, 128, 256, 512] {
705 for scale in [1u32, 2] {
706 let px = size * scale;
707 let suffix = if scale == 2 { "@2x" } else { "" };
708 let dst = iconset.join(format!("icon_{size}x{size}{suffix}.png"));
709 run_tool(
710 "sips",
711 &[
712 "-z",
713 &px.to_string(),
714 &px.to_string(),
715 &src.to_string_lossy(),
716 "--out",
717 &dst.to_string_lossy(),
718 ],
719 )?;
720 }
721 }
722
723 let icns_name = format!("{slug}.icns");
724 let icns_path = resources.join(&icns_name);
725 run_tool(
726 "iconutil",
727 &[
728 "-c",
729 "icns",
730 &iconset.to_string_lossy(),
731 "-o",
732 &icns_path.to_string_lossy(),
733 ],
734 )?;
735 let _ = fs::remove_dir_all(&iconset);
736 Ok(icns_name)
737}
738
739const DEFAULT_ICON_PNG: &[u8] = include_bytes!("../assets/default-icon.png");
743
744fn build_default_icns(resources: &Path, slug: &str) -> io::Result<String> {
748 let tmp = std::env::temp_dir().join(format!("cn-default-icon-{slug}.png"));
749 fs::write(&tmp, DEFAULT_ICON_PNG)?;
750 let result = build_icns(&tmp, resources, slug);
751 let _ = fs::remove_file(&tmp);
752 result
753}
754
755fn build_dmg(app_dir: &Path, slug: &str, volume_name: &str, dmg_path: &Path) -> io::Result<()> {
759 let staging = std::env::temp_dir().join(format!("cn-export-{slug}-dmg"));
760 reset_dir(&staging)?;
761 copy_tree(app_dir, &staging.join(format!("{slug}.app")))?;
762
763 if dmg_path.exists() {
764 fs::remove_file(dmg_path)?;
765 }
766 run_tool(
767 "hdiutil",
768 &[
769 "create",
770 "-volname",
771 volume_name,
772 "-srcfolder",
773 &staging.to_string_lossy(),
774 "-ov",
775 "-format",
776 "UDZO",
777 &dmg_path.to_string_lossy(),
778 ],
779 )?;
780 let _ = fs::remove_dir_all(&staging);
781 Ok(())
782}
783
784fn run_tool(program: &str, args: &[&str]) -> io::Result<()> {
787 let output = std::process::Command::new(program)
788 .args(args)
789 .output()
790 .map_err(|e| io::Error::new(e.kind(), format!("failed to run `{program}`: {e}")))?;
791 if output.status.success() {
792 Ok(())
793 } else {
794 Err(io::Error::other(format!(
795 "`{program}` failed: {}",
796 String::from_utf8_lossy(&output.stderr).trim()
797 )))
798 }
799}
800
801fn copy_runtime_sidecars(
816 runtime: &Path,
817 runtime_platform: Option<&str>,
818 dest_dir: &Path,
819) -> io::Result<()> {
820 let Some(src_dir) = runtime.parent() else {
821 return Ok(());
822 };
823 for entry in fs::read_dir(src_dir)? {
826 let path = entry?.path();
827 let is_dll = path
828 .extension()
829 .and_then(|e| e.to_str())
830 .is_some_and(|e| e.eq_ignore_ascii_case("dll"));
831 if is_dll
832 && path.is_file()
833 && let Some(name) = path.file_name()
834 {
835 fs::copy(&path, dest_dir.join(name))?;
836 }
837 }
838 if runtime_wants_d3d12(runtime_platform) {
841 let d3d12 = src_dir.join("D3D12");
842 if d3d12.is_dir() {
843 copy_tree(&d3d12, &dest_dir.join("D3D12"))?;
844 }
845 }
846 Ok(())
847}
848
849fn runtime_wants_d3d12(runtime_platform: Option<&str>) -> bool {
854 !matches!(runtime_platform, Some("glsl") | Some("metal"))
855}
856
857fn copy_tree(src: &Path, dst: &Path) -> io::Result<()> {
859 fs::create_dir_all(dst)?;
860 for entry in fs::read_dir(src)? {
861 let entry = entry?;
862 let from = entry.path();
863 let to = dst.join(entry.file_name());
864 if from.is_dir() {
865 copy_tree(&from, &to)?;
866 } else {
867 fs::copy(&from, &to)?;
868 }
869 }
870 Ok(())
871}
872
873fn zip_tree(src_dir: &Path, top: &str, exe_rel: &str, zip_path: &Path) -> io::Result<()> {
878 use std::io::Write;
879 use zip::write::SimpleFileOptions;
880
881 let mut files = Vec::new();
882 collect_files(src_dir, &mut files)?;
883 files.sort();
884
885 let file = fs::File::create(zip_path)?;
886 let mut zw = zip::ZipWriter::new(file);
887 for path in files {
888 let rel = path
889 .strip_prefix(src_dir)
890 .map_err(io::Error::other)?
891 .to_string_lossy()
892 .replace('\\', "/");
893 let mode = if rel == exe_rel { 0o755 } else { 0o644 };
894 let options = SimpleFileOptions::default()
895 .compression_method(zip::CompressionMethod::Deflated)
896 .unix_permissions(mode);
897 zw.start_file(format!("{top}/{rel}"), options)
898 .map_err(io::Error::other)?;
899 let bytes = fs::read(&path)?;
900 zw.write_all(&bytes)?;
901 }
902 zw.finish().map_err(io::Error::other)?;
903 Ok(())
904}
905
906fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
908 for entry in fs::read_dir(dir)? {
909 let path = entry?.path();
910 if path.is_dir() {
911 collect_files(&path, out)?;
912 } else {
913 out.push(path);
914 }
915 }
916 Ok(())
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922
923 fn asset(name: &str, ty: &str, args: serde_json::Value) -> WorldJsonlAsset {
924 WorldJsonlAsset {
925 name: name.to_string(),
926 asset_type: ty.to_string(),
927 args,
928 }
929 }
930
931 #[test]
932 fn slug_is_filesystem_safe() {
933 assert_eq!(slug("My Game"), "My-Game");
934 assert_eq!(slug(" Spaced Out "), "Spaced-Out");
935 assert_eq!(slug("weird:/name*?"), "weird-name");
936 assert_eq!(slug("keep_dots.and-dashes"), "keep_dots.and-dashes");
937 assert_eq!(slug("***"), "app");
938 assert_eq!(slug(""), "app");
939 }
940
941 #[test]
942 fn blob_index_matches_only_integer_files() {
943 assert_eq!(blob_index("0"), Some(0));
944 assert_eq!(blob_index("42"), Some(42));
945 assert_eq!(blob_index(""), None);
946 assert_eq!(blob_index("0.metallib"), None);
947 assert_eq!(blob_index("default_vertex_shader.air"), None);
948 assert_eq!(blob_index("settings"), None);
949 }
950
951 #[test]
952 fn name_precedence_is_cli_then_app_config_then_menu_then_default() {
953 let app = asset("app", "AppConfig", serde_json::json!({"name": "App Name"}));
954 let menu = asset("m", "MainMenu", serde_json::json!({"title": "Menu Title"}));
955
956 assert_eq!(
957 resolve_display_name(Some("CLI Name"), &[app.clone(), menu.clone()]),
958 "CLI Name"
959 );
960 assert_eq!(
961 resolve_display_name(None, &[app.clone(), menu.clone()]),
962 "App Name"
963 );
964 assert_eq!(
965 resolve_display_name(None, std::slice::from_ref(&menu)),
966 "Menu Title"
967 );
968 assert_eq!(resolve_display_name(None, &[]), "Concinnity");
969 assert_eq!(resolve_display_name(Some(" "), &[app]), "App Name");
970 }
971
972 #[test]
973 fn normalize_platform_accepts_aliases() {
974 assert_eq!(normalize_platform("macOS"), "macos");
975 assert_eq!(normalize_platform("Darwin"), "macos");
976 assert_eq!(normalize_platform("win"), "windows");
977 assert_eq!(normalize_platform("Linux"), "linux");
978 assert_eq!(normalize_platform("solaris"), "");
979 }
980
981 #[test]
982 fn host_platform_is_accepted_and_others_rejected() {
983 check_target_platform(None).unwrap();
984 check_target_platform(Some(std::env::consts::OS)).unwrap();
985 let foreign = if std::env::consts::OS == "windows" {
986 "linux"
987 } else {
988 "windows"
989 };
990 assert!(check_target_platform(Some(foreign)).is_err());
991 }
992
993 #[test]
994 fn app_meta_derives_id_and_version_defaults() {
995 let meta = read_app_meta(Some("My Cool App"), None, &[]);
997 assert_eq!(meta.display_name, "My Cool App");
998 assert_eq!(meta.identifier, "gg.concinnity.my-cool-app");
999 assert_eq!(meta.version, "0.1.0");
1000 assert!(meta.icon.is_none());
1001
1002 let app = asset(
1004 "app",
1005 "AppConfig",
1006 serde_json::json!({
1007 "name": "Named", "id": "gg.studio.thing", "version": "2.3.4", "icon": "art/i.png"
1008 }),
1009 );
1010 let meta = read_app_meta(None, None, std::slice::from_ref(&app));
1011 assert_eq!(meta.display_name, "Named");
1012 assert_eq!(meta.identifier, "gg.studio.thing");
1013 assert_eq!(meta.version, "2.3.4");
1014 assert_eq!(meta.icon.as_deref(), Some(Path::new("art/i.png")));
1015 }
1016
1017 #[test]
1018 fn version_precedence_is_cli_then_application_then_default() {
1019 let app = asset("app", "AppConfig", serde_json::json!({"version": "2.3.4"}));
1020
1021 assert_eq!(
1023 read_app_meta(None, Some("9.9.9"), std::slice::from_ref(&app)).version,
1024 "9.9.9"
1025 );
1026 assert_eq!(
1028 read_app_meta(None, Some(" "), std::slice::from_ref(&app)).version,
1029 "2.3.4"
1030 );
1031 assert_eq!(read_app_meta(None, Some("3.0"), &[]).version, "3.0");
1033 assert_eq!(read_app_meta(None, None, &[]).version, "0.1.0");
1035 }
1036
1037 #[test]
1038 fn platform_tag_maps_host_os() {
1039 assert_eq!(platform_tag("macos"), "mac");
1040 assert_eq!(platform_tag("windows"), "win");
1041 assert_eq!(platform_tag("linux"), "linux");
1042 assert_eq!(platform_tag("freebsd"), "freebsd");
1044 }
1045
1046 #[test]
1047 fn artifact_stem_is_slug_version_platform() {
1048 let meta = AppMeta {
1049 display_name: "My Game".to_string(),
1050 identifier: "gg.studio.mg".to_string(),
1051 version: "1.0.0".to_string(),
1052 icon: None,
1053 };
1054 assert_eq!(artifact_stem(&meta, "mac"), "My-Game-1.0.0-mac");
1055 let meta = AppMeta {
1057 version: "1.0.0+build 7".to_string(),
1058 ..meta
1059 };
1060 assert_eq!(artifact_stem(&meta, "win"), "My-Game-1.0.0-build-7-win");
1061 }
1062
1063 #[test]
1064 fn info_plist_has_required_keys_and_escapes() {
1065 let meta = AppMeta {
1066 display_name: "Tom & Jerry".to_string(),
1067 identifier: "gg.studio.tj".to_string(),
1068 version: "1.0".to_string(),
1069 icon: None,
1070 };
1071 let plist = info_plist(&meta, "tj", Some("tj.icns"));
1072 assert!(plist.contains("<key>CFBundleExecutable</key>\n\t<string>tj</string>"));
1073 assert!(plist.contains("<key>CFBundleIdentifier</key>\n\t<string>gg.studio.tj</string>"));
1074 assert!(plist.contains("<key>CFBundleShortVersionString</key>\n\t<string>1.0</string>"));
1075 assert!(plist.contains("<key>CFBundleIconFile</key>\n\t<string>tj.icns</string>"));
1076 assert!(plist.contains("Tom & Jerry"));
1078 assert!(!plist.contains("Tom & Jerry"));
1079
1080 let plist = info_plist(&meta, "tj", None);
1082 assert!(!plist.contains("CFBundleIconFile"));
1083 }
1084
1085 #[test]
1086 fn copy_runtime_sidecars_copies_dlls_and_the_d3d12_dir() {
1087 let tmp = tempfile::tempdir().unwrap();
1090 let src = tmp.path().join("src");
1091 fs::create_dir_all(src.join("D3D12")).unwrap();
1092 fs::write(src.join("concinnity-run.exe"), b"exe").unwrap();
1093 fs::write(src.join("amd_fidelityfx_dx12.dll"), b"x").unwrap();
1094 fs::write(src.join("libconcinnity_ffi.dll"), b"x").unwrap();
1095 fs::write(src.join("notes.txt"), b"x").unwrap();
1096 fs::write(src.join("D3D12").join("D3D12Core.dll"), b"x").unwrap();
1097
1098 let dest = tmp.path().join("dest");
1099 fs::create_dir_all(&dest).unwrap();
1100 copy_runtime_sidecars(&src.join("concinnity-run.exe"), Some("hlsl"), &dest).unwrap();
1102
1103 assert!(dest.join("amd_fidelityfx_dx12.dll").exists());
1104 assert!(dest.join("libconcinnity_ffi.dll").exists());
1105 assert!(dest.join("D3D12").join("D3D12Core.dll").exists());
1106 assert!(!dest.join("notes.txt").exists());
1108 assert!(!dest.join("concinnity-run.exe").exists());
1109 }
1110
1111 #[test]
1112 fn copy_runtime_sidecars_skips_d3d12_for_a_non_dx_runtime() {
1113 let tmp = tempfile::tempdir().unwrap();
1117 let src = tmp.path().join("src");
1118 fs::create_dir_all(src.join("D3D12")).unwrap();
1119 fs::write(src.join("concinnity-run.exe"), b"exe").unwrap();
1120 fs::write(src.join("amd_fidelityfx_vk.dll"), b"x").unwrap();
1121 fs::write(src.join("D3D12").join("D3D12Core.dll"), b"x").unwrap();
1122
1123 let dest = tmp.path().join("dest");
1124 fs::create_dir_all(&dest).unwrap();
1125 copy_runtime_sidecars(&src.join("concinnity-run.exe"), Some("glsl"), &dest).unwrap();
1126
1127 assert!(dest.join("amd_fidelityfx_vk.dll").exists());
1128 assert!(!dest.join("D3D12").exists());
1129 }
1130
1131 #[test]
1132 fn runtime_wants_d3d12_only_for_dx_or_unknown() {
1133 assert!(runtime_wants_d3d12(Some("hlsl")));
1134 assert!(runtime_wants_d3d12(None));
1136 assert!(!runtime_wants_d3d12(Some("glsl")));
1137 assert!(!runtime_wants_d3d12(Some("metal")));
1138 }
1139
1140 #[test]
1141 fn find_platform_stamp_reads_the_token_after_the_marker() {
1142 let mut buf = vec![0xAAu8, 0x00, 0xFF, b'x'];
1145 buf.extend_from_slice(b"cn-runtime-platform:hlsl\0");
1146 buf.extend_from_slice(&[0x01, 0x02, 0x03]);
1147 assert_eq!(find_platform_stamp(&buf).as_deref(), Some("hlsl"));
1148
1149 for token in ["metal", "hlsl", "glsl"] {
1151 let stamp = format!("cn-runtime-platform:{token}\0");
1152 assert_eq!(
1153 find_platform_stamp(stamp.as_bytes()).as_deref(),
1154 Some(token)
1155 );
1156 }
1157 }
1158
1159 #[test]
1160 fn find_platform_stamp_is_none_without_the_marker() {
1161 assert_eq!(find_platform_stamp(b"no stamp here"), None);
1162 assert_eq!(find_platform_stamp(b""), None);
1163 assert_eq!(find_platform_stamp(b"cn-runtime-platform:\0"), None);
1165 }
1166
1167 #[test]
1168 fn find_subslice_locates_and_reports_absence() {
1169 assert_eq!(find_subslice(b"abcdef", b"cd"), Some(2));
1170 assert_eq!(find_subslice(b"abcdef", b"abc"), Some(0));
1171 assert_eq!(find_subslice(b"abcdef", b"xy"), None);
1172 assert_eq!(find_subslice(b"ab", b"abc"), None);
1173 assert_eq!(find_subslice(b"abc", b""), None);
1174 }
1175
1176 #[test]
1177 fn backend_label_and_feature_hint_cover_each_platform() {
1178 assert_eq!(backend_label("metal"), "Metal (metallib)");
1179 assert_eq!(backend_label("hlsl"), "DirectX (DXBC)");
1180 assert_eq!(backend_label("glsl"), "Vulkan (SPIR-V)");
1181 assert_eq!(feature_hint("glsl"), ",vulkan");
1182 assert_eq!(feature_hint("hlsl"), "");
1183 assert_eq!(feature_hint("metal"), "");
1184 }
1185
1186 #[test]
1187 fn default_icon_is_a_nonempty_png() {
1188 assert!(DEFAULT_ICON_PNG.len() > 1024);
1192 assert_eq!(&DEFAULT_ICON_PNG[..8], b"\x89PNG\r\n\x1a\n");
1193 }
1194
1195 #[test]
1196 fn copy_runtime_sidecars_is_a_noop_without_dlls() {
1197 let tmp = tempfile::tempdir().unwrap();
1199 let src = tmp.path().join("src");
1200 fs::create_dir_all(&src).unwrap();
1201 fs::write(src.join("concinnity-run"), b"exe").unwrap();
1202 fs::write(src.join("libconcinnity_ffi.dylib"), b"x").unwrap();
1203
1204 let dest = tmp.path().join("dest");
1205 fs::create_dir_all(&dest).unwrap();
1206 copy_runtime_sidecars(&src.join("concinnity-run"), Some("metal"), &dest).unwrap();
1207 assert_eq!(fs::read_dir(&dest).unwrap().count(), 0);
1208 }
1209
1210 #[test]
1211 fn export_rejects_an_unknown_format_up_front() {
1212 let err = export(None, None, None, None, "out", "tarball", false).unwrap_err();
1215 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1216 assert!(err.to_string().contains("tarball"), "got: {err}");
1217 }
1218
1219 #[cfg(not(target_os = "macos"))]
1220 #[test]
1221 fn export_rejects_dmg_off_macos() {
1222 let err = export(None, None, None, None, "out", "zip", true).unwrap_err();
1223 assert_eq!(err.kind(), io::ErrorKind::Unsupported);
1224 }
1225
1226 #[test]
1227 fn derive_identifier_reduces_names_to_bundle_id_form() {
1228 assert_eq!(derive_identifier("My Game"), "gg.concinnity.my-game");
1229 assert_eq!(
1230 derive_identifier("Space: Above!"),
1231 "gg.concinnity.space-above"
1232 );
1233 assert_eq!(derive_identifier("***"), "gg.concinnity.app");
1234 assert_eq!(derive_identifier(""), "gg.concinnity.app");
1235 }
1236
1237 #[test]
1238 fn xml_escape_escapes_every_entity() {
1239 assert_eq!(
1240 xml_escape(r#"<a href="x">Tom & Jerry's</a>"#),
1241 "<a href="x">Tom & Jerry's</a>"
1242 );
1243 assert_eq!(xml_escape("plain"), "plain");
1244 }
1245
1246 #[test]
1247 fn verify_runtime_backend_accepts_matching_or_unstamped() {
1248 let cooked = concinnity_cook::platform::Platform::Metal;
1249 verify_runtime_backend(None, cooked).unwrap();
1250 verify_runtime_backend(Some(cooked.key()), cooked).unwrap();
1251
1252 let err = verify_runtime_backend(Some("hlsl"), cooked).unwrap_err();
1253 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1254 assert!(err.to_string().contains("mismatch"), "got: {err}");
1255 }
1256
1257 #[test]
1258 fn read_runtime_platform_reads_a_stamp_or_warns_none() {
1259 let tmp = tempfile::tempdir().unwrap();
1260 let stamped = tmp.path().join("stamped");
1261 fs::write(&stamped, b"junk cn-runtime-platform:metal\0 junk").unwrap();
1262 assert_eq!(
1263 read_runtime_platform(&stamped).unwrap().as_deref(),
1264 Some("metal")
1265 );
1266
1267 let unstamped = tmp.path().join("unstamped");
1268 fs::write(&unstamped, b"no marker in here").unwrap();
1269 assert_eq!(read_runtime_platform(&unstamped).unwrap(), None);
1270 }
1271
1272 #[test]
1273 fn copy_blobs_takes_only_integer_named_files() {
1274 let tmp = tempfile::tempdir().unwrap();
1275 let src = tmp.path().join("data");
1276 fs::create_dir_all(&src).unwrap();
1277 fs::write(src.join("0"), b"blob0").unwrap();
1278 fs::write(src.join("12"), b"blob12").unwrap();
1279 fs::write(src.join("default_vert.air"), b"scratch").unwrap();
1280 fs::write(src.join("settings"), b"state").unwrap();
1281
1282 let dst = tmp.path().join("out");
1283 let count = copy_blobs(&src, &dst).unwrap();
1284 assert_eq!(count, 2);
1285 assert!(dst.is_dir(), "an overflowing world ships a directory");
1286 assert_eq!(fs::read(dst.join("0")).unwrap(), b"blob0");
1287 assert_eq!(fs::read(dst.join("12")).unwrap(), b"blob12");
1288 assert!(!dst.join("default_vert.air").exists());
1289 assert!(!dst.join("settings").exists());
1290 }
1291
1292 #[test]
1295 fn a_single_blob_world_ships_as_one_file() {
1296 let tmp = tempfile::tempdir().unwrap();
1297 let src = tmp.path().join("data");
1298 fs::create_dir_all(&src).unwrap();
1299 fs::write(src.join("0"), b"blob0").unwrap();
1300 fs::write(src.join("default_vert.air"), b"scratch").unwrap();
1301
1302 let dst = tmp.path().join("bundle").join("data");
1303 assert_eq!(copy_blobs(&src, &dst).unwrap(), 1);
1304 assert!(dst.is_file(), "one blob ships as the `data` file itself");
1305 assert_eq!(fs::read(&dst).unwrap(), b"blob0");
1306 }
1307
1308 #[test]
1312 fn re_exporting_across_the_form_boundary_replaces_the_previous_shape() {
1313 let tmp = tempfile::tempdir().unwrap();
1314 let src = tmp.path().join("data");
1315 fs::create_dir_all(&src).unwrap();
1316 fs::write(src.join("0"), b"blob0").unwrap();
1317 fs::write(src.join("1"), b"blob1").unwrap();
1318 let dst = tmp.path().join("bundle").join("data");
1319
1320 assert_eq!(copy_blobs(&src, &dst).unwrap(), 2);
1321 assert!(dst.is_dir());
1322
1323 fs::remove_file(src.join("1")).unwrap();
1325 assert_eq!(copy_blobs(&src, &dst).unwrap(), 1);
1326 assert!(dst.is_file());
1327 assert_eq!(fs::read(&dst).unwrap(), b"blob0");
1328
1329 fs::write(src.join("1"), b"blob1").unwrap();
1331 assert_eq!(copy_blobs(&src, &dst).unwrap(), 2);
1332 assert!(dst.is_dir());
1333 assert_eq!(fs::read(dst.join("1")).unwrap(), b"blob1");
1334 }
1335
1336 #[test]
1339 fn copy_blobs_refuses_a_data_dir_with_no_blobs() {
1340 let tmp = tempfile::tempdir().unwrap();
1341 let src = tmp.path().join("data");
1342 fs::create_dir_all(&src).unwrap();
1343 fs::write(src.join("default_vert.air"), b"scratch").unwrap();
1344
1345 let err = copy_blobs(&src, &tmp.path().join("out")).unwrap_err();
1346 assert_eq!(err.kind(), io::ErrorKind::NotFound);
1347 }
1348
1349 #[test]
1350 fn reset_dir_clears_previous_content() {
1351 let tmp = tempfile::tempdir().unwrap();
1352 let dir = tmp.path().join("bundle");
1353 fs::create_dir_all(dir.join("nested")).unwrap();
1354 fs::write(dir.join("nested").join("stale"), b"old").unwrap();
1355
1356 reset_dir(&dir).unwrap();
1357 assert!(dir.exists());
1358 assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
1359 }
1360
1361 #[test]
1362 fn copy_tree_copies_nested_directories() {
1363 let tmp = tempfile::tempdir().unwrap();
1364 let src = tmp.path().join("src");
1365 fs::create_dir_all(src.join("a").join("b")).unwrap();
1366 fs::write(src.join("top.txt"), b"1").unwrap();
1367 fs::write(src.join("a").join("b").join("deep.txt"), b"2").unwrap();
1368
1369 let dst = tmp.path().join("dst");
1370 copy_tree(&src, &dst).unwrap();
1371 assert_eq!(fs::read(dst.join("top.txt")).unwrap(), b"1");
1372 assert_eq!(
1373 fs::read(dst.join("a").join("b").join("deep.txt")).unwrap(),
1374 b"2"
1375 );
1376 }
1377
1378 #[test]
1379 fn collect_files_walks_the_whole_tree() {
1380 let tmp = tempfile::tempdir().unwrap();
1381 let dir = tmp.path().join("tree");
1382 fs::create_dir_all(dir.join("sub")).unwrap();
1383 fs::write(dir.join("a"), b"1").unwrap();
1384 fs::write(dir.join("sub").join("b"), b"2").unwrap();
1385
1386 let mut files = Vec::new();
1387 collect_files(&dir, &mut files).unwrap();
1388 files.sort();
1389 assert_eq!(files, vec![dir.join("a"), dir.join("sub").join("b")]);
1390 }
1391
1392 #[test]
1393 fn zip_tree_archives_under_a_top_folder() {
1394 let tmp = tempfile::tempdir().unwrap();
1395 let bundle = tmp.path().join("My-Game");
1396 fs::create_dir_all(bundle.join("data")).unwrap();
1397 fs::write(bundle.join("My-Game"), b"player").unwrap();
1398 fs::write(bundle.join("data").join("0"), b"blob").unwrap();
1399
1400 let zip_path = tmp.path().join("My-Game-1.0.0-mac.zip");
1401 zip_tree(&bundle, "My-Game", "My-Game", &zip_path).unwrap();
1402
1403 let file = fs::File::open(&zip_path).unwrap();
1404 let mut archive = zip::ZipArchive::new(file).unwrap();
1405 let names: Vec<String> = (0..archive.len())
1406 .map(|i| archive.by_index(i).unwrap().name().to_string())
1407 .collect();
1408 assert_eq!(archive.len(), 2);
1409 assert!(
1410 names.contains(&"My-Game/My-Game".to_string()),
1411 "got: {names:?}"
1412 );
1413 assert!(
1414 names.contains(&"My-Game/data/0".to_string()),
1415 "got: {names:?}"
1416 );
1417 let exe = archive.by_name("My-Game/My-Game").unwrap();
1419 assert_eq!(exe.unix_mode().map(|m| m & 0o777), Some(0o755));
1420 }
1421
1422 #[test]
1423 fn build_icns_rejects_a_missing_source_before_running_tools() {
1424 let tmp = tempfile::tempdir().unwrap();
1425 let err = build_icns(&tmp.path().join("missing.png"), tmp.path(), "slug").unwrap_err();
1426 assert_eq!(err.kind(), io::ErrorKind::NotFound);
1427 }
1428
1429 #[test]
1430 fn run_tool_reports_success_failure_and_missing() {
1431 #[cfg(windows)]
1436 {
1437 run_tool("cmd", &["/C", "exit 0"]).unwrap();
1438 let err = run_tool("cmd", &["/C", "exit 1"]).unwrap_err();
1439 assert!(err.to_string().contains("cmd"), "got: {err}");
1440 }
1441 #[cfg(not(windows))]
1442 {
1443 run_tool("true", &[]).unwrap();
1444 let err = run_tool("false", &[]).unwrap_err();
1445 assert!(err.to_string().contains("false"), "got: {err}");
1446 }
1447 let err = run_tool("cn-nonexistent-tool-xyz", &[]).unwrap_err();
1452 assert!(
1453 err.to_string().contains("cn-nonexistent-tool-xyz"),
1454 "got: {err}"
1455 );
1456 }
1457
1458 #[cfg(unix)]
1459 #[test]
1460 fn make_executable_sets_the_exec_bit() {
1461 use std::os::unix::fs::PermissionsExt;
1462 let tmp = tempfile::tempdir().unwrap();
1463 let f = tmp.path().join("player");
1464 fs::write(&f, b"bin").unwrap();
1465 assert_eq!(fs::metadata(&f).unwrap().permissions().mode() & 0o111, 0);
1467 make_executable(&f).unwrap();
1468 assert_ne!(fs::metadata(&f).unwrap().permissions().mode() & 0o111, 0);
1469 }
1470
1471 #[test]
1475 fn export_portable_assembles_folder_and_zip() {
1476 let tmp = tempfile::tempdir().unwrap();
1477 let data = tmp.path().join("data");
1478 fs::create_dir_all(&data).unwrap();
1479 fs::write(data.join("0"), b"blob0").unwrap();
1480 fs::write(data.join("1"), b"blob1").unwrap();
1481 fs::write(data.join("scratch.air"), b"ignored").unwrap();
1482
1483 let runtime = tmp.path().join(exe_file_name("concinnity-run"));
1484 fs::write(&runtime, b"runtime-bin").unwrap();
1485
1486 let out = tmp.path().join("out");
1487 fs::create_dir_all(&out).unwrap();
1488
1489 let meta = AppMeta {
1490 display_name: "My Game".to_string(),
1491 identifier: "gg.studio.mg".to_string(),
1492 version: "1.0.0".to_string(),
1493 icon: None,
1494 };
1495 export_portable(&meta, &runtime, Some("metal"), &out, &data, true).unwrap();
1496
1497 let bundle = out.join("My-Game");
1498 let exe = bundle.join(exe_file_name("My-Game"));
1499 assert!(exe.exists(), "renamed player missing");
1500 assert_eq!(fs::read(&exe).unwrap(), b"runtime-bin");
1501 assert!(bundle.join("data").join("0").exists());
1502 assert!(bundle.join("data").join("1").exists());
1503 assert!(!bundle.join("data").join("scratch.air").exists());
1505
1506 let stem = artifact_stem(&meta, platform_tag(std::env::consts::OS));
1508 assert!(out.join(format!("{stem}.zip")).exists(), "zip missing");
1509 }
1510
1511 #[cfg(target_os = "macos")]
1515 #[test]
1516 fn export_macos_builds_app_bundle_with_default_icon() {
1517 let tmp = tempfile::tempdir().unwrap();
1518 let data = tmp.path().join("data");
1519 fs::create_dir_all(&data).unwrap();
1520 fs::write(data.join("0"), b"blob0").unwrap();
1521
1522 let runtime = tmp.path().join("concinnity-run");
1523 fs::write(&runtime, b"runtime-bin").unwrap();
1524
1525 let out = tmp.path().join("out");
1526 fs::create_dir_all(&out).unwrap();
1527
1528 let meta = AppMeta {
1529 display_name: "My Game".to_string(),
1530 identifier: "gg.studio.mg".to_string(),
1531 version: "1.0.0".to_string(),
1532 icon: None,
1533 };
1534 export_macos(&meta, &runtime, &out, &data, true, false).unwrap();
1537
1538 let app = out.join("My-Game.app");
1539 assert!(app.join("Contents/MacOS/My-Game").exists(), "exe missing");
1540 let data_entry = app.join("Contents/Resources/data");
1543 assert!(data_entry.is_file(), "blob missing");
1544 assert_eq!(fs::read(&data_entry).unwrap(), b"blob0");
1545 assert!(
1546 app.join("Contents/Resources/My-Game.icns").exists(),
1547 "icns missing"
1548 );
1549
1550 let plist = fs::read_to_string(app.join("Contents/Info.plist")).unwrap();
1551 assert!(plist.contains("<string>My-Game.icns</string>"));
1552 assert!(plist.contains("gg.studio.mg"));
1553
1554 let stem = artifact_stem(&meta, platform_tag(std::env::consts::OS));
1555 assert!(out.join(format!("{stem}.zip")).exists(), "zip missing");
1556 }
1557
1558 #[cfg(target_os = "macos")]
1561 #[test]
1562 fn build_icns_produces_an_icns_from_a_valid_png() {
1563 let tmp = tempfile::tempdir().unwrap();
1564 let src = tmp.path().join("icon.png");
1565 fs::write(&src, DEFAULT_ICON_PNG).unwrap();
1566 let resources = tmp.path().join("Resources");
1567 fs::create_dir_all(&resources).unwrap();
1568
1569 let name = build_icns(&src, &resources, "app").unwrap();
1570 assert_eq!(name, "app.icns");
1571 assert!(resources.join("app.icns").exists());
1572 }
1573
1574 #[test]
1575 fn runtime_binary_path_errors_when_the_player_is_absent() {
1576 let err = runtime_binary_path().unwrap_err();
1581 assert_eq!(err.kind(), io::ErrorKind::NotFound);
1582 assert!(
1583 err.to_string().contains("runtime player not found"),
1584 "got: {err}"
1585 );
1586 }
1587
1588 #[test]
1589 fn backend_label_passes_through_an_unknown_key() {
1590 assert_eq!(backend_label("wgpu"), "wgpu");
1593 }
1594}