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