Skip to main content

concinnity_dev/
export.rs

1// src/cli/export.rs
2//
3// `cn export`: package a built world into a distributable app. Builds the world
4// (reusing the normal build cache), then assembles a self-contained bundle: the
5// runtime player executable beside the world's compiled `data/` blobs, in a
6// flat layout the shipped runtime resolves relative to its own executable. On
7// macOS the bundle is a proper `.app` (optionally wrapped in a `.dmg`);
8// elsewhere it is a folder. The result is archived to a `.zip` by default.
9//
10// The player is the prebuilt `concinnity-run` binary that ships beside the
11// `cn`/`concinnity` executable; export copies it rather than compiling, so a
12// user needs no build toolchain and no engine source. Because the runtime is a
13// single compiled binary, a bundle targets exactly the platform this `cn` was
14// built for (host-only for now; see the --platform check).
15
16use 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;
23use concinnity_cook::paths::StateTree;
24use concinnity_host::scratch;
25
26use crate::command::resolve_world_path;
27
28// Resolved naming/metadata for the exported app.
29struct AppMeta {
30    // Display name (window title, macOS bundle display name); may contain spaces.
31    display_name: String,
32    // Reverse-DNS bundle identifier (macOS CFBundleIdentifier).
33    identifier: String,
34    // Version string (macOS CFBundle[Short]Version).
35    version: String,
36    // Source icon path (relative to the world), if the AppConfig asset set one.
37    icon: Option<PathBuf>,
38}
39
40/// Package a built world into a distributable bundle: the player binary, the
41/// blob, and a warmed runtime cache segment, written to `out` as a zip or a
42/// directory.
43pub fn export(
44    json_path: Option<&str>,
45    name: Option<&str>,
46    version: Option<&str>,
47    platform: Option<&str>,
48    out: &str,
49    format: &str,
50    dmg: bool,
51) -> io::Result<()> {
52    let make_zip = match format {
53        "zip" => true,
54        "dir" => false,
55        other => {
56            return Err(io::Error::new(
57                io::ErrorKind::InvalidInput,
58                format!("unknown --format '{other}' (expected 'zip' or 'dir')"),
59            ));
60        }
61    };
62    if dmg && !cfg!(target_os = "macos") {
63        return Err(io::Error::new(
64            io::ErrorKind::Unsupported,
65            "--dmg is only available when exporting on macOS",
66        ));
67    }
68
69    // Fail fast, before building, on the things export cannot recover from: a
70    // target that is not this platform, a missing runtime player, and a runtime
71    // built for a different backend than the blobs this `cn` cooks.
72    check_target_platform(platform)?;
73    let runtime = runtime_binary_path()?;
74    let runtime_platform = read_runtime_platform(&runtime)?;
75    verify_runtime_backend(runtime_platform.as_deref(), crate::cook_platform())?;
76
77    // Build the world exactly like `cn build` (validates, compiles, writes the
78    // blobs + world-lock.json, reuses the build cache).
79    let world_path = resolve_world_path(json_path)?;
80    let tree = crate::project::require()?;
81    build_from_path(&tree, &world_path, crate::cook_platform())?;
82
83    // Read the app metadata from the expanded world. The build above already
84    // validated it, so this cannot fail on validation; map any error plainly.
85    let content = fs::read_to_string(&world_path)?;
86    let loaded = prepare_world(&content, crate::project::assets_dir().as_deref())
87        .map_err(|errs| io::Error::new(io::ErrorKind::InvalidData, errs.join("\n")))?;
88    let meta = read_app_meta(name, version, &loaded.assets);
89
90    let out_dir = Path::new(out);
91    fs::create_dir_all(out_dir)?;
92    let data_dir = crate::project::data_dir().ok_or_else(|| {
93        io::Error::new(
94            io::ErrorKind::NotFound,
95            "no project state directory to read the built blobs from",
96        )
97    })?;
98
99    if cfg!(target_os = "macos") {
100        export_macos(&meta, &runtime, out_dir, &data_dir, make_zip, dmg)
101    } else {
102        export_portable(
103            &meta,
104            &runtime,
105            runtime_platform.as_deref(),
106            out_dir,
107            &data_dir,
108            make_zip,
109        )
110    }
111}
112
113// A folder bundle (Windows / Linux): the renamed player beside `data/`, then a
114// zip of that folder. `runtime_platform` is the player's stamped shader
115// platform (`hlsl`/`glsl`/`metal`, or None when unstamped), used to decide
116// which native sidecars belong beside it.
117fn export_portable(
118    meta: &AppMeta,
119    runtime: &Path,
120    runtime_platform: Option<&str>,
121    out_dir: &Path,
122    data_dir: &Path,
123    make_zip: bool,
124) -> io::Result<()> {
125    let slug = slug(&meta.display_name);
126    let bundle_dir = out_dir.join(&slug);
127    reset_dir(&bundle_dir)?;
128
129    let exe_name = exe_file_name(&slug);
130    let exe_dst = bundle_dir.join(&exe_name);
131    fs::copy(runtime, &exe_dst)?;
132    make_executable(&exe_dst)?;
133    copy_runtime_sidecars(runtime, runtime_platform, &bundle_dir)?;
134
135    let blobs = copy_blobs(data_dir, &StateTree::at(&bundle_dir).data_dir())?;
136    // Before archiving, so the warmed cache segment is inside the zip. The
137    // player resolves its state root to the bundle folder, so `cache/0` sits
138    // beside the exe.
139    precompile_shaders(&bundle_dir);
140    report_export(&meta.display_name, &bundle_dir, blobs);
141
142    if make_zip {
143        let stem = artifact_stem(meta, platform_tag(std::env::consts::OS));
144        let zip_path = out_dir.join(format!("{stem}.zip"));
145        zip_tree(&bundle_dir, &slug, &exe_name, &zip_path)?;
146        println!("Wrote {}", zip_path.display());
147    }
148    Ok(())
149}
150
151// A macOS `.app` bundle: Contents/MacOS/<exe>, Contents/Info.plist, and
152// Contents/Resources/{<icon>.icns, data/}. The runtime resolves its state root
153// to Contents/Resources (see concinnity-run's state_dir_for_exe). Optionally
154// zipped and/or wrapped in a `.dmg`.
155fn export_macos(
156    meta: &AppMeta,
157    runtime: &Path,
158    out_dir: &Path,
159    data_dir: &Path,
160    make_zip: bool,
161    make_dmg: bool,
162) -> io::Result<()> {
163    let slug = slug(&meta.display_name);
164    let app_dir = out_dir.join(format!("{slug}.app"));
165    reset_dir(&app_dir)?;
166
167    let contents = app_dir.join("Contents");
168    let macos_dir = contents.join("MacOS");
169    let resources = contents.join("Resources");
170    fs::create_dir_all(&macos_dir)?;
171    fs::create_dir_all(&resources)?;
172
173    let exe_dst = macos_dir.join(&slug);
174    fs::copy(runtime, &exe_dst)?;
175    make_executable(&exe_dst)?;
176
177    let blobs = copy_blobs(data_dir, &StateTree::at(&resources).data_dir())?;
178    // The player resolves its state root to Contents/Resources; a no-op on
179    // Metal, whose shaders precompile at build time.
180    precompile_shaders(&resources);
181
182    // Build the .icns from the AppConfig's icon, falling back to the bundled
183    // engine default so every macOS bundle carries an icon.
184    let icon_file = Some(match &meta.icon {
185        Some(src) => build_icns(src, &resources, &slug)?,
186        None => build_default_icns(&resources, &slug)?,
187    });
188
189    fs::write(
190        contents.join("Info.plist"),
191        info_plist(meta, &slug, icon_file.as_deref()),
192    )?;
193
194    report_export(&meta.display_name, &app_dir, blobs);
195
196    let stem = artifact_stem(meta, platform_tag(std::env::consts::OS));
197    if make_zip {
198        let zip_path = out_dir.join(format!("{stem}.zip"));
199        let exe_rel = format!("Contents/MacOS/{slug}");
200        zip_tree(&app_dir, &format!("{slug}.app"), &exe_rel, &zip_path)?;
201        println!("Wrote {}", zip_path.display());
202    }
203    if make_dmg {
204        let dmg_path = out_dir.join(format!("{stem}.dmg"));
205        build_dmg(&app_dir, &slug, &meta.display_name, &dmg_path)?;
206        println!("Wrote {}", dmg_path.display());
207    }
208    Ok(())
209}
210
211// Compile the engine's built-in shaders into the bundle's `cache/0`, so a
212// player's first launch reuses every artifact instead of compiling (measured
213// ~1 s on a DirectX release build). The player reads that segment like any
214// other cache, so deleting it costs one slow launch and nothing more.
215// Compilation is pure CPU, so this runs in-process with no GPU device or
216// window: the compile set is enumerated from the same declarations renderer
217// init compiles through, and the pool-sized Vulkan shaders are compiled for the
218// world just built (its texture count). The artifacts are backend IR, not
219// machine code, so ones compiled here are valid on any machine.
220//
221// Best-effort by design: a failed program is reported and simply compiles at
222// the bundle's first launch, so failures warn rather than failing the export.
223#[cfg(any(backend_dx, backend_vk))]
224fn precompile_shaders(state_dir: &Path) {
225    // The suite asserts bundle layout, not shader output: skip so no test runs
226    // the shader compiler, reads the world data anchor, or writes into the
227    // developer's cache segment. Mirrors `shader_cache`'s own test opt-out,
228    // which does not apply here because concinnity-device is a dependency of
229    // this test binary rather than the crate under test.
230    if cfg!(test) {
231        return;
232    }
233    println!("Compiling built-in shaders...");
234    let report = concinnity_engine::precompile_builtin_shaders(state_dir);
235    println!(
236        "  cached {} shader binaries ({} compiled, {} reused)",
237        report.cached(),
238        report.compiled,
239        report.reused
240    );
241    for failure in &report.failed {
242        eprintln!(
243            "warning: shader precompile failed ({failure}); it will compile on \
244             the bundle's first launch"
245        );
246    }
247}
248
249// Metal precompiles its shaders at build time; the bundle needs no cache.
250#[cfg(not(any(backend_dx, backend_vk)))]
251fn precompile_shaders(_state_dir: &Path) {}
252
253// Announce the finished bundle. `copy_blobs` has already refused a build that
254// produced none, so the count here is always at least one.
255fn report_export(display_name: &str, bundle: &Path, blobs: usize) {
256    println!(
257        "Exported \"{}\" -> {} ({} blob{})",
258        display_name,
259        bundle.display(),
260        blobs,
261        if blobs == 1 { "" } else { "s" },
262    );
263}
264
265// Remove `dir` if it exists, then create it fresh, so re-exports are clean.
266fn reset_dir(dir: &Path) -> io::Result<()> {
267    if dir.exists() {
268        fs::remove_dir_all(dir)?;
269    }
270    fs::create_dir_all(dir)
271}
272
273// Copy every compiled blob (the integer-named files) from the build's data
274// directory into `data_dst`, skipping the shader-compile intermediates the
275// build leaves there (named after their asset). Returns the number copied.
276// Copy the built blobs into the bundle as the `data` entry the player looks
277// for, and report how many were copied.
278//
279// A world that fits in one blob ships as a single file named `data`; one that
280// overflows ships as a `data/` directory holding `0`, `1`, ... Overflow blobs
281// are always siblings of blob 0 named by index, so the single-file form can
282// only carry a world that has none -- the player refuses the mismatch rather
283// than reading `1` and `2` out of the folder it was launched from.
284fn copy_blobs(data_src: &Path, data_dst: &Path) -> io::Result<usize> {
285    let blobs = blobs_in(data_src)?;
286    if blobs.is_empty() {
287        return Err(io::Error::new(
288            io::ErrorKind::NotFound,
289            format!("no compiled blobs in {}", data_src.display()),
290        ));
291    }
292    // Whatever the previous export left here: the two forms occupy the same
293    // name, so a shrinking world would otherwise write a file over a directory.
294    let _ = fs::remove_file(data_dst);
295    let _ = fs::remove_dir_all(data_dst);
296
297    if let [(_, only)] = blobs.as_slice() {
298        if let Some(parent) = data_dst.parent() {
299            fs::create_dir_all(parent)?;
300        }
301        fs::copy(only, data_dst)?;
302        return Ok(1);
303    }
304
305    fs::create_dir_all(data_dst)?;
306    for (index, path) in &blobs {
307        fs::copy(path, data_dst.join(index.to_string()))?;
308    }
309    Ok(blobs.len())
310}
311
312// Every blob file in `dir`, paired with its index and ordered by it.
313fn blobs_in(dir: &Path) -> io::Result<Vec<(u32, PathBuf)>> {
314    let mut blobs = Vec::new();
315    for entry in fs::read_dir(dir)? {
316        let entry = entry?;
317        let file_name = entry.file_name();
318        let name = file_name.to_string_lossy();
319        if let Some(index) = blob_index(&name) {
320            blobs.push((index, entry.path()));
321        }
322    }
323    blobs.sort_by_key(|(index, _)| *index);
324    Ok(blobs)
325}
326
327// A blob file is named by its integer index with no extension (blob_path uses
328// `index.to_string()`); everything else in data/ is build scratch.
329fn blob_index(name: &str) -> Option<u32> {
330    if name.is_empty() || !name.bytes().all(|b| b.is_ascii_digit()) {
331        return None;
332    }
333    name.parse().ok()
334}
335
336// Reject a target that is not the platform this `cn` was built for. Cross-
337// platform export is not supported yet: the runtime player is a single compiled
338// binary and cooked blobs embed platform-native shaders, so a bundle can only
339// target the host.
340fn check_target_platform(platform: Option<&str>) -> io::Result<()> {
341    let Some(requested) = platform else {
342        return Ok(());
343    };
344    let host = std::env::consts::OS;
345    if normalize_platform(requested) == host {
346        return Ok(());
347    }
348    Err(io::Error::new(
349        io::ErrorKind::Unsupported,
350        format!(
351            "cross-platform export is not supported yet: this `cn` targets '{host}'. \
352             Run `cn export` on a {requested} machine to produce a {requested} build."
353        ),
354    ))
355}
356
357fn normalize_platform(p: &str) -> &str {
358    match p.to_lowercase().as_str() {
359        "mac" | "macos" | "osx" | "darwin" => "macos",
360        "win" | "windows" => "windows",
361        "linux" => "linux",
362        // Unknown values fall through and simply won't match the host.
363        _ => "",
364    }
365}
366
367// Locate the runtime player that ships beside this executable.
368fn runtime_binary_path() -> io::Result<PathBuf> {
369    let cn = std::env::current_exe()?;
370    let dir = cn
371        .parent()
372        .ok_or_else(|| io::Error::other("cannot locate the cn executable's directory"))?;
373    let path = dir.join(exe_file_name("concinnity-run"));
374    if path.exists() {
375        Ok(path)
376    } else {
377        Err(io::Error::new(
378            io::ErrorKind::NotFound,
379            format!(
380                "runtime player not found at {} -- the `concinnity-run` binary must sit \
381                 beside the `cn` executable (in a dev checkout, build it with \
382                 `cargo build --features player`)",
383                path.display()
384            ),
385        ))
386    }
387}
388
389// The fixed prefix of the backend stamp baked into `concinnity-run` (see its
390// definition in that binary's main.rs); the shader-platform key follows it.
391const RUNTIME_PLATFORM_MARKER: &[u8] = b"cn-runtime-platform:";
392
393// Read the runtime player's stamped shader platform (`hlsl` / `glsl` / `metal`)
394// by scanning its binary for the backend marker. Returns None for an older,
395// unstamped runtime, warning once so the skipped backend check is visible.
396fn read_runtime_platform(runtime: &Path) -> io::Result<Option<String>> {
397    let bytes = fs::read(runtime)?;
398    let found = find_platform_stamp(&bytes);
399    if found.is_none() {
400        eprintln!(
401            "warning: no backend stamp found in {}; skipping the runtime/cook \
402             backend check (rebuild `concinnity-run` to enable it)",
403            runtime.display()
404        );
405    }
406    Ok(found)
407}
408
409// Reject a runtime player built for a different rendering backend than the one
410// this `cn` cooks blobs for. `cn` and `concinnity-run` compile
411// independently, so a DX-built `cn` sitting beside a Vulkan-built runtime would
412// otherwise silently ship a SPIR-V player with DXBC blobs (or vice versa) that
413// fails to load every shader at launch. `found` is the player's stamped shader
414// platform (None for an unstamped runtime, already warned about); compare it to
415// `cooked`, the shader platform this export's blobs were built for.
416fn verify_runtime_backend(
417    found: Option<&str>,
418    cooked: concinnity_cook::platform::Platform,
419) -> io::Result<()> {
420    let expected = cooked.key();
421    match found {
422        // Unstamped runtime: cannot verify, so proceed rather than block a
423        // possibly-fine export (the missing-stamp warning already printed).
424        None => Ok(()),
425        Some(found) if found == expected => Ok(()),
426        Some(found) => Err(io::Error::new(
427            io::ErrorKind::InvalidData,
428            format!(
429                "runtime/cook backend mismatch: this `cn` cooks {} blobs, but the \
430                 `concinnity-run` player beside it was built for {}. Rebuild the \
431                 runtime for the same backend (`cargo build --features player{}`) \
432                 before exporting.",
433                backend_label(expected),
434                backend_label(found),
435                feature_hint(expected),
436            ),
437        )),
438    }
439}
440
441// Extract the shader-platform token from a runtime binary's backend stamp: the
442// lowercase-ASCII run immediately after the marker prefix. Returns None when
443// the marker is absent (an older, unstamped runtime).
444fn find_platform_stamp(bytes: &[u8]) -> Option<String> {
445    let start = find_subslice(bytes, RUNTIME_PLATFORM_MARKER)? + RUNTIME_PLATFORM_MARKER.len();
446    let rest = &bytes[start..];
447    let end = rest
448        .iter()
449        .position(|b| !b.is_ascii_lowercase())
450        .unwrap_or(rest.len());
451    let token = &rest[..end];
452    if token.is_empty() {
453        None
454    } else {
455        std::str::from_utf8(token).ok().map(str::to_string)
456    }
457}
458
459// Index of the first occurrence of `needle` in `haystack`, or None.
460fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
461    if needle.is_empty() || haystack.len() < needle.len() {
462        return None;
463    }
464    haystack.windows(needle.len()).position(|w| w == needle)
465}
466
467// A human-facing label for a shader-platform key.
468fn backend_label(platform_key: &str) -> &str {
469    match platform_key {
470        "metal" => "Metal (MSL)",
471        "hlsl" => "DirectX (DXIL)",
472        "glsl" => "Vulkan (SPIR-V)",
473        other => other,
474    }
475}
476
477// The cargo feature flag that reproduces a given cook backend when rebuilding
478// the runtime: only the Vulkan (SPIR-V) backend is feature-gated.
479// Appended to the `--features player` the rebuild hint already names, so a
480// Vulkan player reads as one feature list rather than two flags.
481fn feature_hint(platform_key: &str) -> &str {
482    match platform_key {
483        "glsl" => ",vulkan",
484        _ => "",
485    }
486}
487
488// Platform executable file name: append `.exe` on Windows.
489fn exe_file_name(stem: &str) -> String {
490    if cfg!(windows) {
491        format!("{stem}.exe")
492    } else {
493        stem.to_string()
494    }
495}
496
497#[cfg(unix)]
498fn make_executable(path: &Path) -> io::Result<()> {
499    use std::os::unix::fs::PermissionsExt;
500    let mut perms = fs::metadata(path)?.permissions();
501    perms.set_mode(0o755);
502    fs::set_permissions(path, perms)
503}
504
505#[cfg(not(unix))]
506fn make_executable(_path: &Path) -> io::Result<()> {
507    Ok(())
508}
509
510// Read the app metadata from the expanded world, applying the `--name` /
511// `--version` overrides and deriving anything the AppConfig asset left unset.
512fn read_app_meta(
513    cli_name: Option<&str>,
514    cli_version: Option<&str>,
515    assets: &[WorldJsonlAsset],
516) -> AppMeta {
517    let display_name = resolve_display_name(cli_name, assets);
518    let identifier =
519        string_arg(assets, "appconfig", "id").unwrap_or_else(|| derive_identifier(&display_name));
520    let version = cli_version
521        .map(str::trim)
522        .filter(|s| !s.is_empty())
523        .map(str::to_string)
524        .or_else(|| string_arg(assets, "appconfig", "version"))
525        .unwrap_or_else(|| "0.1.0".to_string());
526    let icon = string_arg(assets, "appconfig", "icon").map(PathBuf::from);
527    AppMeta {
528        display_name,
529        identifier,
530        version,
531        icon,
532    }
533}
534
535// The app name, by precedence: an explicit `--name`, then the AppConfig
536// asset's name, then a MainMenu title, then the engine default.
537fn resolve_display_name(cli_name: Option<&str>, assets: &[WorldJsonlAsset]) -> String {
538    if let Some(n) = cli_name.map(str::trim).filter(|s| !s.is_empty()) {
539        return n.to_string();
540    }
541    if let Some(n) = string_arg(assets, "appconfig", "name") {
542        return n;
543    }
544    if let Some(n) = string_arg(assets, "mainmenu", "title") {
545        return n;
546    }
547    "Concinnity".to_string()
548}
549
550// A reverse-DNS bundle identifier derived from the name when the AppConfig
551// asset declares none: `gg.concinnity.<name>`, the name reduced to bundle-id
552// characters (ascii alphanumerics, lowercased; other runs become a single `-`).
553fn derive_identifier(name: &str) -> String {
554    let mut comp = String::new();
555    let mut pending_dash = false;
556    for c in name.chars() {
557        if c.is_ascii_alphanumeric() {
558            if pending_dash && !comp.is_empty() {
559                comp.push('-');
560            }
561            pending_dash = false;
562            comp.push(c.to_ascii_lowercase());
563        } else {
564            pending_dash = true;
565        }
566    }
567    let comp = comp.trim_matches('-');
568    if comp.is_empty() {
569        "gg.concinnity.app".to_string()
570    } else {
571        format!("gg.concinnity.{comp}")
572    }
573}
574
575// The first non-empty string value of `key` on the first asset whose normalized
576// type matches `type_norm`.
577fn string_arg(assets: &[WorldJsonlAsset], type_norm: &str, key: &str) -> Option<String> {
578    assets
579        .iter()
580        .find(|a| normalize_type(&a.asset_type) == type_norm)
581        .and_then(|a| a.args.get(key))
582        .and_then(|v| v.as_str())
583        .map(str::trim)
584        .filter(|s| !s.is_empty())
585        .map(str::to_string)
586}
587
588fn normalize_type(t: &str) -> String {
589    t.to_lowercase().replace('_', "")
590}
591
592// A filesystem-safe slug for the bundle folder, executable, and archive name.
593// Keeps alphanumerics, `.`, `-`, `_`; collapses runs of other characters
594// (including spaces) to a single `-`. Falls back to "app" when empty.
595fn slug(name: &str) -> String {
596    let mut out = String::new();
597    let mut pending_dash = false;
598    for c in name.chars() {
599        if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
600            if pending_dash && !out.is_empty() {
601                out.push('-');
602            }
603            pending_dash = false;
604            out.push(c);
605        } else {
606            pending_dash = true;
607        }
608    }
609    let trimmed = out.trim_matches('-');
610    if trimmed.is_empty() {
611        "app".to_string()
612    } else {
613        trimmed.to_string()
614    }
615}
616
617// Short platform token for distributable artifact file names, from an OS string
618// (`std::env::consts::OS`). Export is host-only, so this always reflects the
619// bundle's actual platform.
620fn platform_tag(os: &str) -> &str {
621    match os {
622        "macos" => "mac",
623        "windows" => "win",
624        "linux" => "linux",
625        other => other,
626    }
627}
628
629// The base name for a distributable archive: `<slug>-<version>-<platform>` (e.g.
630// `My-Game-1.0.0-mac`). The `.app` bundle, the executable, and the in-archive top
631// folder keep the bare slug; only the `.zip` / `.dmg` carry the version and
632// platform so a release folder can hold builds of several versions side by side.
633fn artifact_stem(meta: &AppMeta, platform: &str) -> String {
634    format!(
635        "{}-{}-{}",
636        slug(&meta.display_name),
637        slug(&meta.version),
638        platform
639    )
640}
641
642// Synthesize the macOS Info.plist. The bundle display name comes from
643// CFBundleDisplayName, so the `.app` file name can stay a plain slug while
644// Finder still shows the real name.
645fn info_plist(meta: &AppMeta, exe_name: &str, icon_file: Option<&str>) -> String {
646    let icon_entry = match icon_file {
647        Some(icon) => format!(
648            "\t<key>CFBundleIconFile</key>\n\t<string>{}</string>\n",
649            xml_escape(icon)
650        ),
651        None => String::new(),
652    };
653    format!(
654        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
655         <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
656         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
657         <plist version=\"1.0\">\n\
658         <dict>\n\
659         \t<key>CFBundleName</key>\n\t<string>{name}</string>\n\
660         \t<key>CFBundleDisplayName</key>\n\t<string>{name}</string>\n\
661         \t<key>CFBundleExecutable</key>\n\t<string>{exe}</string>\n\
662         \t<key>CFBundleIdentifier</key>\n\t<string>{id}</string>\n\
663         \t<key>CFBundleVersion</key>\n\t<string>{ver}</string>\n\
664         \t<key>CFBundleShortVersionString</key>\n\t<string>{ver}</string>\n\
665         \t<key>CFBundlePackageType</key>\n\t<string>APPL</string>\n\
666         \t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\
667         {icon}\
668         \t<key>LSMinimumSystemVersion</key>\n\t<string>11.0</string>\n\
669         \t<key>NSHighResolutionCapable</key>\n\t<true/>\n\
670         \t<key>NSPrincipalClass</key>\n\t<string>NSApplication</string>\n\
671         </dict>\n\
672         </plist>\n",
673        name = xml_escape(&meta.display_name),
674        exe = xml_escape(exe_name),
675        id = xml_escape(&meta.identifier),
676        ver = xml_escape(&meta.version),
677        icon = icon_entry,
678    )
679}
680
681fn xml_escape(s: &str) -> String {
682    s.replace('&', "&amp;")
683        .replace('<', "&lt;")
684        .replace('>', "&gt;")
685        .replace('"', "&quot;")
686        .replace('\'', "&apos;")
687}
688
689// Build `<resources>/<slug>.icns` from a source PNG using the stock macOS tools
690// `sips` (resize) and `iconutil` (assemble). Returns the icns file name for the
691// Info.plist. Errors if the source is missing or a tool fails.
692fn build_icns(src: &Path, resources: &Path, slug: &str) -> io::Result<String> {
693    if !src.exists() {
694        return Err(io::Error::new(
695            io::ErrorKind::NotFound,
696            format!("AppConfig icon not found: {}", src.display()),
697        ));
698    }
699    // `iconutil` reads the ladder back out of a directory, and takes the whole
700    // tree with it when this returns however it returns.
701    let iconset = scratch::Scratch::dir(&format!("{slug}.iconset"))?;
702    // The standard iconset ladder: each size at 1x and 2x.
703    for size in [16u32, 32, 128, 256, 512] {
704        for scale in [1u32, 2] {
705            let px = size * scale;
706            let suffix = if scale == 2 { "@2x" } else { "" };
707            let dst = iconset
708                .path()
709                .join(format!("icon_{size}x{size}{suffix}.png"));
710            run_tool(
711                "sips",
712                &[
713                    "-z",
714                    &px.to_string(),
715                    &px.to_string(),
716                    &src.to_string_lossy(),
717                    "--out",
718                    &dst.to_string_lossy(),
719                ],
720            )?;
721        }
722    }
723
724    let icns_name = format!("{slug}.icns");
725    let icns_path = resources.join(&icns_name);
726    run_tool(
727        "iconutil",
728        &[
729            "-c",
730            "icns",
731            &iconset.path().to_string_lossy(),
732            "-o",
733            &icns_path.to_string_lossy(),
734        ],
735    )?;
736    Ok(icns_name)
737}
738
739// The engine's built-in fallback icon (the Concinnity mark on a dark gradient),
740// used when the AppConfig asset sets no icon. Baked into the `cn` binary so a
741// shipped toolchain, which has no engine source tree, still carries it.
742const DEFAULT_ICON_PNG: &[u8] = include_bytes!("../assets/default-icon.png");
743
744// Build `<resources>/<slug>.icns` from the bundled default icon. Writes the
745// embedded PNG to a scratch file so it flows through the same sips/iconutil
746// pipeline as a user-supplied icon.
747fn build_default_icns(resources: &Path, slug: &str) -> io::Result<String> {
748    let src = scratch::Scratch::file(&format!("{slug}-default-icon.png"));
749    fs::write(src.path(), DEFAULT_ICON_PNG)?;
750    build_icns(src.path(), resources, slug)
751}
752
753// Wrap a `.app` in a compressed `.dmg` via `hdiutil`. The `.app` is staged into
754// its own folder first so it lands at the disk image's root (hdiutil's
755// -srcfolder makes the folder the volume root).
756fn build_dmg(app_dir: &Path, slug: &str, volume_name: &str, dmg_path: &Path) -> io::Result<()> {
757    let staging = scratch::Scratch::dir(&format!("{slug}-dmg"))?;
758    copy_tree(app_dir, &staging.path().join(format!("{slug}.app")))?;
759
760    if dmg_path.exists() {
761        fs::remove_file(dmg_path)?;
762    }
763    run_tool(
764        "hdiutil",
765        &[
766            "create",
767            "-volname",
768            volume_name,
769            "-srcfolder",
770            &staging.path().to_string_lossy(),
771            "-ov",
772            "-format",
773            "UDZO",
774            &dmg_path.to_string_lossy(),
775        ],
776    )?;
777    Ok(())
778}
779
780// Run an external tool, turning a non-zero exit into an io::Error carrying its
781// stderr.
782fn run_tool(program: &str, args: &[&str]) -> io::Result<()> {
783    let output = std::process::Command::new(program)
784        .args(args)
785        .output()
786        .map_err(|e| io::Error::new(e.kind(), format!("failed to run `{program}`: {e}")))?;
787    if output.status.success() {
788        Ok(())
789    } else {
790        Err(io::Error::other(format!(
791            "`{program}` failed: {}",
792            String::from_utf8_lossy(&output.stderr).trim()
793        )))
794    }
795}
796
797// Copy the runtime player's sibling native libraries into the bundle beside the
798// exe, so the bundle carries everything the player needs to launch. On Windows
799// the graphics-SDK runtime DLLs (FidelityFX / XeSS / DLSS / DXC) sit beside the
800// built runtime binary, and the Agility SDK D3D12 runtime lives in a `D3D12/`
801// subdir that the exe's `D3D12SDKPath` export points at (`.\D3D12\`). A strict
802// no-op on macOS and Linux, where the runtime links only system frameworks /
803// libraries (no sibling `.dll`, no `D3D12/`).
804//
805// `runtime_platform` is the player's stamped shader platform. The `D3D12/` dir
806// is meaningful only to a DirectX player -- its `D3D12SDKPath` export is gated
807// on the DX backend, so a Vulkan or Metal player never references it. It is
808// copied only for a DX (or unstamped, so unknown) runtime, keeping a Vulkan
809// bundle from carrying an inert Agility runtime when built in a target dir
810// shared with a DX build.
811fn copy_runtime_sidecars(
812    runtime: &Path,
813    runtime_platform: Option<&str>,
814    dest_dir: &Path,
815) -> io::Result<()> {
816    let Some(src_dir) = runtime.parent() else {
817        return Ok(());
818    };
819    // Sibling DLLs (the Windows graphics-SDK runtimes). Matched by extension so
820    // the set can change without export knowing backend specifics.
821    for entry in fs::read_dir(src_dir)? {
822        let path = entry?.path();
823        let is_dll = path
824            .extension()
825            .and_then(|e| e.to_str())
826            .is_some_and(|e| e.eq_ignore_ascii_case("dll"));
827        if is_dll
828            && path.is_file()
829            && let Some(name) = path.file_name()
830        {
831            fs::copy(&path, dest_dir.join(name))?;
832        }
833    }
834    // The Agility SDK D3D12 runtime, resolved by the exe relative to itself.
835    // Only a DX player looks for it (see above).
836    if runtime_wants_d3d12(runtime_platform) {
837        let d3d12 = src_dir.join("D3D12");
838        if d3d12.is_dir() {
839            copy_tree(&d3d12, &dest_dir.join("D3D12"))?;
840        }
841    }
842    Ok(())
843}
844
845// Whether a player with the given stamped platform references the `D3D12/`
846// Agility runtime: a DX player does, a Vulkan or Metal player never does, and an
847// unstamped (unknown) runtime is treated as maybe-DX so nothing it needs is
848// dropped.
849fn runtime_wants_d3d12(runtime_platform: Option<&str>) -> bool {
850    !matches!(runtime_platform, Some("glsl") | Some("metal"))
851}
852
853// Recursively copy the directory tree at `src` to `dst`.
854fn copy_tree(src: &Path, dst: &Path) -> io::Result<()> {
855    fs::create_dir_all(dst)?;
856    for entry in fs::read_dir(src)? {
857        let entry = entry?;
858        let from = entry.path();
859        let to = dst.join(entry.file_name());
860        if from.is_dir() {
861            copy_tree(&from, &to)?;
862        } else {
863            fs::copy(&from, &to)?;
864        }
865    }
866    Ok(())
867}
868
869// Zip the tree at `src_dir` under a single top-level `<top>/` folder, marking
870// the player executable (`exe_rel`, relative to `src_dir`) executable so it
871// stays runnable after extraction on Unix. Files are added in sorted order for
872// a reproducible archive.
873fn zip_tree(src_dir: &Path, top: &str, exe_rel: &str, zip_path: &Path) -> io::Result<()> {
874    use std::io::Write;
875    use zip::write::SimpleFileOptions;
876
877    let mut files = Vec::new();
878    collect_files(src_dir, &mut files)?;
879    files.sort();
880
881    let file = fs::File::create(zip_path)?;
882    let mut zw = zip::ZipWriter::new(file);
883    for path in files {
884        let rel = path
885            .strip_prefix(src_dir)
886            .map_err(io::Error::other)?
887            .to_string_lossy()
888            .replace('\\', "/");
889        let mode = if rel == exe_rel { 0o755 } else { 0o644 };
890        let options = SimpleFileOptions::default()
891            .compression_method(zip::CompressionMethod::Deflated)
892            .unix_permissions(mode);
893        zw.start_file(format!("{top}/{rel}"), options)
894            .map_err(io::Error::other)?;
895        let bytes = fs::read(&path)?;
896        zw.write_all(&bytes)?;
897    }
898    zw.finish().map_err(io::Error::other)?;
899    Ok(())
900}
901
902// Collect every file under `dir` (recursively) into `out`.
903fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
904    for entry in fs::read_dir(dir)? {
905        let path = entry?.path();
906        if path.is_dir() {
907            collect_files(&path, out)?;
908        } else {
909            out.push(path);
910        }
911    }
912    Ok(())
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918
919    fn asset(name: &str, ty: &str, args: serde_json::Value) -> WorldJsonlAsset {
920        WorldJsonlAsset {
921            name: name.to_string(),
922            asset_type: ty.to_string(),
923            args,
924        }
925    }
926
927    #[test]
928    fn slug_is_filesystem_safe() {
929        assert_eq!(slug("My Game"), "My-Game");
930        assert_eq!(slug("  Spaced  Out  "), "Spaced-Out");
931        assert_eq!(slug("weird:/name*?"), "weird-name");
932        assert_eq!(slug("keep_dots.and-dashes"), "keep_dots.and-dashes");
933        assert_eq!(slug("***"), "app");
934        assert_eq!(slug(""), "app");
935    }
936
937    #[test]
938    fn blob_index_matches_only_integer_files() {
939        assert_eq!(blob_index("0"), Some(0));
940        assert_eq!(blob_index("42"), Some(42));
941        assert_eq!(blob_index(""), None);
942        assert_eq!(blob_index("0.metallib"), None);
943        assert_eq!(blob_index("default_vertex_shader.air"), None);
944        assert_eq!(blob_index("settings"), None);
945    }
946
947    #[test]
948    fn name_precedence_is_cli_then_app_config_then_menu_then_default() {
949        let app = asset("app", "AppConfig", serde_json::json!({"name": "App Name"}));
950        let menu = asset("m", "MainMenu", serde_json::json!({"title": "Menu Title"}));
951
952        assert_eq!(
953            resolve_display_name(Some("CLI Name"), &[app.clone(), menu.clone()]),
954            "CLI Name"
955        );
956        assert_eq!(
957            resolve_display_name(None, &[app.clone(), menu.clone()]),
958            "App Name"
959        );
960        assert_eq!(
961            resolve_display_name(None, std::slice::from_ref(&menu)),
962            "Menu Title"
963        );
964        assert_eq!(resolve_display_name(None, &[]), "Concinnity");
965        assert_eq!(resolve_display_name(Some("  "), &[app]), "App Name");
966    }
967
968    #[test]
969    fn normalize_platform_accepts_aliases() {
970        assert_eq!(normalize_platform("macOS"), "macos");
971        assert_eq!(normalize_platform("Darwin"), "macos");
972        assert_eq!(normalize_platform("win"), "windows");
973        assert_eq!(normalize_platform("Linux"), "linux");
974        assert_eq!(normalize_platform("solaris"), "");
975    }
976
977    #[test]
978    fn host_platform_is_accepted_and_others_rejected() {
979        check_target_platform(None).unwrap();
980        check_target_platform(Some(std::env::consts::OS)).unwrap();
981        let foreign = if std::env::consts::OS == "windows" {
982            "linux"
983        } else {
984            "windows"
985        };
986        assert!(check_target_platform(Some(foreign)).is_err());
987    }
988
989    #[test]
990    fn app_meta_derives_id_and_version_defaults() {
991        // No AppConfig: name falls to default, id derived, version defaulted.
992        let meta = read_app_meta(Some("My Cool App"), None, &[]);
993        assert_eq!(meta.display_name, "My Cool App");
994        assert_eq!(meta.identifier, "gg.concinnity.my-cool-app");
995        assert_eq!(meta.version, "0.1.0");
996        assert!(meta.icon.is_none());
997
998        // AppConfig supplies id / version / icon verbatim.
999        let app = asset(
1000            "app",
1001            "AppConfig",
1002            serde_json::json!({
1003                "name": "Named", "id": "gg.studio.thing", "version": "2.3.4", "icon": "art/i.png"
1004            }),
1005        );
1006        let meta = read_app_meta(None, None, std::slice::from_ref(&app));
1007        assert_eq!(meta.display_name, "Named");
1008        assert_eq!(meta.identifier, "gg.studio.thing");
1009        assert_eq!(meta.version, "2.3.4");
1010        assert_eq!(meta.icon.as_deref(), Some(Path::new("art/i.png")));
1011    }
1012
1013    #[test]
1014    fn version_precedence_is_cli_then_application_then_default() {
1015        let app = asset("app", "AppConfig", serde_json::json!({"version": "2.3.4"}));
1016
1017        // --version overrides the AppConfig version.
1018        assert_eq!(
1019            read_app_meta(None, Some("9.9.9"), std::slice::from_ref(&app)).version,
1020            "9.9.9"
1021        );
1022        // A blank --version is ignored, falling back to the AppConfig version.
1023        assert_eq!(
1024            read_app_meta(None, Some("  "), std::slice::from_ref(&app)).version,
1025            "2.3.4"
1026        );
1027        // --version with no AppConfig still wins over the default.
1028        assert_eq!(read_app_meta(None, Some("3.0"), &[]).version, "3.0");
1029        // No override, no AppConfig: the default.
1030        assert_eq!(read_app_meta(None, None, &[]).version, "0.1.0");
1031    }
1032
1033    #[test]
1034    fn platform_tag_maps_host_os() {
1035        assert_eq!(platform_tag("macos"), "mac");
1036        assert_eq!(platform_tag("windows"), "win");
1037        assert_eq!(platform_tag("linux"), "linux");
1038        // An unmapped OS passes through unchanged rather than being lost.
1039        assert_eq!(platform_tag("freebsd"), "freebsd");
1040    }
1041
1042    #[test]
1043    fn artifact_stem_is_slug_version_platform() {
1044        let meta = AppMeta {
1045            display_name: "My Game".to_string(),
1046            identifier: "gg.studio.mg".to_string(),
1047            version: "1.0.0".to_string(),
1048            icon: None,
1049        };
1050        assert_eq!(artifact_stem(&meta, "mac"), "My-Game-1.0.0-mac");
1051        // The version is slugged too, so odd characters stay filesystem-safe.
1052        let meta = AppMeta {
1053            version: "1.0.0+build 7".to_string(),
1054            ..meta
1055        };
1056        assert_eq!(artifact_stem(&meta, "win"), "My-Game-1.0.0-build-7-win");
1057    }
1058
1059    #[test]
1060    fn info_plist_has_required_keys_and_escapes() {
1061        let meta = AppMeta {
1062            display_name: "Tom & Jerry".to_string(),
1063            identifier: "gg.studio.tj".to_string(),
1064            version: "1.0".to_string(),
1065            icon: None,
1066        };
1067        let plist = info_plist(&meta, "tj", Some("tj.icns"));
1068        assert!(plist.contains("<key>CFBundleExecutable</key>\n\t<string>tj</string>"));
1069        assert!(plist.contains("<key>CFBundleIdentifier</key>\n\t<string>gg.studio.tj</string>"));
1070        assert!(plist.contains("<key>CFBundleShortVersionString</key>\n\t<string>1.0</string>"));
1071        assert!(plist.contains("<key>CFBundleIconFile</key>\n\t<string>tj.icns</string>"));
1072        // The `&` in the display name is XML-escaped.
1073        assert!(plist.contains("Tom &amp; Jerry"));
1074        assert!(!plist.contains("Tom & Jerry"));
1075
1076        // Without an icon there is no CFBundleIconFile key.
1077        let plist = info_plist(&meta, "tj", None);
1078        assert!(!plist.contains("CFBundleIconFile"));
1079    }
1080
1081    #[test]
1082    fn copy_runtime_sidecars_copies_dlls_and_the_d3d12_dir() {
1083        // Simulate a Windows target/<profile>/ dir: the runtime exe, sibling
1084        // DLLs, a non-lib file, and the Agility D3D12/ subdir.
1085        let tmp = tempfile::tempdir().unwrap();
1086        let src = tmp.path().join("src");
1087        fs::create_dir_all(src.join("D3D12")).unwrap();
1088        fs::write(src.join("concinnity-run.exe"), b"exe").unwrap();
1089        fs::write(src.join("amd_fidelityfx_dx12.dll"), b"x").unwrap();
1090        fs::write(src.join("libconcinnity_ffi.dll"), b"x").unwrap();
1091        fs::write(src.join("notes.txt"), b"x").unwrap();
1092        fs::write(src.join("D3D12").join("D3D12Core.dll"), b"x").unwrap();
1093
1094        let dest = tmp.path().join("dest");
1095        fs::create_dir_all(&dest).unwrap();
1096        // A DX runtime pulls in its sibling DLLs and the Agility `D3D12/` dir.
1097        copy_runtime_sidecars(&src.join("concinnity-run.exe"), Some("hlsl"), &dest).unwrap();
1098
1099        assert!(dest.join("amd_fidelityfx_dx12.dll").exists());
1100        assert!(dest.join("libconcinnity_ffi.dll").exists());
1101        assert!(dest.join("D3D12").join("D3D12Core.dll").exists());
1102        // Non-DLL siblings and the exe itself are not carried along.
1103        assert!(!dest.join("notes.txt").exists());
1104        assert!(!dest.join("concinnity-run.exe").exists());
1105    }
1106
1107    #[test]
1108    fn copy_runtime_sidecars_skips_d3d12_for_a_non_dx_runtime() {
1109        // A Vulkan runtime built in a target dir shared with a DX build has a
1110        // stale `D3D12/` sibling; it must not land in the Vulkan bundle (the VK
1111        // exe never references it), though the sibling DLLs still copy.
1112        let tmp = tempfile::tempdir().unwrap();
1113        let src = tmp.path().join("src");
1114        fs::create_dir_all(src.join("D3D12")).unwrap();
1115        fs::write(src.join("concinnity-run.exe"), b"exe").unwrap();
1116        fs::write(src.join("amd_fidelityfx_vk.dll"), b"x").unwrap();
1117        fs::write(src.join("D3D12").join("D3D12Core.dll"), b"x").unwrap();
1118
1119        let dest = tmp.path().join("dest");
1120        fs::create_dir_all(&dest).unwrap();
1121        copy_runtime_sidecars(&src.join("concinnity-run.exe"), Some("glsl"), &dest).unwrap();
1122
1123        assert!(dest.join("amd_fidelityfx_vk.dll").exists());
1124        assert!(!dest.join("D3D12").exists());
1125    }
1126
1127    #[test]
1128    fn runtime_wants_d3d12_only_for_dx_or_unknown() {
1129        assert!(runtime_wants_d3d12(Some("hlsl")));
1130        // Unstamped: keep everything the runtime may need.
1131        assert!(runtime_wants_d3d12(None));
1132        assert!(!runtime_wants_d3d12(Some("glsl")));
1133        assert!(!runtime_wants_d3d12(Some("metal")));
1134    }
1135
1136    #[test]
1137    fn find_platform_stamp_reads_the_token_after_the_marker() {
1138        // Surround the stamp with binary noise, as it would appear in a real
1139        // executable, and terminate it with the stamp's NUL.
1140        let mut buf = vec![0xAAu8, 0x00, 0xFF, b'x'];
1141        buf.extend_from_slice(b"cn-runtime-platform:hlsl\0");
1142        buf.extend_from_slice(&[0x01, 0x02, 0x03]);
1143        assert_eq!(find_platform_stamp(&buf).as_deref(), Some("hlsl"));
1144
1145        // Each backend token is recovered verbatim.
1146        for token in ["metal", "hlsl", "glsl"] {
1147            let stamp = format!("cn-runtime-platform:{token}\0");
1148            assert_eq!(
1149                find_platform_stamp(stamp.as_bytes()).as_deref(),
1150                Some(token)
1151            );
1152        }
1153    }
1154
1155    #[test]
1156    fn find_platform_stamp_is_none_without_the_marker() {
1157        assert_eq!(find_platform_stamp(b"no stamp here"), None);
1158        assert_eq!(find_platform_stamp(b""), None);
1159        // Marker present but immediately terminated: no token.
1160        assert_eq!(find_platform_stamp(b"cn-runtime-platform:\0"), None);
1161    }
1162
1163    #[test]
1164    fn find_subslice_locates_and_reports_absence() {
1165        assert_eq!(find_subslice(b"abcdef", b"cd"), Some(2));
1166        assert_eq!(find_subslice(b"abcdef", b"abc"), Some(0));
1167        assert_eq!(find_subslice(b"abcdef", b"xy"), None);
1168        assert_eq!(find_subslice(b"ab", b"abc"), None);
1169        assert_eq!(find_subslice(b"abc", b""), None);
1170    }
1171
1172    #[test]
1173    fn backend_label_and_feature_hint_cover_each_platform() {
1174        assert_eq!(backend_label("metal"), "Metal (MSL)");
1175        assert_eq!(backend_label("hlsl"), "DirectX (DXIL)");
1176        assert_eq!(backend_label("glsl"), "Vulkan (SPIR-V)");
1177        assert_eq!(feature_hint("glsl"), ",vulkan");
1178        assert_eq!(feature_hint("hlsl"), "");
1179        assert_eq!(feature_hint("metal"), "");
1180    }
1181
1182    #[test]
1183    fn default_icon_is_a_nonempty_png() {
1184        // The bundled fallback must be a real PNG so sips can rasterize it into
1185        // the iconset ladder; a missing/corrupt file would fail every export
1186        // without an AppConfig icon.
1187        assert!(DEFAULT_ICON_PNG.len() > 1024);
1188        assert_eq!(&DEFAULT_ICON_PNG[..8], b"\x89PNG\r\n\x1a\n");
1189    }
1190
1191    #[test]
1192    fn copy_runtime_sidecars_is_a_noop_without_dlls() {
1193        // The macOS / Linux case: no sibling `.dll`, no `D3D12/`.
1194        let tmp = tempfile::tempdir().unwrap();
1195        let src = tmp.path().join("src");
1196        fs::create_dir_all(&src).unwrap();
1197        fs::write(src.join("concinnity-run"), b"exe").unwrap();
1198        fs::write(src.join("libconcinnity_ffi.dylib"), b"x").unwrap();
1199
1200        let dest = tmp.path().join("dest");
1201        fs::create_dir_all(&dest).unwrap();
1202        copy_runtime_sidecars(&src.join("concinnity-run"), Some("metal"), &dest).unwrap();
1203        assert_eq!(fs::read_dir(&dest).unwrap().count(), 0);
1204    }
1205
1206    #[test]
1207    fn export_rejects_an_unknown_format_up_front() {
1208        // The format check runs before any path resolution or build, so this
1209        // touches nothing on disk.
1210        let err = export(None, None, None, None, "out", "tarball", false).unwrap_err();
1211        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1212        assert!(err.to_string().contains("tarball"), "got: {err}");
1213    }
1214
1215    #[cfg(not(target_os = "macos"))]
1216    #[test]
1217    fn export_rejects_dmg_off_macos() {
1218        let err = export(None, None, None, None, "out", "zip", true).unwrap_err();
1219        assert_eq!(err.kind(), io::ErrorKind::Unsupported);
1220    }
1221
1222    #[test]
1223    fn derive_identifier_reduces_names_to_bundle_id_form() {
1224        assert_eq!(derive_identifier("My Game"), "gg.concinnity.my-game");
1225        assert_eq!(
1226            derive_identifier("Space:  Above!"),
1227            "gg.concinnity.space-above"
1228        );
1229        assert_eq!(derive_identifier("***"), "gg.concinnity.app");
1230        assert_eq!(derive_identifier(""), "gg.concinnity.app");
1231    }
1232
1233    #[test]
1234    fn xml_escape_escapes_every_entity() {
1235        assert_eq!(
1236            xml_escape(r#"<a href="x">Tom & Jerry's</a>"#),
1237            "&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&apos;s&lt;/a&gt;"
1238        );
1239        assert_eq!(xml_escape("plain"), "plain");
1240    }
1241
1242    #[test]
1243    fn verify_runtime_backend_accepts_matching_or_unstamped() {
1244        let cooked = concinnity_cook::platform::Platform::Metal;
1245        verify_runtime_backend(None, cooked).unwrap();
1246        verify_runtime_backend(Some(cooked.key()), cooked).unwrap();
1247
1248        let err = verify_runtime_backend(Some("hlsl"), cooked).unwrap_err();
1249        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1250        assert!(err.to_string().contains("mismatch"), "got: {err}");
1251    }
1252
1253    #[test]
1254    fn read_runtime_platform_reads_a_stamp_or_warns_none() {
1255        let tmp = tempfile::tempdir().unwrap();
1256        let stamped = tmp.path().join("stamped");
1257        fs::write(&stamped, b"junk cn-runtime-platform:metal\0 junk").unwrap();
1258        assert_eq!(
1259            read_runtime_platform(&stamped).unwrap().as_deref(),
1260            Some("metal")
1261        );
1262
1263        let unstamped = tmp.path().join("unstamped");
1264        fs::write(&unstamped, b"no marker in here").unwrap();
1265        assert_eq!(read_runtime_platform(&unstamped).unwrap(), None);
1266    }
1267
1268    #[test]
1269    fn copy_blobs_takes_only_integer_named_files() {
1270        let tmp = tempfile::tempdir().unwrap();
1271        let src = tmp.path().join("data");
1272        fs::create_dir_all(&src).unwrap();
1273        fs::write(src.join("0"), b"blob0").unwrap();
1274        fs::write(src.join("12"), b"blob12").unwrap();
1275        fs::write(src.join("default_vert.air"), b"scratch").unwrap();
1276        fs::write(src.join("settings"), b"state").unwrap();
1277
1278        let dst = tmp.path().join("out");
1279        let count = copy_blobs(&src, &dst).unwrap();
1280        assert_eq!(count, 2);
1281        assert!(dst.is_dir(), "an overflowing world ships a directory");
1282        assert_eq!(fs::read(dst.join("0")).unwrap(), b"blob0");
1283        assert_eq!(fs::read(dst.join("12")).unwrap(), b"blob12");
1284        assert!(!dst.join("default_vert.air").exists());
1285        assert!(!dst.join("settings").exists());
1286    }
1287
1288    // A world that fits in one blob ships as a file named `data`, which is what
1289    // keeps a small game's bundle from carrying integer-named files at all.
1290    #[test]
1291    fn a_single_blob_world_ships_as_one_file() {
1292        let tmp = tempfile::tempdir().unwrap();
1293        let src = tmp.path().join("data");
1294        fs::create_dir_all(&src).unwrap();
1295        fs::write(src.join("0"), b"blob0").unwrap();
1296        fs::write(src.join("default_vert.air"), b"scratch").unwrap();
1297
1298        let dst = tmp.path().join("bundle").join("data");
1299        assert_eq!(copy_blobs(&src, &dst).unwrap(), 1);
1300        assert!(dst.is_file(), "one blob ships as the `data` file itself");
1301        assert_eq!(fs::read(&dst).unwrap(), b"blob0");
1302    }
1303
1304    // The two forms occupy the same name, so a re-export across the boundary
1305    // has to clear whatever the last one left rather than write a file over a
1306    // directory (or leave a stale `1` beside a now-single blob).
1307    #[test]
1308    fn re_exporting_across_the_form_boundary_replaces_the_previous_shape() {
1309        let tmp = tempfile::tempdir().unwrap();
1310        let src = tmp.path().join("data");
1311        fs::create_dir_all(&src).unwrap();
1312        fs::write(src.join("0"), b"blob0").unwrap();
1313        fs::write(src.join("1"), b"blob1").unwrap();
1314        let dst = tmp.path().join("bundle").join("data");
1315
1316        assert_eq!(copy_blobs(&src, &dst).unwrap(), 2);
1317        assert!(dst.is_dir());
1318
1319        // The world shrinks to one blob: the directory gives way to a file.
1320        fs::remove_file(src.join("1")).unwrap();
1321        assert_eq!(copy_blobs(&src, &dst).unwrap(), 1);
1322        assert!(dst.is_file());
1323        assert_eq!(fs::read(&dst).unwrap(), b"blob0");
1324
1325        // ...and back the other way.
1326        fs::write(src.join("1"), b"blob1").unwrap();
1327        assert_eq!(copy_blobs(&src, &dst).unwrap(), 2);
1328        assert!(dst.is_dir());
1329        assert_eq!(fs::read(dst.join("1")).unwrap(), b"blob1");
1330    }
1331
1332    // A build that produced nothing is caught here rather than shipping a
1333    // bundle whose player has no world to open.
1334    #[test]
1335    fn copy_blobs_refuses_a_data_dir_with_no_blobs() {
1336        let tmp = tempfile::tempdir().unwrap();
1337        let src = tmp.path().join("data");
1338        fs::create_dir_all(&src).unwrap();
1339        fs::write(src.join("default_vert.air"), b"scratch").unwrap();
1340
1341        let err = copy_blobs(&src, &tmp.path().join("out")).unwrap_err();
1342        assert_eq!(err.kind(), io::ErrorKind::NotFound);
1343    }
1344
1345    #[test]
1346    fn reset_dir_clears_previous_content() {
1347        let tmp = tempfile::tempdir().unwrap();
1348        let dir = tmp.path().join("bundle");
1349        fs::create_dir_all(dir.join("nested")).unwrap();
1350        fs::write(dir.join("nested").join("stale"), b"old").unwrap();
1351
1352        reset_dir(&dir).unwrap();
1353        assert!(dir.exists());
1354        assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
1355    }
1356
1357    #[test]
1358    fn copy_tree_copies_nested_directories() {
1359        let tmp = tempfile::tempdir().unwrap();
1360        let src = tmp.path().join("src");
1361        fs::create_dir_all(src.join("a").join("b")).unwrap();
1362        fs::write(src.join("top.txt"), b"1").unwrap();
1363        fs::write(src.join("a").join("b").join("deep.txt"), b"2").unwrap();
1364
1365        let dst = tmp.path().join("dst");
1366        copy_tree(&src, &dst).unwrap();
1367        assert_eq!(fs::read(dst.join("top.txt")).unwrap(), b"1");
1368        assert_eq!(
1369            fs::read(dst.join("a").join("b").join("deep.txt")).unwrap(),
1370            b"2"
1371        );
1372    }
1373
1374    #[test]
1375    fn collect_files_walks_the_whole_tree() {
1376        let tmp = tempfile::tempdir().unwrap();
1377        let dir = tmp.path().join("tree");
1378        fs::create_dir_all(dir.join("sub")).unwrap();
1379        fs::write(dir.join("a"), b"1").unwrap();
1380        fs::write(dir.join("sub").join("b"), b"2").unwrap();
1381
1382        let mut files = Vec::new();
1383        collect_files(&dir, &mut files).unwrap();
1384        files.sort();
1385        assert_eq!(files, vec![dir.join("a"), dir.join("sub").join("b")]);
1386    }
1387
1388    #[test]
1389    fn zip_tree_archives_under_a_top_folder() {
1390        let tmp = tempfile::tempdir().unwrap();
1391        let bundle = tmp.path().join("My-Game");
1392        fs::create_dir_all(bundle.join("data")).unwrap();
1393        fs::write(bundle.join("My-Game"), b"player").unwrap();
1394        fs::write(bundle.join("data").join("0"), b"blob").unwrap();
1395
1396        let zip_path = tmp.path().join("My-Game-1.0.0-mac.zip");
1397        zip_tree(&bundle, "My-Game", "My-Game", &zip_path).unwrap();
1398
1399        let file = fs::File::open(&zip_path).unwrap();
1400        let mut archive = zip::ZipArchive::new(file).unwrap();
1401        let names: Vec<String> = (0..archive.len())
1402            .map(|i| archive.by_index(i).unwrap().name().to_string())
1403            .collect();
1404        assert_eq!(archive.len(), 2);
1405        assert!(
1406            names.contains(&"My-Game/My-Game".to_string()),
1407            "got: {names:?}"
1408        );
1409        assert!(
1410            names.contains(&"My-Game/data/0".to_string()),
1411            "got: {names:?}"
1412        );
1413        // The player entry carries the executable bit for Unix extraction.
1414        let exe = archive.by_name("My-Game/My-Game").unwrap();
1415        assert_eq!(exe.unix_mode().map(|m| m & 0o777), Some(0o755));
1416    }
1417
1418    #[test]
1419    fn build_icns_rejects_a_missing_source_before_running_tools() {
1420        let tmp = tempfile::tempdir().unwrap();
1421        let err = build_icns(&tmp.path().join("missing.png"), tmp.path(), "slug").unwrap_err();
1422        assert_eq!(err.kind(), io::ErrorKind::NotFound);
1423    }
1424
1425    #[test]
1426    fn run_tool_reports_success_failure_and_missing() {
1427        // A tool that exits 0 succeeds; a non-zero exit is surfaced as an error
1428        // naming the tool. `true`/`false` are not reliably on PATH on Windows
1429        // (they exist only under a Unix shell), so drive the exit code through
1430        // the platform shell, which always resolves.
1431        #[cfg(windows)]
1432        {
1433            run_tool("cmd", &["/C", "exit 0"]).unwrap();
1434            let err = run_tool("cmd", &["/C", "exit 1"]).unwrap_err();
1435            assert!(err.to_string().contains("cmd"), "got: {err}");
1436        }
1437        #[cfg(not(windows))]
1438        {
1439            run_tool("true", &[]).unwrap();
1440            let err = run_tool("false", &[]).unwrap_err();
1441            assert!(err.to_string().contains("false"), "got: {err}");
1442        }
1443        // A missing program is surfaced as an error rather than a panic. How
1444        // it is reported is platform-dependent: some spawn paths fail to spawn
1445        // (ErrorKind::NotFound -> "failed to run"), others run and exit non-zero
1446        // (127 -> "`prog` failed"). Both name the tool, so assert on that.
1447        let err = run_tool("cn-nonexistent-tool-xyz", &[]).unwrap_err();
1448        assert!(
1449            err.to_string().contains("cn-nonexistent-tool-xyz"),
1450            "got: {err}"
1451        );
1452    }
1453
1454    #[cfg(unix)]
1455    #[test]
1456    fn make_executable_sets_the_exec_bit() {
1457        use std::os::unix::fs::PermissionsExt;
1458        let tmp = tempfile::tempdir().unwrap();
1459        let f = tmp.path().join("player");
1460        fs::write(&f, b"bin").unwrap();
1461        // A freshly written file carries no execute bits.
1462        assert_eq!(fs::metadata(&f).unwrap().permissions().mode() & 0o111, 0);
1463        make_executable(&f).unwrap();
1464        assert_ne!(fs::metadata(&f).unwrap().permissions().mode() & 0o111, 0);
1465    }
1466
1467    // A folder bundle assembles the renamed player beside the copied blobs and
1468    // archives them. Cross-platform: a `metal` runtime pulls in no Windows
1469    // sidecars, so this runs identically on macOS and Linux.
1470    #[test]
1471    fn export_portable_assembles_folder_and_zip() {
1472        let tmp = tempfile::tempdir().unwrap();
1473        let data = tmp.path().join("data");
1474        fs::create_dir_all(&data).unwrap();
1475        fs::write(data.join("0"), b"blob0").unwrap();
1476        fs::write(data.join("1"), b"blob1").unwrap();
1477        fs::write(data.join("scratch.air"), b"ignored").unwrap();
1478
1479        let runtime = tmp.path().join(exe_file_name("concinnity-run"));
1480        fs::write(&runtime, b"runtime-bin").unwrap();
1481
1482        let out = tmp.path().join("out");
1483        fs::create_dir_all(&out).unwrap();
1484
1485        let meta = AppMeta {
1486            display_name: "My Game".to_string(),
1487            identifier: "gg.studio.mg".to_string(),
1488            version: "1.0.0".to_string(),
1489            icon: None,
1490        };
1491        export_portable(&meta, &runtime, Some("metal"), &out, &data, true).unwrap();
1492
1493        let bundle = out.join("My-Game");
1494        let exe = bundle.join(exe_file_name("My-Game"));
1495        assert!(exe.exists(), "renamed player missing");
1496        assert_eq!(fs::read(&exe).unwrap(), b"runtime-bin");
1497        assert!(bundle.join("data").join("0").exists());
1498        assert!(bundle.join("data").join("1").exists());
1499        // Build scratch is not packaged.
1500        assert!(!bundle.join("data").join("scratch.air").exists());
1501
1502        // The versioned archive was written beside the folder.
1503        let stem = artifact_stem(&meta, platform_tag(std::env::consts::OS));
1504        assert!(out.join(format!("{stem}.zip")).exists(), "zip missing");
1505    }
1506
1507    // The full `.app` assembly, including the bundled default `.icns` icon
1508    // ladder (sips + iconutil). macOS-only: it shells out to the stock Apple
1509    // icon tools, which do not exist on the Linux CI runner.
1510    #[cfg(target_os = "macos")]
1511    #[test]
1512    fn export_macos_builds_app_bundle_with_default_icon() {
1513        let tmp = tempfile::tempdir().unwrap();
1514        let data = tmp.path().join("data");
1515        fs::create_dir_all(&data).unwrap();
1516        fs::write(data.join("0"), b"blob0").unwrap();
1517
1518        let runtime = tmp.path().join("concinnity-run");
1519        fs::write(&runtime, b"runtime-bin").unwrap();
1520
1521        let out = tmp.path().join("out");
1522        fs::create_dir_all(&out).unwrap();
1523
1524        let meta = AppMeta {
1525            display_name: "My Game".to_string(),
1526            identifier: "gg.studio.mg".to_string(),
1527            version: "1.0.0".to_string(),
1528            icon: None,
1529        };
1530        // No AppConfig icon -> the bundled default flows through the icon
1531        // pipeline. make_zip on, make_dmg off (dmg needs a slow hdiutil).
1532        export_macos(&meta, &runtime, &out, &data, true, false).unwrap();
1533
1534        let app = out.join("My-Game.app");
1535        assert!(app.join("Contents/MacOS/My-Game").exists(), "exe missing");
1536        // One blob, so the bundle carries `data` as a file rather than a
1537        // directory -- the form the player reads as blob 0 directly.
1538        let data_entry = app.join("Contents/Resources/data");
1539        assert!(data_entry.is_file(), "blob missing");
1540        assert_eq!(fs::read(&data_entry).unwrap(), b"blob0");
1541        assert!(
1542            app.join("Contents/Resources/My-Game.icns").exists(),
1543            "icns missing"
1544        );
1545
1546        let plist = fs::read_to_string(app.join("Contents/Info.plist")).unwrap();
1547        assert!(plist.contains("<string>My-Game.icns</string>"));
1548        assert!(plist.contains("gg.studio.mg"));
1549
1550        let stem = artifact_stem(&meta, platform_tag(std::env::consts::OS));
1551        assert!(out.join(format!("{stem}.zip")).exists(), "zip missing");
1552    }
1553
1554    // The icon ladder rasterizes a real PNG into an `.icns` via sips + iconutil.
1555    // macOS-only for the same reason as the `.app` test.
1556    #[cfg(target_os = "macos")]
1557    #[test]
1558    fn build_icns_produces_an_icns_from_a_valid_png() {
1559        let tmp = tempfile::tempdir().unwrap();
1560        let src = tmp.path().join("icon.png");
1561        fs::write(&src, DEFAULT_ICON_PNG).unwrap();
1562        let resources = tmp.path().join("Resources");
1563        fs::create_dir_all(&resources).unwrap();
1564
1565        let name = build_icns(&src, &resources, "app").unwrap();
1566        assert_eq!(name, "app.icns");
1567        assert!(resources.join("app.icns").exists());
1568    }
1569
1570    #[test]
1571    fn runtime_binary_path_errors_when_the_player_is_absent() {
1572        // The test harness binary lives in `target/<profile>/deps/`, and the
1573        // `concinnity-run` player is built one level up in `target/<profile>/`,
1574        // so it never sits beside the test executable: the lookup must report a
1575        // clear NotFound rather than pointing at a nonexistent path.
1576        let err = runtime_binary_path().unwrap_err();
1577        assert_eq!(err.kind(), io::ErrorKind::NotFound);
1578        assert!(
1579            err.to_string().contains("runtime player not found"),
1580            "got: {err}"
1581        );
1582    }
1583
1584    #[test]
1585    fn backend_label_passes_through_an_unknown_key() {
1586        // A platform key the label table does not recognise is echoed back
1587        // verbatim rather than dropped, so an odd stamp still reads sensibly.
1588        assert_eq!(backend_label("wgpu"), "wgpu");
1589    }
1590}