Bevy Forge 0.1.0: visual scene editor for Bevy 0.20 BSN
This commit is contained in:
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user