use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use concinnity_cook::authoring::world::WorldJsonlAsset;
use concinnity_cook::build_from_path;
use concinnity_cook::build_only::prepare_world;
use concinnity_cook::paths::StateTree;
use crate::command::resolve_world_path;
struct AppMeta {
display_name: String,
identifier: String,
version: String,
icon: Option<PathBuf>,
}
pub fn export(
json_path: Option<&str>,
name: Option<&str>,
version: Option<&str>,
platform: Option<&str>,
out: &str,
format: &str,
dmg: bool,
) -> io::Result<()> {
let make_zip = match format {
"zip" => true,
"dir" => false,
other => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown --format '{other}' (expected 'zip' or 'dir')"),
));
}
};
if dmg && !cfg!(target_os = "macos") {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"--dmg is only available when exporting on macOS",
));
}
check_target_platform(platform)?;
let runtime = runtime_binary_path()?;
let runtime_platform = read_runtime_platform(&runtime)?;
verify_runtime_backend(runtime_platform.as_deref(), crate::cook_platform())?;
let world_path = resolve_world_path(json_path)?;
let tree = crate::project::require()?;
build_from_path(&tree, &world_path, crate::cook_platform())?;
let content = fs::read_to_string(&world_path)?;
let loaded = prepare_world(
&content,
crate::project::assets_dir().as_deref(),
crate::cook_platform(),
)
.map_err(|errs| io::Error::new(io::ErrorKind::InvalidData, errs.join("\n")))?;
let meta = read_app_meta(name, version, &loaded.assets);
let out_dir = Path::new(out);
fs::create_dir_all(out_dir)?;
let data_dir = crate::project::data_dir().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"no project state directory to read the built blobs from",
)
})?;
if cfg!(target_os = "macos") {
export_macos(&meta, &runtime, out_dir, &data_dir, make_zip, dmg)
} else {
export_portable(
&meta,
&runtime,
runtime_platform.as_deref(),
out_dir,
&data_dir,
make_zip,
)
}
}
fn export_portable(
meta: &AppMeta,
runtime: &Path,
runtime_platform: Option<&str>,
out_dir: &Path,
data_dir: &Path,
make_zip: bool,
) -> io::Result<()> {
let slug = slug(&meta.display_name);
let bundle_dir = out_dir.join(&slug);
reset_dir(&bundle_dir)?;
let exe_name = exe_file_name(&slug);
let exe_dst = bundle_dir.join(&exe_name);
fs::copy(runtime, &exe_dst)?;
make_executable(&exe_dst)?;
copy_runtime_sidecars(runtime, runtime_platform, &bundle_dir)?;
let blobs = copy_blobs(data_dir, &StateTree::at(&bundle_dir).data_dir())?;
precompile_shaders(&bundle_dir);
report_export(&meta.display_name, &bundle_dir, blobs);
if make_zip {
let stem = artifact_stem(meta, platform_tag(std::env::consts::OS));
let zip_path = out_dir.join(format!("{stem}.zip"));
zip_tree(&bundle_dir, &slug, &exe_name, &zip_path)?;
println!("Wrote {}", zip_path.display());
}
Ok(())
}
fn export_macos(
meta: &AppMeta,
runtime: &Path,
out_dir: &Path,
data_dir: &Path,
make_zip: bool,
make_dmg: bool,
) -> io::Result<()> {
let slug = slug(&meta.display_name);
let app_dir = out_dir.join(format!("{slug}.app"));
reset_dir(&app_dir)?;
let contents = app_dir.join("Contents");
let macos_dir = contents.join("MacOS");
let resources = contents.join("Resources");
fs::create_dir_all(&macos_dir)?;
fs::create_dir_all(&resources)?;
let exe_dst = macos_dir.join(&slug);
fs::copy(runtime, &exe_dst)?;
make_executable(&exe_dst)?;
let blobs = copy_blobs(data_dir, &StateTree::at(&resources).data_dir())?;
precompile_shaders(&resources);
let icon_file = Some(match &meta.icon {
Some(src) => build_icns(src, &resources, &slug)?,
None => build_default_icns(&resources, &slug)?,
});
fs::write(
contents.join("Info.plist"),
info_plist(meta, &slug, icon_file.as_deref()),
)?;
report_export(&meta.display_name, &app_dir, blobs);
let stem = artifact_stem(meta, platform_tag(std::env::consts::OS));
if make_zip {
let zip_path = out_dir.join(format!("{stem}.zip"));
let exe_rel = format!("Contents/MacOS/{slug}");
zip_tree(&app_dir, &format!("{slug}.app"), &exe_rel, &zip_path)?;
println!("Wrote {}", zip_path.display());
}
if make_dmg {
let dmg_path = out_dir.join(format!("{stem}.dmg"));
build_dmg(&app_dir, &slug, &meta.display_name, &dmg_path)?;
println!("Wrote {}", dmg_path.display());
}
Ok(())
}
#[cfg(any(backend_dx, backend_vk))]
fn precompile_shaders(state_dir: &Path) {
if cfg!(test) {
return;
}
println!("Compiling built-in shaders...");
let report = concinnity_engine::precompile_builtin_shaders(state_dir);
println!(
" cached {} shader binaries ({} compiled, {} reused)",
report.cached(),
report.compiled,
report.reused
);
for failure in &report.failed {
eprintln!(
"warning: shader precompile failed ({failure}); it will compile on \
the bundle's first launch"
);
}
}
#[cfg(not(any(backend_dx, backend_vk)))]
fn precompile_shaders(_state_dir: &Path) {}
fn report_export(display_name: &str, bundle: &Path, blobs: usize) {
println!(
"Exported \"{}\" -> {} ({} blob{})",
display_name,
bundle.display(),
blobs,
if blobs == 1 { "" } else { "s" },
);
}
fn reset_dir(dir: &Path) -> io::Result<()> {
if dir.exists() {
fs::remove_dir_all(dir)?;
}
fs::create_dir_all(dir)
}
fn copy_blobs(data_src: &Path, data_dst: &Path) -> io::Result<usize> {
let blobs = blobs_in(data_src)?;
if blobs.is_empty() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("no compiled blobs in {}", data_src.display()),
));
}
let _ = fs::remove_file(data_dst);
let _ = fs::remove_dir_all(data_dst);
if let [(_, only)] = blobs.as_slice() {
if let Some(parent) = data_dst.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(only, data_dst)?;
return Ok(1);
}
fs::create_dir_all(data_dst)?;
for (index, path) in &blobs {
fs::copy(path, data_dst.join(index.to_string()))?;
}
Ok(blobs.len())
}
fn blobs_in(dir: &Path) -> io::Result<Vec<(u32, PathBuf)>> {
let mut blobs = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if let Some(index) = blob_index(&name) {
blobs.push((index, entry.path()));
}
}
blobs.sort_by_key(|(index, _)| *index);
Ok(blobs)
}
fn blob_index(name: &str) -> Option<u32> {
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
name.parse().ok()
}
fn check_target_platform(platform: Option<&str>) -> io::Result<()> {
let Some(requested) = platform else {
return Ok(());
};
let host = std::env::consts::OS;
if normalize_platform(requested) == host {
return Ok(());
}
Err(io::Error::new(
io::ErrorKind::Unsupported,
format!(
"cross-platform export is not supported yet: this `cn` targets '{host}'. \
Run `cn export` on a {requested} machine to produce a {requested} build."
),
))
}
fn normalize_platform(p: &str) -> &str {
match p.to_lowercase().as_str() {
"mac" | "macos" | "osx" | "darwin" => "macos",
"win" | "windows" => "windows",
"linux" => "linux",
_ => "",
}
}
fn runtime_binary_path() -> io::Result<PathBuf> {
let cn = std::env::current_exe()?;
let dir = cn
.parent()
.ok_or_else(|| io::Error::other("cannot locate the cn executable's directory"))?;
let path = dir.join(exe_file_name("concinnity-run"));
if path.exists() {
Ok(path)
} else {
Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"runtime player not found at {} -- the `concinnity-run` binary must sit \
beside the `cn` executable (in a dev checkout, build it with \
`cargo build --features player`)",
path.display()
),
))
}
}
const RUNTIME_PLATFORM_MARKER: &[u8] = b"cn-runtime-platform:";
fn read_runtime_platform(runtime: &Path) -> io::Result<Option<String>> {
let bytes = fs::read(runtime)?;
let found = find_platform_stamp(&bytes);
if found.is_none() {
eprintln!(
"warning: no backend stamp found in {}; skipping the runtime/cook \
backend check (rebuild `concinnity-run` to enable it)",
runtime.display()
);
}
Ok(found)
}
fn verify_runtime_backend(
found: Option<&str>,
cooked: concinnity_cook::platform::Platform,
) -> io::Result<()> {
let expected = cooked.key();
match found {
None => Ok(()),
Some(found) if found == expected => Ok(()),
Some(found) => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"runtime/cook backend mismatch: this `cn` cooks {} blobs, but the \
`concinnity-run` player beside it was built for {}. Rebuild the \
runtime for the same backend (`cargo build --features player{}`) \
before exporting.",
backend_label(expected),
backend_label(found),
feature_hint(expected),
),
)),
}
}
fn find_platform_stamp(bytes: &[u8]) -> Option<String> {
let start = find_subslice(bytes, RUNTIME_PLATFORM_MARKER)? + RUNTIME_PLATFORM_MARKER.len();
let rest = &bytes[start..];
let end = rest
.iter()
.position(|b| !b.is_ascii_lowercase())
.unwrap_or(rest.len());
let token = &rest[..end];
if token.is_empty() {
None
} else {
std::str::from_utf8(token).ok().map(str::to_string)
}
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
haystack.windows(needle.len()).position(|w| w == needle)
}
fn backend_label(platform_key: &str) -> &str {
match platform_key {
"metal" => "Metal (metallib)",
"hlsl" => "DirectX (DXBC)",
"glsl" => "Vulkan (SPIR-V)",
other => other,
}
}
fn feature_hint(platform_key: &str) -> &str {
match platform_key {
"glsl" => ",vulkan",
_ => "",
}
}
fn exe_file_name(stem: &str) -> String {
if cfg!(windows) {
format!("{stem}.exe")
} else {
stem.to_string()
}
}
#[cfg(unix)]
fn make_executable(path: &Path) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> io::Result<()> {
Ok(())
}
fn read_app_meta(
cli_name: Option<&str>,
cli_version: Option<&str>,
assets: &[WorldJsonlAsset],
) -> AppMeta {
let display_name = resolve_display_name(cli_name, assets);
let identifier =
string_arg(assets, "appconfig", "id").unwrap_or_else(|| derive_identifier(&display_name));
let version = cli_version
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| string_arg(assets, "appconfig", "version"))
.unwrap_or_else(|| "0.1.0".to_string());
let icon = string_arg(assets, "appconfig", "icon").map(PathBuf::from);
AppMeta {
display_name,
identifier,
version,
icon,
}
}
fn resolve_display_name(cli_name: Option<&str>, assets: &[WorldJsonlAsset]) -> String {
if let Some(n) = cli_name.map(str::trim).filter(|s| !s.is_empty()) {
return n.to_string();
}
if let Some(n) = string_arg(assets, "appconfig", "name") {
return n;
}
if let Some(n) = string_arg(assets, "mainmenu", "title") {
return n;
}
"Concinnity".to_string()
}
fn derive_identifier(name: &str) -> String {
let mut comp = String::new();
let mut pending_dash = false;
for c in name.chars() {
if c.is_ascii_alphanumeric() {
if pending_dash && !comp.is_empty() {
comp.push('-');
}
pending_dash = false;
comp.push(c.to_ascii_lowercase());
} else {
pending_dash = true;
}
}
let comp = comp.trim_matches('-');
if comp.is_empty() {
"gg.concinnity.app".to_string()
} else {
format!("gg.concinnity.{comp}")
}
}
fn string_arg(assets: &[WorldJsonlAsset], type_norm: &str, key: &str) -> Option<String> {
assets
.iter()
.find(|a| normalize_type(&a.asset_type) == type_norm)
.and_then(|a| a.args.get(key))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn normalize_type(t: &str) -> String {
t.to_lowercase().replace('_', "")
}
fn slug(name: &str) -> String {
let mut out = String::new();
let mut pending_dash = false;
for c in name.chars() {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
if pending_dash && !out.is_empty() {
out.push('-');
}
pending_dash = false;
out.push(c);
} else {
pending_dash = true;
}
}
let trimmed = out.trim_matches('-');
if trimmed.is_empty() {
"app".to_string()
} else {
trimmed.to_string()
}
}
fn platform_tag(os: &str) -> &str {
match os {
"macos" => "mac",
"windows" => "win",
"linux" => "linux",
other => other,
}
}
fn artifact_stem(meta: &AppMeta, platform: &str) -> String {
format!(
"{}-{}-{}",
slug(&meta.display_name),
slug(&meta.version),
platform
)
}
fn info_plist(meta: &AppMeta, exe_name: &str, icon_file: Option<&str>) -> String {
let icon_entry = match icon_file {
Some(icon) => format!(
"\t<key>CFBundleIconFile</key>\n\t<string>{}</string>\n",
xml_escape(icon)
),
None => String::new(),
};
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
\t<key>CFBundleName</key>\n\t<string>{name}</string>\n\
\t<key>CFBundleDisplayName</key>\n\t<string>{name}</string>\n\
\t<key>CFBundleExecutable</key>\n\t<string>{exe}</string>\n\
\t<key>CFBundleIdentifier</key>\n\t<string>{id}</string>\n\
\t<key>CFBundleVersion</key>\n\t<string>{ver}</string>\n\
\t<key>CFBundleShortVersionString</key>\n\t<string>{ver}</string>\n\
\t<key>CFBundlePackageType</key>\n\t<string>APPL</string>\n\
\t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\
{icon}\
\t<key>LSMinimumSystemVersion</key>\n\t<string>11.0</string>\n\
\t<key>NSHighResolutionCapable</key>\n\t<true/>\n\
\t<key>NSPrincipalClass</key>\n\t<string>NSApplication</string>\n\
</dict>\n\
</plist>\n",
name = xml_escape(&meta.display_name),
exe = xml_escape(exe_name),
id = xml_escape(&meta.identifier),
ver = xml_escape(&meta.version),
icon = icon_entry,
)
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn build_icns(src: &Path, resources: &Path, slug: &str) -> io::Result<String> {
if !src.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("AppConfig icon not found: {}", src.display()),
));
}
let iconset = std::env::temp_dir().join(format!("cn-export-{slug}.iconset"));
reset_dir(&iconset)?;
for size in [16u32, 32, 128, 256, 512] {
for scale in [1u32, 2] {
let px = size * scale;
let suffix = if scale == 2 { "@2x" } else { "" };
let dst = iconset.join(format!("icon_{size}x{size}{suffix}.png"));
run_tool(
"sips",
&[
"-z",
&px.to_string(),
&px.to_string(),
&src.to_string_lossy(),
"--out",
&dst.to_string_lossy(),
],
)?;
}
}
let icns_name = format!("{slug}.icns");
let icns_path = resources.join(&icns_name);
run_tool(
"iconutil",
&[
"-c",
"icns",
&iconset.to_string_lossy(),
"-o",
&icns_path.to_string_lossy(),
],
)?;
let _ = fs::remove_dir_all(&iconset);
Ok(icns_name)
}
const DEFAULT_ICON_PNG: &[u8] = include_bytes!("../assets/default-icon.png");
fn build_default_icns(resources: &Path, slug: &str) -> io::Result<String> {
let tmp = std::env::temp_dir().join(format!("cn-default-icon-{slug}.png"));
fs::write(&tmp, DEFAULT_ICON_PNG)?;
let result = build_icns(&tmp, resources, slug);
let _ = fs::remove_file(&tmp);
result
}
fn build_dmg(app_dir: &Path, slug: &str, volume_name: &str, dmg_path: &Path) -> io::Result<()> {
let staging = std::env::temp_dir().join(format!("cn-export-{slug}-dmg"));
reset_dir(&staging)?;
copy_tree(app_dir, &staging.join(format!("{slug}.app")))?;
if dmg_path.exists() {
fs::remove_file(dmg_path)?;
}
run_tool(
"hdiutil",
&[
"create",
"-volname",
volume_name,
"-srcfolder",
&staging.to_string_lossy(),
"-ov",
"-format",
"UDZO",
&dmg_path.to_string_lossy(),
],
)?;
let _ = fs::remove_dir_all(&staging);
Ok(())
}
fn run_tool(program: &str, args: &[&str]) -> io::Result<()> {
let output = std::process::Command::new(program)
.args(args)
.output()
.map_err(|e| io::Error::new(e.kind(), format!("failed to run `{program}`: {e}")))?;
if output.status.success() {
Ok(())
} else {
Err(io::Error::other(format!(
"`{program}` failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
)))
}
}
fn copy_runtime_sidecars(
runtime: &Path,
runtime_platform: Option<&str>,
dest_dir: &Path,
) -> io::Result<()> {
let Some(src_dir) = runtime.parent() else {
return Ok(());
};
for entry in fs::read_dir(src_dir)? {
let path = entry?.path();
let is_dll = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("dll"));
if is_dll
&& path.is_file()
&& let Some(name) = path.file_name()
{
fs::copy(&path, dest_dir.join(name))?;
}
}
if runtime_wants_d3d12(runtime_platform) {
let d3d12 = src_dir.join("D3D12");
if d3d12.is_dir() {
copy_tree(&d3d12, &dest_dir.join("D3D12"))?;
}
}
Ok(())
}
fn runtime_wants_d3d12(runtime_platform: Option<&str>) -> bool {
!matches!(runtime_platform, Some("glsl") | Some("metal"))
}
fn copy_tree(src: &Path, dst: &Path) -> io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dst.join(entry.file_name());
if from.is_dir() {
copy_tree(&from, &to)?;
} else {
fs::copy(&from, &to)?;
}
}
Ok(())
}
fn zip_tree(src_dir: &Path, top: &str, exe_rel: &str, zip_path: &Path) -> io::Result<()> {
use std::io::Write;
use zip::write::SimpleFileOptions;
let mut files = Vec::new();
collect_files(src_dir, &mut files)?;
files.sort();
let file = fs::File::create(zip_path)?;
let mut zw = zip::ZipWriter::new(file);
for path in files {
let rel = path
.strip_prefix(src_dir)
.map_err(io::Error::other)?
.to_string_lossy()
.replace('\\', "/");
let mode = if rel == exe_rel { 0o755 } else { 0o644 };
let options = SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(mode);
zw.start_file(format!("{top}/{rel}"), options)
.map_err(io::Error::other)?;
let bytes = fs::read(&path)?;
zw.write_all(&bytes)?;
}
zw.finish().map_err(io::Error::other)?;
Ok(())
}
fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
collect_files(&path, out)?;
} else {
out.push(path);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn asset(name: &str, ty: &str, args: serde_json::Value) -> WorldJsonlAsset {
WorldJsonlAsset {
name: name.to_string(),
asset_type: ty.to_string(),
args,
}
}
#[test]
fn slug_is_filesystem_safe() {
assert_eq!(slug("My Game"), "My-Game");
assert_eq!(slug(" Spaced Out "), "Spaced-Out");
assert_eq!(slug("weird:/name*?"), "weird-name");
assert_eq!(slug("keep_dots.and-dashes"), "keep_dots.and-dashes");
assert_eq!(slug("***"), "app");
assert_eq!(slug(""), "app");
}
#[test]
fn blob_index_matches_only_integer_files() {
assert_eq!(blob_index("0"), Some(0));
assert_eq!(blob_index("42"), Some(42));
assert_eq!(blob_index(""), None);
assert_eq!(blob_index("0.metallib"), None);
assert_eq!(blob_index("default_vertex_shader.air"), None);
assert_eq!(blob_index("settings"), None);
}
#[test]
fn name_precedence_is_cli_then_app_config_then_menu_then_default() {
let app = asset("app", "AppConfig", serde_json::json!({"name": "App Name"}));
let menu = asset("m", "MainMenu", serde_json::json!({"title": "Menu Title"}));
assert_eq!(
resolve_display_name(Some("CLI Name"), &[app.clone(), menu.clone()]),
"CLI Name"
);
assert_eq!(
resolve_display_name(None, &[app.clone(), menu.clone()]),
"App Name"
);
assert_eq!(
resolve_display_name(None, std::slice::from_ref(&menu)),
"Menu Title"
);
assert_eq!(resolve_display_name(None, &[]), "Concinnity");
assert_eq!(resolve_display_name(Some(" "), &[app]), "App Name");
}
#[test]
fn normalize_platform_accepts_aliases() {
assert_eq!(normalize_platform("macOS"), "macos");
assert_eq!(normalize_platform("Darwin"), "macos");
assert_eq!(normalize_platform("win"), "windows");
assert_eq!(normalize_platform("Linux"), "linux");
assert_eq!(normalize_platform("solaris"), "");
}
#[test]
fn host_platform_is_accepted_and_others_rejected() {
check_target_platform(None).unwrap();
check_target_platform(Some(std::env::consts::OS)).unwrap();
let foreign = if std::env::consts::OS == "windows" {
"linux"
} else {
"windows"
};
assert!(check_target_platform(Some(foreign)).is_err());
}
#[test]
fn app_meta_derives_id_and_version_defaults() {
let meta = read_app_meta(Some("My Cool App"), None, &[]);
assert_eq!(meta.display_name, "My Cool App");
assert_eq!(meta.identifier, "gg.concinnity.my-cool-app");
assert_eq!(meta.version, "0.1.0");
assert!(meta.icon.is_none());
let app = asset(
"app",
"AppConfig",
serde_json::json!({
"name": "Named", "id": "gg.studio.thing", "version": "2.3.4", "icon": "art/i.png"
}),
);
let meta = read_app_meta(None, None, std::slice::from_ref(&app));
assert_eq!(meta.display_name, "Named");
assert_eq!(meta.identifier, "gg.studio.thing");
assert_eq!(meta.version, "2.3.4");
assert_eq!(meta.icon.as_deref(), Some(Path::new("art/i.png")));
}
#[test]
fn version_precedence_is_cli_then_application_then_default() {
let app = asset("app", "AppConfig", serde_json::json!({"version": "2.3.4"}));
assert_eq!(
read_app_meta(None, Some("9.9.9"), std::slice::from_ref(&app)).version,
"9.9.9"
);
assert_eq!(
read_app_meta(None, Some(" "), std::slice::from_ref(&app)).version,
"2.3.4"
);
assert_eq!(read_app_meta(None, Some("3.0"), &[]).version, "3.0");
assert_eq!(read_app_meta(None, None, &[]).version, "0.1.0");
}
#[test]
fn platform_tag_maps_host_os() {
assert_eq!(platform_tag("macos"), "mac");
assert_eq!(platform_tag("windows"), "win");
assert_eq!(platform_tag("linux"), "linux");
assert_eq!(platform_tag("freebsd"), "freebsd");
}
#[test]
fn artifact_stem_is_slug_version_platform() {
let meta = AppMeta {
display_name: "My Game".to_string(),
identifier: "gg.studio.mg".to_string(),
version: "1.0.0".to_string(),
icon: None,
};
assert_eq!(artifact_stem(&meta, "mac"), "My-Game-1.0.0-mac");
let meta = AppMeta {
version: "1.0.0+build 7".to_string(),
..meta
};
assert_eq!(artifact_stem(&meta, "win"), "My-Game-1.0.0-build-7-win");
}
#[test]
fn info_plist_has_required_keys_and_escapes() {
let meta = AppMeta {
display_name: "Tom & Jerry".to_string(),
identifier: "gg.studio.tj".to_string(),
version: "1.0".to_string(),
icon: None,
};
let plist = info_plist(&meta, "tj", Some("tj.icns"));
assert!(plist.contains("<key>CFBundleExecutable</key>\n\t<string>tj</string>"));
assert!(plist.contains("<key>CFBundleIdentifier</key>\n\t<string>gg.studio.tj</string>"));
assert!(plist.contains("<key>CFBundleShortVersionString</key>\n\t<string>1.0</string>"));
assert!(plist.contains("<key>CFBundleIconFile</key>\n\t<string>tj.icns</string>"));
assert!(plist.contains("Tom & Jerry"));
assert!(!plist.contains("Tom & Jerry"));
let plist = info_plist(&meta, "tj", None);
assert!(!plist.contains("CFBundleIconFile"));
}
#[test]
fn copy_runtime_sidecars_copies_dlls_and_the_d3d12_dir() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
fs::create_dir_all(src.join("D3D12")).unwrap();
fs::write(src.join("concinnity-run.exe"), b"exe").unwrap();
fs::write(src.join("amd_fidelityfx_dx12.dll"), b"x").unwrap();
fs::write(src.join("libconcinnity_ffi.dll"), b"x").unwrap();
fs::write(src.join("notes.txt"), b"x").unwrap();
fs::write(src.join("D3D12").join("D3D12Core.dll"), b"x").unwrap();
let dest = tmp.path().join("dest");
fs::create_dir_all(&dest).unwrap();
copy_runtime_sidecars(&src.join("concinnity-run.exe"), Some("hlsl"), &dest).unwrap();
assert!(dest.join("amd_fidelityfx_dx12.dll").exists());
assert!(dest.join("libconcinnity_ffi.dll").exists());
assert!(dest.join("D3D12").join("D3D12Core.dll").exists());
assert!(!dest.join("notes.txt").exists());
assert!(!dest.join("concinnity-run.exe").exists());
}
#[test]
fn copy_runtime_sidecars_skips_d3d12_for_a_non_dx_runtime() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
fs::create_dir_all(src.join("D3D12")).unwrap();
fs::write(src.join("concinnity-run.exe"), b"exe").unwrap();
fs::write(src.join("amd_fidelityfx_vk.dll"), b"x").unwrap();
fs::write(src.join("D3D12").join("D3D12Core.dll"), b"x").unwrap();
let dest = tmp.path().join("dest");
fs::create_dir_all(&dest).unwrap();
copy_runtime_sidecars(&src.join("concinnity-run.exe"), Some("glsl"), &dest).unwrap();
assert!(dest.join("amd_fidelityfx_vk.dll").exists());
assert!(!dest.join("D3D12").exists());
}
#[test]
fn runtime_wants_d3d12_only_for_dx_or_unknown() {
assert!(runtime_wants_d3d12(Some("hlsl")));
assert!(runtime_wants_d3d12(None));
assert!(!runtime_wants_d3d12(Some("glsl")));
assert!(!runtime_wants_d3d12(Some("metal")));
}
#[test]
fn find_platform_stamp_reads_the_token_after_the_marker() {
let mut buf = vec![0xAAu8, 0x00, 0xFF, b'x'];
buf.extend_from_slice(b"cn-runtime-platform:hlsl\0");
buf.extend_from_slice(&[0x01, 0x02, 0x03]);
assert_eq!(find_platform_stamp(&buf).as_deref(), Some("hlsl"));
for token in ["metal", "hlsl", "glsl"] {
let stamp = format!("cn-runtime-platform:{token}\0");
assert_eq!(
find_platform_stamp(stamp.as_bytes()).as_deref(),
Some(token)
);
}
}
#[test]
fn find_platform_stamp_is_none_without_the_marker() {
assert_eq!(find_platform_stamp(b"no stamp here"), None);
assert_eq!(find_platform_stamp(b""), None);
assert_eq!(find_platform_stamp(b"cn-runtime-platform:\0"), None);
}
#[test]
fn find_subslice_locates_and_reports_absence() {
assert_eq!(find_subslice(b"abcdef", b"cd"), Some(2));
assert_eq!(find_subslice(b"abcdef", b"abc"), Some(0));
assert_eq!(find_subslice(b"abcdef", b"xy"), None);
assert_eq!(find_subslice(b"ab", b"abc"), None);
assert_eq!(find_subslice(b"abc", b""), None);
}
#[test]
fn backend_label_and_feature_hint_cover_each_platform() {
assert_eq!(backend_label("metal"), "Metal (metallib)");
assert_eq!(backend_label("hlsl"), "DirectX (DXBC)");
assert_eq!(backend_label("glsl"), "Vulkan (SPIR-V)");
assert_eq!(feature_hint("glsl"), ",vulkan");
assert_eq!(feature_hint("hlsl"), "");
assert_eq!(feature_hint("metal"), "");
}
#[test]
fn default_icon_is_a_nonempty_png() {
assert!(DEFAULT_ICON_PNG.len() > 1024);
assert_eq!(&DEFAULT_ICON_PNG[..8], b"\x89PNG\r\n\x1a\n");
}
#[test]
fn copy_runtime_sidecars_is_a_noop_without_dlls() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
fs::create_dir_all(&src).unwrap();
fs::write(src.join("concinnity-run"), b"exe").unwrap();
fs::write(src.join("libconcinnity_ffi.dylib"), b"x").unwrap();
let dest = tmp.path().join("dest");
fs::create_dir_all(&dest).unwrap();
copy_runtime_sidecars(&src.join("concinnity-run"), Some("metal"), &dest).unwrap();
assert_eq!(fs::read_dir(&dest).unwrap().count(), 0);
}
#[test]
fn export_rejects_an_unknown_format_up_front() {
let err = export(None, None, None, None, "out", "tarball", false).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(err.to_string().contains("tarball"), "got: {err}");
}
#[cfg(not(target_os = "macos"))]
#[test]
fn export_rejects_dmg_off_macos() {
let err = export(None, None, None, None, "out", "zip", true).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
}
#[test]
fn derive_identifier_reduces_names_to_bundle_id_form() {
assert_eq!(derive_identifier("My Game"), "gg.concinnity.my-game");
assert_eq!(
derive_identifier("Space: Above!"),
"gg.concinnity.space-above"
);
assert_eq!(derive_identifier("***"), "gg.concinnity.app");
assert_eq!(derive_identifier(""), "gg.concinnity.app");
}
#[test]
fn xml_escape_escapes_every_entity() {
assert_eq!(
xml_escape(r#"<a href="x">Tom & Jerry's</a>"#),
"<a href="x">Tom & Jerry's</a>"
);
assert_eq!(xml_escape("plain"), "plain");
}
#[test]
fn verify_runtime_backend_accepts_matching_or_unstamped() {
let cooked = concinnity_cook::platform::Platform::Metal;
verify_runtime_backend(None, cooked).unwrap();
verify_runtime_backend(Some(cooked.key()), cooked).unwrap();
let err = verify_runtime_backend(Some("hlsl"), cooked).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
assert!(err.to_string().contains("mismatch"), "got: {err}");
}
#[test]
fn read_runtime_platform_reads_a_stamp_or_warns_none() {
let tmp = tempfile::tempdir().unwrap();
let stamped = tmp.path().join("stamped");
fs::write(&stamped, b"junk cn-runtime-platform:metal\0 junk").unwrap();
assert_eq!(
read_runtime_platform(&stamped).unwrap().as_deref(),
Some("metal")
);
let unstamped = tmp.path().join("unstamped");
fs::write(&unstamped, b"no marker in here").unwrap();
assert_eq!(read_runtime_platform(&unstamped).unwrap(), None);
}
#[test]
fn copy_blobs_takes_only_integer_named_files() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("data");
fs::create_dir_all(&src).unwrap();
fs::write(src.join("0"), b"blob0").unwrap();
fs::write(src.join("12"), b"blob12").unwrap();
fs::write(src.join("default_vert.air"), b"scratch").unwrap();
fs::write(src.join("settings"), b"state").unwrap();
let dst = tmp.path().join("out");
let count = copy_blobs(&src, &dst).unwrap();
assert_eq!(count, 2);
assert!(dst.is_dir(), "an overflowing world ships a directory");
assert_eq!(fs::read(dst.join("0")).unwrap(), b"blob0");
assert_eq!(fs::read(dst.join("12")).unwrap(), b"blob12");
assert!(!dst.join("default_vert.air").exists());
assert!(!dst.join("settings").exists());
}
#[test]
fn a_single_blob_world_ships_as_one_file() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("data");
fs::create_dir_all(&src).unwrap();
fs::write(src.join("0"), b"blob0").unwrap();
fs::write(src.join("default_vert.air"), b"scratch").unwrap();
let dst = tmp.path().join("bundle").join("data");
assert_eq!(copy_blobs(&src, &dst).unwrap(), 1);
assert!(dst.is_file(), "one blob ships as the `data` file itself");
assert_eq!(fs::read(&dst).unwrap(), b"blob0");
}
#[test]
fn re_exporting_across_the_form_boundary_replaces_the_previous_shape() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("data");
fs::create_dir_all(&src).unwrap();
fs::write(src.join("0"), b"blob0").unwrap();
fs::write(src.join("1"), b"blob1").unwrap();
let dst = tmp.path().join("bundle").join("data");
assert_eq!(copy_blobs(&src, &dst).unwrap(), 2);
assert!(dst.is_dir());
fs::remove_file(src.join("1")).unwrap();
assert_eq!(copy_blobs(&src, &dst).unwrap(), 1);
assert!(dst.is_file());
assert_eq!(fs::read(&dst).unwrap(), b"blob0");
fs::write(src.join("1"), b"blob1").unwrap();
assert_eq!(copy_blobs(&src, &dst).unwrap(), 2);
assert!(dst.is_dir());
assert_eq!(fs::read(dst.join("1")).unwrap(), b"blob1");
}
#[test]
fn copy_blobs_refuses_a_data_dir_with_no_blobs() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("data");
fs::create_dir_all(&src).unwrap();
fs::write(src.join("default_vert.air"), b"scratch").unwrap();
let err = copy_blobs(&src, &tmp.path().join("out")).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
#[test]
fn reset_dir_clears_previous_content() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("bundle");
fs::create_dir_all(dir.join("nested")).unwrap();
fs::write(dir.join("nested").join("stale"), b"old").unwrap();
reset_dir(&dir).unwrap();
assert!(dir.exists());
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
}
#[test]
fn copy_tree_copies_nested_directories() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
fs::create_dir_all(src.join("a").join("b")).unwrap();
fs::write(src.join("top.txt"), b"1").unwrap();
fs::write(src.join("a").join("b").join("deep.txt"), b"2").unwrap();
let dst = tmp.path().join("dst");
copy_tree(&src, &dst).unwrap();
assert_eq!(fs::read(dst.join("top.txt")).unwrap(), b"1");
assert_eq!(
fs::read(dst.join("a").join("b").join("deep.txt")).unwrap(),
b"2"
);
}
#[test]
fn collect_files_walks_the_whole_tree() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("tree");
fs::create_dir_all(dir.join("sub")).unwrap();
fs::write(dir.join("a"), b"1").unwrap();
fs::write(dir.join("sub").join("b"), b"2").unwrap();
let mut files = Vec::new();
collect_files(&dir, &mut files).unwrap();
files.sort();
assert_eq!(files, vec![dir.join("a"), dir.join("sub").join("b")]);
}
#[test]
fn zip_tree_archives_under_a_top_folder() {
let tmp = tempfile::tempdir().unwrap();
let bundle = tmp.path().join("My-Game");
fs::create_dir_all(bundle.join("data")).unwrap();
fs::write(bundle.join("My-Game"), b"player").unwrap();
fs::write(bundle.join("data").join("0"), b"blob").unwrap();
let zip_path = tmp.path().join("My-Game-1.0.0-mac.zip");
zip_tree(&bundle, "My-Game", "My-Game", &zip_path).unwrap();
let file = fs::File::open(&zip_path).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
let names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
assert_eq!(archive.len(), 2);
assert!(
names.contains(&"My-Game/My-Game".to_string()),
"got: {names:?}"
);
assert!(
names.contains(&"My-Game/data/0".to_string()),
"got: {names:?}"
);
let exe = archive.by_name("My-Game/My-Game").unwrap();
assert_eq!(exe.unix_mode().map(|m| m & 0o777), Some(0o755));
}
#[test]
fn build_icns_rejects_a_missing_source_before_running_tools() {
let tmp = tempfile::tempdir().unwrap();
let err = build_icns(&tmp.path().join("missing.png"), tmp.path(), "slug").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
#[test]
fn run_tool_reports_success_failure_and_missing() {
#[cfg(windows)]
{
run_tool("cmd", &["/C", "exit 0"]).unwrap();
let err = run_tool("cmd", &["/C", "exit 1"]).unwrap_err();
assert!(err.to_string().contains("cmd"), "got: {err}");
}
#[cfg(not(windows))]
{
run_tool("true", &[]).unwrap();
let err = run_tool("false", &[]).unwrap_err();
assert!(err.to_string().contains("false"), "got: {err}");
}
let err = run_tool("cn-nonexistent-tool-xyz", &[]).unwrap_err();
assert!(
err.to_string().contains("cn-nonexistent-tool-xyz"),
"got: {err}"
);
}
#[cfg(unix)]
#[test]
fn make_executable_sets_the_exec_bit() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let f = tmp.path().join("player");
fs::write(&f, b"bin").unwrap();
assert_eq!(fs::metadata(&f).unwrap().permissions().mode() & 0o111, 0);
make_executable(&f).unwrap();
assert_ne!(fs::metadata(&f).unwrap().permissions().mode() & 0o111, 0);
}
#[test]
fn export_portable_assembles_folder_and_zip() {
let tmp = tempfile::tempdir().unwrap();
let data = tmp.path().join("data");
fs::create_dir_all(&data).unwrap();
fs::write(data.join("0"), b"blob0").unwrap();
fs::write(data.join("1"), b"blob1").unwrap();
fs::write(data.join("scratch.air"), b"ignored").unwrap();
let runtime = tmp.path().join(exe_file_name("concinnity-run"));
fs::write(&runtime, b"runtime-bin").unwrap();
let out = tmp.path().join("out");
fs::create_dir_all(&out).unwrap();
let meta = AppMeta {
display_name: "My Game".to_string(),
identifier: "gg.studio.mg".to_string(),
version: "1.0.0".to_string(),
icon: None,
};
export_portable(&meta, &runtime, Some("metal"), &out, &data, true).unwrap();
let bundle = out.join("My-Game");
let exe = bundle.join(exe_file_name("My-Game"));
assert!(exe.exists(), "renamed player missing");
assert_eq!(fs::read(&exe).unwrap(), b"runtime-bin");
assert!(bundle.join("data").join("0").exists());
assert!(bundle.join("data").join("1").exists());
assert!(!bundle.join("data").join("scratch.air").exists());
let stem = artifact_stem(&meta, platform_tag(std::env::consts::OS));
assert!(out.join(format!("{stem}.zip")).exists(), "zip missing");
}
#[cfg(target_os = "macos")]
#[test]
fn export_macos_builds_app_bundle_with_default_icon() {
let tmp = tempfile::tempdir().unwrap();
let data = tmp.path().join("data");
fs::create_dir_all(&data).unwrap();
fs::write(data.join("0"), b"blob0").unwrap();
let runtime = tmp.path().join("concinnity-run");
fs::write(&runtime, b"runtime-bin").unwrap();
let out = tmp.path().join("out");
fs::create_dir_all(&out).unwrap();
let meta = AppMeta {
display_name: "My Game".to_string(),
identifier: "gg.studio.mg".to_string(),
version: "1.0.0".to_string(),
icon: None,
};
export_macos(&meta, &runtime, &out, &data, true, false).unwrap();
let app = out.join("My-Game.app");
assert!(app.join("Contents/MacOS/My-Game").exists(), "exe missing");
let data_entry = app.join("Contents/Resources/data");
assert!(data_entry.is_file(), "blob missing");
assert_eq!(fs::read(&data_entry).unwrap(), b"blob0");
assert!(
app.join("Contents/Resources/My-Game.icns").exists(),
"icns missing"
);
let plist = fs::read_to_string(app.join("Contents/Info.plist")).unwrap();
assert!(plist.contains("<string>My-Game.icns</string>"));
assert!(plist.contains("gg.studio.mg"));
let stem = artifact_stem(&meta, platform_tag(std::env::consts::OS));
assert!(out.join(format!("{stem}.zip")).exists(), "zip missing");
}
#[cfg(target_os = "macos")]
#[test]
fn build_icns_produces_an_icns_from_a_valid_png() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("icon.png");
fs::write(&src, DEFAULT_ICON_PNG).unwrap();
let resources = tmp.path().join("Resources");
fs::create_dir_all(&resources).unwrap();
let name = build_icns(&src, &resources, "app").unwrap();
assert_eq!(name, "app.icns");
assert!(resources.join("app.icns").exists());
}
#[test]
fn runtime_binary_path_errors_when_the_player_is_absent() {
let err = runtime_binary_path().unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
assert!(
err.to_string().contains("runtime player not found"),
"got: {err}"
);
}
#[test]
fn backend_label_passes_through_an_unknown_key() {
assert_eq!(backend_label("wgpu"), "wgpu");
}
}