use crate::auth_kit;
use crate::naming;
use crate::stubs::{self, render};
use crate::console;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
const PACKAGES: &[(&str, &[&str])] = &[
("ai", &[]),
("audit", &["database/migrations"]),
("auth", &["storage/sessions"]),
("auth-kit", &["storage/sessions", "resources/views", "public/css", "public/js"]),
("cache", &["storage/cache"]),
("client", &[]),
("db", &["database/migrations", "database/seeders"]),
("debugbar", &[]),
("flags", &[]),
("i18n", &["lang"]),
("ldap", &[]),
("mail", &["resources/views"]),
("mcp", &[]),
("metrics", &[]),
("model-cache", &[]),
("oauth", &["storage/sessions"]),
("oauth-provider", &["storage/sessions"]),
("openapi", &[]),
("otel", &[]),
("queue", &["database/migrations"]),
("rbac", &["database/migrations"]),
("search", &[]),
("storage", &["storage/app"]),
("telescope", &[]),
("validation", &[]),
("vault", &[]),
("view", &["resources/views"]),
("webauthn", &["storage/sessions"]),
("ws", &[]),
];
pub fn run(args: &[String]) -> Result<(), String> {
let mut name = None;
let mut local_framework: Option<String> = None;
let mut packages: Vec<String> = Vec::new();
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--local" => {
local_framework =
Some(iter.next().ok_or("--local needs a path to the framework workspace")?.clone())
}
"--with" => {
let list = iter.next().ok_or("--with needs a comma-separated list of packages")?;
for requested in list.split(',').map(str::trim).filter(|p| !p.is_empty()) {
if !PACKAGES.iter().any(|(known, _)| *known == requested) {
return Err(format!(
"`{requested}` is not a package. Available: {}",
PACKAGES.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")
));
}
packages.push(requested.to_string());
}
}
"--all" => packages = PACKAGES.iter().map(|(name, _)| (*name).to_string()).collect(),
other if other.starts_with('-') => return Err(format!("unknown option `{other}`")),
other => name = Some(other.to_string()),
}
}
let auth_kit = packages.iter().any(|p| p == "auth-kit");
if auth_kit {
for required in
["audit", "auth", "db", "view", "validation", "rbac", "webauthn", "cache", "mail"]
{
packages.push(required.to_string());
}
packages.retain(|p| p != "auth-kit");
}
packages.sort();
packages.dedup();
let name = name.ok_or("usage: rustlavel new <name> [--with db,view] [--local <path>]")?;
let crate_name = naming::snake(&name);
let root = PathBuf::from(&name);
if root.exists() {
return Err(format!("`{name}` already exists"));
}
let source = match &local_framework {
Some(path) => {
let absolute = std::fs::canonicalize(path)
.map_err(|e| format!("cannot resolve --local path `{path}`: {e}"))?;
format!("path = \"{}/crates/rustlavel\"", absolute.display())
}
None => format!("version = \"{}\"", env!("CARGO_PKG_VERSION")),
};
let dependency = if packages.is_empty() {
source
} else {
let list = packages.iter().map(|p| format!("\"{p}\"")).collect::<Vec<_>>().join(", ");
format!("{source}, features = [{list}]")
};
let mut values = BTreeMap::new();
values.insert("plugins", plugin_lines(&packages));
values.insert("name", crate_name.clone());
values.insert("crate_name", crate_name.clone());
values.insert("app_name", naming::pascal(&name));
values.insert("dependency", dependency);
console::heading(&format!("Creating {}", console::accent(&name)));
let files: &[(&str, &str)] = &[
("Cargo.toml", stubs::CARGO_TOML),
(".gitignore", stubs::GITIGNORE),
(".env", stubs::ENV),
(".env.example", stubs::ENV),
("README.md", stubs::README),
("CLAUDE.md", stubs::AGENT_NOTES),
("config/app.json", stubs::CONFIG_APP),
("public/README.md", stubs::PUBLIC_KEEP),
(
"src/main.rs",
if packages.iter().any(|p| p == "db") { stubs::MAIN_RS_DB } else { stubs::MAIN_RS },
),
("src/routes/mod.rs", stubs::ROUTES_MOD),
("src/routes/web.rs", stubs::ROUTES_WEB),
("src/controllers/mod.rs", stubs::CONTROLLERS_MOD),
("src/controllers/welcome_controller.rs", stubs::WELCOME_CONTROLLER),
("tests/web.rs", stubs::TEST_STUB),
];
for (path, template) in files {
write(&root.join(path), &render(template, &values))?;
console::created(path);
}
let lib = if packages.iter().any(|p| p == "db") {
format!("{LIB_RS}pub mod database;\n")
} else {
LIB_RS.to_string()
};
write(&root.join("src/lib.rs"), &lib)?;
console::created("src/lib.rs");
if packages.iter().any(|p| p == "db") {
let empty: BTreeMap<&str, String> =
[("modules", String::new()), ("entries", String::new())].into_iter().collect();
write(
&root.join("database/migrations/mod.rs"),
&render(stubs::MIGRATIONS_REGISTRY, &empty),
)?;
write(&root.join("database/seeders/mod.rs"), &render(stubs::SEEDERS_REGISTRY, &empty))?;
write(&root.join("src/database.rs"), DATABASE_BRIDGE)?;
console::created("database/migrations/mod.rs");
console::created("database/seeders/mod.rs");
console::created("src/database.rs");
}
for (package, directories) in PACKAGES {
if !packages.iter().any(|enabled| enabled == package) {
continue;
}
for directory in *directories {
std::fs::create_dir_all(root.join(directory))
.map_err(|e| format!("cannot create {directory}: {e}"))?;
write(&root.join(directory).join(".gitkeep"), "")?;
console::created(directory);
}
}
if auth_kit {
for (path, contents) in auth_kit::FILES {
write(&root.join(path), &render(contents, &values))?;
}
console::created("src/controllers/auth/ (sign in, register, reset, two-factor)");
console::created("src/controllers/admin/ (users, roles, permissions)");
console::created("resources/views/ (every page, Tailwind)");
console::created("public/css/app.css, public/js/app.js");
console::created("database/migrations/ (users, tokens, sign-in log, factors)");
write(&root.join("src/main.rs"), &render(auth_kit::MAIN_RS, &values))?;
write(&root.join("src/lib.rs"), &format!("{LIB_RS}pub mod database;\npub mod models;\npub mod support;\n"))?;
write(&root.join("database/migrations/mod.rs"), auth_kit::MIGRATIONS_REGISTRY)?;
write(&root.join("database/seeders/auth_kit_seeder.rs"), auth_kit::SEEDER)?;
write(&root.join("database/seeders/mod.rs"), auth_kit::SEEDERS_REGISTRY)?;
console::created("src/main.rs, database/seeders/auth_kit_seeder.rs");
write(&root.join("config/auth.json"), auth_kit::CONFIG_AUTH)?;
write(&root.join("config/rbac.json"), auth_kit::CONFIG_RBAC)?;
write(&root.join("config/webauthn.json"), auth_kit::CONFIG_WEBAUTHN)?;
console::created("config/auth.json, config/rbac.json, config/webauthn.json");
for file in [".env", ".env.example"] {
let path = root.join(file);
let mut contents = std::fs::read_to_string(&path).unwrap_or_default();
contents.push_str(auth_kit::ENV_ADDITIONS);
write(&path, &contents)?;
}
}
let enabled = if packages.is_empty() {
String::new()
} else {
format!("\n Packages: {}", packages.join(", "))
};
console::success(&format!(
"Created {name}.{enabled}\n\n cd {name}\n rustlavel serve"
));
Ok(())
}
const LIB_RS: &str = r#"pub mod controllers;
pub mod routes;
"#;
const DATABASE_BRIDGE: &str = r#"//! Generated by the rustlavel CLI: bridges `database/` into the crate.
#[path = "../database/migrations/mod.rs"]
pub mod migrations;
#[path = "../database/seeders/mod.rs"]
pub mod seeders;
"#;
fn write(path: &Path, contents: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
}
std::fs::write(path, contents).map_err(|e| format!("cannot write {}: {e}", path.display()))
}
fn plugin_lines(packages: &[String]) -> String {
const REGISTERABLE: &[(&str, &str)] = &[
("telescope", "Telescope::new()"),
("debugbar", "DebugBar::new()"),
("metrics", "Metrics::new()"),
];
const NEEDS_WIRING: &[(&str, &str)] = &[
("rbac", "Rbac::from_config(db.clone(), app.config())?"),
("audit", "rustlavel::audit::Audit::new(db.clone())"),
("flags", "FeatureFlags::new(flags)"),
("vault", "Vault::from_config(app.config())?"),
("queue", "QueueDashboard::new(db.clone())"),
];
let mut lines = String::new();
for (package, expression) in REGISTERABLE {
if packages.iter().any(|p| p == package) {
lines.push_str(&format!(" .plugin({expression})\n"));
}
}
let owed: Vec<&str> = NEEDS_WIRING
.iter()
.filter(|(package, _)| packages.iter().any(|p| p == package))
.map(|(_, expression)| *expression)
.collect();
if !owed.is_empty() {
lines.push_str(&format!(
" // Also asked for, and needing something this file does not\n\
\x20 // build yet — a database handle, a store. Add them here:\n\
\x20 // {}\n",
owed.join("\n // ")
));
}
lines
}
#[cfg(test)]
mod tests {
#[test]
fn the_scaffold_registers_the_plugins_it_was_asked_for() {
let asked = ["telescope", "debugbar", "metrics"].map(str::to_string);
let lines = plugin_lines(&asked);
assert!(lines.contains(".plugin(Telescope::new())"), "{lines}");
assert!(lines.contains(".plugin(DebugBar::new())"), "{lines}");
assert!(lines.contains(".plugin(Metrics::new())"), "{lines}");
assert_eq!(plugin_lines(&[]), "");
}
#[test]
fn a_plugin_that_needs_wiring_is_named_rather_than_guessed_at() {
let lines = plugin_lines(&["rbac".to_string(), "audit".to_string()]);
assert!(!lines.contains(".plugin(Rbac"), "an uncompilable line was written: {lines}");
assert!(lines.contains("// Rbac::from_config"), "{lines}");
assert!(lines.contains("// rustlavel::audit::Audit::new"), "{lines}");
}
#[test]
fn every_registered_plugin_is_reachable_from_the_prelude() {
let prelude = include_str!("../../rustlavel/src/lib.rs");
let preludes_at = prelude.find("pub mod prelude").expect("the prelude moved");
let exports = &prelude[preludes_at..];
for kind in ["Telescope", "DebugBar", "Metrics"] {
assert!(
exports.contains(&format!("pub use crate::{kind};")),
"{kind} is written into main.rs but the prelude does not export it"
);
}
}
use super::*;
#[test]
fn every_feature_on_the_meta_crate_is_a_package_the_scaffold_accepts() {
let manifest = include_str!("../../rustlavel/Cargo.toml");
let features: Vec<&str> = manifest
.lines()
.skip_while(|line| line.trim() != "[features]")
.skip(1)
.take_while(|line| !line.trim_start().starts_with('['))
.filter_map(|line| line.split_once('=').map(|(name, _)| name.trim()))
.filter(|name| !name.is_empty() && *name != "default" && *name != "full")
.collect();
assert!(features.len() > 20, "the feature list was not found: {features:?}");
let known: Vec<&str> = PACKAGES.iter().map(|(name, _)| *name).collect();
let missing: Vec<&&str> = features.iter().filter(|f| !known.contains(f)).collect();
assert!(
missing.is_empty(),
"these are features of the rustlavel crate but `--with` refuses them: {missing:?}"
);
}
#[test]
fn the_scaffold_offers_nothing_the_meta_crate_cannot_turn_on() {
let manifest = include_str!("../../rustlavel/Cargo.toml");
for (package, _) in PACKAGES {
if *package == "auth-kit" {
continue;
}
assert!(
manifest.contains(&format!("\n{package} = [")),
"`--with {package}` is offered, but `rustlavel` has no such feature"
);
}
}
#[test]
fn the_package_list_is_sorted_so_the_error_message_reads_in_order() {
let names: Vec<&str> = PACKAGES.iter().map(|(name, _)| *name).collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(names, sorted, "keep PACKAGES alphabetical");
}
}