use std::fs;
use tempfile::TempDir;
use umbral_cli::scaffold::{
ScaffoldError, register_dep_in_cargo_toml, scaffold_app, scaffold_plugin, scaffold_project,
};
#[test]
fn scaffold_project_writes_expected_files() {
let tmp = TempDir::new().unwrap();
let report = scaffold_project("myblog", tmp.path(), None).unwrap();
let root = tmp.path().join("myblog");
assert_eq!(report.root, root);
assert!(root.is_dir());
let expected = [
"Cargo.toml",
"src/main.rs",
"umbral.toml",
".env",
".env.example",
".gitignore",
"README.md",
"templates/base.html",
"templates/home.html",
"templates/dashboard.html",
"templates/404.html",
"templates/500.html",
];
for path in &expected {
let full = root.join(path);
assert!(full.is_file(), "expected file at {}", full.display());
}
}
#[test]
fn scaffold_project_cargo_toml_references_all_plugins() {
let tmp = TempDir::new().unwrap();
let report = scaffold_project("testapp", tmp.path(), None).unwrap();
let cargo = fs::read_to_string(report.root.join("Cargo.toml")).unwrap();
for dep in &[
"umbral-auth",
"umbral-sessions",
"umbral-admin",
"umbral-rest",
"umbral-openapi",
"umbral-security",
"sqlx",
] {
assert!(
cargo.contains(dep),
"Cargo.toml should list {dep} as a dep; got:\n{cargo}"
);
}
}
#[test]
fn scaffold_project_cargo_toml_lists_every_builtin_plugin_at_least_commented() {
let tmp = TempDir::new().unwrap();
let report = scaffold_project("testapp", tmp.path(), None).unwrap();
let cargo = fs::read_to_string(report.root.join("Cargo.toml")).unwrap();
for plugin in &[
"umbral-playground",
"umbral-tasks",
"umbral-permissions",
"umbral-rls",
"umbral-cache",
"umbral-email",
"umbral-storage",
"umbral-signals",
"umbral-security",
] {
assert!(
cargo.contains(plugin),
"Cargo.toml should list every built-in (active or commented); missing {plugin} in:\n{cargo}",
);
}
for plugin in &["umbral-tasks", "umbral-playground", "umbral-cache"] {
assert!(
cargo
.lines()
.any(|l| l.trim_start().starts_with(&format!("# {plugin}"))),
"{plugin} should be present as a commented-out line in Cargo.toml",
);
}
}
#[test]
fn scaffold_project_main_rs_references_all_plugins() {
let tmp = TempDir::new().unwrap();
let report = scaffold_project("testapp", tmp.path(), None).unwrap();
let main_rs = fs::read_to_string(report.root.join("src/main.rs")).unwrap();
let public_rs = fs::read_to_string(report.root.join("src/views/public.rs")).unwrap();
let main_markers = &[
"AuthPlugin",
"SessionsPlugin",
"AdminPlugin",
"RestPlugin",
"OpenApiPlugin",
"SecurityPlugin",
"SecurityConfig",
"csrf_exempt_paths",
"login_required_html",
"ForeignKey",
"ResourceConfig",
"ResourceConfig::new(\"post\")",
"umbral_cli::dispatch(app).await",
".build_deferred()?",
"#[tokio::main]",
"auto_migrate",
];
for marker in main_markers {
assert!(
main_rs.contains(marker),
"main.rs should contain `{marker}`;\ngot:\n{main_rs}"
);
}
for marker in &["LoggedIn", "umbral::transaction"] {
assert!(
public_rs.contains(marker),
"views/public.rs should contain `{marker}`;\ngot:\n{public_rs}"
);
}
}
#[test]
fn scaffold_project_seed_has_no_hardcoded_admin_password() {
let tmp = TempDir::new().unwrap();
let report = scaffold_project("testapp", tmp.path(), None).unwrap();
let creds = fs::read_to_string(report.root.join("src/seed/credentials.rs")).unwrap();
assert!(
!creds.contains(r#"create_superuser("admin", "admin@example.com", "admin")"#),
"generated credentials.rs must not hardcode an admin/admin superuser;\ngot:\n{creds}"
);
assert!(
creds.contains("Environment::Dev"),
"seed must be gated on the Dev environment;\ngot:\n{creds}"
);
assert!(
creds.contains("UMBRAL_DEV_ADMIN_PASSWORD"),
"seed must read the password from an env var;\ngot:\n{creds}"
);
assert!(
creds.contains("create_superuser(\"admin\", \"admin@example.com\", &password)"),
"create_superuser must receive the env-var password by reference;\ngot:\n{creds}"
);
}
#[test]
fn scaffold_project_generates_a_unique_dev_secret_key() {
let tmp = TempDir::new().unwrap();
let report = scaffold_project("keyapp", tmp.path(), None).unwrap();
let toml = fs::read_to_string(report.root.join("umbral.toml")).unwrap();
let env = fs::read_to_string(report.root.join(".env")).unwrap();
assert!(
!toml.contains("umbral-insecure-dev-key-change-me"),
"umbral.toml must not ship the shared literal dev key;\ngot:\n{toml}"
);
assert!(
!env.contains("umbral-insecure-dev-key-change-me"),
".env must not ship the shared literal dev key;\ngot:\n{env}"
);
let toml_key = toml
.lines()
.find_map(|l| l.strip_prefix("secret_key = \"")?.strip_suffix('"'))
.expect("umbral.toml has a secret_key");
let env_key = env
.lines()
.find_map(|l| l.strip_prefix("UMBRAL_SECRET_KEY="))
.expect(".env has UMBRAL_SECRET_KEY");
assert_eq!(toml_key, env_key, "toml + .env keys must match");
assert_eq!(toml_key.len(), 64, "key is 64 hex chars");
assert!(
toml_key.chars().all(|c| c.is_ascii_hexdigit()),
"key is all hex; got {toml_key}"
);
let tmp2 = TempDir::new().unwrap();
let report2 = scaffold_project("keyapp2", tmp2.path(), None).unwrap();
let toml2 = fs::read_to_string(report2.root.join("umbral.toml")).unwrap();
let key2 = toml2
.lines()
.find_map(|l| l.strip_prefix("secret_key = \"")?.strip_suffix('"'))
.expect("second umbral.toml has a secret_key");
assert_ne!(toml_key, key2, "two scaffolds must not share a dev key");
}
#[test]
fn scaffold_project_readme_first_run_is_bare_cargo_run() {
let tmp = TempDir::new().unwrap();
let report = scaffold_project("testapp", tmp.path(), None).unwrap();
let readme = fs::read_to_string(report.root.join("README.md")).unwrap();
assert!(
!readme.contains(
"# First run — applies migrations and starts the server:\ncargo run -- serve"
),
"README must not claim `serve` auto-migrates on first run;\ngot:\n{readme}"
);
assert!(
readme.contains("bare `cargo run`"),
"README should document that a bare `cargo run` auto-migrates;\ngot:\n{readme}"
);
}
#[test]
fn scaffold_project_ships_a_compiled_bundle_not_the_tailwind_cdn() {
let tmp = TempDir::new().unwrap();
let report = scaffold_project("testapp", tmp.path(), None).unwrap();
let base = fs::read_to_string(report.root.join("templates/base.html")).unwrap();
assert!(
!base.contains("<script src=\"https://cdn.tailwindcss.com"),
"base.html must NOT pull the Tailwind play CDN; got:\n{base}"
);
assert!(
base.contains("static('css/app.css')"),
"base.html should link the compiled bundle via static(); got:\n{base}"
);
let css = fs::read_to_string(report.root.join("static/css/app.css"))
.expect("static/css/app.css must be generated");
assert!(
css.contains("--accent") || css.contains("bg-accent"),
"the shipped bundle should carry the umbral palette"
);
for f in [
"styles/input.css",
"styles/tailwind.config.js",
"styles/package.json",
] {
assert!(
report.root.join(f).exists(),
"{f} must be generated so `npm run build` can regenerate the bundle"
);
}
}
#[test]
fn scaffold_project_templates_link_the_docs() {
let tmp = TempDir::new().unwrap();
let report = scaffold_project("testapp", tmp.path(), None).unwrap();
let base = fs::read_to_string(report.root.join("templates/base.html")).unwrap();
let home = fs::read_to_string(report.root.join("templates/home.html")).unwrap();
assert!(
base.contains("dalmasonto.github.io/umbral/docs/"),
"the navbar should link the docs"
);
assert!(
home.contains("getting-started/your-first-app"),
"the home page should point at the getting-started guide"
);
for tpl in [&base, &home] {
assert!(
!tpl.contains("__PROJECT__")
&& !tpl.contains("__DOCS__")
&& !tpl.contains("__INITIAL__"),
"an unsubstituted placeholder leaked into a generated template"
);
}
}
#[test]
fn scaffold_project_substitutes_project_name() {
let tmp = TempDir::new().unwrap();
let report = scaffold_project("acmecorp", tmp.path(), None).unwrap();
let base = fs::read_to_string(report.root.join("templates/base.html")).unwrap();
assert!(
base.contains("acmecorp"),
"base.html should include the project name 'acmecorp'; got:\n{base}"
);
let cargo = fs::read_to_string(report.root.join("Cargo.toml")).unwrap();
assert!(
cargo.contains("name = \"acmecorp\""),
"Cargo.toml should set name = \"acmecorp\"; got:\n{cargo}"
);
let env = fs::read_to_string(report.root.join(".env")).unwrap();
assert!(
env.contains("acmecorp.db"),
".env should reference acmecorp.db; got:\n{env}"
);
}
#[test]
fn scaffold_project_main_rs_wires_dispatch() {
let tmp = TempDir::new().unwrap();
let report = scaffold_project("blog", tmp.path(), None).unwrap();
let main_rs = fs::read_to_string(report.root.join("src/main.rs")).unwrap();
assert!(
main_rs.contains("umbral_cli::dispatch(app).await"),
"generated main.rs should call umbral_cli::dispatch; got:\n{main_rs}"
);
assert!(
main_rs.contains(".build_deferred()?"),
"generated main.rs must defer on_ready to dispatch (gaps3 #41); got:\n{main_rs}"
);
assert!(
main_rs.contains("#[tokio::main]"),
"generated main.rs should declare a tokio runtime"
);
}
#[test]
fn scaffold_project_uses_project_name_in_database_url_and_gitignore() {
let tmp = TempDir::new().unwrap();
let report = scaffold_project("acme", tmp.path(), None).unwrap();
let umbral_toml = fs::read_to_string(report.root.join("umbral.toml")).unwrap();
assert!(
umbral_toml.contains("sqlite://acme.db"),
"umbral.toml should default to a per-project DB URL; got:\n{umbral_toml}"
);
let gitignore = fs::read_to_string(report.root.join(".gitignore")).unwrap();
assert!(
gitignore.contains("/acme.db*"),
".gitignore should ignore the project's DB; got:\n{gitignore}"
);
}
#[test]
fn scaffold_project_refuses_to_overwrite_existing_directory() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("existing")).unwrap();
let err = scaffold_project("existing", tmp.path(), None).unwrap_err();
matches!(err, ScaffoldError::AlreadyExists(_));
}
#[test]
fn scaffold_project_rejects_invalid_names() {
let tmp = TempDir::new().unwrap();
assert!(matches!(
scaffold_project("", tmp.path(), None),
Err(ScaffoldError::InvalidName(_))
));
assert!(matches!(
scaffold_project("2cool", tmp.path(), None),
Err(ScaffoldError::InvalidName(_))
));
assert!(matches!(
scaffold_project("has spaces", tmp.path(), None),
Err(ScaffoldError::InvalidName(_))
));
}
#[test]
fn scaffold_app_writes_plugin_under_plugins_dir() {
let tmp = TempDir::new().unwrap();
scaffold_project("blog", tmp.path(), None).unwrap();
let project_root = tmp.path().join("blog");
let report = scaffold_app("posts", &project_root, None).unwrap();
assert_eq!(report.root, project_root.join("plugins").join("posts"));
let cargo = fs::read_to_string(report.root.join("Cargo.toml")).unwrap();
assert!(cargo.contains("name = \"posts\""));
let lib = fs::read_to_string(report.root.join("src/lib.rs")).unwrap();
assert!(
lib.contains("pub struct PostsPlugin"),
"lib.rs should emit a PostsPlugin struct; got:\n{lib}"
);
assert!(
lib.contains("impl Plugin for PostsPlugin"),
"lib.rs should emit a Plugin impl"
);
assert!(
lib.contains("fn name(&self) -> &'static str {\n \"posts\""),
"Plugin::name should return the lowercase name"
);
}
#[test]
fn scaffold_app_pascal_cases_multi_word_names() {
let tmp = TempDir::new().unwrap();
scaffold_project("blog", tmp.path(), None).unwrap();
let project_root = tmp.path().join("blog");
let report = scaffold_app("blog-engine", &project_root, None).unwrap();
let lib = fs::read_to_string(report.root.join("src/lib.rs")).unwrap();
assert!(
lib.contains("pub struct BlogEnginePlugin"),
"kebab-case name should pascal-case to BlogEnginePlugin; got:\n{lib}"
);
assert!(
lib.contains("blog_engine::BlogEnginePlugin"),
"next-steps should reference the Rust identifier (underscored)"
);
}
#[test]
fn scaffold_app_refuses_to_overwrite_existing_plugin() {
let tmp = TempDir::new().unwrap();
scaffold_project("blog", tmp.path(), None).unwrap();
let project_root = tmp.path().join("blog");
scaffold_app("posts", &project_root, None).unwrap();
let err = scaffold_app("posts", &project_root, None).unwrap_err();
matches!(err, ScaffoldError::AlreadyExists(_));
}
#[test]
fn scaffold_project_local_flag_emits_path_deps() {
let tmp = TempDir::new().unwrap();
let fake_umbral = tmp.path().join("checkout");
std::fs::create_dir_all(&fake_umbral).unwrap();
let report = scaffold_project("acme", tmp.path(), Some(&fake_umbral)).unwrap();
let cargo_toml = std::fs::read_to_string(report.root.join("Cargo.toml")).unwrap();
assert!(
!cargo_toml.contains("git = \"https://github.com/dalmasonto/umbral\""),
"no git deps should remain when --local was set; got:\n{cargo_toml}",
);
let expected_umbral = format!("path = \"{}/crates/umbral\"", fake_umbral.display());
assert!(
cargo_toml.contains(&expected_umbral),
"umbral should path-dep against crates/umbral; expected {expected_umbral:?}, got:\n{cargo_toml}",
);
let expected_auth = format!("path = \"{}/plugins/umbral-auth\"", fake_umbral.display());
assert!(
cargo_toml.contains(&expected_auth),
"umbral-auth should path-dep against plugins/umbral-auth; expected {expected_auth:?}, got:\n{cargo_toml}",
);
}
#[test]
fn scaffold_app_writes_models_rs_stub() {
let tmp = TempDir::new().unwrap();
scaffold_project("acme", tmp.path(), None).unwrap();
let project_root = tmp.path().join("acme");
let report = scaffold_app("posts", &project_root, None).unwrap();
let models = std::fs::read_to_string(report.root.join("src/models.rs")).unwrap();
assert!(
models.contains("#[derive(umbral::orm::Model)]"),
"models.rs should show the canonical derive line in its example block; got:\n{models}",
);
let lib = std::fs::read_to_string(report.root.join("src/lib.rs")).unwrap();
assert!(
lib.contains("pub mod models;"),
"lib.rs should declare `pub mod models;`; got:\n{lib}",
);
assert!(
lib.contains("Plugin::models()") || lib.contains("fn models("),
"lib.rs should reference Plugin::models() so the user sees where to register; got:\n{lib}",
);
}
#[test]
fn scaffold_app_registers_path_dep_in_project_cargo_toml() {
let tmp = TempDir::new().unwrap();
scaffold_project("blog", tmp.path(), None).unwrap();
let project_root = tmp.path().join("blog");
let report = scaffold_app("posts", &project_root, None).unwrap();
assert_eq!(
report.cargo_toml_registered,
Some(true),
"cargo_toml_registered should be Some(true) when the dep was freshly added"
);
let cargo = fs::read_to_string(project_root.join("Cargo.toml")).unwrap();
assert!(
cargo.contains("posts = { path = \"plugins/posts\" }"),
"project Cargo.toml must list the new plugin as a path dep; got:\n{cargo}"
);
}
#[test]
fn scaffold_app_second_plugin_gets_registered_independently() {
let tmp = TempDir::new().unwrap();
scaffold_project("blog", tmp.path(), None).unwrap();
let project_root = tmp.path().join("blog");
scaffold_app("posts", &project_root, None).unwrap();
let report2 = scaffold_app("comments", &project_root, None).unwrap();
assert_eq!(report2.cargo_toml_registered, Some(true));
let cargo = fs::read_to_string(project_root.join("Cargo.toml")).unwrap();
let non_comment_lines: Vec<&str> = cargo
.lines()
.filter(|l| !l.trim_start().starts_with('#'))
.collect();
let posts_count = non_comment_lines
.iter()
.filter(|l| l.contains("posts = { path = \"plugins/posts\" }"))
.count();
let comments_count = non_comment_lines
.iter()
.filter(|l| l.contains("comments = { path = \"plugins/comments\" }"))
.count();
assert_eq!(posts_count, 1, "posts dep should appear exactly once");
assert_eq!(comments_count, 1, "comments dep should appear exactly once");
}
#[test]
fn register_dep_idempotent_no_duplicate() {
let tmp = TempDir::new().unwrap();
scaffold_project("blog", tmp.path(), None).unwrap();
let cargo_path = tmp.path().join("blog").join("Cargo.toml");
let added_first = register_dep_in_cargo_toml(&cargo_path, "posts").unwrap();
assert!(added_first, "first call should return true (dep was added)");
let added_second = register_dep_in_cargo_toml(&cargo_path, "posts").unwrap();
assert!(
!added_second,
"second call should return false (already present)"
);
let cargo = fs::read_to_string(&cargo_path).unwrap();
let count = cargo
.lines()
.filter(|l| !l.trim_start().starts_with('#'))
.filter(|l| l.contains("posts = { path = \"plugins/posts\" }"))
.count();
assert_eq!(count, 1, "dep line must appear exactly once; got:\n{cargo}");
}
#[test]
fn scaffold_app_succeeds_without_project_cargo_toml() {
let tmp = TempDir::new().unwrap();
let report = scaffold_app("widgets", tmp.path(), None).unwrap();
assert_eq!(
report.cargo_toml_registered, None,
"should be None when no project Cargo.toml is present"
);
assert!(
report.root.join("Cargo.toml").is_file(),
"plugin Cargo.toml must be written even when project Cargo.toml is absent"
);
assert!(
report.root.join("src/lib.rs").is_file(),
"plugin src/lib.rs must be written even when project Cargo.toml is absent"
);
}
#[test]
fn scaffold_plugin_registers_path_dep_in_project_cargo_toml() {
let tmp = TempDir::new().unwrap();
scaffold_project("blog", tmp.path(), None).unwrap();
let project_root = tmp.path().join("blog");
let report = scaffold_plugin("widgets", &project_root, None).unwrap();
assert_eq!(
report.cargo_toml_registered,
Some(true),
"scaffold_plugin should register the dep in Cargo.toml"
);
let cargo = fs::read_to_string(project_root.join("Cargo.toml")).unwrap();
assert!(
cargo.contains("widgets = { path = \"plugins/widgets\" }"),
"project Cargo.toml must list widgets as a path dep; got:\n{cargo}"
);
}
#[test]
fn scaffold_app_next_steps_no_longer_mention_manual_cargo_toml_edit() {
let tmp = TempDir::new().unwrap();
scaffold_project("blog", tmp.path(), None).unwrap();
let project_root = tmp.path().join("blog");
let report = scaffold_app("posts", &project_root, None).unwrap();
for step in &report.next_steps {
assert!(
!step.contains("Cargo.toml"),
"next_steps should not ask user to edit Cargo.toml manually \
(auto-registered); offending step: {step:?}"
);
}
let has_builder_step = report
.next_steps
.iter()
.any(|s| s.contains("App::builder") || s.contains(".plugin("));
assert!(
has_builder_step,
"next_steps must still tell the user to wire .plugin(...) in main.rs"
);
}