use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use umbral_casing::pascal_case_from_ident;
#[derive(Debug)]
pub enum ScaffoldError {
InvalidName(String),
AlreadyExists(PathBuf),
ReservedName(String),
ReservedCommandName(String),
NoSuchPlugin {
asked: String,
available: Vec<String>,
},
NotAProject(PathBuf),
Io(io::Error),
}
pub const RESERVED_PLUGIN_NAMES: &[&str] = &[
"admin",
"analytics",
"app",
"auth",
"cache",
"email",
"graphql",
"health",
"livereload",
"logs",
"oauth",
"openapi",
"permissions",
"playground",
"realtime",
"rest",
"rls",
"security",
"sessions",
"signals",
"static",
"storage",
"tasks",
"tenants",
];
pub const RESERVED_PLUGIN_COMMAND_NAMES: &[&str] = &[
"clearsessions",
"collectstatic",
"createsuperuser",
"gen-client",
"migrate_schemas",
"startauthentication",
"startpagination",
"startpermission",
"startthrottle",
"tasks-beat",
"tasks-worker",
];
pub fn reserved_command_names() -> Vec<String> {
let mut names = crate::builtin_command_names();
names.extend(RESERVED_PLUGIN_COMMAND_NAMES.iter().map(|s| s.to_string()));
names.sort();
names.dedup();
names
}
impl std::fmt::Display for ScaffoldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidName(s) => write!(
f,
"invalid name `{s}`: must be ASCII alphanumeric, underscore or hyphen, not starting with a digit",
),
Self::AlreadyExists(p) => write!(
f,
"an app already exists at `{}`; move it aside or pick a different name",
p.display()
),
Self::ReservedName(s) => write!(
f,
"`{s}` is the name of a built-in umbral plugin; pick a different name to avoid conflicts at registration time. Reserved names: {}.",
RESERVED_PLUGIN_NAMES.join(", ")
),
Self::ReservedCommandName(s) => write!(
f,
"`{s}` is already an umbral command; pick another name. A command you register \
is dispatched BEFORE the built-in of the same name, so this one would shadow \
it. Taken names: {}.",
reserved_command_names().join(", ")
),
Self::NoSuchPlugin { asked, available } => {
if available.is_empty() {
write!(
f,
"no plugin named `{asked}` — this project has no `plugins/` directory yet. \
Create one with `umbral startplugin <name>`, or place the command at the \
project root with `--in root`."
)
} else {
write!(
f,
"no plugin named `{asked}`. Available: root, {}.",
available.join(", ")
)
}
}
Self::NotAProject(p) => write!(
f,
"`{}` doesn't look like an umbral project — no `src/main.rs`. cd into your \
project directory, or pass `--path <dir>`.",
p.display()
),
Self::Io(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for ScaffoldError {}
impl From<io::Error> for ScaffoldError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
impl From<umbral::codegen::CodegenError> for ScaffoldError {
fn from(e: umbral::codegen::CodegenError) -> Self {
use umbral::codegen::CodegenError as C;
match e {
C::InvalidName(s) => Self::InvalidName(s),
C::AlreadyExists(p) => Self::AlreadyExists(p),
C::NoSuchPlugin { asked, available } => Self::NoSuchPlugin { asked, available },
C::NotAProject(p) => Self::NotAProject(p),
C::Io(e) => Self::Io(e),
}
}
}
#[derive(Debug, Clone)]
pub struct ScaffoldReport {
pub root: PathBuf,
pub files: Vec<PathBuf>,
pub next_steps: Vec<String>,
pub cargo_toml_registered: Option<bool>,
pub registered: Option<bool>,
}
fn validate_name(name: &str) -> Result<(), ScaffoldError> {
umbral::codegen::validate_ident(name).map_err(Into::into)
}
pub(crate) fn localize_deps(text: &str, umbral_repo: &Path) -> String {
let repo_str = umbral_repo.display().to_string();
let mut out = String::with_capacity(text.len());
for line in text.split_inclusive('\n') {
out.push_str(&rewrite_line(line, &repo_str));
}
out
}
fn rewrite_line(line: &str, repo: &str) -> String {
let body_start = line
.char_indices()
.find(|(_, c)| !matches!(*c, '#' | ' ' | '\t'))
.map(|(i, _)| i)
.unwrap_or(0);
let body = &line[body_start..];
let Some(eq_idx) = body.find('=') else {
return line.to_string();
};
let crate_name = body[..eq_idx].trim();
if !crate_name.starts_with("umbral") || crate_name.contains(|c: char| c.is_whitespace()) {
return line.to_string();
}
let after_eq = &body[eq_idx + 1..];
let spec_offset = after_eq.len() - after_eq.trim_start().len();
let spec = after_eq.trim_start();
let spec_len = if let Some(rest) = spec.strip_prefix('"') {
match rest.find('"') {
Some(i) => 1 + i + 1,
None => return line.to_string(),
}
} else if spec.starts_with('{') {
match spec.find('}') {
Some(i) => i + 1,
None => return line.to_string(),
}
} else {
return line.to_string();
};
let spec_start = body_start + eq_idx + 1 + spec_offset;
let spec_end = spec_start + spec_len;
let subdir = match crate_name {
"umbral" | "umbral-cli" | "umbral-core" | "umbral-macros" | "umbral-testing" => "crates",
_ => "plugins",
};
let path = format!("{repo}/{subdir}/{crate_name}");
let prefix = &line[..spec_start];
let suffix = &line[spec_end..];
format!("{prefix}{{ path = \"{path}\" }}{suffix}")
}
fn rust_ident(name: &str) -> String {
name.replace('-', "_")
}
fn random_dev_secret_key() -> String {
use std::hash::{BuildHasher, Hasher};
let seed = std::collections::hash_map::RandomState::new();
let mut out = String::with_capacity(64);
for i in 0..4u64 {
let mut h = seed.build_hasher();
h.write_u64(i);
h.write_u64(i.wrapping_mul(0x9E37_79B9_7F4A_7C15));
out.push_str(&format!("{:016x}", h.finish()));
}
out
}
const DOCS_URL: &str = "https://dalmasonto.github.io/umbral/docs/v0.0.1";
fn find_umbral_checkout(start: &Path) -> Option<PathBuf> {
start
.ancestors()
.find(|d| d.join("crates/umbral-core/Cargo.toml").is_file())
.map(Path::to_path_buf)
}
fn warn_if_run_from_a_source_checkout(name: &str, parent_dir: &Path) {
let from_cwd = std::env::current_dir()
.ok()
.and_then(|d| find_umbral_checkout(&d));
let Some(repo) = from_cwd.or_else(|| find_umbral_checkout(parent_dir)) else {
return;
};
let version = env!("CARGO_PKG_VERSION");
let repo = repo.display();
eprintln!(
"warning: running `startproject` from an umbral source checkout ({repo}) without `--local`."
);
eprintln!();
eprintln!(
" The new project will depend on the PUBLISHED umbral {version}, while your checkout is on"
);
eprintln!(
" whatever you have got. Any framework surface you have added since {version} was released"
);
eprintln!(
" will be missing, and the generated project will fail to compile against it — looking for"
);
eprintln!(" all the world like a framework bug rather than a version skew.");
eprintln!();
eprintln!(" To build against this checkout instead:");
eprintln!();
eprintln!(" umbral startproject {name} --local {repo}");
eprintln!();
}
pub fn scaffold_project(
name: &str,
parent_dir: &Path,
local_umbral_repo: Option<&Path>,
) -> Result<ScaffoldReport, ScaffoldError> {
validate_name(name)?;
if local_umbral_repo.is_none() {
warn_if_run_from_a_source_checkout(name, parent_dir);
}
let root = parent_dir.join(name);
if root.exists() {
return Err(ScaffoldError::AlreadyExists(root));
}
fs::create_dir_all(&root)?;
fs::create_dir_all(root.join("src"))?;
fs::create_dir_all(root.join("src/views"))?;
fs::create_dir_all(root.join("src/seed"))?;
fs::create_dir_all(root.join("src/widgets"))?;
fs::create_dir_all(root.join("plugins"))?;
fs::create_dir_all(root.join("templates"))?;
let crate_name = rust_ident(name);
let mut files = Vec::new();
let version = env!("CARGO_PKG_VERSION");
let cargo_toml = format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2024"
[dependencies]
# ----- Framework core (always required) ------------------------------------
umbral = "{version}"
umbral-cli = "{version}"
# ----- Active by default ---------------------------------------------------
# What the generated `src/main.rs` wires in. Comment any of these out only
# if you also remove the matching `.plugin(...)` line.
umbral-auth = "{version}"
umbral-sessions = "{version}"
umbral-admin = "{version}"
umbral-rest = "{version}"
umbral-openapi = "{version}"
umbral-security = "{version}"
# Observability init helper (structured JSON logging). Enable the `otel`
# feature to ALSO export OpenTelemetry traces over OTLP to a collector
# (Jaeger/Tempo/Honeycomb): `umbral-logs = {{ version = "{version}", features = ["otel"] }}`.
umbral-logs = "{version}"
# Serves ./static at /static — including the compiled Tailwind bundle this
# project ships. Not optional: the SecurityPlugin's CSP blocks third-party
# script/style CDNs, so an app must serve its own assets.
umbral-storage = "{version}"
# ----- Available built-ins (uncomment + register in main.rs to enable) -----
# umbral-playground = "{version}" # Interactive API playground UI (think mini-Postman) at /playground/.
# umbral-health = "{version}" # Liveness + readiness probes at /healthz and /ready. Zero config.
# umbral-tasks = "{version}" # DB-backed background task queue with a worker process.
# umbral-graphql = "{version}" # A real GraphQL API derived from your models. Expose per model.
# umbral-realtime = "{version}" # Server-Sent Events + WebSocket push, with model-change subscriptions.
# umbral-oauth = "{version}" # Social login / account connection (Google, GitHub). See auth/oauth docs.
# umbral-permissions = "{version}" # ContentType + Group + Permission model.
# umbral-tenants = "{version}" # Multi-tenant schema routing (Postgres).
# umbral-rls = "{version}" # Postgres row-level security policy registration.
# umbral-cache = "{version}" # Per-request caching helper.
# umbral-email = "{version}" # SMTP + MIME email composer + sender.
# umbral-analytics = "{version}" # Pageview / event analytics (needs an API key).
# umbral-signals = "{version}" # Pre/post save/delete signal dispatch.
# umbral-livereload = "{version}" # Dev-only browser live-reload (SSE push + file watcher). Add `.plugin(LiveReloadPlugin::new())`.
# ----- Third-party + framework runtime deps --------------------------------
tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
serde = {{ version = "1", features = ["derive"] }}
chrono = {{ version = "0.4", features = ["serde"] }}
sqlx = {{ version = "0.8", features = ["macros", "sqlite", "postgres", "chrono", "runtime-tokio"] }}
# Once you `umbral startapp <plugin>` or `umbral startplugin <plugin>`, add
# the plugin crate here:
# {crate_name}-posts = {{ path = "plugins/posts" }}
"#
);
let cargo_toml = match local_umbral_repo {
Some(repo) => localize_deps(&cargo_toml, repo),
None => cargo_toml,
};
write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
let main_rs = format!(
r#"//! {name} — application entrypoint.
//!
//! This `main.rs` reads like a table of contents: the App builder lists
//! every model, plugin, and route, and the per-concern submodules below
//! own the detail. As the project grows you slot new handlers into
//! `views/`, new seed steps into `seed/`, and new dashboard widgets into
//! `widgets/` — `main.rs` stays a thin wiring layer.
//!
//! src/
//! main.rs — App builder + route table + boot helpers (this file)
//! views/ — HTTP handlers, one file per resource grouping
//! seed/ — first-run data, `seed::all()` pins dependency order
//! widgets/ — admin dashboard widgets, one file per kind
//! ../plugins/ — local app plugins (`umbral startapp <name>`)
//!
//! Run with:
//! cargo run -- migrate # apply pending migrations (run once after checkout)
//! cargo run -- serve # boot the HTTP server
//!
//! Other management commands:
//! cargo run -- makemigrations
//! cargo run -- showmigrations
//! cargo run -- createsuperuser
// --- Per-concern modules (the table of contents) ---------------------------
mod seed;
mod views;
mod widgets;
use umbral::prelude::*;
use umbral::web::{{SlashRedirect}};
use umbral_auth::{{AuthPlugin, AuthUser, login_required_html}};
use umbral_sessions::SessionsPlugin;
use umbral_admin::AdminPlugin;
use umbral_rest::{{RestPlugin, ResourceConfig}};
use umbral_openapi::OpenApiPlugin;
use umbral_security::SecurityPlugin;
use umbral_storage::StoragePlugin;
// ---------------------------------------------------------------------------
// Models
// ---------------------------------------------------------------------------
/// A blog post. `author` is a FK to the built-in `AuthUser` model — the
/// migration engine emits `REFERENCES "auth_user"("id")` automatically.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]
pub struct Post {{
pub id: i64,
pub title: String,
pub body: String,
pub published: bool,
pub author: ForeignKey<AuthUser>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}}
// ---------------------------------------------------------------------------
// App wiring
// ---------------------------------------------------------------------------
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {{
// Observability: structured logging + (under the `otel` feature on
// `umbral-logs`) OpenTelemetry OTLP trace export. Reads RUST_LOG,
// UMBRAL_LOG_FORMAT=json, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME.
// Keep the guard alive for the whole program: it flushes the OTLP
// exporter on drop so trailing spans aren't lost at exit.
let _obs = umbral_logs::observability::init(umbral_logs::ObservabilityConfig::from_env());
let settings = Settings::from_env()?;
let pool = umbral::db::connect(&settings.database_url).await?;
let app = App::builder()
.settings(settings)
.database("default", pool)
// --- Models ----------------------------------------------------------
// AuthUser and Session are contributed by their plugins below.
// List your own models here.
.model::<Post>()
// --- Plugins ---------------------------------------------------------
// Auth: user table, password hashing, createsuperuser command.
// `with_form_routes()` mounts the POST form-action routes under
// `/auth` (login / logout / signup / …) that the login page below
// submits to; `AuthPlugin::new()` is the no-turbofish constructor
// over the built-in AuthUser (gaps4 #45).
.plugin(AuthPlugin::new().with_form_routes())
// Sessions: session table + cookie middleware.
.plugin(SessionsPlugin::default())
// Admin: auto CRUD UI at /admin/ for every registered model.
// The dashboard mounts one builtin widget from `widgets/` so a
// fresh admin isn't empty — add your own with `.dashboard_section`.
.plugin(
AdminPlugin::default()
.dashboard_section(widgets::cards::overview_section()),
)
// REST: JSON CRUD + filtering at /api/<table>/.
// The Post resource has query-string filtering enabled so
// GET /api/post/?published=true works out of the box.
.plugin(
RestPlugin::default()
.resource(ResourceConfig::new("post")),
)
// OpenAPI: Swagger UI at /openapi/ (override with
// `.at("/api/docs")` if you prefer a different mount).
.plugin(OpenApiPlugin::new())
// Static files: serves ./static at /static, which is where the compiled
// Tailwind bundle lives. Use `{{ static('css/app.css') }}` in templates
// rather than a hardcoded path — in production it resolves through the
// hashed-asset manifest so you get cache-busting for free.
//
// The same plugin also gives you uploaded-file storage (local FS or S3)
// when you add a FileField / ImageField: `.media("/media", "./media")`.
.plugin(StoragePlugin::new().static_files("/static", "./static"))
// Security (on by default): CSRF + clickjacking/HSTS hardening
// headers across the app. `/api` is exempt so token-authenticated
// JSON clients can POST without a browser form CSRF cookie.
.plugin(SecurityPlugin::new().csrf_exempt(["/api"]))
// --- Templates -------------------------------------------------------
.templates_dir("templates")
.not_found_template("404.html")
.server_error_template("500.html")
// Redirect /foo → /foo/ (append trailing slash).
.slash_redirect(SlashRedirect::Append)
// --- Routes ----------------------------------------------------------
// The Routes builder records each (method, path) pair as you
// declare it, so the dev-mode 404 panel surfaces them without
// a parallel declaration list. Handlers live in `views/`; this
// table is the URL conf — open `views/mod.rs` to see them all.
// Per-route middleware (here, login_required_html on /dashboard)
// goes through the explicit `.layered(method, path, mr)` form so
// the layer attaches just to that handler — not all routes.
.routes(
Routes::new()
// Public home page.
.get("/", views::public::home)
// API: list posts as JSON (no auth required — demo).
.get("/api/posts", views::public::api_list_posts)
// Login page. The form POSTs to /auth/login (mounted by
// `with_form_routes()` above); on success it redirects to
// `?next`. This is the page login_required_html sends
// anonymous visitors to.
.get("/login", views::public::login)
// Dashboard: only reachable when logged in. The
// login_required_html("/login") layer issues a 302 to
// /login?next=/dashboard/ for anonymous visitors.
.layered(
"GET",
"/dashboard",
get(views::public::dashboard).layer(login_required_html("/login")),
),
)
// Auto-migrate + seed on `serve` (gaps4 #47) so `cargo run -- serve`
// Just Works against a fresh database, and NEVER during
// `makemigrations` / `migrate` / any other subcommand. In Dev,
// auto_migrate_on_serve also autodetects (the makemigrations half);
// in Prod it only applies pending migrations. `seed::all()` is
// idempotent — see seed/mod.rs.
.auto_migrate_on_serve()
.seed_on_serve(seed::all)
// `build_deferred`, not `build`: it wires everything (pools, model
// registry, router, system checks) but leaves each plugin's `on_ready`
// hook unfired. Those hooks seed content and backfill rows, so they must
// not run during `migrate` — the command whose whole job is to create the
// tables they write to. `dispatch` fires them once it has read argv.
.build_deferred()?;
umbral_cli::dispatch(app).await
}}
"#
);
write_file(&root, "src/main.rs", &main_rs, &mut files)?;
let views_mod_rs = r#"//! HTTP handlers, split by concern — the re-export / discoverability
//! layer. Open this file and you see the whole web surface in a few
//! lines: one submodule per resource grouping.
//!
//! Submodules:
//! - `public` — pages anyone can hit (home, JSON listings).
//!
//! Add `pub mod account;` here when auth-gated views land (dashboard,
//! /me, staff-only pages), then re-export it below so `main.rs` keeps
//! referencing handlers as `views::public::home` without caring which
//! file owns each one. This is a recommended convention, not a rule —
//! the router reads handlers directly, so you're free to restructure.
pub mod public;
// No `internal_error` helper, on purpose.
//
// Handlers return `Result<_, umbral::web::ApiError>` and use a bare `?`. ApiError
// converts from sqlx / WriteError / TemplateError, logs the real cause server-side, and
// returns an opaque 500 — so a missing table or a SQL fragment never reaches the browser.
// The `(StatusCode, String)` + `err.to_string()` pattern does the opposite.
"#;
write_file(&root, "src/views/mod.rs", views_mod_rs, &mut files)?;
let views_public_rs = r#"//! Public storefront views — anyone can hit these, no auth required.
//!
//! Every handler returns `Result<_, ApiError>` and lets `?` do the work. `ApiError`
//! converts from a database error, a `WriteError` and a template error, so there is no
//! per-handler error helper to write — and a 500 logs the real cause server-side while
//! the client gets an opaque message. Never hand `err.to_string()` to a browser: that is
//! how table names and SQL fragments end up on someone else's screen.
use umbral::prelude::*;
use umbral::templates::context;
use crate::Post;
use crate::post;
/// Home page. Counts published posts and renders home.html.
pub async fn home() -> Result<Html<String>, ApiError> {
let post_count = Post::objects()
.filter(post::PUBLISHED.eq(true))
.count()
.await?;
let body = umbral::templates::render("home.html", &context!(post_count))?;
Ok(Html(body))
}
/// JSON list of all posts — demonstrates the ORM QuerySet.
pub async fn api_list_posts() -> Result<Json<Vec<Post>>, ApiError> {
let posts = Post::objects().order_by(post::ID.desc()).fetch().await?;
Ok(Json(posts))
}
/// Login page. Renders the form in `login.html`; the form POSTs to the
/// auth plugin's `/auth/login` action. The `?next` query param (set by
/// the `login_required_html` layer when it bounced an anonymous visitor
/// here) is passed through to the template so a successful login returns
/// the user to where they were headed.
pub async fn login(umbral::web::Query(q): umbral::web::Query<LoginQuery>) -> Result<Html<String>, ApiError> {
let next = q.next.unwrap_or_else(|| "/dashboard".to_string());
let body = umbral::templates::render("login.html", &context!(next))?;
Ok(Html(body))
}
/// `?next=<path>` on the login page — where to return after signing in.
#[derive(serde::Deserialize)]
pub struct LoginQuery {
pub next: Option<String>,
}
/// Dashboard: only reachable when logged in (see the `login_required_html`
/// layer in `main.rs`). The `LoggedIn<AuthUser>` extractor supplies the
/// current user — the layer already checked the session, so this is a
/// cheap field read, not a second DB query.
pub async fn dashboard(
user: umbral_auth::LoggedIn<umbral_auth::AuthUser>,
) -> Result<Html<String>, ApiError> {
// Demonstrates a transaction: fetch the user's post list atomically.
let user_id = user.id;
let my_posts = umbral::transaction(|tx| {
Box::pin(async move {
Post::objects()
.filter(post::AUTHOR.eq(user_id))
.on_tx(tx)
.fetch()
.await
})
})
.await?;
let body = umbral::templates::render("dashboard.html", &context!(user, my_posts))?;
Ok(Html(body))
}
"#;
write_file(&root, "src/views/public.rs", views_public_rs, &mut files)?;
let seed_mod_rs = r#"//! Seed orchestrator — the re-export / dependency-order layer. One
//! file per concern keeps each step small and focused; `all()` pins
//! the order in which they run.
//!
//! Submodules:
//! - `credentials` — first-run dev superuser so you can log in to
//! /admin/ without a manual `createsuperuser`.
//!
//! Add a `pub mod <concern>;` here for each new seed step, then call it
//! from `all()` in dependency order (e.g. catalog rows before the orders
//! that reference them). The order in `all()` doubles as documentation
//! of which step depends on which.
pub mod credentials;
/// Run every seed step in the right order. Each step is idempotent
/// (short-circuits on a non-empty table), so calling `all()` on a
/// partially-seeded DB tops up the missing pieces without re-inserting.
pub async fn all() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
credentials::test_credentials().await?;
Ok(())
}
"#;
write_file(&root, "src/seed/mod.rs", seed_mod_rs, &mut files)?;
let seed_credentials_rs = r#"//! First-run convenience: mints a dev superuser `admin` when no users
//! exist yet — but ONLY in the Dev environment AND only when you opt in
//! by exporting a password. There is deliberately NO hardcoded default
//! password: a bare `./app` launch against an empty production database
//! must never plant a known-credential admin account.
//!
//! To auto-seed the dev superuser:
//!
//! UMBRAL_DEV_ADMIN_PASSWORD=your-dev-password cargo run
//!
//! Otherwise the first boot prints guidance to run
//! `cargo run -- createsuperuser` and seeds nothing. Idempotent —
//! subsequent boots find the user and stay quiet.
use umbral::Environment;
use umbral_auth::AuthUser;
/// Env var that opts a fresh install into the dev-superuser seed and
/// supplies its password. Unset => no seed (print guidance instead).
const DEV_ADMIN_PASSWORD_ENV: &str = "UMBRAL_DEV_ADMIN_PASSWORD";
pub async fn test_credentials() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Never mint a dev superuser outside the Dev environment — belt and
// suspenders on top of the caller only running us on a bare launch.
if umbral::settings::get().environment != Environment::Dev {
return Ok(());
}
// Idempotent: bail out the moment any user exists.
if AuthUser::objects().count().await? > 0 {
return Ok(());
}
// Opt-in only: without an explicit password we plant nothing. This
// is what keeps a known `admin`/`admin` account off every fresh DB.
let password = match std::env::var(DEV_ADMIN_PASSWORD_ENV) {
Ok(p) if !p.is_empty() => p,
_ => {
eprintln!();
eprintln!("No users yet, and no dev superuser was seeded. To create one:");
eprintln!(" • interactive: cargo run -- createsuperuser");
eprintln!(" • auto on boot: set {DEV_ADMIN_PASSWORD_ENV}=... and restart");
eprintln!(" (Dev environment only; never seeds in Prod)");
eprintln!();
return Ok(());
}
};
umbral_auth::create_superuser("admin", "admin@example.com", &password)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
eprintln!();
eprintln!("======================================================================");
eprintln!(" DEV SUPERUSER seeded (Dev environment, {DEV_ADMIN_PASSWORD_ENV} set)");
eprintln!("----------------------------------------------------------------------");
eprintln!(" Username : admin");
eprintln!(" Password : (the value of {DEV_ADMIN_PASSWORD_ENV})");
eprintln!(" Log in : http://127.0.0.1:8000/admin/");
eprintln!(" Remove or edit src/seed/credentials.rs before shipping.");
eprintln!("======================================================================");
eprintln!();
Ok(())
}
"#;
write_file(
&root,
"src/seed/credentials.rs",
seed_credentials_rs,
&mut files,
)?;
let widgets_mod_rs = r#"//! Admin dashboard widgets — the re-export / discoverability layer,
//! grouped by kind so each file stays small and focused on one
//! rendering shape.
//!
//! Submodules:
//! - `cards` — KPI tiles + dashboard sections.
//!
//! Add `pub mod charts;`, `pub mod tables;`, etc. as your dashboard
//! grows, then re-export the builders so `main.rs` calls them as
//! `widgets::cards::overview_section()` without knowing which file owns
//! each one. A recommended convention — restructure freely.
pub mod cards;
"#;
write_file(&root, "src/widgets/mod.rs", widgets_mod_rs, &mut files)?;
let widgets_cards_rs = r#"//! Dashboard widget builders. This starter re-exports one framework
//! builtin so a fresh `/admin/` dashboard isn't empty; replace it with
//! your own KPI tiles as the app grows.
//!
//! A widget is a `Widget` value handed to `WidgetSection::widget(...)`.
//! Each section becomes one row of tiles on the admin dashboard. See
//! `documentation/docs/v0.0.1/admin/` and the `examples/shop/src/widgets`
//! reference for the data-closure pattern that hits the ORM.
use umbral_admin::WidgetSection;
/// One dashboard section wiring two framework builtins: a model-count
/// tile and a recent-users list. Mounted from `main.rs` via
/// `.dashboard_section(widgets::cards::overview_section())`.
pub fn overview_section() -> WidgetSection {
WidgetSection::new("Overview")
.subtitle("Framework-wide health + recent activity")
.widget(umbral_admin::builtin_total_models_widget().with_span(8, 2))
.widget(umbral_admin::builtin_recent_users_widget().with_span(4, 2))
}
"#;
write_file(&root, "src/widgets/cards.rs", widgets_cards_rs, &mut files)?;
write_file(&root, "plugins/.gitkeep", "", &mut files)?;
let plugins_readme = "# plugins/\n\nLocal plugins go here; create one with `umbral startplugin <name>`.\nEach is its own crate (`lib.rs` + `models.rs` + `handlers.rs`) and is\nauto-wired into this project's `Cargo.toml` `[dependencies]`.\n";
write_file(&root, "plugins/README.md", plugins_readme, &mut files)?;
let dev_secret = random_dev_secret_key();
let umbral_toml = format!(
r#"# umbral settings for {name}.
# Environment variables (UMBRAL_*) override these at runtime.
# See umbral::settings for the full schema.
database_url = "sqlite://{name}.db?mode=rwc"
# Bind address for `cargo run -- serve`.
# Override via UMBRAL_BIND_ADDR or the --addr flag.
bind_addr = "127.0.0.1:8000"
environment = "Dev"
# A random dev-only key, unique to this project. CHANGE THIS IN PRODUCTION —
# the framework errors at boot if a dev key is used with environment = "Prod".
secret_key = "{dev_secret}"
"#
);
write_file(&root, "umbral.toml", &umbral_toml, &mut files)?;
let dot_env = format!(
r#"# Working .env for {name}. Do not commit this file.
# Generate a real secret key: openssl rand -hex 32
UMBRAL_DATABASE_URL=sqlite://{name}.db?mode=rwc
UMBRAL_BIND_ADDR=127.0.0.1:8000
UMBRAL_SECRET_KEY={dev_secret}
RUST_LOG=info,umbral=debug
"#
);
write_file(&root, ".env", &dot_env, &mut files)?;
let env_example = r#"# Copy to `.env` and source from your shell, or use a tool like direnv.
# Settings here override the umbral.toml values at runtime.
#
# UMBRAL_SECRET_KEY=$(openssl rand -hex 32)
# UMBRAL_DATABASE_URL=sqlite://my.db?mode=rwc
# UMBRAL_BIND_ADDR=0.0.0.0:8000
# UMBRAL_ENVIRONMENT=prod
# RUST_LOG=info,umbral=debug
"#;
write_file(&root, ".env.example", env_example, &mut files)?;
let gitignore = format!("/target\n/{name}.db*\n.env\nCargo.lock\n");
write_file(&root, ".gitignore", &gitignore, &mut files)?;
let readme = format!(
r#"# {name}
Your umbral app.
It starts with one model (`Post`), an admin, a JSON API and an OpenAPI browser, so there
is something running from the first `cargo run`. All of it is ordinary code in this
repository — rename it, gut it, replace it.
## What's in the project
| File | What it shows |
|---|---|
| `src/main.rs` | App wiring: models, plugins, routes, auto-migrate |
| `Post` model | `ForeignKey<AuthUser>`, ORM QuerySet, `#[derive(Model)]` |
| `/` route | Template rendering with context |
| `/api/posts` | JSON endpoint via the ORM |
| `/dashboard` | `login_required_html("/login")` layer, `LoggedIn<AuthUser>` extractor, transaction |
| `RestPlugin` | JSON CRUD at `/api/post/` with query-string filtering (`?published=true`) |
| `AdminPlugin` | Auto CRUD UI at `/admin/` |
| `OpenApiPlugin` | Swagger UI at `/openapi/` |
| `SecurityPlugin` | CSRF middleware + hardening headers, with `/api` exempt for token clients |
## Running
```bash
# First run — `serve` (or a bare `cargo run`, which defaults to serve)
# auto-migrates AND seeds against a fresh database before starting the
# server (auto_migrate_on_serve + seed_on_serve in main.rs). In dev it
# also autodetects model changes (the makemigrations half), so editing a
# model and re-running `serve` just works. Schema commands like `migrate`
# / `makemigrations` do NOT auto-migrate — they drive the flow themselves.
cargo run -- serve
# Separate steps (production pattern) — migrate explicitly, then serve:
cargo run -- makemigrations # autodetect model changes into a migration file
cargo run -- migrate # apply pending migrations
cargo run -- serve
# Create a superuser (the login page above uses these credentials):
cargo run -- createsuperuser
# Inspect the schema:
cargo run -- showmigrations
# Background tasks: run a worker alongside the server to drain the queue.
# Any #[umbral::task] handler you write is discovered automatically.
cargo run -- tasks-worker
```
## Styling
The pages use Tailwind, compiled to `static/css/app.css` and served by the
StoragePlugin at `/static`. That bundle ships **prebuilt**, so this project renders
correctly with no `npm install`.
You only need Node once you edit a template and reach for a utility class that is not
already in the bundle:
```bash
cd styles
npm install
npm run build # or: npm run watch
```
The palette lives in `styles/input.css` as CSS variables (`--accent` is the violet).
Change them there and every page follows. There is deliberately no `cdn.tailwindcss.com`
script: it is versionless, it pulls a third party into every page load, and it is the
first thing a `default-src 'self'` Content-Security-Policy blocks.
## Where to go next
- Add a plugin: `umbral startplugin posts`
- Your first app: {docs}/getting-started/your-first-app
- Models & the ORM: {docs}/orm/models
- Migrations: {docs}/migrations/managed-migrations
- Admin: {docs}/plugins/admin
- REST: {docs}/rest/index
- Login & signup pages: {docs}/auth/login-and-signup-pages
- The Plugin trait: {docs}/plugins/the-plugin-trait
"#,
docs = DOCS_URL,
);
write_file(&root, "README.md", &readme, &mut files)?;
let initial = name
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "U".to_string());
let fill = |tpl: &str| -> String {
tpl.replace("__PROJECT__", name)
.replace("__INITIAL__", &initial)
.replace("__DOCS__", DOCS_URL)
};
for (path, body) in [
(
"templates/base.html",
include_str!("../assets/scaffold/templates/base.html"),
),
(
"templates/home.html",
include_str!("../assets/scaffold/templates/home.html"),
),
(
"templates/dashboard.html",
include_str!("../assets/scaffold/templates/dashboard.html"),
),
(
"templates/login.html",
include_str!("../assets/scaffold/templates/login.html"),
),
(
"templates/404.html",
include_str!("../assets/scaffold/templates/404.html"),
),
(
"templates/500.html",
include_str!("../assets/scaffold/templates/500.html"),
),
(
"styles/input.css",
include_str!("../assets/scaffold/styles/input.css"),
),
(
"styles/tailwind.config.js",
include_str!("../assets/scaffold/styles/tailwind.config.js"),
),
(
"styles/package.json",
include_str!("../assets/scaffold/styles/package.json"),
),
(
"static/css/app.css",
include_str!("../assets/scaffold/static/css/app.css"),
),
(
"static/favicon.svg",
include_str!("../assets/scaffold/static/favicon.svg"),
),
] {
write_file(&root, path, &fill(body), &mut files)?;
}
let next_steps = vec![
format!("cd {name}"),
"cargo run -- migrate # apply schema migrations".to_string(),
"cargo run -- serve # boot the HTTP server on http://127.0.0.1:8000".to_string(),
"cargo run -- createsuperuser # create an admin login".to_string(),
"umbral startplugin <name> # add a plugin to this project".to_string(),
];
Ok(ScaffoldReport {
root,
files,
next_steps,
cargo_toml_registered: None,
registered: None,
})
}
pub fn scaffold_app(
name: &str,
project_root: &Path,
local_umbral_repo: Option<&Path>,
) -> Result<ScaffoldReport, ScaffoldError> {
scaffold_plugin(name, project_root, local_umbral_repo)
}
pub fn scaffold_plugin(
name: &str,
project_root: &Path,
local_umbral_repo: Option<&Path>,
) -> Result<ScaffoldReport, ScaffoldError> {
let normalized = name.replace('-', "_");
if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
return Err(ScaffoldError::ReservedName(name.to_string()));
}
validate_name(name)?;
let plugins_dir = project_root.join("plugins");
let root = plugins_dir.join(name);
if root.exists() {
return Err(ScaffoldError::AlreadyExists(root));
}
fs::create_dir_all(&root)?;
fs::create_dir_all(root.join("src"))?;
let crate_name = rust_ident(name);
let pascal = pascal_case_from_ident(name);
let mut files = Vec::new();
let version = env!("CARGO_PKG_VERSION");
let cargo_toml = format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2024"
description = "A {crate_name} plugin for umbral."
[dependencies]
umbral = "{version}"
serde = {{ version = "1", features = ["derive"] }}
sqlx = {{ version = "0.8", default-features = false, features = ["macros", "runtime-tokio"] }}
chrono = {{ version = "0.4", features = ["serde"] }}
async-trait = "0.1"
"#
);
let cargo_toml = match local_umbral_repo {
Some(repo) => localize_deps(&cargo_toml, repo),
None => cargo_toml,
};
write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
let readme = format!(
r#"# {name}
A {crate_name} plugin for [umbral](https://github.com/dalmasonto/umbral).
Generated by `umbral startplugin {name}`.
## What's inside
| File | Purpose |
|---|---|
| `src/lib.rs` | `{pascal}Plugin` struct + `impl Plugin` (registers models, routes, lifecycle hooks). |
| `src/models.rs` | One example model showing common field types (`#[umbral(...)]` attributes for `max_length`, `choices`, FK, defaults). |
| `src/handlers.rs` | One example axum handler showing how to read query params and return JSON. |
## Wiring it in
In your project's `Cargo.toml`:
```toml
[dependencies]
{name} = {{ path = "plugins/{name}" }}
```
In `src/main.rs`:
```rust,ignore
let app = umbral::App::builder()
.plugin({crate_name}::{pascal}Plugin::default())
// ... your other plugins
.build_deferred()?; // build_deferred + dispatch: lets `dispatch` fire
umbral_cli::dispatch(app).await // on_ready AFTER a management command runs
```
Then:
```sh
cargo run -- makemigrations # generates 0001_initial.json from your models
cargo run -- migrate # applies the schema
cargo run -- serve # boots the HTTP server
```
## Next steps
- Add your own models in `src/models.rs` (or split into a `models/` module).
- Add routes in `routes()` and handlers in `src/handlers.rs`.
- Use `on_ready(&AppContext)` for one-shot setup work (seed default rows, register signals).
- See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for the full trait surface.
"#
);
write_file(&root, "README.md", &readme, &mut files)?;
let lib_rs = format!(
r#"//! {pascal}Plugin — a distributable umbral plugin.
//!
//! Wire this into your App in `src/main.rs`:
//!
//! ```ignore
//! .plugin({crate_name}::{pascal}Plugin::default())
//! ```
//!
//! See `README.md` for the full file tour.
pub mod handlers;
pub mod models;
use async_trait::async_trait;
use umbral::migrate::ModelMeta;
use umbral::plugin::{{AppContext, Plugin, PluginError}};
use umbral::web::{{Router, get}};
/// The plugin entry point. Register one instance per `App::builder()`.
#[derive(Debug, Default, Clone)]
pub struct {pascal}Plugin;
#[async_trait]
impl Plugin for {pascal}Plugin {{
fn name(&self) -> &'static str {{
"{name}"
}}
/// Models the framework's migration engine should track. Each
/// returned [`ModelMeta`] becomes one row in the
/// `umbral_migrations` tracking table once the initial migration
/// applies.
fn models(&self) -> Vec<ModelMeta> {{
// One entry per model the plugin owns. `umbral::discovered_models!()`
// finds every #[derive(Model)] in this crate automatically if you'd
// rather not maintain the list by hand.
vec![ModelMeta::for_::<models::{pascal}Item>()]
}}
/// HTTP routes contributed by this plugin. The base path is
/// up to you — convention is `/<name>/...` for HTML and
/// `/api/<name>/...` for JSON.
fn routes(&self) -> Router {{
Router::new().route("/{name}/hello", get(handlers::hello))
}}
/// One-shot setup after `App::build()` finishes. Use this for
/// seeding default rows, registering signal handlers, or any
/// work that needs the database available. Sync because the
/// `Plugin` trait signature is sync (BUG-3 in bugs/tests/testBugs.md);
/// reach into a runtime via `tokio::runtime::Handle::current()
/// .block_on(...)` if you need to await something here.
fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
Ok(())
}}
}}
"#
);
write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
let models_rs = format!(
r#"//! Example model. Replace or extend with your own.
//!
//! What this demonstrates:
//! - `#[umbral(max_length = 200)]` — DDL `VARCHAR(200)` + admin form hint.
//! - `#[umbral(choices)]` on an enum — closed-set column with OpenAPI
//! `enum` and a Postgres `CHECK (col IN (...))` constraint.
//! - `Option<DateTime<Utc>>` — nullable timestamptz column.
//! - `#[umbral(noedit)]` — read-only on admin forms; not editable via
//! PUT/PATCH through the REST plugin.
use chrono::{{DateTime, Utc}};
use serde::{{Deserialize, Serialize}};
/// One {crate_name} item. Replace with whatever your plugin actually
/// stores.
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
pub struct {pascal}Item {{
/// Auto-incrementing primary key.
pub id: i64,
/// Display title. Capped at 200 chars; admin renders a single-line
/// input.
#[umbral(string, max_length = 200)]
pub title: String,
/// Lifecycle state. `#[umbral(choices)]` maps the column 1:1 to the
/// enum variants: the migration engine emits a CHECK constraint, the
/// admin renders a `<select>`, and the OpenAPI schema gets an `enum`.
#[umbral(choices)]
pub status: {pascal}Status,
/// When the item was last published. Read-only on edit forms.
#[umbral(noedit)]
pub published_at: Option<DateTime<Utc>>,
}}
/// Lifecycle state for [`{pascal}Item`]. The `Choices` derive teaches the
/// ORM the closed set; `rename_all` controls how variants serialize to the
/// stored string (`Draft` → `"draft"`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, umbral::orm::Choices)]
#[choices(rename_all = "lowercase")]
pub enum {pascal}Status {{
Draft,
Review,
Published,
Archived,
}}
"#
);
write_file(&root, "src/models.rs", &models_rs, &mut files)?;
let handlers_rs = format!(
r#"//! Example HTTP handlers. Replace or extend with your own.
//!
//! `GET /{name}/hello?name=world` returns `{{"greeting": "Hello, world!"}}`.
use serde::{{Deserialize, Serialize}};
use umbral::web::{{Json, Query}};
#[derive(Debug, Deserialize, Default)]
pub struct HelloParams {{
/// Who to greet. Defaults to "{name}" when omitted.
#[serde(default)]
pub name: Option<String>,
}}
#[derive(Debug, Serialize)]
pub struct HelloResponse {{
pub greeting: String,
}}
pub async fn hello(Query(params): Query<HelloParams>) -> Json<HelloResponse> {{
let who = params.name.as_deref().unwrap_or("{name}");
Json(HelloResponse {{
greeting: format!("Hello, {{who}}!"),
}})
}}
"#
);
write_file(&root, "src/handlers.rs", &handlers_rs, &mut files)?;
let project_cargo_toml = project_root.join("Cargo.toml");
let cargo_toml_registered = if project_cargo_toml.is_file() {
register_dep_in_cargo_toml(&project_cargo_toml, name).ok()
} else {
None
};
let next_steps = vec![
"Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
format!(" .plugin({crate_name}::{pascal}Plugin::default())"),
"Generate + apply the initial migration:".to_string(),
" cargo run -- makemigrations".to_string(),
" cargo run -- migrate".to_string(),
format!("Then visit http://127.0.0.1:8000/{name}/hello?name=you"),
];
Ok(ScaffoldReport {
root,
files,
next_steps,
cargo_toml_registered,
registered: None,
})
}
pub use umbral::codegen::Target as CommandTarget;
const MODS_MARKER: &str =
"// umbral:startcommand — `umbral startcommand` declares new modules above this line.";
const REGISTRY_MARKER: &str =
"// umbral:startcommand — `umbral startcommand` registers new commands above this line.";
pub use umbral::codegen::discover_plugins;
pub fn scaffold_command(
name: &str,
target: &CommandTarget,
project_root: &Path,
) -> Result<ScaffoldReport, ScaffoldError> {
if reserved_command_names().iter().any(|r| r == name) {
return Err(ScaffoldError::ReservedCommandName(name.to_string()));
}
validate_name(name)?;
let module = rust_ident(name);
let pascal = pascal_case_from_ident(name);
let struct_name = format!("{pascal}Command");
let resolved = umbral::codegen::resolve_target(project_root, target)?;
let crate_root = resolved.crate_root.clone();
let owner_file = resolved.owner_file.clone();
let mut files = Vec::new();
umbral::codegen::write_new_file(
&crate_root,
&format!("src/commands/{module}.rs"),
&render_command_file(name, &struct_name, target),
&mut files,
)?;
let mod_rs = crate_root.join("src/commands/mod.rs");
let mut next_steps: Vec<String> = Vec::new();
if mod_rs.is_file() {
let text = fs::read_to_string(&mod_rs)?;
match append_to_registry(&text, &module, &struct_name) {
Some(updated) => {
fs::write(&mod_rs, updated)?;
files.push(PathBuf::from("src/commands/mod.rs"));
}
None => {
next_steps.push(
"src/commands/mod.rs has no `umbral:startcommand` markers — add by hand:"
.to_string(),
);
next_steps.push(format!(" pub mod {module};"));
next_steps.push(format!(
" ...and inside `all()`: Box::new({module}::{struct_name}),"
));
}
}
} else {
umbral::codegen::write_new_file(
&crate_root,
"src/commands/mod.rs",
&render_registry_file(&module, &struct_name, target),
&mut files,
)?;
}
let owner_text = fs::read_to_string(&owner_file)?;
let wiring = match target {
CommandTarget::Root => wire_registry_into_main(&owner_text),
CommandTarget::Plugin(_) => wire_registry_into_plugin(&owner_text),
};
let registered = match wiring {
Wiring::Updated { text, steps } => {
fs::write(&owner_file, text)?;
let complete = steps.is_empty();
next_steps.extend(steps);
complete
}
Wiring::AlreadyWired => true,
Wiring::Manual(steps) => {
next_steps.extend(steps);
false
}
};
if registered {
next_steps.push(format!("Run it: cargo run -- {name} --help"));
} else {
next_steps.push(format!(
"Then run it: cargo run -- {name} --help (after the steps above — \
it is NOT registered yet)"
));
}
Ok(ScaffoldReport {
root: crate_root,
files,
next_steps,
cargo_toml_registered: None,
registered: Some(registered),
})
}
enum Wiring {
Updated { text: String, steps: Vec<String> },
AlreadyWired,
Manual(Vec<String>),
}
fn wire_registry_into_main(text: &str) -> Wiring {
let already_mod = text.lines().any(|l| l.trim() == "mod commands;");
let already_registered = text.contains(".commands(commands::all())");
if already_mod && already_registered {
return Wiring::AlreadyWired;
}
let mut out = text.to_string();
let mut steps: Vec<String> = Vec::new();
if !already_mod {
match umbral::codegen::declare_module(&out, "mod commands;") {
Some(text) => out = text,
None => steps.push("Add to src/main.rs: mod commands;".to_string()),
}
}
if !already_registered {
match builder_terminal_line(&out) {
Some(idx) => {
let indent: String = out
.lines()
.nth(idx)
.map(|l| l.chars().take_while(|c| c.is_whitespace()).collect())
.unwrap_or_default();
let call = format!(
"{indent}// Project-owned management commands (`umbral startcommand`).\n\
{indent}.commands(commands::all())"
);
out = insert_line_at_before(&out, idx, &call);
}
None => steps.push(
"Add to the App::builder() chain in src/main.rs: .commands(commands::all())"
.to_string(),
),
}
}
if out == text {
if steps.is_empty() {
Wiring::AlreadyWired
} else {
Wiring::Manual(steps)
}
} else {
Wiring::Updated { text: out, steps }
}
}
fn builder_terminal_line(text: &str) -> Option<usize> {
let builder_at = text.lines().position(|l| l.contains("App::builder()"))?;
text.lines()
.enumerate()
.skip(builder_at)
.find(|(_, l)| {
let t = l.trim_start();
t.starts_with(".build_deferred()") || t.starts_with(".build()")
})
.map(|(idx, _)| idx)
}
fn wire_registry_into_plugin(text: &str) -> Wiring {
let already_mod = text.lines().any(|l| l.trim() == "pub mod commands;");
let has_commands_fn = text.contains("fn commands(");
if already_mod && has_commands_fn {
return Wiring::AlreadyWired;
}
let mut out = text.to_string();
let mut steps: Vec<String> = Vec::new();
if !already_mod {
match umbral::codegen::declare_module(&out, "pub mod commands;") {
Some(text) => out = text,
None => steps.push("Add to src/lib.rs: pub mod commands;".to_string()),
}
}
if !has_commands_fn {
match out
.lines()
.position(|l| l.starts_with("impl Plugin for ") && l.trim_end().ends_with('{'))
{
Some(idx) => {
let method = "\n fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> {\n \
// Every command in `src/commands/` — `umbral startcommand`\n \
// appends to the registry in `commands/mod.rs`, so this line\n \
// never needs to change again.\n \
commands::all()\n }";
out = insert_line_at(&out, idx, method);
}
None => steps.push(
"Add to your `impl Plugin`: fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> { commands::all() }"
.to_string(),
),
}
} else {
steps.push(
"Your plugin already has a `fn commands()` — make sure it returns \
`commands::all()` (or extends it) so the new command is registered."
.to_string(),
);
}
if out == text {
if steps.is_empty() {
Wiring::AlreadyWired
} else {
Wiring::Manual(steps)
}
} else {
Wiring::Updated { text: out, steps }
}
}
fn insert_line_at(text: &str, idx: usize, line: &str) -> String {
umbral::codegen::insert_line_after(text, idx, line)
}
fn append_to_registry(text: &str, module: &str, struct_name: &str) -> Option<String> {
let mod_line = format!("pub mod {module};");
let entry = format!("Box::new({module}::{struct_name}),");
let mut out = text.to_string();
if !out.lines().any(|l| l.trim() == mod_line) {
out = umbral::codegen::insert_before_marker(&out, MODS_MARKER, &mod_line)?;
}
if !out.lines().any(|l| l.trim() == entry) {
out = umbral::codegen::insert_before_marker(
&out,
REGISTRY_MARKER,
&format!(" {entry}"),
)?;
}
Some(out)
}
fn insert_line_at_before(text: &str, idx: usize, line: &str) -> String {
umbral::codegen::insert_line_before(text, idx, line)
}
fn render_registry_file(module: &str, struct_name: &str, target: &CommandTarget) -> String {
let (owner, wiring) = match target {
CommandTarget::Root => (
"this project",
"`main.rs` passes `all()` to `App::builder().commands(...)`.",
),
CommandTarget::Plugin(_) => (
"this plugin",
"`lib.rs` returns `all()` from `Plugin::commands()`.",
),
};
format!(
r#"//! Management commands owned by {owner} — one file per command,
//! and `all()` is the registry that hands them to the framework.
//!
//! {wiring}
//!
//! Rust can't discover a module by scanning this directory at runtime, so
//! `all()` IS the auto-detection: `umbral startcommand` appends to it for
//! you (that's what the marker comments below are for). You can also edit
//! it by hand — comment a command out and it stops existing, which is
//! harder to do with a magic registry you can't see.
use umbral::cli::PluginCommand;
pub mod {module};
{MODS_MARKER}
/// Every command {owner} registers.
pub fn all() -> Vec<Box<dyn PluginCommand>> {{
vec![
Box::new({module}::{struct_name}),
{REGISTRY_MARKER}
]
}}
"#
)
}
fn render_command_file(name: &str, struct_name: &str, target: &CommandTarget) -> String {
let orm_note = match target {
CommandTarget::Root => "// use crate::{Post, post};",
CommandTarget::Plugin(_) => "// use crate::models::{Post, post};",
};
format!(
r#"//! `{name}` — a management command.
//!
//! ```bash
//! cargo run -- {name} --help # what it takes
//! cargo run -- {name} hello --limit 5 --dry-run # a real run
//! umbral {name} hello --tag a --tag b # same thing, via the umbral CLI
//! ```
//!
//! Registered through `commands::all()` in `commands/mod.rs`. It runs against
//! a fully-built app: settings loaded, pool open, every model registered — so
//! the ORM works ambiently here, with no pool to thread through.
use umbral::cli::{{CliError, PluginCommand, clap}};
/// The `{name}` command.
///
/// A unit struct is enough when the command is stateless. It doesn't have to
/// be: the trait is object-safe over `&self`, so anything the command needs
/// configured (a prefix, a client, a channel) can live on the struct and be
/// passed in at registration — which is exactly why this is a trait and not a
/// bare `fn` pointer.
pub struct {struct_name};
#[umbral::async_trait]
impl PluginCommand for {struct_name} {{
/// Declare the command: its name, its help, and its arguments.
///
/// This is plain `clap`, so everything clap can do is available here —
/// value parsing and validation, defaults, conflicts, subcommands of your
/// own. Note the import: `umbral::cli::clap`, the framework's own clap.
/// Add `clap` to your Cargo.toml separately and a major-version bump on
/// either side turns into a type mismatch a page long.
fn command(&self) -> clap::Command {{
clap::Command::new("{name}")
// Shown next to the command in `umbral help`. Write it — a command
// with no `about` lists as a dash and nobody discovers it.
.about("TODO: one line on what {name} does")
.long_about(
"TODO: the longer story, shown on `{name} --help`. What it \
changes, whether it's safe to re-run, what it needs first.",
)
// POSITIONAL argument — `{name} <slug>`. Required, so clap
// rejects the call with a usage error if it's missing and `run`
// never sees a half-formed invocation.
.arg(
clap::Arg::new("slug")
.required(true)
.help("The thing to operate on"),
)
// NAMED argument with a value and a default — `--limit 25` / `-l 25`.
// `value_parser` is what makes it a `u64` on the other side rather
// than a string you'd have to parse (and mis-parse) yourself.
.arg(
clap::Arg::new("limit")
.long("limit")
.short('l')
.value_name("N")
.value_parser(clap::value_parser!(u64))
.default_value("25")
.help("How many rows to touch at most"),
)
// REPEATABLE named argument — `--tag a --tag b` collects both.
// `ArgAction::Append` is the difference between the second `--tag`
// overwriting the first and the two accumulating.
.arg(
clap::Arg::new("tag")
.long("tag")
.value_name("TAG")
.action(clap::ArgAction::Append)
.help("Filter by tag. Repeat for more than one."),
)
// BOOLEAN flag — `--dry-run`, no value. `SetTrue` is what makes it
// a flag rather than an option that demands a value.
.arg(
clap::Arg::new("dry-run")
.long("dry-run")
.action(clap::ArgAction::SetTrue)
.help("Report what would change without writing anything"),
)
}}
/// Run the command. `matches` is this subcommand's own `ArgMatches` —
/// clap has already validated it against `command()` above, so every
/// `get_one` here is reading a value that exists and typechecked.
async fn run(&self, matches: &clap::ArgMatches) -> Result<(), CliError> {{
let slug = matches
.get_one::<String>("slug")
.expect("clap enforces `required(true)`");
let limit = *matches
.get_one::<u64>("limit")
.expect("clap fills in `default_value`");
let tags: Vec<&String> = matches
.get_many::<String>("tag")
.map(Iterator::collect)
.unwrap_or_default();
let dry_run = matches.get_flag("dry-run");
println!("{name}: slug={{slug}} limit={{limit}} tags={{tags:?}} dry_run={{dry_run}}");
// The app is already built by the time this runs, so the ORM is live:
//
{orm_note}
//
// let posts = Post::objects()
// .filter(post::PUBLISHED.eq(true))
// .limit(limit as i64)
// .fetch()
// .await?;
//
// if dry_run {{
// println!("would touch {{}} post(s)", posts.len());
// return Ok(());
// }}
//
// `?` just works: `CliError` is a boxed error, so every umbral error
// converts into it. Return `Err(...)` and the process exits non-zero,
// which is what a CI step or a cron job is watching for.
Ok(())
}}
}}
"#
)
}
fn write_file(
root: &Path,
rel_path: &str,
contents: &str,
files: &mut Vec<PathBuf>,
) -> Result<(), ScaffoldError> {
umbral::codegen::write_new_file(root, rel_path, contents, files).map_err(Into::into)
}
pub fn register_dep_in_cargo_toml(cargo_toml_path: &Path, name: &str) -> io::Result<bool> {
umbral::codegen::ensure_dependency(
cargo_toml_path,
name,
&format!("{{ path = \"plugins/{name}\" }}"),
)
.map_err(|e| match e {
umbral::codegen::CodegenError::Io(e) => e,
other => io::Error::new(io::ErrorKind::InvalidData, other.to_string()),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_name_accepts_simple_identifiers() {
assert!(validate_name("posts").is_ok());
assert!(validate_name("blog_engine").is_ok());
assert!(validate_name("blog-engine").is_ok());
assert!(validate_name("api2").is_ok());
}
#[test]
fn validate_name_rejects_empty() {
assert!(validate_name("").is_err());
}
#[test]
fn validate_name_rejects_leading_digit() {
assert!(validate_name("2cool").is_err());
}
#[test]
fn validate_name_rejects_special_chars() {
assert!(validate_name("foo bar").is_err());
assert!(validate_name("foo!bar").is_err());
assert!(validate_name("foo/bar").is_err());
}
#[test]
fn pascal_case_handles_kebab_and_snake() {
assert_eq!(pascal_case_from_ident("posts"), "Posts");
assert_eq!(pascal_case_from_ident("blog_engine"), "BlogEngine");
assert_eq!(pascal_case_from_ident("blog-engine"), "BlogEngine");
assert_eq!(pascal_case_from_ident("api2"), "Api2");
}
#[test]
fn rust_ident_replaces_hyphens() {
assert_eq!(rust_ident("blog-engine"), "blog_engine");
assert_eq!(rust_ident("posts"), "posts");
}
#[test]
fn scaffold_app_rejects_reserved_built_in_plugin_names() {
let tmp = tempfile::tempdir().expect("tempdir");
for name in RESERVED_PLUGIN_NAMES {
let result = scaffold_app(name, tmp.path(), None);
assert!(
matches!(result, Err(ScaffoldError::ReservedName(_))),
"expected ReservedName error for `{name}`, got: {result:?}",
);
assert!(
!tmp.path().join("plugins").join(name).exists(),
"directory must NOT be created when name is reserved: {name}",
);
}
}
#[test]
fn scaffold_app_rejects_reserved_name_with_hyphen_variant() {
let tmp = tempfile::tempdir().expect("tempdir");
let result = scaffold_app("auth", tmp.path(), None);
assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
}
#[test]
fn scaffold_app_message_lists_reserved_names() {
let err = ScaffoldError::ReservedName("auth".to_string());
let msg = format!("{err}");
assert!(msg.contains("`auth`"), "error names the offending input");
assert!(
msg.contains("admin") && msg.contains("sessions") && msg.contains("permissions"),
"error lists the reserved set so the user can pick again: {msg}",
);
}
#[test]
fn scaffold_app_already_exists_message_says_app() {
let err = ScaffoldError::AlreadyExists(PathBuf::from("plugins/blog"));
let msg = format!("{err}");
assert!(msg.contains("app already exists"), "got: {msg}");
assert!(msg.contains("plugins/blog"), "got: {msg}");
}
#[test]
fn scaffold_plugin_writes_richer_layout() {
let tmp = tempfile::tempdir().expect("tempdir");
let report = scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
let root = tmp.path().join("plugins").join("widgets");
assert!(root.is_dir());
for rel in [
"Cargo.toml",
"README.md",
"src/lib.rs",
"src/models.rs",
"src/handlers.rs",
] {
assert!(
root.join(rel).exists(),
"missing expected file: {rel}; got {:?}",
report.files,
);
}
}
#[test]
fn scaffold_plugin_lib_rs_references_sibling_modules() {
let tmp = tempfile::tempdir().expect("tempdir");
scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
let lib = fs::read_to_string(tmp.path().join("plugins/widgets/src/lib.rs")).unwrap();
assert!(
lib.contains("pub mod handlers;"),
"lib.rs must publish handlers"
);
assert!(
lib.contains("pub mod models;"),
"lib.rs must publish models"
);
assert!(lib.contains("WidgetsPlugin"), "PascalCase plugin name");
assert!(
lib.contains("ModelMeta::for_::<models::WidgetsItem>()"),
"models() should register the example model",
);
assert!(
lib.contains("/widgets/hello"),
"routes() should register the example handler",
);
}
#[test]
fn scaffold_plugin_models_rs_uses_real_umbral_attributes() {
let tmp = tempfile::tempdir().expect("tempdir");
scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
let models = fs::read_to_string(tmp.path().join("plugins/widgets/src/models.rs")).unwrap();
assert!(
models.contains("umbral::orm::Model"),
"model derive must reference the framework's Model trait",
);
assert!(
models.contains("max_length = 200"),
"example model should demonstrate max_length",
);
assert!(
models.contains("WidgetsStatus"),
"example model should declare a Choice enum",
);
assert!(
models.contains("#[umbral(choices)]"),
"the status field needs #[umbral(choices)] or the model won't compile",
);
assert!(
models.contains("Choices"),
"the enum needs the Choices derive, not a bare sqlx::Type",
);
assert!(
models.contains("noedit"),
"example model should show the noedit attribute",
);
}
#[test]
fn scaffold_plugin_rejects_reserved_built_in_plugin_names() {
let tmp = tempfile::tempdir().expect("tempdir");
for name in RESERVED_PLUGIN_NAMES {
let result = scaffold_plugin(name, tmp.path(), None);
assert!(
matches!(result, Err(ScaffoldError::ReservedName(_))),
"expected ReservedName error for `{name}`, got: {result:?}",
);
}
}
#[test]
fn scaffold_plugin_refuses_to_overwrite_existing_directory() {
let tmp = tempfile::tempdir().expect("tempdir");
scaffold_plugin("widgets", tmp.path(), None).expect("first scaffold ok");
let result = scaffold_plugin("widgets", tmp.path(), None);
assert!(matches!(result, Err(ScaffoldError::AlreadyExists(_))));
}
#[test]
fn scaffold_project_writes_per_concern_tree() {
let tmp = tempfile::tempdir().expect("tempdir");
let report = scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
let root = tmp.path().join("blog");
assert!(root.is_dir());
for rel in [
"src/main.rs",
"src/views/mod.rs",
"src/views/public.rs",
"src/seed/mod.rs",
"src/seed/credentials.rs",
"src/widgets/mod.rs",
"src/widgets/cards.rs",
"plugins/.gitkeep",
"plugins/README.md",
] {
assert!(
root.join(rel).exists(),
"missing expected file: {rel}; got {:?}",
report.files,
);
}
}
#[test]
fn scaffold_project_mod_files_carry_orchestrator_markers() {
let tmp = tempfile::tempdir().expect("tempdir");
scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
let root = tmp.path().join("blog");
let views_mod = fs::read_to_string(root.join("src/views/mod.rs")).unwrap();
assert!(
views_mod.contains("re-export"),
"views/mod.rs should describe itself as the re-export layer",
);
assert!(
!views_mod.contains("fn internal_error"),
"the scaffold must NOT generate an internal_error helper — handlers return \
ApiError, which logs the cause and keeps it off the wire",
);
let views_public = fs::read_to_string(root.join("src/views/public.rs")).unwrap();
assert!(
views_public.contains("Result<Html<String>, ApiError>")
&& !views_public.contains("map_err(internal_error)"),
"generated handlers must return ApiError and use a bare `?`",
);
let seed_mod = fs::read_to_string(root.join("src/seed/mod.rs")).unwrap();
assert!(
seed_mod.contains("pub async fn all()"),
"seed/mod.rs must declare the all() orchestrator",
);
assert!(
seed_mod.contains("credentials::test_credentials()"),
"seed::all() must call the credentials step",
);
assert!(
seed_mod.contains("dependency order") || seed_mod.contains("order in which"),
"seed/mod.rs should explain it pins dependency order",
);
let credentials = fs::read_to_string(root.join("src/seed/credentials.rs")).unwrap();
assert!(
credentials.contains("fn test_credentials"),
"credentials.rs must define the test_credentials seed",
);
assert!(
credentials.contains("count().await? > 0"),
"test_credentials must be idempotent (short-circuit on existing users)",
);
let widgets_mod = fs::read_to_string(root.join("src/widgets/mod.rs")).unwrap();
assert!(
widgets_mod.contains("pub mod cards;"),
"widgets/mod.rs must publish the cards submodule",
);
let cards = fs::read_to_string(root.join("src/widgets/cards.rs")).unwrap();
assert!(
cards.contains("builtin_total_models_widget")
|| cards.contains("builtin_recent_users_widget"),
"cards.rs should re-export a builtin widget so the dashboard isn't empty",
);
}
#[test]
fn scaffold_project_main_declares_modules_and_mounts_security() {
let tmp = tempfile::tempdir().expect("tempdir");
scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
let main = fs::read_to_string(tmp.path().join("blog/src/main.rs")).unwrap();
assert!(
main.contains("mod views;"),
"main.rs must declare mod views"
);
assert!(main.contains("mod seed;"), "main.rs must declare mod seed");
assert!(
main.contains("mod widgets;"),
"main.rs must declare mod widgets",
);
assert!(
main.contains("views::public::home"),
"route table should wire views::public::home",
);
assert!(
main.contains(".seed_on_serve(seed::all)"),
"boot should seed via .seed_on_serve(seed::all)",
);
assert!(
main.contains("SecurityPlugin"),
"SecurityPlugin must be mounted by default",
);
}
#[test]
fn scaffold_project_creates_empty_plugins_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
let readme = fs::read_to_string(tmp.path().join("blog/plugins/README.md")).unwrap();
assert!(
readme.contains("umbral startplugin"),
"plugins/README.md should point at the canonical `umbral startplugin`",
);
}
#[test]
fn scaffold_app_forwards_to_the_plugin_generator() {
let tmp = tempfile::tempdir().expect("tempdir");
scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
let root = tmp.path().join("plugins/posts");
for rel in [
"Cargo.toml",
"README.md",
"src/lib.rs",
"src/models.rs",
"src/handlers.rs",
] {
assert!(root.join(rel).exists(), "missing expected file: {rel}");
}
let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
assert!(lib.contains("pub mod models;"), "lib.rs publishes models");
assert!(
lib.contains("pub mod handlers;"),
"lib.rs publishes handlers"
);
assert!(lib.contains("PostsPlugin"), "PascalCase plugin name");
}
#[test]
fn scaffold_app_auto_registers_path_dep_in_project_cargo() {
let tmp = tempfile::tempdir().expect("tempdir");
let project_cargo = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nserde = \"1\"\n";
fs::write(tmp.path().join("Cargo.toml"), project_cargo).unwrap();
let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
assert_eq!(
report.cargo_toml_registered,
Some(true),
"the path dep should have been added",
);
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(
cargo.contains("posts = { path = \"plugins/posts\" }"),
"project Cargo.toml must gain the plugin path dep; got:\n{cargo}",
);
let second = register_dep_in_cargo_toml(&tmp.path().join("Cargo.toml"), "posts").unwrap();
assert!(!second, "re-registering the same dep must be a no-op");
}
#[test]
fn scaffold_app_still_rejects_reserved_names() {
let tmp = tempfile::tempdir().expect("tempdir");
let result = scaffold_app("auth", tmp.path(), None);
assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
}
#[test]
fn scaffold_plugin_validates_name_like_startapp() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(matches!(
scaffold_plugin("2cool", tmp.path(), None),
Err(ScaffoldError::InvalidName(_))
));
assert!(matches!(
scaffold_plugin("foo bar", tmp.path(), None),
Err(ScaffoldError::InvalidName(_))
));
}
fn project(tmp: &tempfile::TempDir) -> PathBuf {
scaffold_project("demo", tmp.path(), None).expect("scaffold_project");
tmp.path().join("demo")
}
fn read(root: &Path, rel: &str) -> String {
fs::read_to_string(root.join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}"))
}
#[test]
fn startcommand_root_writes_the_command_and_wires_main() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
let report =
scaffold_command("backfill_slugs", &CommandTarget::Root, &root).expect("scaffold");
assert!(
report
.files
.contains(&PathBuf::from("src/commands/backfill_slugs.rs"))
);
assert!(report.files.contains(&PathBuf::from("src/commands/mod.rs")));
let cmd = read(&root, "src/commands/backfill_slugs.rs");
assert!(cmd.contains("pub struct BackfillSlugsCommand;"), "{cmd}");
assert!(
cmd.contains("impl PluginCommand for BackfillSlugsCommand"),
"{cmd}"
);
assert!(
cmd.contains("use umbral::cli::{CliError, PluginCommand, clap};"),
"the generated file must import the framework's clap, not its own: {cmd}"
);
assert!(
cmd.contains(r#"clap::Command::new("backfill_slugs")"#),
"{cmd}"
);
let registry = read(&root, "src/commands/mod.rs");
assert!(registry.contains("pub mod backfill_slugs;"), "{registry}");
assert!(
registry.contains("Box::new(backfill_slugs::BackfillSlugsCommand),"),
"{registry}"
);
let main_rs = read(&root, "src/main.rs");
assert!(
main_rs.contains("mod commands;"),
"main.rs never declared the module: {main_rs}"
);
assert!(
main_rs.contains(".commands(commands::all())"),
"main.rs never registered the command registry: {main_rs}"
);
let reg = main_rs.find(".commands(commands::all())").unwrap();
let build = main_rs.find(".build_deferred()").unwrap();
assert!(
reg < build,
"`.commands(...)` landed after `.build_deferred()`, which doesn't compile"
);
}
#[test]
fn startcommand_second_command_appends_and_leaves_main_alone() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
scaffold_command("backfill_slugs", &CommandTarget::Root, &root).expect("first");
let main_after_first = read(&root, "src/main.rs");
scaffold_command("import-prices", &CommandTarget::Root, &root).expect("second");
let main_after_second = read(&root, "src/main.rs");
assert_eq!(
main_after_first, main_after_second,
"the second startcommand edited main.rs again"
);
let registry = read(&root, "src/commands/mod.rs");
assert!(registry.contains("pub mod backfill_slugs;"), "{registry}");
assert!(registry.contains("pub mod import_prices;"), "{registry}");
assert!(
registry.contains("Box::new(import_prices::ImportPricesCommand),"),
"{registry}"
);
let cmd = read(&root, "src/commands/import_prices.rs");
assert!(
cmd.contains(r#"clap::Command::new("import-prices")"#),
"the clap name should be what the user typed, hyphens and all: {cmd}"
);
assert_eq!(
main_after_second
.matches(".commands(commands::all())")
.count(),
1,
"main.rs registered the registry twice"
);
assert_eq!(
main_after_second.matches("\nmod commands;").count(),
1,
"main.rs declared `mod commands;` twice"
);
}
#[test]
fn startcommand_plugin_writes_the_command_and_wires_the_plugin() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
scaffold_app("blog", &root, None).expect("scaffold_app");
scaffold_command("reindex", &CommandTarget::Plugin("blog".to_string()), &root)
.expect("scaffold");
let plugin_root = root.join("plugins/blog");
let registry = read(&plugin_root, "src/commands/mod.rs");
assert!(registry.contains("pub mod reindex;"), "{registry}");
assert!(
registry.contains("Box::new(reindex::ReindexCommand),"),
"{registry}"
);
let lib_rs = read(&plugin_root, "src/lib.rs");
assert!(lib_rs.contains("pub mod commands;"), "{lib_rs}");
assert!(
lib_rs.contains("fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>>"),
"the plugin never got a `Plugin::commands()` impl: {lib_rs}"
);
assert!(
lib_rs.contains("commands::all()"),
"the impl doesn't return the registry: {lib_rs}"
);
let impl_start = lib_rs.find("impl Plugin for BlogPlugin {").unwrap();
let method = lib_rs.find("fn commands(&self)").unwrap();
assert!(method > impl_start, "the method landed outside the impl");
}
#[test]
fn startcommand_rejects_a_rust_keyword_as_a_command_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
for kw in ["move", "type", "match"] {
assert!(
matches!(
scaffold_command(kw, &CommandTarget::Root, &root),
Err(ScaffoldError::InvalidName(_))
),
"`{kw}` is a Rust keyword — `pub mod {kw};` does not parse"
);
}
}
#[test]
fn startcommand_rejects_a_builtin_command_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
for taken in ["migrate", "serve", "makemigrations", "dev"] {
assert!(
matches!(
scaffold_command(taken, &CommandTarget::Root, &root),
Err(ScaffoldError::ReservedCommandName(_))
),
"`{taken}` is a built-in and must be rejected"
);
}
}
#[test]
fn startcommand_rejects_a_builtin_plugin_command_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
assert!(matches!(
scaffold_command("createsuperuser", &CommandTarget::Root, &root),
Err(ScaffoldError::ReservedCommandName(_))
));
assert!(matches!(
scaffold_command("tasks-worker", &CommandTarget::Root, &root),
Err(ScaffoldError::ReservedCommandName(_))
));
}
#[test]
fn reserved_command_names_are_read_off_the_real_parser() {
let names = reserved_command_names();
for expected in ["migrate", "serve", "typegen", "squashmigrations", "help"] {
assert!(
names.iter().any(|n| n == expected),
"`{expected}` missing from the reserved set: {names:?}"
);
}
}
#[test]
fn startcommand_rejects_an_unknown_plugin_and_lists_the_real_ones() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
scaffold_app("blog", &root, None).expect("scaffold_app");
let err = scaffold_command("reindex", &CommandTarget::Plugin("blgo".into()), &root)
.expect_err("a typo'd plugin name must not scaffold anything");
match err {
ScaffoldError::NoSuchPlugin { asked, available } => {
assert_eq!(asked, "blgo");
assert_eq!(available, vec!["blog".to_string()]);
}
other => panic!("expected NoSuchPlugin, got {other:?}"),
}
}
#[test]
fn startcommand_refuses_to_overwrite_an_existing_command() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
scaffold_command("reindex", &CommandTarget::Root, &root).expect("first");
assert!(matches!(
scaffold_command("reindex", &CommandTarget::Root, &root),
Err(ScaffoldError::AlreadyExists(_))
));
}
#[test]
fn startcommand_outside_a_project_says_so() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(matches!(
scaffold_command("reindex", &CommandTarget::Root, tmp.path()),
Err(ScaffoldError::NotAProject(_))
));
}
#[test]
fn discover_plugins_lists_plugin_crates_only() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
assert!(discover_plugins(&root).is_empty());
scaffold_app("blog", &root, None).expect("scaffold_app");
scaffold_app("shop", &root, None).expect("scaffold_app");
fs::create_dir_all(root.join("plugins/notacrate")).unwrap();
assert_eq!(
discover_plugins(&root),
vec!["blog".to_string(), "shop".to_string()]
);
}
#[test]
fn startcommand_does_not_splice_into_someone_elses_builder_chain() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
let main_rs = root.join("src/main.rs");
let original = read(&root, "src/main.rs");
let with_client = original.replace(
" let settings = Settings::from_env()?;",
" let client = reqwest::Client::builder()\n\
\x20 .timeout(Duration::from_secs(5))\n\
\x20 .build()?;\n\n\
\x20 let settings = Settings::from_env()?;",
);
assert_ne!(with_client, original, "fixture did not apply");
fs::write(&main_rs, &with_client).unwrap();
scaffold_command("import_prices", &CommandTarget::Root, &root).expect("scaffold");
let after = read(&root, "src/main.rs");
let commands_at = after.find(".commands(commands::all())").expect("wired");
let client_build_at = after.find(".build()?;").expect("client chain still there");
let app_builder_at = after.find("App::builder()").expect("app chain still there");
assert!(
commands_at > client_build_at,
"`.commands(...)` was spliced into the reqwest chain:\n{after}"
);
assert!(
commands_at > app_builder_at,
"`.commands(...)` landed outside the App::builder() chain:\n{after}"
);
}
#[test]
fn startcommand_declines_when_there_is_no_app_builder_chain() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
fs::write(
root.join("src/main.rs"),
"mod seed;\n\nfn main() {\n println!(\"no app here\");\n}\n",
)
.unwrap();
let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("scaffold");
assert_eq!(
report.registered,
Some(false),
"the tool must not claim a registration it could not perform"
);
let steps = report.next_steps.join("\n");
assert!(steps.contains(".commands(commands::all())"), "{steps}");
assert!(root.join("src/commands/backfill.rs").is_file());
}
#[test]
fn startcommand_keeps_the_module_declaration_it_managed_to_add() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
fs::write(
root.join("src/main.rs"),
"mod seed;\n\nfn main() {\n println!(\"no app\");\n}\n",
)
.unwrap();
let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("scaffold");
let after = read(&root, "src/main.rs");
assert!(
after.contains("mod commands;"),
"the module declaration the tool made was thrown away: {after}"
);
assert_eq!(report.registered, Some(false));
assert!(
report
.next_steps
.join("\n")
.contains(".commands(commands::all())"),
"the user was not told the one step that remained"
);
}
#[test]
fn startcommand_declines_a_plugin_impl_whose_brace_is_on_the_next_line() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
scaffold_app("blog", &root, None).expect("scaffold_app");
let lib_rs = root.join("plugins/blog/src/lib.rs");
fs::write(
&lib_rs,
"pub mod models;\n\npub struct BlogPlugin;\n\n\
impl Plugin for BlogPlugin\nwhere\n Self: Send,\n{\n \
fn name(&self) -> &'static str {\n \"blog\"\n }\n}\n",
)
.unwrap();
let report = scaffold_command("reindex", &CommandTarget::Plugin("blog".to_string()), &root)
.expect("scaffold");
let after = read(&root, "plugins/blog/src/lib.rs");
assert!(
after.contains("impl Plugin for BlogPlugin\nwhere\n Self: Send,\n{"),
"the generator spliced a method into a multi-line impl header:\n{after}"
);
assert_eq!(report.registered, Some(false));
assert!(
report.next_steps.join("\n").contains("fn commands"),
"the user was not told to add the method by hand"
);
}
#[test]
fn startcommand_repairs_a_registry_missing_only_its_entry() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
scaffold_command("backfill", &CommandTarget::Root, &root).expect("first");
let mod_rs = root.join("src/commands/mod.rs");
let text = read(&root, "src/commands/mod.rs")
.lines()
.filter(|l| !l.contains("Box::new(backfill::BackfillCommand)"))
.collect::<Vec<_>>()
.join("\n");
fs::write(&mod_rs, format!("{text}\n")).unwrap();
fs::remove_file(root.join("src/commands/backfill.rs")).unwrap();
let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("re-run");
let registry = read(&root, "src/commands/mod.rs");
assert_eq!(
registry.matches("pub mod backfill;").count(),
1,
"duplicate module declaration:\n{registry}"
);
assert!(
registry.contains("Box::new(backfill::BackfillCommand),"),
"the registry entry was never restored, but the command reports as \
registered:\n{registry}"
);
assert_eq!(report.registered, Some(true));
}
#[test]
fn startcommand_reports_manual_steps_when_the_registry_markers_are_gone() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = project(&tmp);
scaffold_command("first", &CommandTarget::Root, &root).expect("first");
let mod_rs = root.join("src/commands/mod.rs");
let mangled = read(&root, "src/commands/mod.rs")
.lines()
.filter(|l| !l.trim().starts_with("// umbral:startcommand"))
.collect::<Vec<_>>()
.join("\n");
fs::write(&mod_rs, &mangled).unwrap();
let report = scaffold_command("second", &CommandTarget::Root, &root).expect("second");
assert_eq!(read(&root, "src/commands/mod.rs"), mangled);
let steps = report.next_steps.join("\n");
assert!(steps.contains("pub mod second;"), "{steps}");
assert!(steps.contains("Box::new(second::SecondCommand)"), "{steps}");
}
}