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", &[]),
("auth", &["storage/sessions"]),
("cache", &["storage/cache"]),
("client", &[]),
("db", &["database/migrations", "database/seeders"]),
("debugbar", &[]),
("i18n", &["lang"]),
("ldap", &[]),
("mail", &["resources/views"]),
("mcp", &[]),
("metrics", &[]),
("oauth", &["storage/sessions"]),
("oauth-provider", &["storage/sessions"]),
("openapi", &[]),
("otel", &[]),
("queue", &["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()),
}
}
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("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);
}
}
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()))
}