use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("slave-test-{}-{n}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp dir");
Self(dir)
}
fn new_app(&self) -> PathBuf {
let out = slave(
&self.0,
&["new", "app", "--framework-path", &framework_path()],
);
assert!(out.status.success(), "{}", stderr(&out));
self.0.join("app")
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn framework_path() -> String {
format!("{}/framework", env!("CARGO_MANIFEST_DIR"))
}
fn slave(dir: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_slave"))
.args(args)
.current_dir(dir)
.output()
.expect("run slave")
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
fn read(path: impl AsRef<Path>) -> String {
fs::read_to_string(path.as_ref())
.unwrap_or_else(|err| panic!("read {}: {err}", path.as_ref().display()))
}
const SKELETON: &[&str] = &[
"Cargo.toml",
".env.example",
".env",
"src/main.rs",
"src/app.rs",
"src/base/mod.rs",
"src/base/auth.rs",
"src/base/mcp.rs",
"src/base/config.rs",
"src/base/error.rs",
"src/base/state.rs",
"src/modules/mod.rs",
"src/modules/user/mod.rs",
"src/modules/user/tools.rs",
"src/modules/user/models/user.rs",
"src/modules/user/repositories/user_repository.rs",
"src/modules/user/services/user_service.rs",
"src/modules/user/dto/user.rs",
"src/modules/role/mod.rs",
"src/modules/role/tools.rs",
"src/modules/role/models/role.rs",
"src/modules/role/repositories/role_repository.rs",
"src/modules/role/services/role_service.rs",
"src/modules/role/dto/role.rs",
"src/modules/auth/mod.rs",
"src/modules/auth/tools.rs",
"src/modules/auth/repositories/token_repository.rs",
"src/modules/auth/services/auth_service.rs",
"src/modules/auth/dto/auth.rs",
"src/bin/migrate.rs",
"src/bin/gateway.rs",
"config/apps.toml",
"database/migrations/mod.rs",
"tests/mcp.rs",
];
const INITIAL_MIGRATIONS: &[&str] = &[
"create_users_table",
"create_roles_tables",
"create_auth_tokens_table",
];
#[test]
fn new_creates_core_application() {
let tmp = TempDir::new();
let app = tmp.new_app();
for file in SKELETON {
assert!(app.join(file).is_file(), "missing {file}");
}
let manifest = read(app.join("Cargo.toml"));
assert!(manifest.contains("name = \"app\""));
assert!(manifest.contains("[package.metadata.slave]"));
assert!(manifest.contains("slave-framework = { path = "));
assert!(manifest.contains("default-run = \"app\""));
assert!(manifest.contains("members = [\"apps/*\"]"), "{manifest}");
assert!(manifest.contains("slave-gateway = { path = "), "{manifest}");
assert!(read(app.join("config/apps.toml")).contains("app = \"mcp\""));
let modules = read(app.join("src/modules/mod.rs"));
for module in ["auth", "role", "user"] {
assert!(modules.contains(&format!("pub mod {module};")), "{modules}");
assert!(
modules.contains(&format!("{module}::tools(),")),
"{modules}"
);
}
assert!(read(app.join(".env")).contains("DATABASE_PATH="));
}
#[test]
fn new_application_has_no_rest_business_api() {
let tmp = TempDir::new();
let app = tmp.new_app();
for dir in ["auth", "role", "user"] {
let domain = app.join("src/modules").join(dir);
assert!(!domain.join("routes.rs").exists(), "{dir}/routes.rs");
assert!(!domain.join("controllers").exists(), "{dir}/controllers");
}
let router = read(app.join("src/app.rs"));
assert_eq!(router.matches(".route(").count(), 1, "{router}");
assert!(
router.contains(".route(\"/health\", get(health))"),
"{router}"
);
assert!(router.contains(".nest_service(mcp::PATH,"), "{router}");
assert!(!router.contains(".merge("), "{router}");
assert!(read(app.join("src/base/mcp.rs")).contains("pub const PATH: &str = \"/mcp\";"));
}
#[test]
fn new_defaults_to_crates_io_framework() {
let tmp = TempDir::new();
assert!(slave(&tmp.0, &["new", "app"]).status.success());
let manifest = read(tmp.0.join("app/Cargo.toml"));
assert!(
manifest.contains("slave-framework = { version = \"0.1\", default-features = false }"),
"{manifest}"
);
assert!(manifest.contains("slave-gateway = \"0.1\""), "{manifest}");
}
#[test]
fn new_refuses_existing_directory_and_bad_input() {
let tmp = TempDir::new();
fs::create_dir(tmp.0.join("taken")).unwrap();
assert!(!slave(&tmp.0, &["new", "taken"]).status.success());
assert!(!slave(&tmp.0, &["new", "../escape"]).status.success());
assert!(!slave(&tmp.0, &["new", "has space"]).status.success());
let out = slave(&tmp.0, &["new", "x", "--framework-path", "/nonexistent"]);
assert!(!out.status.success());
assert!(!tmp.0.join("x").exists());
}
#[test]
fn make_model_creates_domain_with_default_module() {
let tmp = TempDir::new();
let app = tmp.new_app();
let out = slave(&app, &["make:model", "BlogPost"]);
assert!(out.status.success(), "{}", stderr(&out));
let domain = app.join("src/modules/blog_post");
assert!(read(domain.join("models/blog_post.rs")).contains("pub struct BlogPost"));
assert!(read(domain.join("models/mod.rs")).contains("pub mod blog_post;"));
assert!(read(domain.join("mod.rs")).contains("mod models;"));
assert!(read(app.join("src/modules/mod.rs")).contains("pub mod blog_post;"));
}
#[test]
fn make_model_supports_several_models_per_module() {
let tmp = TempDir::new();
let app = tmp.new_app();
let out = slave(&app, &["make:model", "Profile", "--module", "user"]);
assert!(out.status.success(), "{}", stderr(&out));
let models = read(app.join("src/modules/user/models/mod.rs"));
assert_eq!(models, "pub mod profile;\npub mod user;\n");
assert!(app.join("src/modules/user/models/profile.rs").is_file());
assert_eq!(
read(app.join("src/modules/user/mod.rs"))
.matches("mod models;")
.count(),
1
);
}
#[test]
fn make_tools_creates_and_registers_tools() {
let tmp = TempDir::new();
let app = tmp.new_app();
assert!(slave(&app, &["make:model", "Post"]).status.success());
let out = slave(&app.join("src"), &["make:tools", "PostTools"]);
assert!(out.status.success(), "{}", stderr(&out));
let domain = read(app.join("src/modules/post/mod.rs"));
for line in ["mod models;", "mod tools;", "pub use tools::tools;"] {
assert!(domain.contains(line), "missing `{line}` in:\n{domain}");
}
let tools = read(app.join("src/modules/post/tools.rs"));
for expected in [
"#[tool_router(router = post_tools,",
"McpServer::post_tools()",
"async fn post_list(",
"async fn post_get(",
"self.authenticate(&parts)",
] {
assert!(
tools.contains(expected),
"missing `{expected}` in:\n{tools}"
);
}
assert!(!app.join("src/modules/post/routes.rs").exists());
assert!(!app.join("src/modules/post/controllers").exists());
let modules = read(app.join("src/modules/mod.rs"));
assert_eq!(modules.matches("pub mod post;").count(), 1, "{modules}");
assert_eq!(modules.matches("post::tools(),").count(), 1, "{modules}");
let out = slave(&app, &["make:tools", "PostTools"]);
assert!(!out.status.success());
assert_eq!(read(app.join("src/modules/mod.rs")), modules);
}
#[test]
fn make_accepts_explicit_module() {
let tmp = TempDir::new();
let app = tmp.new_app();
let args = ["make:tools", "AdminTools", "--module", "admin_panel"];
let out = slave(&app, &args);
assert!(out.status.success(), "{}", stderr(&out));
let tools = read(app.join("src/modules/admin_panel/tools.rs"));
assert!(tools.contains("McpServer::admin_panel_tools()"), "{tools}");
assert!(tools.contains("async fn admin_list("), "{tools}");
let modules = read(app.join("src/modules/mod.rs"));
assert!(modules.contains("admin_panel::tools(),"), "{modules}");
}
#[test]
fn make_controller_is_gone() {
let tmp = TempDir::new();
let app = tmp.new_app();
let out = slave(&app, &["make:controller", "PostController"]);
assert!(!out.status.success());
assert!(!app.join("src/modules/post").exists());
}
fn routed_hosts(app: &Path) -> Vec<String> {
read(app.join("config/apps.toml"))
.lines()
.filter_map(|line| line.trim().strip_prefix("host = "))
.map(|host| host.trim_matches('"').to_owned())
.collect()
}
#[test]
fn make_app_creates_an_axum_app_and_routes_its_domain() {
let tmp = TempDir::new();
let app = tmp.new_app();
let out = slave(
&app,
&["make:app", "storefront", "--domain", "{toko}.localhost"],
);
assert!(out.status.success(), "{}", stderr(&out));
let out = slave(
&app.join("src"),
&["make:app", "dashboard", "--domain", "admin.localhost"],
);
assert!(out.status.success(), "{}", stderr(&out));
for file in [
"Cargo.toml",
"src/main.rs",
"src/mcp.rs",
"src/html.rs",
"src/pages.rs",
] {
assert!(
app.join("apps/dashboard").join(file).is_file(),
"missing {file}"
);
}
let manifest = read(app.join("apps/dashboard/Cargo.toml"));
assert!(manifest.contains("name = \"dashboard\""), "{manifest}");
assert!(
manifest.contains("slave-framework.workspace = true"),
"{manifest}"
);
assert!(read(app.join("apps/dashboard/src/main.rs")).contains("DEFAULT_PORT: u16 = 4001;"));
assert!(read(app.join("apps/storefront/src/main.rs")).contains("DEFAULT_PORT: u16 = 4000;"));
assert!(read(app.join("apps/dashboard/src/html.rs")).contains("<strong>Dashboard</strong>"));
assert_eq!(
routed_hosts(&app),
["mcp.localhost", "admin.localhost", "{toko}.localhost"]
);
let out = slave(&app, &["route:list"]);
assert!(out.status.success(), "{}", stderr(&out));
let list = String::from_utf8_lossy(&out.stdout);
for expected in [
"admin.localhost",
"dashboard",
"127.0.0.1:4001",
"down",
"3 route(s)",
] {
assert!(list.contains(expected), "missing `{expected}` in:\n{list}");
}
let out = slave(&app, &["route:match", "TokoX.localhost:8000"]);
assert!(out.status.success(), "{}", stderr(&out));
let matched = String::from_utf8_lossy(&out.stdout);
assert!(matched.contains("storefront (127.0.0.1:4000)"), "{matched}");
assert!(matched.contains("x-slave-param-toko: tokox"), "{matched}");
let out = slave(&app, &["route:match", "a.b.localhost"]);
assert!(!out.status.success());
assert!(stderr(&out).contains("no route matches"));
}
#[test]
fn make_app_creates_a_dioxus_spa() {
let tmp = TempDir::new();
let app = tmp.new_app();
let args = [
"make:app",
"console",
"--domain",
"console.localhost",
"--stack",
"dioxus",
];
let out = slave(&app, &args);
assert!(out.status.success(), "{}", stderr(&out));
let dir = app.join("apps/console");
for file in [
"Cargo.toml",
"Dioxus.toml",
"src/main.rs",
"src/app.rs",
"src/mcp.rs",
"src/server.rs",
] {
assert!(dir.join(file).is_file(), "missing {file}");
}
assert!(
!dir.join("src/pages.rs").exists(),
"no Axum pages in a Dioxus app"
);
let manifest = read(dir.join("Cargo.toml"));
assert!(
manifest.contains("cfg(target_arch = \"wasm32\")"),
"{manifest}"
);
assert!(manifest.contains("\"web\", \"router\""), "{manifest}");
assert!(read(dir.join("Dioxus.toml")).contains("title = \"Console\""));
assert!(read(dir.join("src/server.rs")).contains("DEFAULT_PORT: u16 = 4000;"));
assert_eq!(routed_hosts(&app), ["mcp.localhost", "console.localhost"]);
let out = slave(
&app,
&["make:app", "x", "--domain", "x.localhost", "--stack", "vue"],
);
assert!(!out.status.success(), "unknown stacks are rejected");
}
#[test]
fn make_app_rejects_conflicts_and_bad_input() {
let tmp = TempDir::new();
let app = tmp.new_app();
let ok = slave(
&app,
&["make:app", "dashboard", "--domain", "admin.localhost"],
);
assert!(ok.status.success(), "{}", stderr(&ok));
let config = read(app.join("config/apps.toml"));
for args in [
&["make:app", "dashboard", "--domain", "other.localhost"][..],
&["make:app", "panel", "--domain", "admin.localhost"],
&[
"make:app",
"panel",
"--domain",
"x.localhost",
"--port",
"4000",
],
&["make:app", "mcp", "--domain", "x.localhost"],
&["make:app", "app", "--domain", "x.localhost"],
&["make:app", "gateway", "--domain", "x.localhost"],
&["make:app", "Panel", "--domain", "x.localhost"],
&["make:app", "panel", "--domain", "{a}{b}.localhost"],
&["make:app", "panel", "--domain", "bad host"],
] {
let out = slave(&app, args);
assert!(!out.status.success(), "{args:?} should fail");
}
assert_eq!(read(app.join("config/apps.toml")), config, "unchanged");
assert!(!app.join("apps/panel").exists());
}
#[test]
fn make_refuses_to_overwrite_without_force() {
let tmp = TempDir::new();
let app = tmp.new_app();
let model = app.join("src/modules/user/models/user.rs");
let original = read(&model);
let modules_before = read(app.join("src/modules/mod.rs"));
let out = slave(&app, &["make:model", "User"]);
assert!(!out.status.success());
assert!(stderr(&out).contains("already exists"));
assert_eq!(read(&model), original);
assert_eq!(read(app.join("src/modules/mod.rs")), modules_before);
let out = slave(&app, &["make:model", "User", "--force"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_ne!(read(&model), original);
let models = read(app.join("src/modules/user/models/mod.rs"));
assert_eq!(models.matches("pub mod user;").count(), 1);
}
#[test]
fn make_requires_a_project() {
let tmp = TempDir::new();
let out = slave(&tmp.0, &["make:model", "User"]);
assert!(!out.status.success());
assert!(stderr(&out).contains("not inside a slave application"));
}
#[test]
fn make_rejects_invalid_names() {
let tmp = TempDir::new();
let app = tmp.new_app();
for args in [
&["make:model", "user"][..],
&["make:model", "User-Model"],
&["make:model", "../User"],
&["make:model", "Type"],
&["make:tools", "Tools"],
&["make:model", "Post", "--module", "Post"],
&["make:model", "Post", "--module", "../post"],
] {
assert!(!slave(&app, args).status.success(), "{args:?}");
}
}
fn migration_files(app: &Path) -> Vec<String> {
let mut files: Vec<String> = fs::read_dir(app.join("database/migrations"))
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name != "mod.rs")
.collect();
files.sort();
files
}
fn migration_names(files: &[String]) -> Vec<&str> {
files.iter().map(|f| &f[15..f.len() - 3]).collect()
}
#[test]
fn new_includes_core_migrations_in_order() {
let tmp = TempDir::new();
let app = tmp.new_app();
let files = migration_files(&app);
assert_eq!(migration_names(&files), INITIAL_MIGRATIONS);
let registry = read(app.join("database/migrations/mod.rs"));
for file in &files {
assert!(
registry.contains(&format!("#[path = \"{file}\"]")),
"{registry}"
);
}
}
#[test]
fn make_migration_creates_and_registers_in_order() {
let tmp = TempDir::new();
let app = tmp.new_app();
let added = [
"create_posts_table",
"add_phone_to_users",
"add_index_to_posts",
];
for name in added {
let out = slave(&app, &["make:migration", name]);
assert!(out.status.success(), "{}", stderr(&out));
}
let files = migration_files(&app);
let expected: Vec<&str> = INITIAL_MIGRATIONS.iter().copied().chain(added).collect();
assert_eq!(migration_names(&files), expected);
let registry = read(app.join("database/migrations/mod.rs"));
let mut last = 0;
for file in &files {
let module = format!(" m{},", &file[..file.len() - 3]);
let at = registry
.find(&module)
.unwrap_or_else(|| panic!("{module} in {registry}"));
assert!(at > last, "registry out of order:\n{registry}");
last = at;
}
let dir = app.join("database/migrations");
let posts = read(dir.join(&files[3]));
assert!(posts.contains("pub struct CreatePostsTable;"));
assert!(posts.contains("CREATE TABLE posts"));
assert!(posts.contains("DROP TABLE posts"));
let blank = read(dir.join(&files[4]));
assert!(blank.contains("pub struct AddPhoneToUsers;"));
assert!(
blank.contains("None"),
"blank migrations start irreversible"
);
}
#[test]
fn make_migration_rejects_duplicates_and_bad_names() {
let tmp = TempDir::new();
let app = tmp.new_app();
let out = slave(&app, &["make:migration", "create_users_table"]);
assert!(!out.status.success());
assert!(stderr(&out).contains("already exists"), "{}", stderr(&out));
for name in ["CreatePosts", "create-posts", "1_init", "migration", ""] {
let out = slave(&app, &["make:migration", name]);
assert!(!out.status.success(), "{name}");
}
assert_eq!(migration_files(&app).len(), INITIAL_MIGRATIONS.len());
}
#[test]
fn migrate_commands_require_a_project() {
let tmp = TempDir::new();
for command in [
"migrate",
"migrate:status",
"migrate:rollback",
"make:migration",
] {
let mut args = vec![command];
if command == "make:migration" {
args.push("create_users_table");
}
let out = slave(&tmp.0, &args);
assert!(!out.status.success(), "{command}");
assert!(
stderr(&out).contains("not inside a slave application"),
"{command}"
);
}
}
#[test]
#[ignore]
fn generated_application_builds_migrates_and_passes_its_tests() {
let tmp = TempDir::new();
let app = tmp.new_app();
for args in [
&["make:model", "Post"][..],
&["make:tools", "PostTools"],
&["make:migration", "create_posts_table"],
&["make:app", "dashboard", "--domain", "admin.localhost"],
&[
"make:app",
"console",
"--domain",
"console.localhost",
"--stack",
"dioxus",
],
] {
let out = slave(&app, args);
assert!(out.status.success(), "{args:?}: {}", stderr(&out));
}
let target = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/generated-app");
let run = |program: &str, args: &[&str]| {
let out = Command::new(program)
.args(args)
.env("CARGO_TARGET_DIR", &target)
.current_dir(&app)
.output()
.expect("run command");
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
assert!(
out.status.success(),
"{program} {args:?}\n{stdout}\n{}",
stderr(&out)
);
stdout
};
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
run(&cargo, &["fmt", "--all", "--check"]);
run(&cargo, &["test", "--workspace", "--quiet"]);
let slave_bin = env!("CARGO_BIN_EXE_slave");
let total = INITIAL_MIGRATIONS.len() + 1;
let migrated = run(slave_bin, &["migrate"]);
assert!(
migrated.contains(&format!("Batch 1: {total} applied")),
"{migrated}"
);
let again = run(slave_bin, &["migrate"]);
assert!(
again.contains(&format!("Nothing to migrate ({total} already applied)")),
"{again}"
);
let status = run(slave_bin, &["migrate:status"]);
assert_eq!(status.matches("Ran ").count(), total, "{status}");
let rolled_back = run(slave_bin, &["migrate:rollback"]);
assert!(
rolled_back.contains("Rolling back batch 1"),
"{rolled_back}"
);
let status = run(slave_bin, &["migrate:status"]);
assert_eq!(status.matches("Pending").count(), total, "{status}");
}