use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use foglet_game::{ConfigError, GameConfig};
use crate::emit_manifest::{self, EmitManifestError, GAME_TOML_RELATIVE};
const RUN_SH_TEMPLATE: &str = r#"#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
USER_ID="${FOGLET_USER_ID:-local-dev}"
exec "$DIR/{slug}" \
--assets "$DIR/assets" \
--save-dir "${FGK_SAVE_DIR:-$DIR/saves/$USER_ID}"
"#;
#[derive(Debug, thiserror::Error)]
pub enum PackageError {
#[error("failed to load game config from `{path}`: {source}")]
Config {
path: String,
#[source]
source: ConfigError,
},
#[error(transparent)]
Manifest(#[from] EmitManifestError),
#[error("output directory `{0}` already exists and is not empty — pass a fresh path or empty directory")]
OutDirNotEmpty(PathBuf),
#[error("expected release binary at `{0}` but it does not exist — pass `--binary <path>` if your project builds it elsewhere")]
BinaryMissing(PathBuf),
#[error("`cargo build --release` failed (exit status {0}) — see cargo's stderr above")]
CargoBuildFailed(i32),
#[error("failed to spawn `cargo`: {0}")]
CargoSpawn(#[source] std::io::Error),
#[error("filesystem error at `{path}`: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
pub type PackageResult<T> = Result<T, PackageError>;
#[derive(Debug, Clone)]
pub struct PackageInputs<'a> {
pub project_dir: &'a Path,
pub out_dir: &'a Path,
pub install_dir: Option<&'a str>,
}
#[derive(Debug, Clone)]
pub struct PackageOutputs {
pub out_dir: PathBuf,
pub binary: PathBuf,
pub run_sh: PathBuf,
pub manifest: PathBuf,
pub assets: PathBuf,
pub world_dir: Option<PathBuf>,
pub slug: String,
}
pub fn package_project(inputs: PackageInputs<'_>) -> PackageResult<PackageOutputs> {
let slug = load_slug(inputs.project_dir)?;
cargo_build_release(inputs.project_dir)?;
let binary = inputs
.project_dir
.join("target")
.join("release")
.join(&slug);
assemble_bundle(inputs, &binary)
}
pub fn assemble_bundle(inputs: PackageInputs<'_>, binary: &Path) -> PackageResult<PackageOutputs> {
let PackageInputs {
project_dir,
out_dir,
install_dir,
} = inputs;
if !binary.is_file() {
return Err(PackageError::BinaryMissing(binary.to_path_buf()));
}
let config = load_config(project_dir)?;
let slug = config.game.slug.clone();
let install_dir_owned = match install_dir {
Some(s) => s.to_string(),
None => format!("/srv/foglet/doors/{slug}"),
};
ensure_out_dir(out_dir)?;
let dest_binary = out_dir.join(&slug);
copy_file(binary, &dest_binary)?;
set_executable(&dest_binary)?;
let run_sh_path = out_dir.join("run.sh");
let run_sh_body = render_run_sh(&slug);
write_file(&run_sh_path, run_sh_body.as_bytes())?;
set_executable(&run_sh_path)?;
let manifest_path = out_dir.join("manifest.json");
let manifest_body = emit_manifest::emit_manifest_json(project_dir, &install_dir_owned)?;
write_file(&manifest_path, manifest_body.as_bytes())?;
let dest_assets = out_dir.join("assets");
let src_assets = project_dir.join("assets");
copy_dir_recursive(&src_assets, &dest_assets)?;
let world_dir = if config.world.enabled {
materialize_world_dir(out_dir, &config.world.path)?
} else {
None
};
Ok(PackageOutputs {
out_dir: out_dir.to_path_buf(),
binary: dest_binary,
run_sh: run_sh_path,
manifest: manifest_path,
assets: dest_assets,
world_dir,
slug,
})
}
fn materialize_world_dir(out_dir: &Path, world_path: &str) -> PackageResult<Option<PathBuf>> {
let parent = Path::new(world_path).parent();
let Some(parent) = parent else {
return Ok(None);
};
if parent.as_os_str().is_empty() {
return Ok(None);
}
let world_dir = out_dir.join(parent);
fs::create_dir_all(&world_dir).map_err(|source| PackageError::Io {
path: world_dir.clone(),
source,
})?;
let keep = world_dir.join(".keep");
write_file(&keep, b"")?;
Ok(Some(world_dir))
}
pub fn render_run_sh(slug: &str) -> String {
RUN_SH_TEMPLATE.replace("{slug}", slug)
}
fn load_slug(project_dir: &Path) -> PackageResult<String> {
Ok(load_config(project_dir)?.game.slug)
}
fn load_config(project_dir: &Path) -> PackageResult<GameConfig> {
let path = project_dir.join(GAME_TOML_RELATIVE);
GameConfig::load(&path).map_err(|source| PackageError::Config {
path: path.display().to_string(),
source,
})
}
fn ensure_out_dir(out_dir: &Path) -> PackageResult<()> {
match fs::read_dir(out_dir) {
Ok(mut entries) => {
if entries.next().is_some() {
Err(PackageError::OutDirNotEmpty(out_dir.to_path_buf()))
} else {
Ok(())
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir_all(out_dir).map_err(|source| PackageError::Io {
path: out_dir.to_path_buf(),
source,
})
}
Err(source) => Err(PackageError::Io {
path: out_dir.to_path_buf(),
source,
}),
}
}
fn cargo_build_release(project_dir: &Path) -> PackageResult<()> {
let status = Command::new("cargo")
.args(["build", "--release"])
.current_dir(project_dir)
.status()
.map_err(PackageError::CargoSpawn)?;
if status.success() {
Ok(())
} else {
Err(PackageError::CargoBuildFailed(status.code().unwrap_or(-1)))
}
}
fn copy_file(src: &Path, dest: &Path) -> PackageResult<()> {
fs::copy(src, dest)
.map(|_| ())
.map_err(|source| PackageError::Io {
path: dest.to_path_buf(),
source,
})
}
fn write_file(path: &Path, bytes: &[u8]) -> PackageResult<()> {
fs::write(path, bytes).map_err(|source| PackageError::Io {
path: path.to_path_buf(),
source,
})
}
fn copy_dir_recursive(src: &Path, dest: &Path) -> PackageResult<()> {
fs::create_dir_all(dest).map_err(|source| PackageError::Io {
path: dest.to_path_buf(),
source,
})?;
let entries = fs::read_dir(src).map_err(|source| PackageError::Io {
path: src.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| PackageError::Io {
path: src.to_path_buf(),
source,
})?;
let from = entry.path();
let to = dest.join(entry.file_name());
let file_type = entry.file_type().map_err(|source| PackageError::Io {
path: from.clone(),
source,
})?;
if file_type.is_dir() {
copy_dir_recursive(&from, &to)?;
} else if file_type.is_file() {
copy_file(&from, &to)?;
}
}
Ok(())
}
#[cfg(unix)]
fn set_executable(path: &Path) -> PackageResult<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)
.map_err(|source| PackageError::Io {
path: path.to_path_buf(),
source,
})?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms).map_err(|source| PackageError::Io {
path: path.to_path_buf(),
source,
})
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> PackageResult<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
const CANONICAL_GAME_TOML: &str = r#"
[game]
title = "Murder Motel"
slug = "murder-motel"
description = "A tiny BBS mystery built with foglet-game-kit."
min_width = 80
min_height = 24
start_map = "lobby"
start_x = 12
start_y = 8
[save]
strategy = "per_foglet_user"
[manifest]
timeout_ms = 1800000
idle_timeout_ms = 300000
visibility = "members"
auth_scope = "site"
"#;
fn project_with_assets(toml: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
let assets = dir.path().join("assets");
let maps = assets.join("maps");
fs::create_dir_all(&maps).expect("mkdir assets/maps");
fs::write(assets.join("game.toml"), toml).expect("write game.toml");
fs::write(maps.join("lobby.txt"), "..#..\n.....\n").expect("write lobby.txt");
dir
}
fn fake_binary(td: &TempDir, name: &str) -> PathBuf {
let p = td.path().join(name);
fs::write(&p, b"#!/bin/sh\necho fake\n").expect("write fake binary");
p
}
#[test]
fn run_sh_renders_correctly_for_canonical_slug() {
let expected = "#!/usr/bin/env bash\n\
set -euo pipefail\n\
\n\
DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\
USER_ID=\"${FOGLET_USER_ID:-local-dev}\"\n\
\n\
exec \"$DIR/murder-motel\" \\\n \
--assets \"$DIR/assets\" \\\n \
--save-dir \"${FGK_SAVE_DIR:-$DIR/saves/$USER_ID}\"\n";
assert_eq!(render_run_sh("murder-motel"), expected);
}
#[test]
fn run_sh_substitutes_slug() {
let body = render_run_sh("test-game");
assert!(body.contains(r#"exec "$DIR/test-game""#));
assert!(!body.contains("{slug}"));
}
#[test]
fn assemble_writes_full_layout() {
let project = project_with_assets(CANONICAL_GAME_TOML);
let bin_td = TempDir::new().unwrap();
let bin = fake_binary(&bin_td, "murder-motel");
let out_td = TempDir::new().unwrap();
let out = out_td.path().join("dist");
let outputs = assemble_bundle(
PackageInputs {
project_dir: project.path(),
out_dir: &out,
install_dir: Some("/srv/foglet/doors/murder-motel"),
},
&bin,
)
.expect("assemble");
assert_eq!(outputs.slug, "murder-motel");
assert!(outputs.binary.is_file(), "binary missing");
assert!(outputs.run_sh.is_file(), "run.sh missing");
assert!(outputs.manifest.is_file(), "manifest.json missing");
assert!(outputs.assets.is_dir(), "assets/ missing");
let copied_map = outputs.assets.join("maps").join("lobby.txt");
assert!(copied_map.is_file(), "nested asset not copied");
assert_eq!(
fs::read_to_string(&copied_map).unwrap(),
"..#..\n.....\n",
"asset content must match the source"
);
}
#[test]
fn assemble_marks_binary_and_run_sh_executable() {
let project = project_with_assets(CANONICAL_GAME_TOML);
let bin_td = TempDir::new().unwrap();
let bin = fake_binary(&bin_td, "murder-motel");
let out_td = TempDir::new().unwrap();
let out = out_td.path().join("dist");
let outputs = assemble_bundle(
PackageInputs {
project_dir: project.path(),
out_dir: &out,
install_dir: Some("/srv/foglet/doors/murder-motel"),
},
&bin,
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let bin_mode = fs::metadata(&outputs.binary).unwrap().permissions().mode();
let run_mode = fs::metadata(&outputs.run_sh).unwrap().permissions().mode();
assert_eq!(bin_mode & 0o777, 0o755, "binary must be 0o755");
assert_eq!(run_mode & 0o777, 0o755, "run.sh must be 0o755");
}
let _ = outputs;
}
#[test]
fn assemble_writes_valid_manifest_json() {
let project = project_with_assets(CANONICAL_GAME_TOML);
let bin_td = TempDir::new().unwrap();
let bin = fake_binary(&bin_td, "murder-motel");
let out_td = TempDir::new().unwrap();
let out = out_td.path().join("dist");
let outputs = assemble_bundle(
PackageInputs {
project_dir: project.path(),
out_dir: &out,
install_dir: Some("/srv/foglet/doors/murder-motel"),
},
&bin,
)
.unwrap();
let body = fs::read_to_string(&outputs.manifest).unwrap();
let v: serde_json::Value = serde_json::from_str(&body).expect("manifest is valid JSON");
assert_eq!(v["slug"], "murder-motel");
assert_eq!(v["command"], "/srv/foglet/doors/murder-motel/run.sh");
assert_eq!(v["working_dir"], "/srv/foglet/doors/murder-motel");
assert_eq!(v["runtime"], "external_pty");
}
#[test]
fn assemble_defaults_install_dir_from_slug_when_unset() {
let project = project_with_assets(CANONICAL_GAME_TOML);
let bin_td = TempDir::new().unwrap();
let bin = fake_binary(&bin_td, "murder-motel");
let out_td = TempDir::new().unwrap();
let out = out_td.path().join("dist");
let outputs = assemble_bundle(
PackageInputs {
project_dir: project.path(),
out_dir: &out,
install_dir: None,
},
&bin,
)
.unwrap();
let body = fs::read_to_string(&outputs.manifest).unwrap();
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["command"], "/srv/foglet/doors/murder-motel/run.sh");
}
#[test]
fn assemble_rejects_relative_install_dir() {
let project = project_with_assets(CANONICAL_GAME_TOML);
let bin_td = TempDir::new().unwrap();
let bin = fake_binary(&bin_td, "murder-motel");
let out_td = TempDir::new().unwrap();
let out = out_td.path().join("dist");
let err = assemble_bundle(
PackageInputs {
project_dir: project.path(),
out_dir: &out,
install_dir: Some("relative/path"),
},
&bin,
)
.expect_err("relative install_dir must be rejected");
assert!(
matches!(
err,
PackageError::Manifest(EmitManifestError::InstallDirNotAbsolute(_))
),
"expected InstallDirNotAbsolute, got {err:?}"
);
}
#[test]
fn assemble_rejects_non_empty_out_dir() {
let project = project_with_assets(CANONICAL_GAME_TOML);
let bin_td = TempDir::new().unwrap();
let bin = fake_binary(&bin_td, "murder-motel");
let out_td = TempDir::new().unwrap();
let out = out_td.path().join("dist");
fs::create_dir_all(&out).unwrap();
fs::write(out.join("EXISTING"), b"hi").unwrap();
let err = assemble_bundle(
PackageInputs {
project_dir: project.path(),
out_dir: &out,
install_dir: Some("/srv/foglet/doors/murder-motel"),
},
&bin,
)
.expect_err("non-empty out_dir must be rejected");
assert!(matches!(err, PackageError::OutDirNotEmpty(_)));
assert_eq!(fs::read_to_string(out.join("EXISTING")).unwrap(), "hi");
}
#[test]
fn assemble_into_existing_empty_dir_succeeds() {
let project = project_with_assets(CANONICAL_GAME_TOML);
let bin_td = TempDir::new().unwrap();
let bin = fake_binary(&bin_td, "murder-motel");
let out_td = TempDir::new().unwrap();
let out = out_td.path().join("dist");
fs::create_dir_all(&out).unwrap();
let outputs = assemble_bundle(
PackageInputs {
project_dir: project.path(),
out_dir: &out,
install_dir: Some("/srv/foglet/doors/murder-motel"),
},
&bin,
)
.expect("empty existing dir is fine");
assert!(outputs.binary.is_file());
}
#[test]
fn assemble_errors_when_binary_missing() {
let project = project_with_assets(CANONICAL_GAME_TOML);
let out_td = TempDir::new().unwrap();
let out = out_td.path().join("dist");
let missing = out_td.path().join("nope");
let err = assemble_bundle(
PackageInputs {
project_dir: project.path(),
out_dir: &out,
install_dir: Some("/srv/foglet/doors/murder-motel"),
},
&missing,
)
.expect_err("missing binary must error");
assert!(matches!(err, PackageError::BinaryMissing(_)));
assert!(!out.exists(), "out_dir created despite validation failure");
}
const CANONICAL_GAME_TOML_WITH_WORLD: &str = r#"
[game]
title = "Murder Motel"
slug = "murder-motel"
description = "A tiny BBS mystery built with foglet-game-kit."
min_width = 80
min_height = 24
start_map = "lobby"
start_x = 12
start_y = 8
[save]
strategy = "per_foglet_user"
[manifest]
timeout_ms = 1800000
idle_timeout_ms = 300000
visibility = "members"
auth_scope = "site"
[world]
enabled = true
"#;
#[test]
fn assemble_skips_world_dir_when_world_not_enabled() {
let project = project_with_assets(CANONICAL_GAME_TOML);
let bin_td = TempDir::new().unwrap();
let bin = fake_binary(&bin_td, "murder-motel");
let out_td = TempDir::new().unwrap();
let out = out_td.path().join("dist");
let outputs = assemble_bundle(
PackageInputs {
project_dir: project.path(),
out_dir: &out,
install_dir: Some("/srv/foglet/doors/murder-motel"),
},
&bin,
)
.unwrap();
assert!(
outputs.world_dir.is_none(),
"world_dir set for game without world enabled"
);
assert!(
!out.join("world").exists(),
"world/ directory created for game without world enabled"
);
}
#[test]
fn assemble_creates_world_keep_for_enabled_world() {
let project = project_with_assets(CANONICAL_GAME_TOML_WITH_WORLD);
let bin_td = TempDir::new().unwrap();
let bin = fake_binary(&bin_td, "murder-motel");
let out_td = TempDir::new().unwrap();
let out = out_td.path().join("dist");
let outputs = assemble_bundle(
PackageInputs {
project_dir: project.path(),
out_dir: &out,
install_dir: Some("/srv/foglet/doors/murder-motel"),
},
&bin,
)
.unwrap();
let world_dir = outputs.world_dir.expect("world_dir reported");
assert_eq!(world_dir, out.join("world"));
assert!(world_dir.is_dir(), "world/ must be a directory");
let keep = world_dir.join(".keep");
assert!(keep.is_file(), "world/.keep must exist");
assert_eq!(fs::read(&keep).unwrap(), b"");
}
#[test]
fn assemble_honours_custom_world_path() {
let toml = r#"
[game]
title = "Custom World"
slug = "custom-world"
description = ""
min_width = 80
min_height = 24
start_map = "lobby"
start_x = 0
start_y = 0
[world]
enabled = true
path = "db/state/world.db"
"#;
let project = project_with_assets(toml);
let bin_td = TempDir::new().unwrap();
let bin = fake_binary(&bin_td, "custom-world");
let out_td = TempDir::new().unwrap();
let out = out_td.path().join("dist");
let outputs = assemble_bundle(
PackageInputs {
project_dir: project.path(),
out_dir: &out,
install_dir: Some("/srv/foglet/doors/custom-world"),
},
&bin,
)
.unwrap();
let world_dir = outputs.world_dir.expect("world_dir reported");
assert_eq!(world_dir, out.join("db").join("state"));
assert!(world_dir.join(".keep").is_file());
}
#[test]
fn assemble_skips_world_dir_when_world_path_has_no_parent() {
let toml = r#"
[game]
title = "Bare World"
slug = "bare-world"
description = ""
min_width = 80
min_height = 24
start_map = "lobby"
start_x = 0
start_y = 0
[world]
enabled = true
path = "world.sqlite"
"#;
let project = project_with_assets(toml);
let bin_td = TempDir::new().unwrap();
let bin = fake_binary(&bin_td, "bare-world");
let out_td = TempDir::new().unwrap();
let out = out_td.path().join("dist");
let outputs = assemble_bundle(
PackageInputs {
project_dir: project.path(),
out_dir: &out,
install_dir: Some("/srv/foglet/doors/bare-world"),
},
&bin,
)
.unwrap();
assert!(outputs.world_dir.is_none());
assert!(!out.join(".keep").exists());
}
#[test]
fn run_sh_never_touches_world_directory() {
for slug in ["murder-motel", "test-game"] {
let body = render_run_sh(slug);
let lower = body.to_ascii_lowercase();
assert!(
!lower.contains("world"),
"run.sh for `{slug}` references `world` — the wrapper must \
leave the shared-world directory entirely to the runtime: \
{body}"
);
for forbidden in ["rm ", "rm\t", "rmdir", "mkfs", "dd "] {
assert!(
!body.contains(forbidden),
"run.sh for `{slug}` contains forbidden command \
fragment `{forbidden}` — wrappers must not mutate \
the bundle filesystem at launch:\n{body}"
);
}
}
}
#[cfg(unix)]
#[test]
fn rendered_run_sh_passes_bash_syntax_check() {
let body = render_run_sh("murder-motel");
let td = TempDir::new().unwrap();
let p = td.path().join("run.sh");
fs::write(&p, &body).unwrap();
let status = Command::new("bash").arg("-n").arg(&p).status();
if let Ok(status) = status {
assert!(status.success(), "bash -n rejected the rendered run.sh");
}
}
}