use std::path::{Path, PathBuf};
use foglet_game::{ConfigError, FogletManifest, GameConfig, ManifestError, ManifestInputs};
pub const GAME_TOML_RELATIVE: &str = "assets/game.toml";
#[derive(Debug, thiserror::Error)]
pub enum EmitManifestError {
#[error("--install-dir must be an absolute path (starts with `/`); got `{0}`")]
InstallDirNotAbsolute(String),
#[error("failed to load game config from `{path}`: {source}")]
Config {
path: String,
#[source]
source: ConfigError,
},
#[error("manifest validation failed: {0}")]
Manifest(#[from] ManifestError),
}
pub fn build_manifest(
project_dir: &Path,
install_dir: &str,
) -> Result<FogletManifest, EmitManifestError> {
if !install_dir.starts_with('/') {
return Err(EmitManifestError::InstallDirNotAbsolute(
install_dir.to_string(),
));
}
let config_path = config_path(project_dir);
let config = GameConfig::load(&config_path).map_err(|source| EmitManifestError::Config {
path: config_path.display().to_string(),
source,
})?;
let mut manifest = FogletManifest::new(ManifestInputs {
slug: &config.game.slug,
display_name: &config.game.title,
description: &config.game.description,
install_dir,
})?;
manifest.timeout_ms = config.manifest.timeout_ms;
manifest.idle_timeout_ms = config.manifest.idle_timeout_ms;
manifest.visibility = config.manifest.visibility.clone();
manifest.auth_scope = config.manifest.auth_scope.clone();
manifest.validate()?;
Ok(manifest)
}
pub fn emit_manifest_json(
project_dir: &Path,
install_dir: &str,
) -> Result<String, EmitManifestError> {
let manifest = build_manifest(project_dir, install_dir)?;
Ok(manifest
.to_json_pretty()
.expect("FogletManifest serializes to JSON"))
}
fn config_path(project_dir: &Path) -> PathBuf {
project_dir.join(GAME_TOML_RELATIVE)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
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_config(toml: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
let assets = dir.path().join("assets");
fs::create_dir_all(&assets).expect("mkdir assets");
fs::write(assets.join("game.toml"), toml).expect("write game.toml");
dir
}
#[test]
fn emits_canonical_manifest_shape() {
let project = project_with_config(CANONICAL_GAME_TOML);
let json = emit_manifest_json(project.path(), "/srv/foglet/doors/murder-motel")
.expect("manifest emits");
let v: serde_json::Value = serde_json::from_str(&json).expect("emitted JSON parses");
assert_eq!(v["id"], "murder-motel");
assert_eq!(v["slug"], "murder-motel");
assert_eq!(v["display_name"], "Murder Motel");
assert_eq!(v["runtime"], "external_pty");
assert_eq!(v["command"], "/srv/foglet/doors/murder-motel/run.sh");
assert_eq!(v["working_dir"], "/srv/foglet/doors/murder-motel");
assert_eq!(v["timeout_ms"], 1_800_000);
assert_eq!(v["idle_timeout_ms"], 300_000);
assert_eq!(v["visibility"], "members");
assert_eq!(v["auth_scope"], "site");
assert_eq!(v["pty"], true);
assert!(json.ends_with('\n'), "operators expect a trailing newline");
}
#[test]
fn rejects_relative_install_dir() {
let project = project_with_config(CANONICAL_GAME_TOML);
let err = emit_manifest_json(project.path(), "relative/path")
.expect_err("relative path must be rejected");
match err {
EmitManifestError::InstallDirNotAbsolute(value) => {
assert_eq!(value, "relative/path");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn rejects_empty_install_dir() {
let project = project_with_config(CANONICAL_GAME_TOML);
let err = emit_manifest_json(project.path(), "").expect_err("empty path must be rejected");
assert!(matches!(err, EmitManifestError::InstallDirNotAbsolute(_)));
}
#[test]
fn applies_manifest_overrides_from_config() {
let toml = r#"
[game]
title = "Tight Timeouts"
slug = "tight-timeouts"
description = "Pin shorter caps than the defaults."
min_width = 80
min_height = 24
start_map = "lobby"
start_x = 1
start_y = 1
[manifest]
timeout_ms = 600000
idle_timeout_ms = 60000
visibility = "public"
auth_scope = "none"
"#;
let project = project_with_config(toml);
let manifest = build_manifest(project.path(), "/srv/foglet/doors/tight-timeouts")
.expect("manifest builds");
assert_eq!(manifest.timeout_ms, 600_000);
assert_eq!(manifest.idle_timeout_ms, 60_000);
assert_eq!(manifest.visibility, "public");
assert_eq!(manifest.auth_scope, "none");
}
#[test]
fn inherits_defaults_when_manifest_section_omitted() {
let toml = 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
"#;
let project = project_with_config(toml);
let manifest =
build_manifest(project.path(), "/srv/foglet/doors/murder-motel").expect("builds");
assert_eq!(manifest.timeout_ms, 1_800_000);
assert_eq!(manifest.idle_timeout_ms, 300_000);
assert_eq!(manifest.visibility, "members");
assert_eq!(manifest.auth_scope, "site");
}
#[test]
fn surfaces_missing_config_with_path_in_error() {
let dir = TempDir::new().expect("tempdir");
let err = emit_manifest_json(dir.path(), "/srv/foglet/doors/x")
.expect_err("missing game.toml must error");
match err {
EmitManifestError::Config { path, .. } => {
assert!(
path.ends_with("assets/game.toml"),
"error path should point at assets/game.toml, got `{path}`"
);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn surfaces_malformed_config_as_config_error() {
let project = project_with_config("not = valid = toml");
let err = emit_manifest_json(project.path(), "/srv/foglet/doors/x")
.expect_err("garbage TOML must error");
assert!(matches!(err, EmitManifestError::Config { .. }));
}
#[test]
fn normalizes_trailing_slash_on_install_dir() {
let project = project_with_config(CANONICAL_GAME_TOML);
let manifest = build_manifest(project.path(), "/srv/foglet/doors/murder-motel/")
.expect("trailing slash is fine");
assert_eq!(manifest.command, "/srv/foglet/doors/murder-motel/run.sh");
assert_eq!(manifest.working_dir, "/srv/foglet/doors/murder-motel");
}
}