Bevy Forge 0.1.0: visual scene editor for Bevy 0.20 BSN

This commit is contained in:
Emil Shanaty
2026-10-27 18:52:10 +03:00
commit 13bf16d510
79 changed files with 44353 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "bevy_forge_cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description = "Command line tools for Bevy Forge scenes and projects"
[[bin]]
name = "forge"
path = "src/main.rs"
[dependencies]
bevy_forge_core = { path = "../bevy_forge_core" }
clap.workspace = true
serde_json.workspace = true
anyhow.workspace = true
[dev-dependencies]
tempfile.workspace = true
+424
View File
@@ -0,0 +1,424 @@
//! Implementations of the `forge` subcommands.
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use bevy_forge_core::{
validate_scene, AssetIndex, CodegenOptions, ComponentData, ExportFormat, Issue, Project,
SceneDoc, Severity,
};
use serde_json::{json, Value};
use crate::scaffold;
use crate::{Commands, FormatArg, TemplateArg};
/// Execute one `forge` subcommand, returning its process exit code.
pub(crate) fn run(command: Commands) -> Result<u8> {
match command {
Commands::New {
dir,
name,
template,
} => new_project(&dir, name.as_deref(), template),
Commands::Validate {
scene,
project,
json,
} => validate(&scene, project.as_deref(), json),
Commands::Export {
scene,
format,
output,
function,
} => export(&scene, format, output.as_deref(), &function),
Commands::Info { scene, json } => info(&scene, json),
Commands::Scenes { project } => scenes(&project),
}
}
// ---------------------------------------------------------------------------
// new
// ---------------------------------------------------------------------------
fn new_project(dir: &Path, name: Option<&str>, template: TemplateArg) -> Result<u8> {
let project_template = template.into();
let created = scaffold::create(dir, name, project_template)?;
let mut output = String::new();
output.push_str(&format!(
"Created Bevy project `{}` at {}\n",
created.name,
created.root.display()
));
output.push_str(&format!(" template: {}\n", template_label(template)));
output.push_str(&format!(" scene: {}\n", created.scene_path.display()));
output.push_str("\nNext steps:\n");
output.push_str(&format!(" cd {}\n", created.root.display()));
output.push_str(" cargo run\n");
output.push_str(" forge validate scenes/demo.forge.ron\n");
if matches!(template, TemplateArg::Empty) {
output
.push_str(" # add entities in Bevy Forge before exporting the empty starter scene\n");
} else {
output.push_str(" forge export scenes/demo.forge.ron --format bsn -o src/scene.rs\n");
}
print_stdout(&output)?;
Ok(0)
}
fn template_label(template: TemplateArg) -> &'static str {
match template {
TemplateArg::Empty => "empty",
TemplateArg::TwoD => "2D",
TemplateArg::ThreeD => "3D",
}
}
// ---------------------------------------------------------------------------
// validate
// ---------------------------------------------------------------------------
fn validate(scene: &Path, project_arg: Option<&Path>, json: bool) -> Result<u8> {
let project = resolve_project(scene, project_arg)?;
let document = load_scene(scene, project.as_ref())?;
let assets = project
.as_ref()
.map(|project| AssetIndex::scan(&project.assets_dir));
if let Some(error) = assets.as_ref().and_then(|assets| assets.error.as_deref()) {
eprintln!("forge: warning: could not scan assets: {error}");
}
let report = validate_scene(&document, assets.as_ref());
if json {
let issues: Vec<Value> = report.sorted().into_iter().map(issue_json).collect();
let payload = json!({
"valid": report.is_ok(),
"errors": report.errors(),
"warnings": report.warnings(),
"issues": issues,
});
print_stdout(&format!("{}\n", serde_json::to_string(&payload)?))?;
} else {
let mut output = String::new();
for issue in report.sorted() {
output.push_str(&format_issue(issue));
output.push('\n');
}
output.push_str(&format!(
"Summary: {} errors, {} warnings, {} info — {}\n",
report.errors(),
report.warnings(),
report.count(Severity::Info),
if report.is_ok() { "valid" } else { "invalid" }
));
print_stdout(&output)?;
}
Ok(if report.is_ok() { 0 } else { 1 })
}
fn format_issue(issue: &Issue) -> String {
let mut line = issue.severity.label().to_string();
if let Some(entity) = issue.entity {
line.push_str(&format!(" [entity {entity}]"));
}
if let Some(field) = &issue.field {
line.push_str(&format!(" [{field}]"));
}
line.push(' ');
line.push_str(&issue.message);
line
}
fn issue_json(issue: &Issue) -> Value {
json!({
"severity": issue.severity.label(),
"entity": issue.entity,
"field": issue.field,
"message": issue.message,
})
}
// ---------------------------------------------------------------------------
// export
// ---------------------------------------------------------------------------
fn export(scene: &Path, format: FormatArg, output: Option<&Path>, function: &str) -> Result<u8> {
let document = read_scene_file(scene)?;
let export_format: ExportFormat = format.into();
let options = CodegenOptions {
function_name: function.to_string(),
// The CLI contract requires exported files to start with the Bevy
// prelude import; the editor adds its own attribution header for
// previews.
include_header: false,
};
let mut code = match export_format.generate(&document, &options) {
Ok(code) => code,
Err(error) => {
eprintln!("forge: export failed: {error}");
return Ok(1);
}
};
// The codegen contract promises a trailing newline; make sure we never
// write a file without one even if a future generator forgets it.
if !code.ends_with('\n') {
code.push('\n');
}
match output {
Some(path) => {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).with_context(|| {
format!("could not create output directory {}", parent.display())
})?;
}
}
fs::write(path, code.as_bytes())
.with_context(|| format!("could not write {}", path.display()))?;
print_stdout(&format!(
"Wrote {} to {}\n",
export_format.label(),
path.display()
))?;
}
None => print_stdout(&code)?,
}
Ok(0)
}
// ---------------------------------------------------------------------------
// info
// ---------------------------------------------------------------------------
fn info(scene: &Path, json: bool) -> Result<u8> {
let project = discover_project(scene);
let document = load_scene(scene, project.as_ref())?;
let assets = project
.as_ref()
.map(|project| AssetIndex::scan(&project.assets_dir));
if let Some(error) = assets.as_ref().and_then(|assets| assets.error.as_deref()) {
eprintln!("forge: warning: could not scan assets: {error}");
}
let report = validate_scene(&document, assets.as_ref());
let stats = document.stats();
let path = display_path(scene);
let mut component_counts: BTreeMap<String, usize> = BTreeMap::new();
let mut asset_references: Vec<AssetReference> = Vec::new();
for node in document.entities.values() {
for component in &node.components {
let key = component_key(component);
*component_counts.entry(key).or_insert(0) += 1;
for (field, path) in component.asset_references() {
asset_references.push(AssetReference {
entity: node.id,
field,
path: path.to_string(),
});
}
}
}
if json {
let components: BTreeMap<&str, usize> = component_counts
.iter()
.map(|(key, count)| (key.as_str(), *count))
.collect();
let references: Vec<Value> = asset_references
.iter()
.map(|reference| {
json!({
"entity": reference.entity,
"field": reference.field,
"path": reference.path,
})
})
.collect();
let project_json = project.as_ref().map(|project| {
json!({
"name": project.name,
"root": path_string(&project.root),
})
});
let payload = json!({
"name": document.name,
"path": path_string(&path),
"format_version": document.format_version,
"entities": stats.entities,
"roots": stats.roots,
"stats": {
"sprites": stats.sprites,
"meshes": stats.meshes,
"lights": stats.lights,
"cameras": stats.cameras,
"custom_components": stats.custom_components,
},
"components": components,
"asset_references": references,
"project": project_json,
"validation": {
"valid": report.is_ok(),
"errors": report.errors(),
"warnings": report.warnings(),
"info": report.count(Severity::Info),
},
});
print_stdout(&format!("{}\n", serde_json::to_string(&payload)?))?;
} else {
let mut output = String::new();
output.push_str(&format!("Scene: {}\n", document.name));
output.push_str(&format!("Path: {}\n", path.display()));
output.push_str(&format!("Format: version {}\n", document.format_version));
match &project {
Some(project) => output.push_str(&format!(
"Project: {} ({})\n",
project.name,
project.root.display()
)),
None => output.push_str("Project: (not found)\n"),
}
output.push_str(&format!(
"Entities: {} (roots: {})\n",
stats.entities, stats.roots
));
output.push_str(&format!(
"Stats: sprites {}, meshes {}, lights {}, cameras {}, custom components {}\n",
stats.sprites, stats.meshes, stats.lights, stats.cameras, stats.custom_components
));
if component_counts.is_empty() {
output.push_str("Components: (none)\n");
} else {
output.push_str("Components:\n");
for (key, count) in &component_counts {
output.push_str(&format!(" {key}: {count}\n"));
}
}
if asset_references.is_empty() {
output.push_str("Asset references: (none)\n");
} else {
output.push_str("Asset references:\n");
for reference in &asset_references {
output.push_str(&format!(
" [entity {}] {} {}\n",
reference.entity, reference.field, reference.path
));
}
}
output.push_str(&format!(
"Validation: {} errors, {} warnings, {} info ({})\n",
report.errors(),
report.warnings(),
report.count(Severity::Info),
if report.is_ok() { "valid" } else { "invalid" }
));
print_stdout(&output)?;
}
Ok(0)
}
struct AssetReference {
entity: u64,
field: &'static str,
path: String,
}
fn component_key(component: &ComponentData) -> String {
match component {
ComponentData::Custom(custom) => format!("custom:{}", custom.type_path),
other => other.key(),
}
}
// ---------------------------------------------------------------------------
// scenes
// ---------------------------------------------------------------------------
fn scenes(project_arg: &Path) -> Result<u8> {
let project = Project::open(project_arg)?;
let mut scenes = project.list_scenes();
scenes.sort();
let mut output = String::new();
for scene in scenes {
output.push_str(&path_string(&scene));
output.push('\n');
}
print_stdout(&output)?;
Ok(0)
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/// Resolve the project for a scene: explicit flag first, then walk up.
fn resolve_project(scene: &Path, explicit: Option<&Path>) -> Result<Option<Project>> {
match explicit {
Some(root) => Ok(Some(Project::open(root)?)),
None => Ok(discover_project(scene)),
}
}
/// Walk up from `scene`, returning the first directory that opens as a Bevy project.
fn discover_project(scene: &Path) -> Option<Project> {
let scene = std::path::absolute(scene).ok()?;
let mut current = scene.parent();
while let Some(directory) = current {
if directory.join("Cargo.toml").is_file() {
if let Ok(project) = Project::open(directory) {
return Some(project);
}
}
current = directory.parent();
}
None
}
/// Load a scene, using the project loader when a project is available.
fn load_scene(scene: &Path, project: Option<&Project>) -> Result<SceneDoc> {
match project {
Some(project) => Ok(project.load_scene(scene)?),
None => read_scene_file(scene),
}
}
/// Load a scene file that may live outside a Bevy project.
fn read_scene_file(scene: &Path) -> Result<SceneDoc> {
// `Project::load_scene` is a pure RON file reader; a minimal project
// description keeps parsing and version normalisation in one place for
// standalone scenes as well.
let root = scene
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let loader = Project {
root: root.clone(),
name: String::new(),
assets_dir: root,
bevy_version: None,
};
Ok(loader.load_scene(scene)?)
}
fn display_path(path: &Path) -> PathBuf {
fs::canonicalize(path)
.unwrap_or_else(|_| std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()))
}
fn path_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
/// Write to stdout without panicking on a broken pipe.
fn print_stdout(text: &str) -> Result<()> {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
handle
.write_all(text.as_bytes())
.context("could not write to stdout")?;
handle.flush().context("could not flush stdout")
}
+131
View File
@@ -0,0 +1,131 @@
//! `forge` — command line tools for Bevy Forge scenes.
//!
//! The binary validates and exports `.forge.ron` scenes and scaffolds new Bevy
//! 0.20 projects. Every subcommand prints human readable output by default and
//! supports machine-readable output where the contract asks for it (`--json`).
//!
//! Exit codes:
//!
//! * `0` — success (and `validate` found no errors)
//! * `1` — validation errors or a code generation failure
//! * `2` — usage, I/O, parse errors, or a directory that is not a Bevy project
mod commands;
mod scaffold;
use std::path::PathBuf;
use std::process::ExitCode;
use bevy_forge_core::{ExportFormat, ProjectTemplate};
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Debug, Parser)]
#[command(
name = "forge",
version,
about = "Validate, export and scaffold Bevy Forge scenes for Bevy 0.20 BSN",
propagate_version = true
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
pub(crate) enum Commands {
/// Create a new Bevy 0.20 project with a starter scene.
New {
/// Target directory (must not exist or be empty).
dir: PathBuf,
/// Cargo package name; defaults to a sanitised directory name.
#[arg(long, value_name = "NAME")]
name: Option<String>,
/// Starter scene template.
#[arg(long, value_enum, value_name = "TEMPLATE", default_value_t = TemplateArg::TwoD)]
template: TemplateArg,
},
/// Validate a .forge.ron scene and its asset references.
Validate {
/// Scene file to validate.
scene: PathBuf,
/// Project root; discovered by walking up from the scene when omitted.
#[arg(long, value_name = "DIR")]
project: Option<PathBuf>,
/// Machine-readable output.
#[arg(long)]
json: bool,
},
/// Export a scene as Bevy 0.20 BSN or imperative Rust code.
Export {
/// Scene file to export.
scene: PathBuf,
/// Generated code style.
#[arg(long, value_enum, value_name = "FORMAT", default_value_t = FormatArg::Bsn)]
format: FormatArg,
/// Output file; code is printed to stdout when omitted.
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
/// Name of the generated Rust function.
#[arg(long, value_name = "NAME", default_value = "scene")]
function: String,
},
/// Print scene information and validation status.
Info {
/// Scene file to inspect.
scene: PathBuf,
/// Machine-readable output.
#[arg(long)]
json: bool,
},
/// List the scenes of a Bevy project, one absolute path per line.
Scenes {
/// Bevy project root.
project: PathBuf,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub(crate) enum TemplateArg {
Empty,
#[value(name = "2d")]
TwoD,
#[value(name = "3d")]
ThreeD,
}
impl From<TemplateArg> for ProjectTemplate {
fn from(value: TemplateArg) -> Self {
match value {
TemplateArg::Empty => Self::Empty,
TemplateArg::TwoD => Self::TwoD,
TemplateArg::ThreeD => Self::ThreeD,
}
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub(crate) enum FormatArg {
Bsn,
Rust,
}
impl From<FormatArg> for ExportFormat {
fn from(value: FormatArg) -> Self {
match value {
FormatArg::Bsn => Self::Bsn,
FormatArg::Rust => Self::Rust,
}
}
}
fn main() -> ExitCode {
// Clap handles `--help` (exit 0) and usage errors (exit 2) itself.
let cli = Cli::parse();
match commands::run(cli.command) {
Ok(code) => ExitCode::from(code),
Err(error) => {
eprintln!("forge: {error}");
ExitCode::from(2)
}
}
}
+319
View File
@@ -0,0 +1,319 @@
//! Project scaffolding for `forge new`.
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use bevy_forge_core::codegen::{CodegenOptions, ExportFormat};
use bevy_forge_core::{samples, Project, ProjectTemplate};
/// Default scene written into a freshly scaffolded project.
const DEMO_SCENE_FILE: &str = "demo.forge.ron";
/// A project created by [`create`].
pub(crate) struct CreatedProject {
pub name: String,
pub root: PathBuf,
pub scene_path: PathBuf,
}
/// Create a Bevy 0.20 project in `dir` with a starter scene for `template`.
///
/// `dir` must not exist yet or must be an empty directory. Existing content is
/// never overwritten: the emptiness check runs before the first write.
pub(crate) fn create(
dir: &Path,
name: Option<&str>,
template: ProjectTemplate,
) -> Result<CreatedProject> {
ensure_empty_target(dir)?;
let package_name = match name {
Some(name) => validate_package_name(name)?,
None => default_package_name(dir),
};
fs::create_dir_all(dir)
.with_context(|| format!("could not create target directory {}", dir.display()))?;
for subdirectory in ["src", "assets", "scenes"] {
fs::create_dir_all(dir.join(subdirectory))
.with_context(|| format!("could not create {}", dir.join(subdirectory).display()))?;
}
write_file(&dir.join("Cargo.toml"), &manifest(&package_name))?;
write_file(&dir.join("assets/.gitkeep"), "")?;
write_file(&dir.join(".gitignore"), GITIGNORE)?;
write_file(&dir.join("README.md"), &readme(&package_name, template))?;
// Re-open the project so the starter scene goes through the same atomic
// serialisation path the editor uses.
let project = Project::open(dir).with_context(|| {
format!(
"could not open the newly created project at {}",
dir.display()
)
})?;
let document = samples::for_template(template);
let scene_path = project.scenes_dir().join(DEMO_SCENE_FILE);
project
.save_scene(&document, Some(&scene_path))
.with_context(|| {
format!(
"could not write the starter scene to {}",
scene_path.display()
)
})?;
// Make `cargo run` show the starter scene immediately instead of a blank
// window: generate the BSN scene module and call it from a startup system.
if template != ProjectTemplate::Empty {
let code = ExportFormat::Bsn
.generate(
&document,
&CodegenOptions {
function_name: "scene".to_string(),
include_header: true,
},
)
.context("could not generate starter BSN scene")?;
write_file(&dir.join("src/scene.rs"), &code)?;
let needs_meshes = code.contains("meshes: &mut Assets<Mesh>");
let main = if needs_meshes {
r#"//! Bevy 0.20 app scaffolded by Bevy Forge.
mod scene;
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.run();
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
commands.spawn_scene_list(scene::scene(&mut meshes, &mut materials));
}
"#
} else {
r#"//! Bevy 0.20 app scaffolded by Bevy Forge.
mod scene;
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.run();
}
fn setup(mut commands: Commands) {
commands.spawn_scene_list(scene::scene());
}
"#
};
write_file(&dir.join("src/main.rs"), main)?;
} else {
write_file(&dir.join("src/main.rs"), MAIN_RS)?;
}
Ok(CreatedProject {
name: project.name.clone(),
root: project.root.clone(),
scene_path,
})
}
/// Refuse to scaffold into a non-empty directory.
fn ensure_empty_target(dir: &Path) -> Result<()> {
if !dir.exists() {
return Ok(());
}
if !dir.is_dir() {
bail!(
"target path exists and is not a directory: {}",
dir.display()
);
}
let mut entries = fs::read_dir(dir)
.with_context(|| format!("could not read target directory {}", dir.display()))?;
if let Some(entry) = entries.next() {
let entry =
entry.with_context(|| format!("could not read the contents of {}", dir.display()))?;
bail!(
"the target directory already exists and is not empty: {} (found {})",
dir.display(),
entry.file_name().to_string_lossy()
);
}
Ok(())
}
/// Derive a Cargo package name from the target directory name.
fn default_package_name(dir: &Path) -> String {
let base = dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("bevy-forge-project");
let mut slug = String::new();
let mut last_was_dash = false;
for character in base.chars() {
let character = character.to_ascii_lowercase();
if character.is_ascii_alphanumeric() || character == '_' {
slug.push(character);
last_was_dash = false;
} else if !last_was_dash && !slug.is_empty() {
slug.push('-');
last_was_dash = true;
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
"bevy-forge-project".to_string()
} else if slug.starts_with(|character: char| character.is_ascii_digit()) {
format!("forge-{slug}")
} else {
slug
}
}
/// Reject `--name` values Cargo would refuse.
fn validate_package_name(name: &str) -> Result<String> {
if name.is_empty() {
bail!("invalid package name: the name must not be empty");
}
let valid_first = name
.chars()
.next()
.is_some_and(|character| character.is_ascii_alphabetic() || character == '_');
if !valid_first {
bail!("invalid package name {name:?}: it must start with an ASCII letter or '_'");
}
if !name
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '-')
{
bail!("invalid package name {name:?}: use letters, digits, '-' and '_' only");
}
Ok(name.to_string())
}
fn write_file(path: &Path, contents: &str) -> Result<()> {
fs::write(path, contents).with_context(|| format!("could not write {}", path.display()))
}
fn manifest(package_name: &str) -> String {
format!(
"[package]\n\
name = \"{package_name}\"\n\
version = \"0.1.0\"\n\
edition = \"2021\"\n\
\n\
[dependencies]\n\
bevy = \"0.20\"\n"
)
}
fn readme(package_name: &str, template: ProjectTemplate) -> String {
let scene_hint = match template {
ProjectTemplate::Empty => {
"`scenes/demo.forge.ron` is an empty starter scene. Add entities in the Bevy Forge\n\
editor, then export it to paste-ready Rust."
}
_ => {
"`scenes/demo.forge.ron` is a starter scene. Open it with the Bevy Forge\n\
editor, or export it to paste-ready Rust."
}
};
let spawn_hint = match template {
ProjectTemplate::Empty => {
"Once the scene has entities, export it with `forge export`; an empty scene\n\
cannot be exported until something is added."
}
_ => {
"The starter scenes have multiple roots, so spawn the exported function with\n\
`commands.spawn_scene_list(scene())`. A single-root scene uses\n\
`commands.spawn_scene(scene())`; mesh scenes also need the `meshes` and\n\
`materials` parameters. Add `use bevy::scene::SceneList` (or `Scene`) if the\n\
trait is not already in scope."
}
};
format!(
"# {package_name}\n\
\n\
A Bevy 0.20 project scaffolded with [Bevy Forge](https://github.com/bevy-forge/bevy-forge).\n\
\n\
## Run\n\
\n\
```sh\n\
cargo run\n\
```\n\
\n\
## Scene\n\
\n\
{scene_hint}\n\
\n\
```sh\n\
forge export scenes/demo.forge.ron --format bsn -o src/scene.rs\n\
```\n\
\n\
Generated BSN code is ordinary Rust for Bevy 0.20. {spawn_hint}\n"
)
}
const MAIN_RS: &str = r#"//! Minimal Bevy 0.20 app scaffolded by Bevy Forge.
use bevy::prelude::*;
fn main() {
App::new().add_plugins(DefaultPlugins).run();
}
"#;
const GITIGNORE: &str = "/target\n.forge/\n";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_name_validation_accepts_common_names() {
assert_eq!(validate_package_name("my-game").unwrap(), "my-game");
assert_eq!(validate_package_name("game_2").unwrap(), "game_2");
assert!(validate_package_name("").is_err());
assert!(validate_package_name("2fast").is_err());
assert!(validate_package_name("my game").is_err());
assert!(validate_package_name("game!").is_err());
}
#[test]
fn default_package_name_sanitises_directory_names() {
assert_eq!(
default_package_name(Path::new("/tmp/My Game 2")),
"my-game-2"
);
assert_eq!(
default_package_name(Path::new("/tmp/***")),
"bevy-forge-project"
);
assert_eq!(default_package_name(Path::new("/tmp/3d")), "forge-3d");
}
#[test]
fn empty_target_is_accepted_but_non_empty_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
assert!(ensure_empty_target(&tmp.path().join("fresh")).is_ok());
let empty = tmp.path().join("empty-target");
std::fs::create_dir_all(&empty).unwrap();
assert!(ensure_empty_target(&empty).is_ok());
std::fs::write(tmp.path().join("occupied.txt"), "x").unwrap();
let error = ensure_empty_target(tmp.path()).unwrap_err().to_string();
assert!(error.contains("not empty"), "unexpected error: {error}");
}
}
+644
View File
@@ -0,0 +1,644 @@
//! End-to-end tests for the `forge` binary.
//!
//! Scenes are produced through the core API so these tests exercise the real
//! serialisation path instead of hand-written RON.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use bevy_forge_core::{ComponentData, Project, SceneDoc, SpriteData, Text2dData};
use serde_json::Value;
fn forge(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_forge"))
.args(args)
.output()
.expect("failed to spawn the forge binary")
}
fn exit_code(output: &Output) -> i32 {
output
.status
.code()
.expect("the forge process was terminated by a signal")
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
/// Create a minimal Bevy project (manifest + empty assets dir) in `dir`.
fn create_project(dir: &Path) -> Project {
fs::create_dir_all(dir.join("assets")).unwrap();
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"test-project\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n",
)
.unwrap();
Project::open(dir).unwrap()
}
fn save_scene(project: &Project, file: &str, document: &SceneDoc) -> PathBuf {
let path = project.scenes_dir().join(file);
project.save_scene(document, Some(&path)).unwrap();
path
}
/// A tiny scene with one textured sprite and one text entity.
fn sprite_scene(name: &str, image: &str) -> SceneDoc {
let mut document = SceneDoc::new(name);
let player = document.add_entity("Player", None);
document.add_component(
player,
ComponentData::Sprite(SpriteData {
image: Some(image.to_string()),
..Default::default()
}),
);
let label = document.add_entity("Label", None);
document.add_component(
label,
ComponentData::Text2d(Text2dData {
text: "Hello".to_string(),
..Default::default()
}),
);
document
}
/// Run `forge new` and return the project root plus its demo scene path.
fn scaffold_2d(dir: &Path) -> (PathBuf, PathBuf) {
let output = forge(&["new", dir.to_str().unwrap()]);
assert_eq!(
exit_code(&output),
0,
"forge new failed\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let project = Project::open(dir).unwrap();
let scene = project.scenes_dir().join("demo.forge.ron");
(project.root.clone(), scene)
}
// ---------------------------------------------------------------------------
// Top-level CLI behaviour
// ---------------------------------------------------------------------------
#[test]
fn help_lists_every_subcommand() {
let output = forge(&["--help"]);
assert_eq!(exit_code(&output), 0, "stderr: {}", stderr(&output));
let text = stdout(&output);
for subcommand in ["new", "validate", "export", "info", "scenes"] {
assert!(
text.contains(subcommand),
"`{subcommand}` missing from --help output:\n{text}"
);
}
}
#[test]
fn version_prints_the_package_version() {
let output = forge(&["--version"]);
assert_eq!(exit_code(&output), 0, "stderr: {}", stderr(&output));
assert!(
stdout(&output).contains(env!("CARGO_PKG_VERSION")),
"unexpected version output: {}",
stdout(&output)
);
}
#[test]
fn unknown_command_exits_with_a_usage_error() {
let output = forge(&["frobnicate"]);
assert_eq!(exit_code(&output), 2);
assert!(!stderr(&output).is_empty());
}
// ---------------------------------------------------------------------------
// new
// ---------------------------------------------------------------------------
#[test]
fn new_creates_a_valid_project() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("my-game");
let output = forge(&["new", dir.to_str().unwrap()]);
assert_eq!(
exit_code(&output),
0,
"forge new failed\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
for relative in [
"Cargo.toml",
"src/main.rs",
"assets/.gitkeep",
"scenes/demo.forge.ron",
".gitignore",
"README.md",
] {
assert!(dir.join(relative).is_file(), "missing {relative}");
}
let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
assert!(
manifest.contains("name = \"my-game\""),
"manifest: {manifest}"
);
assert!(manifest.contains("bevy = \"0.20\""), "manifest: {manifest}");
let project = Project::open(&dir).unwrap();
let scene_path = project.scenes_dir().join("demo.forge.ron");
let document = project.load_scene(&scene_path).unwrap();
assert!(
!document.entities.is_empty(),
"starter scene should contain entities"
);
assert!(
!document.roots.is_empty(),
"starter scene should have roots"
);
let output = forge(&["validate", scene_path.to_str().unwrap()]);
assert_eq!(
exit_code(&output),
0,
"scaffolded scene should validate\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
}
#[test]
fn new_refuses_a_non_empty_directory() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("occupied");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("keep.txt"), "keep me").unwrap();
let output = forge(&["new", dir.to_str().unwrap()]);
assert_eq!(exit_code(&output), 2);
assert!(
stderr(&output).contains("not empty"),
"stderr: {}",
stderr(&output)
);
assert!(!dir.join("Cargo.toml").exists());
assert_eq!(fs::read_to_string(dir.join("keep.txt")).unwrap(), "keep me");
}
#[test]
fn new_fills_an_existing_empty_directory() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("already-there");
fs::create_dir_all(&dir).unwrap();
let output = forge(&["new", dir.to_str().unwrap()]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
assert!(dir.join("Cargo.toml").is_file());
assert!(dir.join("scenes/demo.forge.ron").is_file());
}
#[test]
fn new_accepts_name_and_3d_template() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("space");
let output = forge(&[
"new",
dir.to_str().unwrap(),
"--name",
"space-game",
"--template",
"3d",
]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
assert!(
manifest.contains("name = \"space-game\""),
"manifest: {manifest}"
);
let project = Project::open(&dir).unwrap();
let document = project
.load_scene(project.scenes_dir().join("demo.forge.ron"))
.unwrap();
assert!(
document.stats().meshes > 0,
"the 3D starter scene should contain mesh entities"
);
}
// ---------------------------------------------------------------------------
// validate
// ---------------------------------------------------------------------------
#[test]
fn validate_accepts_a_good_scene_and_emits_json() {
let tmp = tempfile::tempdir().unwrap();
let (_root, scene) = scaffold_2d(&tmp.path().join("good-project"));
let output = forge(&["validate", scene.to_str().unwrap()]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
assert!(stdout(&output).contains("Summary:"));
let output = forge(&["validate", scene.to_str().unwrap(), "--json"]);
assert_eq!(exit_code(&output), 0);
let value: Value =
serde_json::from_str(&stdout(&output)).expect("validate --json must emit JSON");
assert_eq!(value["valid"], true);
assert_eq!(value["errors"], 0);
assert!(value["warnings"].is_u64());
assert!(value["issues"].is_array());
for issue in value["issues"].as_array().unwrap() {
for key in ["severity", "entity", "field", "message"] {
assert!(issue.get(key).is_some(), "issue missing `{key}`: {issue}");
}
assert!(["error", "warning", "info"].contains(&issue["severity"].as_str().unwrap()));
}
}
#[test]
fn validate_reports_parse_errors_with_exit_code_two() {
let tmp = tempfile::tempdir().unwrap();
let broken = tmp.path().join("broken.forge.ron");
fs::write(&broken, "this is definitely not RON").unwrap();
let output = forge(&["validate", broken.to_str().unwrap()]);
assert_eq!(exit_code(&output), 2);
assert!(
stderr(&output).contains("parse"),
"stderr should explain the parse error: {}",
stderr(&output)
);
assert!(stdout(&output).is_empty());
}
#[test]
fn validate_reports_a_missing_asset_as_a_validation_error() {
let tmp = tempfile::tempdir().unwrap();
let project = create_project(tmp.path());
let document = sprite_scene("Missing asset", "textures/missing.png");
let scene = save_scene(&project, "missing.forge.ron", &document);
let output = forge(&["validate", scene.to_str().unwrap()]);
assert_eq!(exit_code(&output), 1);
let text = stdout(&output);
assert!(text.contains("error"), "stdout: {text}");
assert!(text.contains("textures/missing.png"), "stdout: {text}");
assert!(text.contains("Summary:"), "stdout: {text}");
let output = forge(&["validate", scene.to_str().unwrap(), "--json"]);
assert_eq!(exit_code(&output), 1);
let value: Value = serde_json::from_str(&stdout(&output)).unwrap();
assert_eq!(value["valid"], false);
assert!(value["errors"].as_u64().unwrap() >= 1);
let issues = value["issues"].as_array().unwrap();
assert!(issues.iter().any(|issue| issue["message"]
.as_str()
.is_some_and(|message| message.contains("textures/missing.png"))));
}
#[test]
fn validate_uses_an_explicit_project_for_assets() {
let tmp = tempfile::tempdir().unwrap();
// The scene lives outside the project; `--project` must still resolve assets.
let project = create_project(&tmp.path().join("project"));
let outside = tmp.path().join("outside");
fs::create_dir_all(&outside).unwrap();
let document = sprite_scene("Outside", "textures/hero.png");
fs::create_dir_all(project.assets_dir.join("textures")).unwrap();
fs::write(project.assets_dir.join("textures/hero.png"), b"png").unwrap();
// Serialise inside the project, then move the file out of it.
let saved = save_scene(&project, "outside.forge.ron", &document);
let scene = outside.join("outside.forge.ron");
fs::copy(&saved, &scene).unwrap();
fs::remove_file(&saved).unwrap();
let output = forge(&[
"validate",
scene.to_str().unwrap(),
"--project",
project.root.to_str().unwrap(),
]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
}
// ---------------------------------------------------------------------------
// export
// ---------------------------------------------------------------------------
#[test]
fn export_bsn_to_stdout_and_file() {
let tmp = tempfile::tempdir().unwrap();
let project = create_project(tmp.path());
let document = sprite_scene("Export scene", "textures/player.png");
let scene = save_scene(&project, "export.forge.ron", &document);
let output = forge(&["export", scene.to_str().unwrap(), "--format", "bsn"]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let stdout_code = stdout(&output);
assert!(
stdout_code.starts_with("use bevy::prelude::*;"),
"generated code must start with the Bevy prelude import:\n{stdout_code}"
);
assert!(stdout_code.ends_with('\n'));
assert!(
stdout_code.contains("bsn!") || stdout_code.contains("bsn_list!"),
"expected BSN macro code:\n{stdout_code}"
);
let target = tmp.path().join("generated/nested/scene.rs");
let output = forge(&[
"export",
scene.to_str().unwrap(),
"--format",
"bsn",
"-o",
target.to_str().unwrap(),
]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let written = fs::read_to_string(&target).expect("output file must exist");
assert_eq!(written, stdout_code);
assert!(
target.parent().unwrap().is_dir(),
"parent directories must be created"
);
}
#[test]
fn export_bsn_single_root_uses_the_bsn_macro() {
let tmp = tempfile::tempdir().unwrap();
let project = create_project(tmp.path());
let mut document = SceneDoc::new("Single root");
let player = document.add_entity("Player", None);
document.add_component(
player,
ComponentData::Sprite(SpriteData {
image: Some("textures/player.png".to_string()),
..Default::default()
}),
);
let scene = save_scene(&project, "single.forge.ron", &document);
let output = forge(&["export", scene.to_str().unwrap(), "--format", "bsn"]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let code = stdout(&output);
assert!(code.contains("bsn! {"), "code:\n{code}");
assert!(!code.contains("bsn_list!"), "code:\n{code}");
assert!(code.contains("-> impl Scene"), "code:\n{code}");
}
#[test]
fn export_rust_format() {
let tmp = tempfile::tempdir().unwrap();
let project = create_project(tmp.path());
let document = sprite_scene("Export scene", "textures/player.png");
let scene = save_scene(&project, "export.forge.ron", &document);
let output = forge(&["export", scene.to_str().unwrap(), "--format", "rust"]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let code = stdout(&output);
assert!(code.starts_with("use bevy::prelude::*;"), "code:\n{code}");
assert!(
code.contains("Commands"),
"expected a Commands parameter:\n{code}"
);
assert!(
code.contains("commands.spawn"),
"expected imperative spawn code:\n{code}"
);
assert!(code.ends_with('\n'));
}
#[test]
fn export_rejects_an_invalid_function_name() {
let tmp = tempfile::tempdir().unwrap();
let project = create_project(tmp.path());
let document = sprite_scene("Export scene", "textures/player.png");
let scene = save_scene(&project, "export.forge.ron", &document);
let output = forge(&[
"export",
scene.to_str().unwrap(),
"--format",
"bsn",
"--function",
"not a valid name",
]);
assert_eq!(exit_code(&output), 1);
assert!(
stderr(&output).contains("identifier"),
"stderr: {}",
stderr(&output)
);
assert!(stdout(&output).is_empty());
}
#[test]
fn export_reports_parse_errors_with_exit_code_two() {
let tmp = tempfile::tempdir().unwrap();
let broken = tmp.path().join("broken.forge.ron");
fs::write(&broken, "not ron at all").unwrap();
let target = tmp.path().join("never-written.rs");
let output = forge(&[
"export",
broken.to_str().unwrap(),
"-o",
target.to_str().unwrap(),
]);
assert_eq!(exit_code(&output), 2);
assert!(
!target.exists(),
"no output must be written on a parse error"
);
}
// ---------------------------------------------------------------------------
// info
// ---------------------------------------------------------------------------
#[test]
fn info_json_has_the_documented_fields() {
let tmp = tempfile::tempdir().unwrap();
let project = create_project(tmp.path());
let document = sprite_scene("Info scene", "textures/hero.png");
let scene = save_scene(&project, "info.forge.ron", &document);
let output = forge(&["info", scene.to_str().unwrap(), "--json"]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let value: Value = serde_json::from_str(&stdout(&output)).expect("info --json must emit JSON");
assert_eq!(value["name"], "Info scene");
assert_eq!(value["entities"], 2);
assert_eq!(value["roots"], 2);
assert!(value["path"]
.as_str()
.is_some_and(|path| Path::new(path).is_absolute()));
assert_eq!(value["components"]["sprite"], 1);
assert_eq!(value["components"]["text2d"], 1);
let references = value["asset_references"].as_array().unwrap();
assert!(references
.iter()
.any(|reference| reference["path"] == "textures/hero.png"));
assert!(value["validation"].is_object());
// A missing texture is an error, but `info` itself always succeeds.
assert_eq!(value["validation"]["valid"], false);
let output = forge(&["info", scene.to_str().unwrap()]);
assert_eq!(exit_code(&output), 0);
let text = stdout(&output);
assert!(text.contains("Scene:"));
assert!(text.contains("Entities: 2"));
assert!(text.contains("Validation:"));
}
// ---------------------------------------------------------------------------
// scenes
// ---------------------------------------------------------------------------
#[test]
fn scenes_lists_absolute_paths_in_sorted_order() {
let tmp = tempfile::tempdir().unwrap();
let project = create_project(tmp.path());
save_scene(&project, "beta.forge.ron", &SceneDoc::new("Beta"));
save_scene(&project, "alpha.forge.ron", &SceneDoc::new("Alpha"));
let output = forge(&["scenes", project.root.to_str().unwrap()]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let lines: Vec<String> = stdout(&output).lines().map(ToOwned::to_owned).collect();
let expected: Vec<String> = project
.list_scenes()
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect();
assert_eq!(lines, expected);
assert_eq!(lines.len(), 2);
assert!(lines.iter().all(|line| Path::new(line).is_absolute()));
assert!(lines[0].ends_with("alpha.forge.ron"));
}
#[test]
fn scenes_rejects_a_directory_that_is_not_a_bevy_project() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"not-bevy\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let output = forge(&["scenes", tmp.path().to_str().unwrap()]);
assert_eq!(exit_code(&output), 2);
assert!(
stderr(&output).contains("not a Bevy project"),
"stderr: {}",
stderr(&output)
);
}
// ---------------------------------------------------------------------------
// Demo project integration
// ---------------------------------------------------------------------------
#[test]
fn demo_project_scene_validates_and_exports() {
let demo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo-project");
let scene = demo.join("scenes/garden.forge.ron");
assert!(
scene.is_file(),
"demo project scene is missing: {}",
scene.display()
);
let output = forge(&["validate", scene.to_str().unwrap()]);
assert_eq!(
exit_code(&output),
0,
"demo garden scene should validate\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let output = forge(&["export", scene.to_str().unwrap(), "--format", "bsn"]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
assert!(stdout(&output).starts_with("use bevy::prelude::*;"));
let output = forge(&["scenes", demo.to_str().unwrap()]);
assert_eq!(exit_code(&output), 0);
assert!(stdout(&output).contains("garden.forge.ron"));
}