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