use std::path::{Path, PathBuf};
use std::process::{Command, Output};
const RUSTLAVEL: &str = env!("CARGO_BIN_EXE_rustlavel");
fn framework() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().expect("framework root")
}
fn fixture(name: &str) -> PathBuf {
let directory = std::env::temp_dir().join(format!("rustlavel-cli-generators-{name}"));
let _ = std::fs::remove_dir_all(&directory);
std::fs::create_dir_all(&directory).expect("fixture directory");
directory
}
fn run(directory: &Path, args: &[&str]) -> Output {
Command::new(RUSTLAVEL)
.args(args)
.current_dir(directory)
.output()
.unwrap_or_else(|e| panic!("cannot run rustlavel {args:?}: {e}"))
}
fn run_ok(directory: &Path, args: &[&str]) -> String {
let output = run(directory, args);
assert!(
output.status.success(),
"rustlavel {args:?} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).to_string()
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
}
fn scaffold(name: &str) -> PathBuf {
let directory = fixture(name);
run_ok(
&directory,
&[
"new",
"app",
"--local",
&framework().display().to_string(),
"--with",
"db,view,validation",
],
);
directory.join("app")
}
#[test]
fn make_crud_writes_every_file_the_resource_needs() {
let app = scaffold("crud-files");
let output = run_ok(&app, &["make:crud", "Post", "--fields", "title:string,body:text,published:bool"]);
for path in [
"src/models/post.rs",
"src/controllers/post_controller.rs",
"resources/views/posts/index.rl.html",
"resources/views/posts/form.rl.html",
"resources/views/layouts/app.rl.html",
] {
assert!(app.join(path).exists(), "{path} was not written\n{output}");
}
let migrations: Vec<String> = std::fs::read_dir(app.join("database/migrations"))
.expect("migrations directory")
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.file_name().into_string().ok())
.filter(|name| name.ends_with("_create_posts_table.rs"))
.collect();
assert_eq!(migrations.len(), 1, "expected one migration, found {migrations:?}");
}
#[test]
fn make_crud_keeps_the_model_the_migration_and_the_form_in_step() {
let app = scaffold("crud-columns");
run_ok(
&app,
&[
"make:crud",
"Post",
"--fields",
"title:string,body:text,published:bool,views:integer,email:string?",
],
);
let model = read(&app.join("src/models/post.rs"));
assert!(model.contains("#[model(table = \"posts\")]"), "{model}");
assert!(model.contains("pub title: String,"), "{model}");
assert!(model.contains("pub published: bool,"), "{model}");
assert!(model.contains("pub views: i32,"), "{model}");
assert!(model.contains("pub email: Option<String>,"), "{model}");
let migration = std::fs::read_dir(app.join("database/migrations"))
.unwrap()
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.find(|path| path.to_string_lossy().ends_with("_create_posts_table.rs"))
.map(|path| read(&path))
.expect("the migration");
assert!(migration.contains("t.string(\"title\");"), "{migration}");
assert!(migration.contains("t.text(\"body\");"), "{migration}");
assert!(migration.contains("t.boolean(\"published\").default_bool(false);"), "{migration}");
assert!(migration.contains("t.integer(\"views\");"), "{migration}");
assert!(migration.contains("t.string(\"email\").nullable();"), "{migration}");
assert!(migration.contains("t.timestamps();"), "{migration}");
let controller = read(&app.join("src/controllers/post_controller.rs"));
for handler in ["index", "create", "store", "edit", "update", "destroy"] {
assert!(controller.contains(&format!("pub async fn {handler}(")), "no {handler} handler");
}
assert!(controller.contains("(\"title\", \"required|string|max:255\"),"), "{controller}");
assert!(controller.contains("(\"email\", \"nullable|string|max:255\"),"), "{controller}");
assert!(controller.contains("validate(&mut req, RULES).await"), "{controller}");
let form = read(&app.join("resources/views/posts/form.rl.html"));
for column in ["title", "body", "published", "views", "email"] {
assert!(form.contains(&format!("name=\"{column}\"")), "no input for {column}:\n{form}");
assert!(form.contains(&format!("@if(error_{column})")), "no error for {column}");
}
assert!(form.contains("<textarea id=\"body\""), "{form}");
assert!(form.contains("type=\"checkbox\""), "{form}");
let index = read(&app.join("resources/views/posts/index.rl.html"));
for column in ["title", "body", "published", "views", "email"] {
assert!(index.contains(&format!("{{{{ record.{column} }}}}")), "no cell for {column}");
}
}
#[test]
fn make_crud_registers_the_migration_declares_the_modules_and_wires_the_routes() {
let app = scaffold("crud-registry");
run_ok(&app, &["make:crud", "Post", "--fields", "title:string"]);
let registry = read(&app.join("database/migrations/mod.rs"));
assert!(registry.contains("_create_posts_table;"), "{registry}");
assert!(registry.contains("::CreatePostsTable,"), "{registry}");
assert!(read(&app.join("src/models/mod.rs")).contains("pub mod post;"));
assert!(read(&app.join("src/lib.rs")).contains("pub mod models;"));
assert!(read(&app.join("src/controllers/mod.rs")).contains("pub mod post_controller;"));
let routes = read(&app.join("src/routes/web.rs"));
assert!(routes.contains("use crate::controllers::post_controller::PostController;"), "{routes}");
assert!(routes.contains("r.get(\"/posts\", PostController::index).name(\"posts.index\");"), "{routes}");
assert!(routes.contains("r.post(\"/posts/{id}/delete\", PostController::destroy)"), "{routes}");
let inserted = routes.find("PostController::index").expect("the route");
let closing = routes.rfind('}').expect("the closing brace");
assert!(inserted < closing, "the routes were written outside `fn routes`:\n{routes}");
assert!(routes.contains("WelcomeController::index"), "{routes}");
}
#[test]
fn make_crud_without_fields_says_so_and_still_generates() {
let app = scaffold("crud-skeleton");
let output = run_ok(&app, &["make:crud", "Post"]);
assert!(output.contains("no --fields given"), "the skeleton was not announced:\n{output}");
let model = read(&app.join("src/models/post.rs"));
assert!(model.contains("pub id: i64,"), "{model}");
assert!(model.contains("pub created_at: Option<String>,"), "{model}");
assert!(!model.contains("pub title"), "{model}");
}
#[test]
fn a_bad_field_spec_is_refused_and_names_the_token() {
let app = scaffold("crud-bad-spec");
let output = run(&app, &["make:crud", "Post", "--fields", "title:strng"]);
assert!(!output.status.success(), "a bad spec should fail");
let message = String::from_utf8_lossy(&output.stderr);
assert!(message.contains("`strng`"), "{message}");
assert!(message.contains("string"), "{message}");
assert!(!app.join("src/models/post.rs").exists(), "a file was written despite the error");
}
#[test]
fn make_crud_refuses_to_clobber_a_resource_that_exists() {
let app = scaffold("crud-twice");
run_ok(&app, &["make:crud", "Post", "--fields", "title:string"]);
let again = run(&app, &["make:crud", "Post", "--fields", "title:string"]);
assert!(!again.status.success(), "the second run should refuse");
let message = String::from_utf8_lossy(&again.stderr);
assert!(message.contains("already exists"), "{message}");
}
#[test]
fn make_crud_extends_a_layout_the_project_already_has() {
let app = scaffold("crud-layout");
let layout = app.join("resources/views/layouts/app.rl.html");
std::fs::create_dir_all(layout.parent().unwrap()).unwrap();
std::fs::write(&layout, "MINE @yield(\"content\")\n").unwrap();
run_ok(&app, &["make:crud", "Post", "--fields", "title:string"]);
assert_eq!(read(&layout), "MINE @yield(\"content\")\n", "the layout was overwritten");
assert!(read(&app.join("resources/views/posts/index.rl.html")).contains("@extends(\"layouts.app\")"));
}
#[test]
fn make_package_writes_a_publishable_crate() {
let directory = fixture("package");
run_ok(&directory, &["make:package", "rustlavel-audit"]);
let package = directory.join("rustlavel-audit");
assert!(package.join("Cargo.toml").exists());
assert!(package.join("src/lib.rs").exists());
assert!(package.join("README.md").exists());
let manifest = read(&package.join("Cargo.toml"));
for required in ["name = \"rustlavel-audit\"", "description = ", "license = ", "repository = "] {
assert!(manifest.contains(required), "the manifest is missing {required}:\n{manifest}");
}
let lib = read(&package.join("src/lib.rs"));
assert!(lib.contains("impl Plugin for Audit"), "{lib}");
assert!(lib.contains("pub fn from_config(config: &Config) -> Audit"), "{lib}");
assert!(lib.contains("config.bool(\"audit.enabled\", true)"), "{lib}");
assert!(lib.contains("pub mod prelude"), "{lib}");
assert!(lib.contains("#[cfg(test)]"), "{lib}");
assert!(read(&package.join("README.md")).contains("a crate plus a feature flag"));
}
#[test]
fn make_package_does_not_need_an_application_around_it() {
let directory = fixture("package-standalone");
let output = run(&directory, &["make:package", "audit"]);
assert!(
output.status.success(),
"make:package should not require a project\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(directory.join("audit/src/lib.rs").exists());
}
#[test]
#[ignore = "compiles a whole scaffolded application; run with --ignored"]
fn the_generated_crud_application_compiles() {
let app = scaffold("crud-compiles");
run_ok(
&app,
&[
"make:crud",
"Post",
"--fields",
"title:string,body:text,published:bool,views:integer,rating:float,\
price:decimal,due:date,seen_at:timestamp,email:string?,rank:bigint?",
],
);
let build = Command::new("cargo")
.arg("build")
.current_dir(&app)
.output()
.expect("cannot run cargo build");
assert!(
build.status.success(),
"the generated application did not compile:\n{}",
String::from_utf8_lossy(&build.stderr)
);
}