use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use clap::{Args, Subcommand};
use serde::Deserialize;
use tempfile::TempDir;
use toml::Value;
#[derive(Subcommand, Clone, Debug)]
pub enum GenerateCommand {
Ts(GenerateArgs),
Manifest(GenerateArgs),
Descriptors(GenerateArgs),
Routes(GenerateArgs),
}
#[derive(Args, Clone, Debug)]
pub struct GenerateArgs {
#[arg(long)]
pub out: Option<PathBuf>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ArtifactKind {
Ts,
Manifest,
Descriptors,
Routes,
InspectRoutes,
}
impl ArtifactKind {
fn as_cli_arg(self) -> &'static str {
match self {
Self::Ts => "ts",
Self::Manifest => "manifest",
Self::Descriptors => "descriptors",
Self::Routes => "routes",
Self::InspectRoutes => "inspect-routes",
}
}
fn default_output_path(self, root: &Path) -> PathBuf {
match self {
Self::Ts => root.join("generated").join("types.ts"),
Self::Manifest => root.join("generated").join("manifest.json"),
Self::Descriptors => root.join("generated").join("descriptors.json"),
Self::Routes => root.join("generated").join("routes.rs"),
Self::InspectRoutes => root.join("generated").join("routes-inspect.json"),
}
}
}
#[derive(Debug)]
struct ProjectContext {
root: PathBuf,
protocol_dir: PathBuf,
protocol_package_name: String,
protocol_package_version: String,
engine_dependency: Value,
}
impl ProjectContext {
fn discover(start_dir: &Path) -> Result<Self, String> {
let root = find_project_root(start_dir)?;
let galeon_config = root.join("galeon.toml");
let project_name = read_galeon_config(&galeon_config)?.project.name;
if project_name.trim().is_empty() {
return Err(format!(
"project name in {} must not be empty",
galeon_config.display()
));
}
let protocol_manifest = root.join("crates").join("protocol").join("Cargo.toml");
if !protocol_manifest.exists() {
return Err(format!(
"expected Galeon protocol crate at {}",
protocol_manifest.display()
));
}
let protocol_dir = protocol_manifest
.parent()
.ok_or_else(|| {
format!(
"invalid protocol manifest path {}",
protocol_manifest.display()
)
})?
.to_path_buf();
let protocol_toml = read_toml_file(&protocol_manifest)?;
let protocol_package = protocol_toml
.get("package")
.and_then(Value::as_table)
.ok_or_else(|| {
format!(
"protocol manifest {} is missing a [package] table",
protocol_manifest.display()
)
})?;
let protocol_package_name = protocol_package
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| {
format!(
"protocol manifest {} is missing package.name",
protocol_manifest.display()
)
})?
.to_string();
let protocol_package_version = protocol_package
.get("version")
.and_then(Value::as_str)
.ok_or_else(|| {
format!(
"protocol manifest {} is missing package.version",
protocol_manifest.display()
)
})?
.to_string();
let engine_dependency =
resolve_engine_dependency(&root, &protocol_manifest, &protocol_toml)?;
Ok(Self {
root,
protocol_dir,
protocol_package_name,
protocol_package_version,
engine_dependency,
})
}
fn protocol_version(&self) -> String {
format!(
"{}@{}",
self.protocol_package_name, self.protocol_package_version
)
}
}
#[derive(Debug, Deserialize)]
struct GaleonConfig {
project: GaleonProject,
}
#[derive(Debug, Deserialize)]
struct GaleonProject {
name: String,
}
pub fn run(command: GenerateCommand) -> Result<PathBuf, String> {
let start_dir = env::current_dir().map_err(|e| format!("failed to read current dir: {e}"))?;
let (kind, args) = match command {
GenerateCommand::Ts(args) => (ArtifactKind::Ts, args),
GenerateCommand::Manifest(args) => (ArtifactKind::Manifest, args),
GenerateCommand::Descriptors(args) => (ArtifactKind::Descriptors, args),
GenerateCommand::Routes(args) => (ArtifactKind::Routes, args),
};
run_from_dir(kind, args.out.as_deref(), &start_dir)
}
fn run_from_dir(
kind: ArtifactKind,
out: Option<&Path>,
start_dir: &Path,
) -> Result<PathBuf, String> {
let context = ProjectContext::discover(start_dir)?;
let extra_args = if kind == ArtifactKind::Routes || kind == ArtifactKind::InspectRoutes {
let api_paths = scan_api_directory(&context.protocol_dir)?;
let json = serde_json::to_string(&api_paths)
.map_err(|e| format!("failed to serialize api paths: {e}"))?;
vec!["--api-paths".to_string(), json]
} else {
vec![]
};
let artifact = execute_reflection_helper(&context, kind, &extra_args)?;
let output_path = out
.map(PathBuf::from)
.unwrap_or_else(|| kind.default_output_path(&context.root));
write_artifact(&output_path, artifact.as_bytes())?;
Ok(output_path)
}
pub(crate) fn inspect_routes_json(start_dir: &Path) -> Result<(String, String), String> {
let context = ProjectContext::discover(start_dir)?;
let api_paths = scan_api_directory(&context.protocol_dir)?;
let json = serde_json::to_string(&api_paths)
.map_err(|e| format!("failed to serialize api paths: {e}"))?;
let extra_args = vec!["--api-paths".to_string(), json];
let artifact = execute_reflection_helper(&context, ArtifactKind::InspectRoutes, &extra_args)?;
Ok((artifact, context.protocol_version()))
}
fn scan_api_directory(protocol_dir: &Path) -> Result<Vec<String>, String> {
let api_dir = protocol_dir.join("src").join("api");
if !api_dir.exists() {
return Ok(vec![]);
}
let mut paths = Vec::new();
collect_rs_files(&api_dir, &protocol_dir.join("src"), &mut paths)?;
paths.sort();
Ok(paths)
}
fn collect_rs_files(dir: &Path, base: &Path, out: &mut Vec<String>) -> Result<(), String> {
let entries =
fs::read_dir(dir).map_err(|e| format!("failed to read {}: {e}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
let path = entry.path();
if path.is_dir() {
collect_rs_files(&path, base, out)?;
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
let relative = path
.strip_prefix(base)
.map_err(|e| format!("path prefix error: {e}"))?;
out.push(relative.to_string_lossy().replace('\\', "/"));
}
}
Ok(())
}
fn execute_reflection_helper(
context: &ProjectContext,
kind: ArtifactKind,
extra_args: &[String],
) -> Result<String, String> {
let helper_dir = TempDir::new().map_err(|e| format!("failed to create helper dir: {e}"))?;
let helper_manifest = helper_dir.path().join("Cargo.toml");
let helper_src_dir = helper_dir.path().join("src");
let helper_main = helper_src_dir.join("main.rs");
fs::create_dir_all(&helper_src_dir)
.map_err(|e| format!("failed to create helper source dir: {e}"))?;
fs::write(
&helper_manifest,
helper_manifest_contents(context).as_bytes(),
)
.map_err(|e| format!("failed to write helper Cargo.toml: {e}"))?;
fs::write(
&helper_main,
helper_main_source(kind, &context.protocol_version()).as_bytes(),
)
.map_err(|e| format!("failed to write helper main.rs: {e}"))?;
let mut cmd = Command::new("cargo");
cmd.arg("run")
.arg("--quiet")
.arg("--manifest-path")
.arg(&helper_manifest)
.arg("--target-dir")
.arg(context.root.join("target").join("galeon-generate"))
.arg("--")
.arg(kind.as_cli_arg());
for arg in extra_args {
cmd.arg(arg);
}
let output = cmd
.current_dir(&context.root)
.output()
.map_err(|e| format!("failed to launch cargo for reflection helper: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let detail = if !stderr.is_empty() {
stderr
} else if !stdout.is_empty() {
stdout
} else {
format!("cargo exited with status {}", output.status)
};
return Err(format!(
"reflection helper failed for `{}`: {}",
kind.as_cli_arg(),
detail
));
}
String::from_utf8(output.stdout)
.map_err(|e| format!("reflection helper emitted non-utf8 output: {e}"))
}
fn helper_manifest_contents(context: &ProjectContext) -> String {
let engine_dependency = render_toml_value(&context.engine_dependency);
let protocol_path = render_toml_string(&context.protocol_dir.to_string_lossy());
let protocol_package = render_toml_string(&context.protocol_package_name);
format!(
r#"[package]
name = "galeon-generate-helper"
version = "0.0.0"
edition = "2024"
publish = false
[dependencies]
galeon-engine = {engine_dependency}
serde_json = "1"
target_protocol = {{ path = {protocol_path}, package = {protocol_package} }}
"#
)
}
fn helper_main_source(kind: ArtifactKind, protocol_version: &str) -> String {
let template = match kind {
ArtifactKind::Ts => {
r#"// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
use galeon_engine::{generate_typescript, ProtocolManifest};
use target_protocol as _;
fn main() {
let manifest = ProtocolManifest::collect(__PROTOCOL_VERSION__);
print!("{}", generate_typescript(&manifest));
}
"#
}
ArtifactKind::Manifest => {
r#"// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
use galeon_engine::ProtocolManifest;
use target_protocol as _;
fn main() {
let manifest = ProtocolManifest::collect(__PROTOCOL_VERSION__);
let output = manifest
.to_json_pretty()
.expect("manifest json generation should succeed");
print!("{output}");
}
"#
}
ArtifactKind::Descriptors => {
r#"// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
use galeon_engine::{generate_descriptors, ProtocolManifest};
use target_protocol as _;
fn main() {
let manifest = ProtocolManifest::collect(__PROTOCOL_VERSION__);
let output = serde_json::to_string_pretty(&generate_descriptors(&manifest))
.expect("descriptor json generation should succeed");
print!("{output}");
}
"#
}
ArtifactKind::Routes => {
r#"// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
use std::process;
use galeon_engine::{
generate_axum_routes, resolve_routes, scan_api_routes, HandlerMeta, ProtocolManifest,
ResolvedRoute,
};
use target_protocol as _;
fn main() {
let args: Vec<String> = std::env::args().collect();
let manifest = ProtocolManifest::collect(__PROTOCOL_VERSION__);
let output = generate_routes(&args, &manifest);
print!("{output}");
}
fn resolve_from_args(args: &[String], manifest: &ProtocolManifest) -> Vec<ResolvedRoute> {
let api_paths_json = args
.windows(2)
.find(|pair| pair[0] == "--api-paths")
.map(|pair| pair[1].as_str())
.unwrap_or("[]");
let api_paths: Vec<String> =
serde_json::from_str(api_paths_json).expect("failed to parse --api-paths JSON");
let path_refs: Vec<&str> = api_paths.iter().map(String::as_str).collect();
let scanned = scan_api_routes(&path_refs);
let handlers = HandlerMeta::collect_all();
resolve_routes(&scanned, &handlers, manifest).unwrap_or_else(|errors| {
for error in &errors {
eprintln!("route resolution error: {error}");
}
process::exit(1);
})
}
fn generate_routes(args: &[String], manifest: &ProtocolManifest) -> String {
let resolved = resolve_from_args(args, manifest);
generate_axum_routes(&resolved, manifest).unwrap_or_else(|e| {
eprintln!("route codegen error: {e}");
process::exit(1);
})
}
"#
}
ArtifactKind::InspectRoutes => {
r#"// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
use std::process;
use galeon_engine::{resolve_routes, scan_api_routes, HandlerMeta, ProtocolManifest, ResolvedRoute};
use target_protocol as _;
fn main() {
let args: Vec<String> = std::env::args().collect();
let manifest = ProtocolManifest::collect(__PROTOCOL_VERSION__);
let output = inspect_routes(&args, &manifest);
print!("{output}");
}
fn resolve_from_args(args: &[String], manifest: &ProtocolManifest) -> Vec<ResolvedRoute> {
let api_paths_json = args
.windows(2)
.find(|pair| pair[0] == "--api-paths")
.map(|pair| pair[1].as_str())
.unwrap_or("[]");
let api_paths: Vec<String> =
serde_json::from_str(api_paths_json).expect("failed to parse --api-paths JSON");
let path_refs: Vec<&str> = api_paths.iter().map(String::as_str).collect();
let scanned = scan_api_routes(&path_refs);
let handlers = HandlerMeta::collect_all();
resolve_routes(&scanned, &handlers, manifest).unwrap_or_else(|errors| {
for error in &errors {
eprintln!("route resolution error: {error}");
}
process::exit(1);
})
}
fn inspect_routes(args: &[String], manifest: &ProtocolManifest) -> String {
let resolved = resolve_from_args(args, manifest);
serde_json::to_string(&serde_json::json!({
"default_surface": manifest.default_surface,
"routes": resolved,
})).expect("failed to serialize resolved routes")
}
"#
}
};
template.replace(
"__PROTOCOL_VERSION__",
&render_rust_string(protocol_version),
)
}
fn find_project_root(start_dir: &Path) -> Result<PathBuf, String> {
for dir in start_dir.ancestors() {
if dir.join("galeon.toml").exists() {
return Ok(dir.to_path_buf());
}
}
Err(format!(
"could not find galeon.toml by walking up from {}",
start_dir.display()
))
}
fn read_galeon_config(path: &Path) -> Result<GaleonConfig, String> {
let source =
fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))?;
toml::from_str(&source).map_err(|e| format!("failed to parse {}: {e}", path.display()))
}
fn read_toml_file(path: &Path) -> Result<Value, String> {
let source =
fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))?;
toml::from_str(&source).map_err(|e| format!("failed to parse {}: {e}", path.display()))
}
fn resolve_engine_dependency(
project_root: &Path,
protocol_manifest: &Path,
protocol_toml: &Value,
) -> Result<Value, String> {
let dependency = protocol_toml
.get("dependencies")
.and_then(Value::as_table)
.and_then(|deps| deps.get("galeon-engine"))
.cloned()
.ok_or_else(|| {
format!(
"protocol manifest {} is missing dependencies.galeon-engine",
protocol_manifest.display()
)
})?;
if dependency
.as_table()
.and_then(|table| table.get("workspace"))
.and_then(Value::as_bool)
== Some(true)
{
let workspace_manifest = read_toml_file(&project_root.join("Cargo.toml"))?;
let workspace_dependency = workspace_manifest
.get("workspace")
.and_then(|workspace| workspace.get("dependencies"))
.and_then(Value::as_table)
.and_then(|deps| deps.get("galeon-engine"))
.cloned()
.ok_or_else(|| {
format!(
"workspace root {} does not define workspace.dependencies.galeon-engine",
project_root.join("Cargo.toml").display()
)
})?;
return normalize_dependency_value(workspace_dependency, project_root);
}
let protocol_dir = protocol_manifest.parent().ok_or_else(|| {
format!(
"invalid protocol manifest path {}",
protocol_manifest.display()
)
})?;
normalize_dependency_value(dependency, protocol_dir)
}
fn normalize_dependency_value(value: Value, base_dir: &Path) -> Result<Value, String> {
match value {
Value::String(_) => Ok(value),
Value::Table(mut table) => {
if let Some(path_value) = table.get_mut("path") {
let path_str = path_value.as_str().ok_or_else(|| {
"dependencies.galeon-engine.path must be a string".to_string()
})?;
let absolute = absolutize(base_dir, Path::new(path_str));
*path_value = Value::String(absolute.to_string_lossy().into_owned());
}
table.remove("workspace");
Ok(Value::Table(table))
}
other => Err(format!(
"unsupported dependencies.galeon-engine format: {}",
other.type_str()
)),
}
}
fn absolutize(base_dir: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
base_dir.join(path)
}
}
fn render_toml_value(value: &Value) -> String {
match value {
Value::String(value) => render_toml_string(value),
Value::Integer(value) => value.to_string(),
Value::Float(value) => value.to_string(),
Value::Boolean(value) => value.to_string(),
Value::Datetime(value) => value.to_string(),
Value::Array(values) => {
let rendered = values.iter().map(render_toml_value).collect::<Vec<_>>();
format!("[{}]", rendered.join(", "))
}
Value::Table(table) => {
let mut entries = table.iter().collect::<Vec<_>>();
entries.sort_by_key(|(left, _)| *left);
let rendered = entries
.into_iter()
.map(|(key, value)| format!("{key} = {}", render_toml_value(value)))
.collect::<Vec<_>>();
format!("{{ {} }}", rendered.join(", "))
}
}
}
fn render_toml_string(value: &str) -> String {
serde_json::to_string(value).expect("json string rendering should succeed")
}
fn render_rust_string(value: &str) -> String {
serde_json::to_string(value).expect("json string rendering should succeed")
}
fn write_artifact(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
}
fs::write(path, bytes).map_err(|e| format!("failed to write {}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf()
}
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
fn fixture_protocol_manifest(engine_dependency: &str) -> String {
format!(
r#"[package]
name = "fixture-protocol"
version = "0.3.1"
edition = "2024"
[dependencies]
galeon-engine = {engine_dependency}
"#
)
}
fn fixture_protocol_source() -> String {
r#"// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
#[galeon_engine::command]
pub struct SpawnUnit {
pub unit_id: u64,
}
#[galeon_engine::query]
pub struct GetWorldSnapshot;
#[galeon_engine::dto]
pub struct UnitSummary {
pub unit_id: u64,
}
"#
.to_string()
}
fn create_fixture_project() -> TempDir {
let temp = TempDir::new().unwrap();
let root = temp.path();
let engine_path = repo_root().join("crates").join("engine");
let engine_dependency = format!(
"{{ path = {} }}",
render_toml_string(&engine_path.to_string_lossy())
);
write_file(
&root.join("galeon.toml"),
r#"[project]
name = "fixture"
engine = "0.2"
preset = "local-first"
"#,
);
write_file(
&root.join("crates").join("protocol").join("Cargo.toml"),
&fixture_protocol_manifest(&engine_dependency),
);
write_file(
&root
.join("crates")
.join("protocol")
.join("src")
.join("lib.rs"),
&fixture_protocol_source(),
);
temp
}
fn fixture_empty_protocol_source() -> String {
r#"// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
"#
.to_string()
}
fn fixture_minimal_engine_source() -> String {
r#"// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
pub struct ProtocolManifest {
pub default_surface: &'static str,
protocol_version: String,
}
impl ProtocolManifest {
pub fn collect(version: &str) -> Self {
Self {
default_surface: "default",
protocol_version: version.to_string(),
}
}
pub fn to_json_pretty(&self) -> Result<String, String> {
Ok(format!(
"{{\n \"protocol_version\": \"{}\"\n}}",
self.protocol_version
))
}
}
pub fn generate_typescript(_manifest: &ProtocolManifest) -> String {
"export interface Placeholder {}".to_string()
}
pub fn generate_descriptors(_manifest: &ProtocolManifest) -> Vec<serde_json::Value> {
vec![serde_json::json!({
"route": "/placeholder",
"name": "Placeholder",
})]
}
"#
.to_string()
}
fn create_fixture_project_with_minimal_engine_surface() -> TempDir {
let temp = TempDir::new().unwrap();
let root = temp.path();
let engine_dir = root.join("engine-stub");
let engine_dependency = format!(
"{{ path = {} }}",
render_toml_string(&engine_dir.to_string_lossy())
);
write_file(
&engine_dir.join("Cargo.toml"),
r#"[package]
name = "galeon-engine"
version = "0.0.0"
edition = "2024"
[dependencies]
serde_json = "1"
"#,
);
write_file(
&engine_dir.join("src").join("lib.rs"),
&fixture_minimal_engine_source(),
);
write_file(
&root.join("galeon.toml"),
r#"[project]
name = "fixture-minimal"
engine = "0.2"
preset = "local-first"
"#,
);
write_file(
&root.join("crates").join("protocol").join("Cargo.toml"),
&fixture_protocol_manifest(&engine_dependency),
);
write_file(
&root
.join("crates")
.join("protocol")
.join("src")
.join("lib.rs"),
&fixture_empty_protocol_source(),
);
temp
}
#[test]
fn discover_project_from_nested_directory() {
let temp = create_fixture_project();
let nested = temp.path().join("client").join("nested").join("deeper");
fs::create_dir_all(&nested).unwrap();
let context = ProjectContext::discover(&nested).unwrap();
assert_eq!(context.root, temp.path());
assert_eq!(context.protocol_package_name, "fixture-protocol");
assert_eq!(context.protocol_version(), "fixture-protocol@0.3.1");
}
#[test]
fn normalize_dependency_path_makes_relative_paths_absolute() {
let base = Path::new("C:\\projects\\fixture");
let value = toml::from_str::<Value>(r#"dep = { path = "../engine", version = "0.2.0" }"#)
.unwrap()
.get("dep")
.cloned()
.unwrap();
let normalized = normalize_dependency_value(value, base).unwrap();
let table = normalized.as_table().unwrap();
let expected = absolutize(base, Path::new("../engine"));
assert_eq!(
table.get("path").and_then(Value::as_str),
Some(expected.to_string_lossy().as_ref())
);
assert_eq!(table.get("version").and_then(Value::as_str), Some("0.2.0"));
}
#[test]
fn generate_commands_write_expected_artifacts() {
let temp = create_fixture_project();
let ts_path = run_from_dir(ArtifactKind::Ts, None, temp.path()).unwrap();
let manifest_path = run_from_dir(ArtifactKind::Manifest, None, temp.path()).unwrap();
let descriptors_path = run_from_dir(ArtifactKind::Descriptors, None, temp.path()).unwrap();
assert_eq!(ts_path, temp.path().join("generated").join("types.ts"));
assert_eq!(
manifest_path,
temp.path().join("generated").join("manifest.json")
);
assert_eq!(
descriptors_path,
temp.path().join("generated").join("descriptors.json")
);
let typescript = fs::read_to_string(ts_path).unwrap();
assert!(typescript.contains("export interface SpawnUnit"));
assert!(typescript.contains("export interface UnitSummary"));
let manifest = fs::read_to_string(manifest_path).unwrap();
assert!(manifest.contains(r#""protocol_version": "fixture-protocol@0.3.1""#));
assert!(manifest.contains(r#""name": "SpawnUnit""#));
let descriptors = fs::read_to_string(descriptors_path).unwrap();
assert!(descriptors.contains(r#""route": "/commands/spawn-unit""#));
assert!(descriptors.contains(r#""name": "GetWorldSnapshot""#));
}
#[test]
fn custom_out_path_overrides_default_output() {
let temp = create_fixture_project();
let custom = temp.path().join("artifacts").join("protocol-types.ts");
let written = run_from_dir(ArtifactKind::Ts, Some(&custom), temp.path()).unwrap();
assert_eq!(written, custom);
assert!(written.exists());
assert!(!temp.path().join("generated").join("types.ts").exists());
}
#[test]
fn helper_manifest_uses_target_protocol_alias() {
let temp = create_fixture_project();
let context = ProjectContext::discover(temp.path()).unwrap();
let manifest = helper_manifest_contents(&context);
assert!(manifest.contains("target_protocol"));
assert!(manifest.contains("fixture-protocol"));
}
#[test]
fn helper_main_source_scopes_route_apis_to_route_artifacts() {
for kind in [
ArtifactKind::Ts,
ArtifactKind::Manifest,
ArtifactKind::Descriptors,
] {
let source = helper_main_source(kind, "fixture-protocol@0.3.1");
assert!(!source.contains("generate_axum_routes"));
assert!(!source.contains("resolve_routes"));
assert!(!source.contains("scan_api_routes"));
assert!(!source.contains("HandlerMeta"));
assert!(!source.contains("ResolvedRoute"));
}
let routes_source = helper_main_source(ArtifactKind::Routes, "fixture-protocol@0.3.1");
assert!(routes_source.contains("generate_axum_routes"));
assert!(routes_source.contains("resolve_routes"));
assert!(routes_source.contains("scan_api_routes"));
assert!(routes_source.contains("HandlerMeta"));
let inspect_source =
helper_main_source(ArtifactKind::InspectRoutes, "fixture-protocol@0.3.1");
assert!(inspect_source.contains("resolve_routes"));
assert!(inspect_source.contains("scan_api_routes"));
assert!(inspect_source.contains("HandlerMeta"));
}
#[test]
fn generate_non_route_commands_work_with_minimal_engine_surface() {
let temp = create_fixture_project_with_minimal_engine_surface();
let manifest_path = run_from_dir(ArtifactKind::Manifest, None, temp.path()).unwrap();
let ts_path = run_from_dir(ArtifactKind::Ts, None, temp.path()).unwrap();
let descriptors_path = run_from_dir(ArtifactKind::Descriptors, None, temp.path()).unwrap();
let manifest = fs::read_to_string(manifest_path).unwrap();
assert!(manifest.contains(r#""protocol_version": "fixture-protocol@0.3.1""#));
let typescript = fs::read_to_string(ts_path).unwrap();
assert!(typescript.contains("export interface Placeholder {}"));
let descriptors = fs::read_to_string(descriptors_path).unwrap();
assert!(descriptors.contains(r#""route": "/placeholder""#));
assert!(descriptors.contains(r#""name": "Placeholder""#));
}
#[test]
fn run_requires_galeon_project_root() {
let temp = TempDir::new().unwrap();
let error = run_from_dir(ArtifactKind::Manifest, None, temp.path()).unwrap_err();
assert!(error.contains("could not find galeon.toml"));
}
fn fixture_routes_protocol_source() -> String {
r#"// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
#[galeon_engine::command]
pub struct SpawnUnit {
pub unit_id: u64,
}
#[galeon_engine::query]
pub struct GetWorldSnapshot;
#[galeon_engine::dto]
pub struct UnitSummary {
pub unit_id: u64,
}
pub mod api {
pub mod fleet {
pub mod dispatch;
pub mod snapshot;
}
}
"#
.to_string()
}
fn fixture_handler_dispatch() -> String {
r#"// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
#[galeon_engine::handler]
pub fn dispatch_fleet(cmd: crate::SpawnUnit) -> Result<(), String> {
let _ = cmd;
Ok(())
}
"#
.to_string()
}
fn fixture_handler_snapshot() -> String {
r#"// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
#[galeon_engine::handler]
pub fn fleet_snapshot(query: crate::GetWorldSnapshot) -> Result<crate::UnitSummary, String> {
let _ = query;
Ok(crate::UnitSummary { unit_id: 0 })
}
"#
.to_string()
}
fn fixture_helper_types() -> String {
r#"// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
/// Shared types — this file is _-prefixed, so it must NOT become a route.
pub type FleetId = u64;
"#
.to_string()
}
fn create_fixture_routes_project() -> TempDir {
let temp = TempDir::new().unwrap();
let root = temp.path();
let engine_path = repo_root().join("crates").join("engine");
let engine_dependency = format!(
"{{ path = {} }}",
render_toml_string(&engine_path.to_string_lossy())
);
write_file(
&root.join("galeon.toml"),
r#"[project]
name = "fixture-routes"
engine = "0.2"
preset = "server-authoritative"
"#,
);
write_file(
&root.join("crates").join("protocol").join("Cargo.toml"),
&fixture_protocol_manifest(&engine_dependency),
);
write_file(
&root
.join("crates")
.join("protocol")
.join("src")
.join("lib.rs"),
&fixture_routes_protocol_source(),
);
write_file(
&root
.join("crates")
.join("protocol")
.join("src")
.join("api")
.join("fleet")
.join("dispatch.rs"),
&fixture_handler_dispatch(),
);
write_file(
&root
.join("crates")
.join("protocol")
.join("src")
.join("api")
.join("fleet")
.join("snapshot.rs"),
&fixture_handler_snapshot(),
);
write_file(
&root
.join("crates")
.join("protocol")
.join("src")
.join("api")
.join("_types.rs"),
&fixture_helper_types(),
);
temp
}
#[test]
fn generate_routes_produces_axum_glue() {
let temp = create_fixture_routes_project();
let routes_path = run_from_dir(ArtifactKind::Routes, None, temp.path()).unwrap();
assert_eq!(routes_path, temp.path().join("generated").join("routes.rs"));
let routes = fs::read_to_string(routes_path).unwrap();
assert!(routes.contains("Auto-generated by Galeon Engine"));
assert!(routes.contains("fixture-protocol@0.3.1"));
assert!(routes.contains("\"/api/fleet/dispatch\""));
assert!(routes.contains("routing::post(api_fleet_dispatch)"));
assert!(routes.contains("__galeon_axum_json"));
assert!(routes.contains("crate::api::fleet::dispatch::dispatch_fleet__galeon_axum_json"));
assert!(routes.contains("// protocol: SpawnUnit"));
assert!(routes.contains("\"/api/fleet/snapshot\""));
assert!(routes.contains("routing::post(api_fleet_snapshot)"));
assert!(routes.contains("__galeon_axum_json"));
assert!(routes.contains("crate::api::fleet::snapshot::fleet_snapshot__galeon_axum_json"));
assert!(routes.contains("// protocol: GetWorldSnapshot"));
assert!(!routes.contains("_types"));
assert!(!routes.contains("api_types"));
}
#[test]
fn generate_routes_no_api_directory_produces_empty_router() {
let temp = create_fixture_project();
let routes_path = run_from_dir(ArtifactKind::Routes, None, temp.path()).unwrap();
let routes = fs::read_to_string(routes_path).unwrap();
assert!(routes.contains("Router::new()"));
assert!(!routes.contains(".route("));
}
#[test]
fn inspect_routes_returns_envelope_with_default_surface() {
let temp = create_fixture_routes_project();
let (json, protocol_version) = inspect_routes_json(temp.path()).unwrap();
assert_eq!(protocol_version, "fixture-protocol@0.3.1");
let envelope: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(envelope["default_surface"], "default");
let entries = envelope["routes"].as_array().unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0]["route_path"], "/api/fleet/dispatch");
assert_eq!(entries[0]["handler_fn_name"], "dispatch_fleet");
assert_eq!(entries[0]["protocol_name"], "SpawnUnit");
assert_eq!(entries[0]["kind"], "Command");
assert!(entries[0]["surfaces"].as_array().unwrap().is_empty());
assert_eq!(entries[1]["route_path"], "/api/fleet/snapshot");
assert_eq!(entries[1]["handler_fn_name"], "fleet_snapshot");
assert_eq!(entries[1]["protocol_name"], "GetWorldSnapshot");
assert_eq!(entries[1]["kind"], "Query");
}
#[test]
fn inspect_routes_empty_project_returns_empty_routes() {
let temp = create_fixture_project();
let (json, _) = inspect_routes_json(temp.path()).unwrap();
let envelope: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(envelope["routes"].as_array().unwrap().is_empty());
}
#[test]
fn scan_api_directory_collects_rs_files() {
let temp = create_fixture_routes_project();
let protocol_dir = temp.path().join("crates").join("protocol");
let paths = scan_api_directory(&protocol_dir).unwrap();
assert!(paths.contains(&"api/fleet/dispatch.rs".to_string()));
assert!(paths.contains(&"api/fleet/snapshot.rs".to_string()));
assert!(paths.contains(&"api/_types.rs".to_string()));
}
}