1use std::fs;
15use std::io;
16use std::path::{Path, PathBuf};
17
18use umbral_casing::pascal_case_from_ident;
19
20#[derive(Debug)]
23pub enum ScaffoldError {
24 InvalidName(String),
28 AlreadyExists(PathBuf),
31 ReservedName(String),
38 Io(io::Error),
40}
41
42pub const RESERVED_PLUGIN_NAMES: &[&str] = &[
46 "admin",
47 "app",
48 "auth",
49 "cache",
50 "email",
51 "openapi",
52 "permissions",
53 "rest",
54 "rls",
55 "security",
56 "sessions",
57 "signals",
58 "static",
59 "tasks",
60];
61
62impl std::fmt::Display for ScaffoldError {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 Self::InvalidName(s) => write!(
66 f,
67 "invalid name `{s}`: must be ASCII alphanumeric, underscore or hyphen, not starting with a digit",
68 ),
69 Self::AlreadyExists(p) => write!(
70 f,
71 "an app already exists at `{}`; move it aside or pick a different name",
72 p.display()
73 ),
74 Self::ReservedName(s) => write!(
75 f,
76 "`{s}` is the name of a built-in umbral plugin; pick a different name to avoid conflicts at registration time. Reserved names: {}.",
77 RESERVED_PLUGIN_NAMES.join(", ")
78 ),
79 Self::Io(e) => write!(f, "{e}"),
80 }
81 }
82}
83
84impl std::error::Error for ScaffoldError {}
85
86impl From<io::Error> for ScaffoldError {
87 fn from(e: io::Error) -> Self {
88 Self::Io(e)
89 }
90}
91
92#[derive(Debug, Clone)]
95pub struct ScaffoldReport {
96 pub root: PathBuf,
99 pub files: Vec<PathBuf>,
101 pub next_steps: Vec<String>,
104 pub cargo_toml_registered: Option<bool>,
110}
111
112fn validate_name(name: &str) -> Result<(), ScaffoldError> {
119 if name.is_empty() {
120 return Err(ScaffoldError::InvalidName(String::new()));
121 }
122 let first = name.chars().next().unwrap();
123 if first.is_ascii_digit() {
124 return Err(ScaffoldError::InvalidName(name.to_string()));
125 }
126 if !name
127 .chars()
128 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
129 {
130 return Err(ScaffoldError::InvalidName(name.to_string()));
131 }
132 Ok(())
133}
134
135pub(crate) fn localize_deps(text: &str, umbral_repo: &Path) -> String {
153 let repo_str = umbral_repo.display().to_string();
154 let mut out = String::with_capacity(text.len());
155 for line in text.split_inclusive('\n') {
156 out.push_str(&rewrite_line(line, &repo_str));
157 }
158 out
159}
160
161fn rewrite_line(line: &str, repo: &str) -> String {
168 let body_start = line
172 .char_indices()
173 .find(|(_, c)| !matches!(*c, '#' | ' ' | '\t'))
174 .map(|(i, _)| i)
175 .unwrap_or(0);
176 let body = &line[body_start..];
177 let Some(eq_idx) = body.find('=') else {
178 return line.to_string();
179 };
180 let crate_name = body[..eq_idx].trim();
181 if !crate_name.starts_with("umbral") || crate_name.contains(|c: char| c.is_whitespace()) {
184 return line.to_string();
185 }
186 let after_eq = &body[eq_idx + 1..];
190 let spec_offset = after_eq.len() - after_eq.trim_start().len();
191 let spec = after_eq.trim_start();
192 let spec_len = if let Some(rest) = spec.strip_prefix('"') {
193 match rest.find('"') {
194 Some(i) => 1 + i + 1,
195 None => return line.to_string(),
196 }
197 } else if spec.starts_with('{') {
198 match spec.find('}') {
199 Some(i) => i + 1,
200 None => return line.to_string(),
201 }
202 } else {
203 return line.to_string();
204 };
205 let spec_start = body_start + eq_idx + 1 + spec_offset;
206 let spec_end = spec_start + spec_len;
207 let subdir = match crate_name {
208 "umbral" | "umbral-cli" | "umbral-core" | "umbral-macros" | "umbral-testing" => "crates",
209 _ => "plugins",
210 };
211 let path = format!("{repo}/{subdir}/{crate_name}");
212 let prefix = &line[..spec_start];
213 let suffix = &line[spec_end..];
214 format!("{prefix}{{ path = \"{path}\" }}{suffix}")
215}
216
217fn rust_ident(name: &str) -> String {
218 name.replace('-', "_")
219}
220
221fn random_dev_secret_key() -> String {
228 use std::hash::{BuildHasher, Hasher};
229 let seed = std::collections::hash_map::RandomState::new();
233 let mut out = String::with_capacity(64);
234 for i in 0..4u64 {
235 let mut h = seed.build_hasher();
236 h.write_u64(i);
237 h.write_u64(i.wrapping_mul(0x9E37_79B9_7F4A_7C15));
238 out.push_str(&format!("{:016x}", h.finish()));
239 }
240 out
241}
242const DOCS_URL: &str = "https://dalmasonto.github.io/umbral/docs/v0.0.1";
244
245fn find_umbral_checkout(start: &Path) -> Option<PathBuf> {
296 start
297 .ancestors()
298 .find(|d| d.join("crates/umbral-core/Cargo.toml").is_file())
299 .map(Path::to_path_buf)
300}
301
302fn warn_if_run_from_a_source_checkout(name: &str, parent_dir: &Path) {
316 let from_cwd = std::env::current_dir()
317 .ok()
318 .and_then(|d| find_umbral_checkout(&d));
319 let Some(repo) = from_cwd.or_else(|| find_umbral_checkout(parent_dir)) else {
320 return;
321 };
322 let version = env!("CARGO_PKG_VERSION");
323 let repo = repo.display();
324 eprintln!(
325 "warning: running `startproject` from an umbral source checkout ({repo}) without `--local`."
326 );
327 eprintln!();
328 eprintln!(
329 " The new project will depend on the PUBLISHED umbral {version}, while your checkout is on"
330 );
331 eprintln!(
332 " whatever you have got. Any framework surface you have added since {version} was released"
333 );
334 eprintln!(
335 " will be missing, and the generated project will fail to compile against it — looking for"
336 );
337 eprintln!(" all the world like a framework bug rather than a version skew.");
338 eprintln!();
339 eprintln!(" To build against this checkout instead:");
340 eprintln!();
341 eprintln!(" umbral startproject {name} --local {repo}");
342 eprintln!();
343}
344
345pub fn scaffold_project(
346 name: &str,
347 parent_dir: &Path,
348 local_umbral_repo: Option<&Path>,
349) -> Result<ScaffoldReport, ScaffoldError> {
350 validate_name(name)?;
351
352 if local_umbral_repo.is_none() {
353 warn_if_run_from_a_source_checkout(name, parent_dir);
354 }
355
356 let root = parent_dir.join(name);
357 if root.exists() {
358 return Err(ScaffoldError::AlreadyExists(root));
359 }
360
361 fs::create_dir_all(&root)?;
362 fs::create_dir_all(root.join("src"))?;
363 fs::create_dir_all(root.join("src/views"))?;
364 fs::create_dir_all(root.join("src/seed"))?;
365 fs::create_dir_all(root.join("src/widgets"))?;
366 fs::create_dir_all(root.join("plugins"))?;
367 fs::create_dir_all(root.join("templates"))?;
368
369 let crate_name = rust_ident(name);
370 let mut files = Vec::new();
371
372 let version = env!("CARGO_PKG_VERSION");
376 let cargo_toml = format!(
377 r#"[package]
378name = "{name}"
379version = "0.1.0"
380edition = "2024"
381
382[dependencies]
383
384# ----- Framework core (always required) ------------------------------------
385umbral = "{version}"
386umbral-cli = "{version}"
387
388# ----- Active by default ---------------------------------------------------
389# What the generated `src/main.rs` wires in. Comment any of these out only
390# if you also remove the matching `.plugin(...)` line.
391umbral-auth = "{version}"
392umbral-sessions = "{version}"
393umbral-admin = "{version}"
394umbral-rest = "{version}"
395umbral-openapi = "{version}"
396umbral-security = "{version}"
397# Observability init helper (structured JSON logging). Enable the `otel`
398# feature to ALSO export OpenTelemetry traces over OTLP to a collector
399# (Jaeger/Tempo/Honeycomb): `umbral-logs = {{ version = "{version}", features = ["otel"] }}`.
400umbral-logs = "{version}"
401# Serves ./static at /static — including the compiled Tailwind bundle this
402# project ships. Not optional: the SecurityPlugin's CSP blocks third-party
403# script/style CDNs, so an app must serve its own assets.
404umbral-storage = "{version}"
405
406# ----- Available built-ins (uncomment + register in main.rs to enable) -----
407# umbral-playground = "{version}" # Interactive API playground UI (think mini-Postman) at /playground/.
408# umbral-tasks = "{version}" # DB-backed background task queue with a worker process.
409# umbral-permissions = "{version}" # ContentType + Group + Permission model.
410# umbral-rls = "{version}" # Postgres row-level security policy registration.
411# umbral-cache = "{version}" # Per-request caching helper.
412# umbral-email = "{version}" # SMTP + MIME email composer + sender.
413# umbral-signals = "{version}" # Pre/post save/delete signal dispatch.
414# umbral-livereload = "{version}" # Dev-only browser live-reload (SSE push + file watcher). Add `.plugin(LiveReloadPlugin::new())`.
415
416# ----- Third-party + framework runtime deps --------------------------------
417tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
418tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
419serde = {{ version = "1", features = ["derive"] }}
420chrono = {{ version = "0.4", features = ["serde"] }}
421sqlx = {{ version = "0.8", features = ["macros", "sqlite", "postgres", "chrono", "runtime-tokio"] }}
422
423# Once you `umbral startapp <plugin>` or `umbral startplugin <plugin>`, add
424# the plugin crate here:
425# {crate_name}-posts = {{ path = "plugins/posts" }}
426"#
427 );
428 let cargo_toml = match local_umbral_repo {
435 Some(repo) => localize_deps(&cargo_toml, repo),
436 None => cargo_toml,
437 };
438 write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
439
440 let main_rs = format!(
444 r#"//! {name} — application entrypoint.
445//!
446//! This `main.rs` reads like a table of contents: the App builder lists
447//! every model, plugin, and route, and the per-concern submodules below
448//! own the detail. As the project grows you slot new handlers into
449//! `views/`, new seed steps into `seed/`, and new dashboard widgets into
450//! `widgets/` — `main.rs` stays a thin wiring layer.
451//!
452//! src/
453//! main.rs — App builder + route table + boot helpers (this file)
454//! views/ — HTTP handlers, one file per resource grouping
455//! seed/ — first-run data, `seed::all()` pins dependency order
456//! widgets/ — admin dashboard widgets, one file per kind
457//! ../plugins/ — local app plugins (`umbral startapp <name>`)
458//!
459//! Run with:
460//! cargo run -- migrate # apply pending migrations (run once after checkout)
461//! cargo run -- serve # boot the HTTP server
462//!
463//! Other management commands:
464//! cargo run -- makemigrations
465//! cargo run -- showmigrations
466//! cargo run -- createsuperuser
467
468// --- Per-concern modules (the table of contents) ---------------------------
469mod seed;
470mod views;
471mod widgets;
472
473use umbral::prelude::*;
474use umbral::web::{{SlashRedirect}};
475use umbral::migrate::MigrateError;
476use umbral_auth::{{AuthPlugin, AuthUser, login_required_html}};
477use umbral_sessions::SessionsPlugin;
478use umbral_admin::AdminPlugin;
479use umbral_rest::{{RestPlugin, ResourceConfig}};
480use umbral_openapi::OpenApiPlugin;
481use umbral_security::{{SecurityConfig, SecurityPlugin}};
482use umbral_storage::StoragePlugin;
483
484// ---------------------------------------------------------------------------
485// Models
486// ---------------------------------------------------------------------------
487
488/// A blog post. `author` is a FK to the built-in `AuthUser` model — the
489/// migration engine emits `REFERENCES "auth_user"("id")` automatically.
490#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]
491pub struct Post {{
492 pub id: i64,
493 pub title: String,
494 pub body: String,
495 pub published: bool,
496 pub author: ForeignKey<AuthUser>,
497 pub created_at: Option<chrono::DateTime<chrono::Utc>>,
498}}
499
500// ---------------------------------------------------------------------------
501// App wiring
502// ---------------------------------------------------------------------------
503
504#[tokio::main]
505async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {{
506 // Observability: structured logging + (under the `otel` feature on
507 // `umbral-logs`) OpenTelemetry OTLP trace export. Reads RUST_LOG,
508 // UMBRAL_LOG_FORMAT=json, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME.
509 // Keep the guard alive for the whole program: it flushes the OTLP
510 // exporter on drop so trailing spans aren't lost at exit.
511 let _obs = umbral_logs::observability::init(umbral_logs::ObservabilityConfig::from_env());
512
513 let settings = Settings::from_env()?;
514 let pool = umbral::db::connect(&settings.database_url).await?;
515
516 let app = App::builder()
517 .settings(settings)
518 .database("default", pool)
519
520 // --- Models ----------------------------------------------------------
521 // AuthUser and Session are contributed by their plugins below.
522 // List your own models here.
523 .model::<Post>()
524
525 // --- Plugins ---------------------------------------------------------
526 // Auth: user table, password hashing, createsuperuser command.
527 .plugin(AuthPlugin::<AuthUser>::default())
528 // Sessions: session table + cookie middleware.
529 .plugin(SessionsPlugin::default())
530 // Admin: auto CRUD UI at /admin/ for every registered model.
531 // The dashboard mounts one builtin widget from `widgets/` so a
532 // fresh admin isn't empty — add your own with `.dashboard_section`.
533 .plugin(
534 AdminPlugin::default()
535 .dashboard_section(widgets::cards::overview_section()),
536 )
537 // REST: JSON CRUD + filtering at /api/<table>/.
538 // The Post resource has query-string filtering enabled so
539 // GET /api/post/?published=true works out of the box.
540 .plugin(
541 RestPlugin::default()
542 .resource(ResourceConfig::new("post")),
543 )
544 // OpenAPI: Swagger UI at /openapi/ (override with
545 // `.at("/api/docs")` if you prefer a different mount).
546 .plugin(OpenApiPlugin::new())
547 // Static files: serves ./static at /static, which is where the compiled
548 // Tailwind bundle lives. Use `{{ static('css/app.css') }}` in templates
549 // rather than a hardcoded path — in production it resolves through the
550 // hashed-asset manifest so you get cache-busting for free.
551 //
552 // The same plugin also gives you uploaded-file storage (local FS or S3)
553 // when you add a FileField / ImageField: `.media("/media", "./media")`.
554 .plugin(StoragePlugin::new().static_files("/static", "./static"))
555 // Security (on by default): CSRF + clickjacking/HSTS hardening
556 // headers across the app. `/api` is exempt so token-authenticated
557 // JSON clients can POST without a browser form CSRF cookie.
558 .plugin(SecurityPlugin::with_config(SecurityConfig {{
559 csrf_exempt_paths: vec!["/api".to_string()],
560 ..Default::default()
561 }}))
562
563 // --- Templates -------------------------------------------------------
564 .templates_dir("templates")
565 .not_found_template("404.html")
566 .server_error_template("500.html")
567
568 // Redirect /foo → /foo/ (append trailing slash).
569 .slash_redirect(SlashRedirect::Append)
570
571 // --- Routes ----------------------------------------------------------
572 // The Routes builder records each (method, path) pair as you
573 // declare it, so the dev-mode 404 panel surfaces them without
574 // a parallel declaration list. Handlers live in `views/`; this
575 // table is the URL conf — open `views/mod.rs` to see them all.
576 // Per-route middleware (here, login_required_html on /dashboard)
577 // goes through the explicit `.layered(method, path, mr)` form so
578 // the layer attaches just to that handler — not all routes.
579 .routes(
580 Routes::new()
581 // Public home page.
582 .get("/", views::public::home)
583 // API: list posts as JSON (no auth required — demo).
584 .get("/api/posts", views::public::api_list_posts)
585 // Dashboard: only reachable when logged in. The
586 // login_required_html("/login") layer issues a 302 to
587 // /login?next=/dashboard/ for anonymous visitors.
588 .layered(
589 "GET",
590 "/dashboard",
591 get(views::public::dashboard).layer(login_required_html("/login")),
592 ),
593 )
594 // `build_deferred`, not `build`: it wires everything (pools, model
595 // registry, router, system checks) but leaves each plugin's `on_ready`
596 // hook unfired. Those hooks seed content and backfill rows, so they must
597 // not run during `migrate` — the command whose whole job is to create the
598 // tables they write to. `dispatch` fires them once it has read argv.
599 .build_deferred()?;
600
601 // Auto-migrate + seed on boot so `cargo run -- serve` Just Works
602 // against a fresh database — but only when we're actually starting
603 // the server. Running `cargo run -- makemigrations` or `migrate`
604 // from the CLI used to silently trigger `auto_migrate()` first and
605 // then report "no changes detected" (IMP-1 in bugs/tests/testBugs.md).
606 // The guard reads `std::env::args` before dispatch picks them apart
607 // so it matches whatever subcommand the user actually typed.
608 let argv: Vec<String> = std::env::args().collect();
609 let user_invoked_cli = argv.iter().skip(1).any(|a| !a.starts_with('-'));
610 if !user_invoked_cli {{
611 auto_migrate().await?;
612 // First-run data. `seed::all()` is idempotent — see seed/mod.rs.
613 seed::all().await?;
614 }}
615
616 umbral_cli::dispatch(app).await
617}}
618
619// ---------------------------------------------------------------------------
620// Boot helpers
621// ---------------------------------------------------------------------------
622
623/// Run `makemigrations` + `migrate` on boot. Demo-only convenience.
624async fn auto_migrate() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {{
625 match umbral::migrate::make().await {{
626 Ok(paths) => {{
627 for path in paths {{
628 eprintln!("auto-migrate: wrote {{}}", path.display());
629 }}
630 }}
631 Err(MigrateError::NoChanges) => {{}}
632 Err(err) => return Err(Box::new(err)),
633 }}
634 let n = umbral::migrate::run().await?;
635 if n > 0 {{
636 eprintln!("auto-migrate: applied {{n}} migration(s)");
637 }}
638 Ok(())
639}}
640"#
641 );
642 write_file(&root, "src/main.rs", &main_rs, &mut files)?;
643
644 let views_mod_rs = r#"//! HTTP handlers, split by concern — the re-export / discoverability
648//! layer. Open this file and you see the whole web surface in a few
649//! lines: one submodule per resource grouping.
650//!
651//! Submodules:
652//! - `public` — pages anyone can hit (home, JSON listings).
653//!
654//! Add `pub mod account;` here when auth-gated views land (dashboard,
655//! /me, staff-only pages), then re-export it below so `main.rs` keeps
656//! referencing handlers as `views::public::home` without caring which
657//! file owns each one. This is a recommended convention, not a rule —
658//! the router reads handlers directly, so you're free to restructure.
659
660pub mod public;
661
662// No `internal_error` helper, on purpose.
663//
664// Handlers return `Result<_, umbral::web::ApiError>` and use a bare `?`. ApiError
665// converts from sqlx / WriteError / TemplateError, logs the real cause server-side, and
666// returns an opaque 500 — so a missing table or a SQL fragment never reaches the browser.
667// The `(StatusCode, String)` + `err.to_string()` pattern does the opposite.
668"#;
669 write_file(&root, "src/views/mod.rs", views_mod_rs, &mut files)?;
670
671 let views_public_rs = r#"//! Public storefront views — anyone can hit these, no auth required.
675//!
676//! Every handler returns `Result<_, ApiError>` and lets `?` do the work. `ApiError`
677//! converts from a database error, a `WriteError` and a template error, so there is no
678//! per-handler error helper to write — and a 500 logs the real cause server-side while
679//! the client gets an opaque message. Never hand `err.to_string()` to a browser: that is
680//! how table names and SQL fragments end up on someone else's screen.
681
682use umbral::prelude::*;
683use umbral::templates::context;
684
685use crate::Post;
686use crate::post;
687
688/// Home page. Counts published posts and renders home.html.
689pub async fn home() -> Result<Html<String>, ApiError> {
690 let post_count = Post::objects()
691 .filter(post::PUBLISHED.eq(true))
692 .count()
693 .await?;
694
695 let body = umbral::templates::render("home.html", &context!(post_count))?;
696 Ok(Html(body))
697}
698
699/// JSON list of all posts — demonstrates the ORM QuerySet.
700pub async fn api_list_posts() -> Result<Json<Vec<Post>>, ApiError> {
701 let posts = Post::objects().order_by(post::ID.desc()).fetch().await?;
702 Ok(Json(posts))
703}
704
705/// Dashboard: only reachable when logged in (see the `login_required_html`
706/// layer in `main.rs`). The `LoggedIn<AuthUser>` extractor supplies the
707/// current user — the layer already checked the session, so this is a
708/// cheap field read, not a second DB query.
709pub async fn dashboard(
710 user: umbral_auth::LoggedIn<umbral_auth::AuthUser>,
711) -> Result<Html<String>, ApiError> {
712 // Demonstrates a transaction: fetch the user's post list atomically.
713 let user_id = user.id;
714 let my_posts = umbral::transaction(|tx| {
715 Box::pin(async move {
716 Post::objects()
717 .filter(post::AUTHOR.eq(user_id))
718 .on_tx(tx)
719 .fetch()
720 .await
721 })
722 })
723 .await?;
724
725 let body = umbral::templates::render("dashboard.html", &context!(user, my_posts))?;
726 Ok(Html(body))
727}
728"#;
729 write_file(&root, "src/views/public.rs", views_public_rs, &mut files)?;
730
731 let seed_mod_rs = r#"//! Seed orchestrator — the re-export / dependency-order layer. One
735//! file per concern keeps each step small and focused; `all()` pins
736//! the order in which they run.
737//!
738//! Submodules:
739//! - `credentials` — first-run dev superuser so you can log in to
740//! /admin/ without a manual `createsuperuser`.
741//!
742//! Add a `pub mod <concern>;` here for each new seed step, then call it
743//! from `all()` in dependency order (e.g. catalog rows before the orders
744//! that reference them). The order in `all()` doubles as documentation
745//! of which step depends on which.
746
747pub mod credentials;
748
749/// Run every seed step in the right order. Each step is idempotent
750/// (short-circuits on a non-empty table), so calling `all()` on a
751/// partially-seeded DB tops up the missing pieces without re-inserting.
752pub async fn all() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
753 credentials::test_credentials().await?;
754 Ok(())
755}
756"#;
757 write_file(&root, "src/seed/mod.rs", seed_mod_rs, &mut files)?;
758
759 let seed_credentials_rs = r#"//! First-run convenience: mints a dev superuser `admin` when no users
763//! exist yet — but ONLY in the Dev environment AND only when you opt in
764//! by exporting a password. There is deliberately NO hardcoded default
765//! password: a bare `./app` launch against an empty production database
766//! must never plant a known-credential admin account.
767//!
768//! To auto-seed the dev superuser:
769//!
770//! UMBRAL_DEV_ADMIN_PASSWORD=your-dev-password cargo run
771//!
772//! Otherwise the first boot prints guidance to run
773//! `cargo run -- createsuperuser` and seeds nothing. Idempotent —
774//! subsequent boots find the user and stay quiet.
775
776use umbral::Environment;
777use umbral_auth::AuthUser;
778
779/// Env var that opts a fresh install into the dev-superuser seed and
780/// supplies its password. Unset => no seed (print guidance instead).
781const DEV_ADMIN_PASSWORD_ENV: &str = "UMBRAL_DEV_ADMIN_PASSWORD";
782
783pub async fn test_credentials() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
784 // Never mint a dev superuser outside the Dev environment — belt and
785 // suspenders on top of the caller only running us on a bare launch.
786 if umbral::settings::get().environment != Environment::Dev {
787 return Ok(());
788 }
789
790 // Idempotent: bail out the moment any user exists.
791 if AuthUser::objects().count().await? > 0 {
792 return Ok(());
793 }
794
795 // Opt-in only: without an explicit password we plant nothing. This
796 // is what keeps a known `admin`/`admin` account off every fresh DB.
797 let password = match std::env::var(DEV_ADMIN_PASSWORD_ENV) {
798 Ok(p) if !p.is_empty() => p,
799 _ => {
800 eprintln!();
801 eprintln!("No users yet, and no dev superuser was seeded. To create one:");
802 eprintln!(" • interactive: cargo run -- createsuperuser");
803 eprintln!(" • auto on boot: set {DEV_ADMIN_PASSWORD_ENV}=... and restart");
804 eprintln!(" (Dev environment only; never seeds in Prod)");
805 eprintln!();
806 return Ok(());
807 }
808 };
809
810 umbral_auth::create_superuser("admin", "admin@example.com", &password)
811 .await
812 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
813
814 eprintln!();
815 eprintln!("======================================================================");
816 eprintln!(" DEV SUPERUSER seeded (Dev environment, {DEV_ADMIN_PASSWORD_ENV} set)");
817 eprintln!("----------------------------------------------------------------------");
818 eprintln!(" Username : admin");
819 eprintln!(" Password : (the value of {DEV_ADMIN_PASSWORD_ENV})");
820 eprintln!(" Log in : http://127.0.0.1:8000/admin/");
821 eprintln!(" Remove or edit src/seed/credentials.rs before shipping.");
822 eprintln!("======================================================================");
823 eprintln!();
824
825 Ok(())
826}
827"#;
828 write_file(
829 &root,
830 "src/seed/credentials.rs",
831 seed_credentials_rs,
832 &mut files,
833 )?;
834
835 let widgets_mod_rs = r#"//! Admin dashboard widgets — the re-export / discoverability layer,
839//! grouped by kind so each file stays small and focused on one
840//! rendering shape.
841//!
842//! Submodules:
843//! - `cards` — KPI tiles + dashboard sections.
844//!
845//! Add `pub mod charts;`, `pub mod tables;`, etc. as your dashboard
846//! grows, then re-export the builders so `main.rs` calls them as
847//! `widgets::cards::overview_section()` without knowing which file owns
848//! each one. A recommended convention — restructure freely.
849
850pub mod cards;
851"#;
852 write_file(&root, "src/widgets/mod.rs", widgets_mod_rs, &mut files)?;
853
854 let widgets_cards_rs = r#"//! Dashboard widget builders. This starter re-exports one framework
859//! builtin so a fresh `/admin/` dashboard isn't empty; replace it with
860//! your own KPI tiles as the app grows.
861//!
862//! A widget is a `Widget` value handed to `WidgetSection::widget(...)`.
863//! Each section becomes one row of tiles on the admin dashboard. See
864//! `documentation/docs/v0.0.1/admin/` and the `examples/shop/src/widgets`
865//! reference for the data-closure pattern that hits the ORM.
866
867use umbral_admin::WidgetSection;
868
869/// One dashboard section wiring two framework builtins: a model-count
870/// tile and a recent-users list. Mounted from `main.rs` via
871/// `.dashboard_section(widgets::cards::overview_section())`.
872pub fn overview_section() -> WidgetSection {
873 WidgetSection::new("Overview")
874 .subtitle("Framework-wide health + recent activity")
875 .widget(umbral_admin::builtin_total_models_widget().with_span(8, 2))
876 .widget(umbral_admin::builtin_recent_users_widget().with_span(4, 2))
877}
878"#;
879 write_file(&root, "src/widgets/cards.rs", widgets_cards_rs, &mut files)?;
880
881 write_file(&root, "plugins/.gitkeep", "", &mut files)?;
885 let plugins_readme = "# plugins/\n\nLocal app plugins go here; create one with `umbral startapp <name>`.\nEach is its own crate (`lib/models/views/urls`) and is\nauto-wired into this project's `Cargo.toml` `[dependencies]`.\n";
886 write_file(&root, "plugins/README.md", plugins_readme, &mut files)?;
887
888 let dev_secret = random_dev_secret_key();
894 let umbral_toml = format!(
895 r#"# umbral settings for {name}.
896# Environment variables (UMBRAL_*) override these at runtime.
897# See umbral::settings for the full schema.
898
899database_url = "sqlite://{name}.db?mode=rwc"
900
901# Bind address for `cargo run -- serve`.
902# Override via UMBRAL_BIND_ADDR or the --addr flag.
903bind_addr = "127.0.0.1:8000"
904
905environment = "Dev"
906
907# A random dev-only key, unique to this project. CHANGE THIS IN PRODUCTION —
908# the framework errors at boot if a dev key is used with environment = "Prod".
909secret_key = "{dev_secret}"
910"#
911 );
912 write_file(&root, "umbral.toml", &umbral_toml, &mut files)?;
913
914 let dot_env = format!(
918 r#"# Working .env for {name}. Do not commit this file.
919# Generate a real secret key: openssl rand -hex 32
920UMBRAL_DATABASE_URL=sqlite://{name}.db?mode=rwc
921UMBRAL_BIND_ADDR=127.0.0.1:8000
922UMBRAL_SECRET_KEY={dev_secret}
923RUST_LOG=info,umbral=debug
924"#
925 );
926 write_file(&root, ".env", &dot_env, &mut files)?;
927
928 let env_example = r#"# Copy to `.env` and source from your shell, or use a tool like direnv.
932# Settings here override the umbral.toml values at runtime.
933#
934# UMBRAL_SECRET_KEY=$(openssl rand -hex 32)
935# UMBRAL_DATABASE_URL=sqlite://my.db?mode=rwc
936# UMBRAL_BIND_ADDR=0.0.0.0:8000
937# UMBRAL_ENVIRONMENT=prod
938# RUST_LOG=info,umbral=debug
939"#;
940 write_file(&root, ".env.example", env_example, &mut files)?;
941
942 let gitignore = format!("/target\n/{name}.db*\n.env\nCargo.lock\n");
946 write_file(&root, ".gitignore", &gitignore, &mut files)?;
947
948 let readme = format!(
952 r#"# {name}
953
954Your umbral app.
955
956It starts with one model (`Post`), an admin, a JSON API and an OpenAPI browser, so there
957is something running from the first `cargo run`. All of it is ordinary code in this
958repository — rename it, gut it, replace it.
959
960## What's in the project
961
962| File | What it shows |
963|---|---|
964| `src/main.rs` | App wiring: models, plugins, routes, auto-migrate |
965| `Post` model | `ForeignKey<AuthUser>`, ORM QuerySet, `#[derive(Model)]` |
966| `/` route | Template rendering with context |
967| `/api/posts` | JSON endpoint via the ORM |
968| `/dashboard` | `login_required_html("/login")` layer, `LoggedIn<AuthUser>` extractor, transaction |
969| `RestPlugin` | JSON CRUD at `/api/post/` with query-string filtering (`?published=true`) |
970| `AdminPlugin` | Auto CRUD UI at `/admin/` |
971| `OpenApiPlugin` | Swagger UI at `/openapi/` |
972| `SecurityPlugin` | CSRF middleware + hardening headers, with `/api` exempt for token clients |
973
974## Running
975
976```bash
977# First run — a bare `cargo run` (no subcommand) auto-migrates the
978# database and then starts the server. Passing an explicit subcommand
979# (like `serve`) SKIPS the auto-migrate, so `serve` alone assumes the
980# schema already exists.
981cargo run
982
983# Separate steps (production pattern) — migrate explicitly, then serve:
984cargo run -- migrate
985cargo run -- serve
986
987# Create a superuser to log in to the admin:
988cargo run -- createsuperuser
989
990# Inspect the schema:
991cargo run -- showmigrations
992cargo run -- makemigrations
993```
994
995## Styling
996
997The pages use Tailwind, compiled to `static/css/app.css` and served by the
998StoragePlugin at `/static`. That bundle ships **prebuilt**, so this project renders
999correctly with no `npm install`.
1000
1001You only need Node once you edit a template and reach for a utility class that is not
1002already in the bundle:
1003
1004```bash
1005cd styles
1006npm install
1007npm run build # or: npm run watch
1008```
1009
1010The palette lives in `styles/input.css` as CSS variables (`--accent` is the violet).
1011Change them there and every page follows. There is deliberately no `cdn.tailwindcss.com`
1012script: it is versionless, it pulls a third party into every page load, and it is the
1013first thing a `default-src 'self'` Content-Security-Policy blocks.
1014
1015## Where to go next
1016
1017- Add a plugin: `umbral startapp posts`
1018- Your first app: {docs}/getting-started/your-first-app
1019- Models & the ORM: {docs}/orm/models
1020- Migrations: {docs}/migrations/managed-migrations
1021- Admin: {docs}/plugins/admin
1022- REST: {docs}/rest/index
1023- Login & signup pages: {docs}/auth/login-and-signup-pages
1024- The Plugin trait: {docs}/plugins/the-plugin-trait
1025"#,
1026 docs = DOCS_URL,
1027 );
1028 write_file(&root, "README.md", &readme, &mut files)?;
1029
1030 let initial = name
1039 .chars()
1040 .next()
1041 .map(|c| c.to_uppercase().to_string())
1042 .unwrap_or_else(|| "U".to_string());
1043 let fill = |tpl: &str| -> String {
1044 tpl.replace("__PROJECT__", name)
1045 .replace("__INITIAL__", &initial)
1046 .replace("__DOCS__", DOCS_URL)
1047 };
1048
1049 for (path, body) in [
1050 (
1051 "templates/base.html",
1052 include_str!("../assets/scaffold/templates/base.html"),
1053 ),
1054 (
1055 "templates/home.html",
1056 include_str!("../assets/scaffold/templates/home.html"),
1057 ),
1058 (
1059 "templates/dashboard.html",
1060 include_str!("../assets/scaffold/templates/dashboard.html"),
1061 ),
1062 (
1063 "templates/404.html",
1064 include_str!("../assets/scaffold/templates/404.html"),
1065 ),
1066 (
1067 "templates/500.html",
1068 include_str!("../assets/scaffold/templates/500.html"),
1069 ),
1070 (
1071 "styles/input.css",
1072 include_str!("../assets/scaffold/styles/input.css"),
1073 ),
1074 (
1075 "styles/tailwind.config.js",
1076 include_str!("../assets/scaffold/styles/tailwind.config.js"),
1077 ),
1078 (
1079 "styles/package.json",
1080 include_str!("../assets/scaffold/styles/package.json"),
1081 ),
1082 (
1086 "static/css/app.css",
1087 include_str!("../assets/scaffold/static/css/app.css"),
1088 ),
1089 ] {
1090 write_file(&root, path, &fill(body), &mut files)?;
1091 }
1092
1093 let next_steps = vec![
1094 format!("cd {name}"),
1095 "cargo run -- migrate # apply schema migrations".to_string(),
1096 "cargo run -- serve # boot the HTTP server on http://127.0.0.1:8000".to_string(),
1097 "cargo run -- createsuperuser # create an admin login".to_string(),
1098 "umbral startapp <name> # add another app to this project".to_string(),
1099 ];
1100
1101 Ok(ScaffoldReport {
1102 root,
1103 files,
1104 next_steps,
1105 cargo_toml_registered: None,
1106 })
1107}
1108
1109pub fn scaffold_app(
1129 name: &str,
1130 project_root: &Path,
1131 local_umbral_repo: Option<&Path>,
1132) -> Result<ScaffoldReport, ScaffoldError> {
1133 validate_name(name)?;
1134
1135 let normalized = name.replace('-', "_");
1140 if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1141 return Err(ScaffoldError::ReservedName(name.to_string()));
1142 }
1143
1144 let plugins_dir = project_root.join("plugins");
1145 let root = plugins_dir.join(name);
1146 if root.exists() {
1147 return Err(ScaffoldError::AlreadyExists(root));
1148 }
1149
1150 fs::create_dir_all(&root)?;
1151 fs::create_dir_all(root.join("src"))?;
1152
1153 let crate_name = rust_ident(name);
1154 let pascal = pascal_case_from_ident(name);
1155 let mut files = Vec::new();
1156
1157 let version = env!("CARGO_PKG_VERSION");
1158 let cargo_toml = format!(
1159 r#"[package]
1160name = "{name}"
1161version = "0.1.0"
1162edition = "2024"
1163
1164[dependencies]
1165umbral = "{version}"
1166serde = {{ version = "1", features = ["derive"] }}
1167sqlx = {{ version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] }}
1168chrono = {{ version = "0.4", features = ["serde"] }}
1169"#
1170 );
1171 let cargo_toml = match local_umbral_repo {
1172 Some(repo) => localize_deps(&cargo_toml, repo),
1173 None => cargo_toml,
1174 };
1175 write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1176
1177 let lib_rs = format!(
1178 r#"//! {pascal}Plugin — generated by `umbral startapp {name}`.
1179//!
1180//! A plugin split one file per concern:
1181//!
1182//! src/
1183//! lib.rs — the `Plugin` impl: glues models + routes together (this file)
1184//! models.rs — `#[derive(Model)]` structs (this app's tables)
1185//! views.rs — HTTP handlers
1186//! urls.rs — the URL conf: maps paths to `views::` handlers
1187//!
1188//! Wire this into your App by adding to `src/main.rs`:
1189//!
1190//! ```ignore
1191//! .plugin({crate_name}::{pascal}Plugin::default())
1192//! ```
1193//!
1194//! See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for
1195//! what each `Plugin` method does. This layout is a recommended
1196//! convention — the framework only needs a type that impls `Plugin`.
1197
1198pub mod models;
1199pub mod urls;
1200pub mod views;
1201
1202use umbral::plugin::{{AppContext, Plugin, PluginError}};
1203use umbral::web::Router;
1204
1205#[derive(Debug, Default, Clone)]
1206pub struct {pascal}Plugin;
1207
1208impl Plugin for {pascal}Plugin {{
1209 fn name(&self) -> &'static str {{
1210 "{name}"
1211 }}
1212
1213 fn models(&self) -> Vec<umbral::migrate::ModelMeta> {{
1214 // Register every model the plugin owns so makemigrations
1215 // picks them up. Uncomment + extend once you've defined one
1216 // in src/models.rs.
1217 // vec![umbral::migrate::ModelMeta::for_::<models::Example>()]
1218 Vec::new()
1219 }}
1220
1221 fn routes(&self) -> Router {{
1222 // Routes live in `urls.rs` (this app's URL conf), one place to
1223 // see every path the plugin serves.
1224 urls::router()
1225 }}
1226
1227 fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1228 Ok(())
1229 }}
1230}}
1231"#
1232 );
1233 write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1234
1235 let models_rs = format!(
1239 r#"//! Models for the `{name}` plugin.
1240//!
1241//! Declare one `#[derive(umbral::orm::Model)]` struct per database
1242//! table. Once registered via `Plugin::models()` in lib.rs, the
1243//! migration engine picks them up on the next `makemigrations`.
1244//!
1245//! ```ignore
1246//! use chrono::{{DateTime, Utc}};
1247//! use serde::{{Deserialize, Serialize}};
1248//!
1249//! #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1250//! pub struct Example {{
1251//! pub id: i64,
1252//! #[umbral(string, max_length = 200)]
1253//! pub title: String,
1254//! #[umbral(noedit)]
1255//! pub created_at: DateTime<Utc>,
1256//! }}
1257//! ```
1258"#
1259 );
1260 write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1261
1262 let views_rs = format!(
1265 r#"//! HTTP handlers for the `{name}` plugin.
1266//!
1267//! Each handler is an axum handler — return anything that implements
1268//! `IntoResponse` (`Html<String>`, `Json<T>`, `&'static str`, a
1269//! `Result<_, (StatusCode, String)>`, …). Read this app's data through
1270//! the ORM (`models::*::objects()`), never raw SQL.
1271//!
1272//! Routes that reach these handlers are declared in `urls.rs`.
1273
1274/// Sample landing handler. `GET /{name}/` hits this; rewire the path in
1275/// `urls.rs`.
1276pub async fn index() -> &'static str {{
1277 "Hello from the {name} plugin"
1278}}
1279"#
1280 );
1281 write_file(&root, "src/views.rs", &views_rs, &mut files)?;
1282
1283 let urls_rs = format!(
1286 r#"//! URL conf for the `{name}` plugin — the route table.
1287//! `router()` returns the axum `Router` that
1288//! `Plugin::routes()` in lib.rs hands back to the framework.
1289//!
1290//! Convention: `/<name>/...` for HTML pages, `/api/<name>/...` for JSON.
1291//! Map each path to a handler in `views.rs` so this file reads as the
1292//! single index of everything the plugin serves.
1293
1294use umbral::web::{{Router, get}};
1295
1296use crate::views;
1297
1298/// Build this plugin's route table. Add one `.route(path, method(handler))`
1299/// line per endpoint.
1300pub fn router() -> Router {{
1301 Router::new().route("/{name}/", get(views::index))
1302}}
1303"#
1304 );
1305 write_file(&root, "src/urls.rs", &urls_rs, &mut files)?;
1306
1307 let project_cargo_toml = project_root.join("Cargo.toml");
1312 let cargo_toml_registered = if project_cargo_toml.is_file() {
1313 register_dep_in_cargo_toml(&project_cargo_toml, name).ok()
1314 } else {
1315 None
1316 };
1317
1318 let next_steps = vec![
1319 "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1320 format!(" .plugin({crate_name}::{pascal}Plugin::default())"),
1321 "(The plugin crate was auto-added to your project dependencies.)".to_string(),
1322 "Declare your first model in src/models.rs and uncomment the".to_string(),
1323 " `Plugin::models()` line in src/lib.rs.".to_string(),
1324 "Add handlers in src/views.rs and route them in src/urls.rs.".to_string(),
1325 ];
1326
1327 Ok(ScaffoldReport {
1328 root,
1329 files,
1330 next_steps,
1331 cargo_toml_registered,
1332 })
1333}
1334
1335pub fn scaffold_plugin(
1355 name: &str,
1356 project_root: &Path,
1357 local_umbral_repo: Option<&Path>,
1358) -> Result<ScaffoldReport, ScaffoldError> {
1359 validate_name(name)?;
1360
1361 let normalized = name.replace('-', "_");
1362 if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1363 return Err(ScaffoldError::ReservedName(name.to_string()));
1364 }
1365
1366 let plugins_dir = project_root.join("plugins");
1367 let root = plugins_dir.join(name);
1368 if root.exists() {
1369 return Err(ScaffoldError::AlreadyExists(root));
1370 }
1371
1372 fs::create_dir_all(&root)?;
1373 fs::create_dir_all(root.join("src"))?;
1374
1375 let crate_name = rust_ident(name);
1376 let pascal = pascal_case_from_ident(name);
1377 let mut files = Vec::new();
1378
1379 let version = env!("CARGO_PKG_VERSION");
1385 let cargo_toml = format!(
1386 r#"[package]
1387name = "{name}"
1388version = "0.1.0"
1389edition = "2024"
1390description = "A {crate_name} plugin for umbral."
1391
1392[dependencies]
1393umbral = "{version}"
1394serde = {{ version = "1", features = ["derive"] }}
1395sqlx = {{ version = "0.8", default-features = false, features = ["macros", "runtime-tokio"] }}
1396chrono = {{ version = "0.4", features = ["serde"] }}
1397async-trait = "0.1"
1398"#
1399 );
1400 let cargo_toml = match local_umbral_repo {
1401 Some(repo) => localize_deps(&cargo_toml, repo),
1402 None => cargo_toml,
1403 };
1404 write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1405
1406 let readme = format!(
1409 r#"# {name}
1410
1411A {crate_name} plugin for [umbral](https://github.com/dalmasonto/umbral).
1412
1413Generated by `umbral startplugin {name}`.
1414
1415## What's inside
1416
1417| File | Purpose |
1418|---|---|
1419| `src/lib.rs` | `{pascal}Plugin` struct + `impl Plugin` (registers models, routes, lifecycle hooks). |
1420| `src/models.rs` | One example model showing common field types (`#[umbral(...)]` attributes for `max_length`, `choices`, FK, defaults). |
1421| `src/handlers.rs` | One example axum handler showing how to read query params and return JSON. |
1422
1423## Wiring it in
1424
1425In your project's `Cargo.toml`:
1426
1427```toml
1428[dependencies]
1429{name} = {{ path = "plugins/{name}" }}
1430```
1431
1432In `src/main.rs`:
1433
1434```rust,ignore
1435let app = umbral::App::builder()
1436 .plugin({crate_name}::{pascal}Plugin::default())
1437 // ... your other plugins
1438 .build()?;
1439```
1440
1441Then:
1442
1443```sh
1444cargo run -- makemigrations # generates 0001_initial.json from your models
1445cargo run -- migrate # applies the schema
1446cargo run -- serve # boots the HTTP server
1447```
1448
1449## Next steps
1450
1451- Add your own models in `src/models.rs` (or split into a `models/` module).
1452- Add routes in `routes()` and handlers in `src/handlers.rs`.
1453- Use `on_ready(&AppContext)` for one-shot setup work (seed default rows, register signals).
1454- See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for the full trait surface.
1455"#
1456 );
1457 write_file(&root, "README.md", &readme, &mut files)?;
1458
1459 let lib_rs = format!(
1463 r#"//! {pascal}Plugin — a distributable umbral plugin.
1464//!
1465//! Wire this into your App in `src/main.rs`:
1466//!
1467//! ```ignore
1468//! .plugin({crate_name}::{pascal}Plugin::default())
1469//! ```
1470//!
1471//! See `README.md` for the full file tour.
1472
1473pub mod handlers;
1474pub mod models;
1475
1476use async_trait::async_trait;
1477use umbral::migrate::ModelMeta;
1478use umbral::orm::Model;
1479use umbral::plugin::{{AppContext, Plugin, PluginError}};
1480use umbral::web::{{Router, get}};
1481
1482/// The plugin entry point. Register one instance per `App::builder()`.
1483#[derive(Debug, Default, Clone)]
1484pub struct {pascal}Plugin;
1485
1486#[async_trait]
1487impl Plugin for {pascal}Plugin {{
1488 fn name(&self) -> &'static str {{
1489 "{name}"
1490 }}
1491
1492 /// Models the framework's migration engine should track. Each
1493 /// returned [`ModelMeta`] becomes one row in the
1494 /// `umbral_migrations` tracking table once the initial migration
1495 /// applies.
1496 fn models(&self) -> Vec<ModelMeta> {{
1497 vec![models::{pascal}Item::meta()]
1498 }}
1499
1500 /// HTTP routes contributed by this plugin. The base path is
1501 /// up to you — convention is `/<name>/...` for HTML and
1502 /// `/api/<name>/...` for JSON.
1503 fn routes(&self) -> Router {{
1504 Router::new().route("/{name}/hello", get(handlers::hello))
1505 }}
1506
1507 /// One-shot setup after `App::build()` finishes. Use this for
1508 /// seeding default rows, registering signal handlers, or any
1509 /// work that needs the database available. Sync because the
1510 /// `Plugin` trait signature is sync (BUG-3 in bugs/tests/testBugs.md);
1511 /// reach into a runtime via `tokio::runtime::Handle::current()
1512 /// .block_on(...)` if you need to await something here.
1513 fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1514 Ok(())
1515 }}
1516}}
1517"#
1518 );
1519 write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1520
1521 let models_rs = format!(
1525 r#"//! Example model. Replace or extend with your own.
1526//!
1527//! What this demonstrates:
1528//! - `#[umbral(max_length = 200)]` — DDL `VARCHAR(200)` + admin form hint.
1529//! - `#[umbral(choices)]` on an enum — closed-set column with OpenAPI
1530//! `enum` and a Postgres `CHECK (col IN (...))` constraint.
1531//! - `Option<DateTime<Utc>>` — nullable timestamptz column.
1532//! - `#[umbral(noedit)]` — read-only on admin forms; not editable via
1533//! PUT/PATCH through the REST plugin.
1534
1535use chrono::{{DateTime, Utc}};
1536use serde::{{Deserialize, Serialize}};
1537
1538/// One {crate_name} item. Replace with whatever your plugin actually
1539/// stores.
1540#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1541pub struct {pascal}Item {{
1542 /// Auto-incrementing primary key.
1543 pub id: i64,
1544
1545 /// Display title. Capped at 200 chars; admin renders a single-line
1546 /// input.
1547 #[umbral(string, max_length = 200)]
1548 pub title: String,
1549
1550 /// Lifecycle state. The choices map 1:1 to enum variants; the
1551 /// migration engine emits a CHECK constraint, the admin renders a
1552 /// `<select>`, and the OpenAPI schema gets an `enum` array.
1553 pub status: {pascal}Status,
1554
1555 /// When the item was last published. Read-only on edit forms.
1556 #[umbral(noedit)]
1557 pub published_at: Option<DateTime<Utc>>,
1558}}
1559
1560/// Lifecycle state for [`{pascal}Item`].
1561#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
1562#[sqlx(rename_all = "lowercase")]
1563#[serde(rename_all = "lowercase")]
1564pub enum {pascal}Status {{
1565 Draft,
1566 Review,
1567 Published,
1568 Archived,
1569}}
1570"#
1571 );
1572 write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1573
1574 let handlers_rs = format!(
1577 r#"//! Example HTTP handlers. Replace or extend with your own.
1578//!
1579//! `GET /{name}/hello?name=world` returns `{{"greeting": "Hello, world!"}}`.
1580
1581use serde::{{Deserialize, Serialize}};
1582use umbral::web::{{Json, extract::Query}};
1583
1584#[derive(Debug, Deserialize, Default)]
1585pub struct HelloParams {{
1586 /// Who to greet. Defaults to "{name}" when omitted.
1587 #[serde(default)]
1588 pub name: Option<String>,
1589}}
1590
1591#[derive(Debug, Serialize)]
1592pub struct HelloResponse {{
1593 pub greeting: String,
1594}}
1595
1596pub async fn hello(Query(params): Query<HelloParams>) -> Json<HelloResponse> {{
1597 let who = params.name.as_deref().unwrap_or("{name}");
1598 Json(HelloResponse {{
1599 greeting: format!("Hello, {{who}}!"),
1600 }})
1601}}
1602"#
1603 );
1604 write_file(&root, "src/handlers.rs", &handlers_rs, &mut files)?;
1605
1606 let project_cargo_toml = project_root.join("Cargo.toml");
1608 let cargo_toml_registered = if project_cargo_toml.is_file() {
1609 register_dep_in_cargo_toml(&project_cargo_toml, name).ok()
1610 } else {
1611 None
1612 };
1613
1614 let next_steps = vec![
1615 "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1616 format!(" .plugin({crate_name}::{pascal}Plugin::default())"),
1617 "Generate + apply the initial migration:".to_string(),
1618 " cargo run -- makemigrations".to_string(),
1619 " cargo run -- migrate".to_string(),
1620 format!("Then visit http://127.0.0.1:8000/{name}/hello?name=you"),
1621 ];
1622
1623 Ok(ScaffoldReport {
1624 root,
1625 files,
1626 next_steps,
1627 cargo_toml_registered,
1628 })
1629}
1630
1631fn write_file(
1634 root: &Path,
1635 rel_path: &str,
1636 contents: &str,
1637 files: &mut Vec<PathBuf>,
1638) -> io::Result<()> {
1639 let full = root.join(rel_path);
1640 if let Some(parent) = full.parent() {
1641 fs::create_dir_all(parent)?;
1642 }
1643 fs::write(&full, contents)?;
1644 files.push(PathBuf::from(rel_path));
1645 Ok(())
1646}
1647
1648pub fn register_dep_in_cargo_toml(cargo_toml_path: &Path, name: &str) -> io::Result<bool> {
1664 let text = fs::read_to_string(cargo_toml_path)?;
1665
1666 let dep_key = format!("{name} =");
1669 if text.lines().any(|l| l.trim_start().starts_with(&dep_key)) {
1670 return Ok(false);
1672 }
1673
1674 let dep_line = format!("{name} = {{ path = \"plugins/{name}\" }}\n");
1679
1680 let mut out = String::with_capacity(text.len() + dep_line.len());
1681 let mut inserted = false;
1682
1683 for line in text.split_inclusive('\n') {
1684 out.push_str(line);
1685 if !inserted && line.trim() == "[dependencies]" {
1688 out.push_str(&dep_line);
1689 inserted = true;
1690 }
1691 }
1692
1693 if !inserted {
1694 if !out.ends_with('\n') {
1697 out.push('\n');
1698 }
1699 out.push_str("\n[dependencies]\n");
1700 out.push_str(&dep_line);
1701 }
1702
1703 fs::write(cargo_toml_path, &out)?;
1704 Ok(true)
1705}
1706
1707#[cfg(test)]
1708mod tests {
1709 use super::*;
1710
1711 #[test]
1712 fn validate_name_accepts_simple_identifiers() {
1713 assert!(validate_name("posts").is_ok());
1714 assert!(validate_name("blog_engine").is_ok());
1715 assert!(validate_name("blog-engine").is_ok());
1716 assert!(validate_name("api2").is_ok());
1717 }
1718
1719 #[test]
1720 fn validate_name_rejects_empty() {
1721 assert!(validate_name("").is_err());
1722 }
1723
1724 #[test]
1725 fn validate_name_rejects_leading_digit() {
1726 assert!(validate_name("2cool").is_err());
1727 }
1728
1729 #[test]
1730 fn validate_name_rejects_special_chars() {
1731 assert!(validate_name("foo bar").is_err());
1732 assert!(validate_name("foo!bar").is_err());
1733 assert!(validate_name("foo/bar").is_err());
1734 }
1735
1736 #[test]
1737 fn pascal_case_handles_kebab_and_snake() {
1738 assert_eq!(pascal_case_from_ident("posts"), "Posts");
1739 assert_eq!(pascal_case_from_ident("blog_engine"), "BlogEngine");
1740 assert_eq!(pascal_case_from_ident("blog-engine"), "BlogEngine");
1741 assert_eq!(pascal_case_from_ident("api2"), "Api2");
1742 }
1743
1744 #[test]
1745 fn rust_ident_replaces_hyphens() {
1746 assert_eq!(rust_ident("blog-engine"), "blog_engine");
1747 assert_eq!(rust_ident("posts"), "posts");
1748 }
1749
1750 #[test]
1751 fn scaffold_app_rejects_reserved_built_in_plugin_names() {
1752 let tmp = tempfile::tempdir().expect("tempdir");
1753 for name in RESERVED_PLUGIN_NAMES {
1754 let result = scaffold_app(name, tmp.path(), None);
1755 assert!(
1756 matches!(result, Err(ScaffoldError::ReservedName(_))),
1757 "expected ReservedName error for `{name}`, got: {result:?}",
1758 );
1759 assert!(
1760 !tmp.path().join("plugins").join(name).exists(),
1761 "directory must NOT be created when name is reserved: {name}",
1762 );
1763 }
1764 }
1765
1766 #[test]
1767 fn scaffold_app_rejects_reserved_name_with_hyphen_variant() {
1768 let tmp = tempfile::tempdir().expect("tempdir");
1773 let result = scaffold_app("auth", tmp.path(), None);
1777 assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
1778 }
1779
1780 #[test]
1781 fn scaffold_app_message_lists_reserved_names() {
1782 let err = ScaffoldError::ReservedName("auth".to_string());
1783 let msg = format!("{err}");
1784 assert!(msg.contains("`auth`"), "error names the offending input");
1785 assert!(
1786 msg.contains("admin") && msg.contains("sessions") && msg.contains("permissions"),
1787 "error lists the reserved set so the user can pick again: {msg}",
1788 );
1789 }
1790
1791 #[test]
1792 fn scaffold_app_already_exists_message_says_app() {
1793 let err = ScaffoldError::AlreadyExists(PathBuf::from("plugins/blog"));
1797 let msg = format!("{err}");
1798 assert!(msg.contains("app already exists"), "got: {msg}");
1799 assert!(msg.contains("plugins/blog"), "got: {msg}");
1800 }
1801
1802 #[test]
1807 fn scaffold_plugin_writes_richer_layout() {
1808 let tmp = tempfile::tempdir().expect("tempdir");
1809 let report = scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
1810
1811 let root = tmp.path().join("plugins").join("widgets");
1812 assert!(root.is_dir());
1813
1814 for rel in [
1816 "Cargo.toml",
1817 "README.md",
1818 "src/lib.rs",
1819 "src/models.rs",
1820 "src/handlers.rs",
1821 ] {
1822 assert!(
1823 root.join(rel).exists(),
1824 "missing expected file: {rel}; got {:?}",
1825 report.files,
1826 );
1827 }
1828 }
1829
1830 #[test]
1831 fn scaffold_plugin_lib_rs_references_sibling_modules() {
1832 let tmp = tempfile::tempdir().expect("tempdir");
1833 scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
1834 let lib = fs::read_to_string(tmp.path().join("plugins/widgets/src/lib.rs")).unwrap();
1835
1836 assert!(
1837 lib.contains("pub mod handlers;"),
1838 "lib.rs must publish handlers"
1839 );
1840 assert!(
1841 lib.contains("pub mod models;"),
1842 "lib.rs must publish models"
1843 );
1844 assert!(lib.contains("WidgetsPlugin"), "PascalCase plugin name");
1845 assert!(
1846 lib.contains("models::WidgetsItem::meta()"),
1847 "models() should register the example model",
1848 );
1849 assert!(
1850 lib.contains("/widgets/hello"),
1851 "routes() should register the example handler",
1852 );
1853 }
1854
1855 #[test]
1856 fn scaffold_plugin_models_rs_uses_real_umbral_attributes() {
1857 let tmp = tempfile::tempdir().expect("tempdir");
1858 scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
1859 let models = fs::read_to_string(tmp.path().join("plugins/widgets/src/models.rs")).unwrap();
1860
1861 assert!(
1862 models.contains("umbral::orm::Model"),
1863 "model derive must reference the framework's Model trait",
1864 );
1865 assert!(
1866 models.contains("max_length = 200"),
1867 "example model should demonstrate max_length",
1868 );
1869 assert!(
1870 models.contains("WidgetsStatus"),
1871 "example model should declare a Choice enum",
1872 );
1873 assert!(
1874 models.contains("noedit"),
1875 "example model should show the noedit attribute",
1876 );
1877 }
1878
1879 #[test]
1880 fn scaffold_plugin_rejects_reserved_built_in_plugin_names() {
1881 let tmp = tempfile::tempdir().expect("tempdir");
1882 for name in RESERVED_PLUGIN_NAMES {
1883 let result = scaffold_plugin(name, tmp.path(), None);
1884 assert!(
1885 matches!(result, Err(ScaffoldError::ReservedName(_))),
1886 "expected ReservedName error for `{name}`, got: {result:?}",
1887 );
1888 }
1889 }
1890
1891 #[test]
1892 fn scaffold_plugin_refuses_to_overwrite_existing_directory() {
1893 let tmp = tempfile::tempdir().expect("tempdir");
1894 scaffold_plugin("widgets", tmp.path(), None).expect("first scaffold ok");
1895 let result = scaffold_plugin("widgets", tmp.path(), None);
1896 assert!(matches!(result, Err(ScaffoldError::AlreadyExists(_))));
1897 }
1898
1899 #[test]
1905 fn scaffold_project_writes_per_concern_tree() {
1906 let tmp = tempfile::tempdir().expect("tempdir");
1907 let report = scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
1908
1909 let root = tmp.path().join("blog");
1910 assert!(root.is_dir());
1911
1912 for rel in [
1914 "src/main.rs",
1915 "src/views/mod.rs",
1916 "src/views/public.rs",
1917 "src/seed/mod.rs",
1918 "src/seed/credentials.rs",
1919 "src/widgets/mod.rs",
1920 "src/widgets/cards.rs",
1921 "plugins/.gitkeep",
1922 "plugins/README.md",
1923 ] {
1924 assert!(
1925 root.join(rel).exists(),
1926 "missing expected file: {rel}; got {:?}",
1927 report.files,
1928 );
1929 }
1930 }
1931
1932 #[test]
1933 fn scaffold_project_mod_files_carry_orchestrator_markers() {
1934 let tmp = tempfile::tempdir().expect("tempdir");
1935 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
1936 let root = tmp.path().join("blog");
1937
1938 let views_mod = fs::read_to_string(root.join("src/views/mod.rs")).unwrap();
1939 assert!(
1940 views_mod.contains("re-export"),
1941 "views/mod.rs should describe itself as the re-export layer",
1942 );
1943 assert!(
1950 !views_mod.contains("fn internal_error"),
1951 "the scaffold must NOT generate an internal_error helper — handlers return \
1952 ApiError, which logs the cause and keeps it off the wire",
1953 );
1954 let views_public = fs::read_to_string(root.join("src/views/public.rs")).unwrap();
1955 assert!(
1956 views_public.contains("Result<Html<String>, ApiError>")
1957 && !views_public.contains("map_err(internal_error)"),
1958 "generated handlers must return ApiError and use a bare `?`",
1959 );
1960
1961 let seed_mod = fs::read_to_string(root.join("src/seed/mod.rs")).unwrap();
1962 assert!(
1963 seed_mod.contains("pub async fn all()"),
1964 "seed/mod.rs must declare the all() orchestrator",
1965 );
1966 assert!(
1967 seed_mod.contains("credentials::test_credentials()"),
1968 "seed::all() must call the credentials step",
1969 );
1970 assert!(
1971 seed_mod.contains("dependency order") || seed_mod.contains("order in which"),
1972 "seed/mod.rs should explain it pins dependency order",
1973 );
1974
1975 let credentials = fs::read_to_string(root.join("src/seed/credentials.rs")).unwrap();
1976 assert!(
1977 credentials.contains("fn test_credentials"),
1978 "credentials.rs must define the test_credentials seed",
1979 );
1980 assert!(
1981 credentials.contains("count().await? > 0"),
1982 "test_credentials must be idempotent (short-circuit on existing users)",
1983 );
1984
1985 let widgets_mod = fs::read_to_string(root.join("src/widgets/mod.rs")).unwrap();
1986 assert!(
1987 widgets_mod.contains("pub mod cards;"),
1988 "widgets/mod.rs must publish the cards submodule",
1989 );
1990
1991 let cards = fs::read_to_string(root.join("src/widgets/cards.rs")).unwrap();
1992 assert!(
1993 cards.contains("builtin_total_models_widget")
1994 || cards.contains("builtin_recent_users_widget"),
1995 "cards.rs should re-export a builtin widget so the dashboard isn't empty",
1996 );
1997 }
1998
1999 #[test]
2000 fn scaffold_project_main_declares_modules_and_mounts_security() {
2001 let tmp = tempfile::tempdir().expect("tempdir");
2002 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2003 let main = fs::read_to_string(tmp.path().join("blog/src/main.rs")).unwrap();
2004
2005 assert!(
2007 main.contains("mod views;"),
2008 "main.rs must declare mod views"
2009 );
2010 assert!(main.contains("mod seed;"), "main.rs must declare mod seed");
2011 assert!(
2012 main.contains("mod widgets;"),
2013 "main.rs must declare mod widgets",
2014 );
2015
2016 assert!(
2018 main.contains("views::public::home"),
2019 "route table should wire views::public::home",
2020 );
2021 assert!(
2023 main.contains("seed::all().await"),
2024 "boot should run seed::all()",
2025 );
2026
2027 assert!(
2029 main.contains("SecurityPlugin"),
2030 "SecurityPlugin must be mounted by default",
2031 );
2032 }
2033
2034 #[test]
2035 fn scaffold_project_creates_empty_plugins_dir() {
2036 let tmp = tempfile::tempdir().expect("tempdir");
2037 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2038 let readme = fs::read_to_string(tmp.path().join("blog/plugins/README.md")).unwrap();
2039 assert!(
2040 readme.contains("umbral startapp"),
2041 "plugins/README.md should point at `umbral startapp`",
2042 );
2043 }
2044
2045 #[test]
2050 fn scaffold_app_writes_per_concern_plugin_layout() {
2051 let tmp = tempfile::tempdir().expect("tempdir");
2052 let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2053
2054 let root = tmp.path().join("plugins").join("posts");
2055 assert!(root.is_dir());
2056
2057 for rel in [
2058 "Cargo.toml",
2059 "src/lib.rs",
2060 "src/models.rs",
2061 "src/views.rs",
2062 "src/urls.rs",
2063 ] {
2064 assert!(
2065 root.join(rel).exists(),
2066 "missing expected file: {rel}; got {:?}",
2067 report.files,
2068 );
2069 }
2070 }
2071
2072 #[test]
2073 fn scaffold_app_lib_wires_urls_and_views() {
2074 let tmp = tempfile::tempdir().expect("tempdir");
2075 scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2076 let root = tmp.path().join("plugins/posts");
2077
2078 let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
2079 assert!(
2080 lib.contains("pub mod models;"),
2081 "lib.rs must publish models"
2082 );
2083 assert!(lib.contains("pub mod views;"), "lib.rs must publish views");
2084 assert!(lib.contains("pub mod urls;"), "lib.rs must publish urls");
2085 assert!(
2086 lib.contains("urls::router()"),
2087 "routes() must return urls::router()",
2088 );
2089 assert!(lib.contains("PostsPlugin"), "PascalCase plugin name");
2090
2091 let urls = fs::read_to_string(root.join("src/urls.rs")).unwrap();
2092 assert!(
2093 urls.contains("pub fn router() -> Router"),
2094 "urls.rs must expose a router() returning a Router",
2095 );
2096 assert!(
2097 urls.contains("views::index"),
2098 "urls.rs route table should map to a views:: handler",
2099 );
2100
2101 let views = fs::read_to_string(root.join("src/views.rs")).unwrap();
2102 assert!(
2103 views.contains("pub async fn index"),
2104 "views.rs should ship a sample index handler",
2105 );
2106 }
2107
2108 #[test]
2109 fn scaffold_app_auto_registers_path_dep_in_project_cargo() {
2110 let tmp = tempfile::tempdir().expect("tempdir");
2111 let project_cargo = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nserde = \"1\"\n";
2113 fs::write(tmp.path().join("Cargo.toml"), project_cargo).unwrap();
2114
2115 let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2116 assert_eq!(
2117 report.cargo_toml_registered,
2118 Some(true),
2119 "the path dep should have been added",
2120 );
2121
2122 let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
2123 assert!(
2124 cargo.contains("posts = { path = \"plugins/posts\" }"),
2125 "project Cargo.toml must gain the plugin path dep; got:\n{cargo}",
2126 );
2127
2128 let second = register_dep_in_cargo_toml(&tmp.path().join("Cargo.toml"), "posts").unwrap();
2131 assert!(!second, "re-registering the same dep must be a no-op");
2132 }
2133
2134 #[test]
2135 fn scaffold_app_still_rejects_reserved_names() {
2136 let tmp = tempfile::tempdir().expect("tempdir");
2137 let result = scaffold_app("auth", tmp.path(), None);
2138 assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
2139 }
2140
2141 #[test]
2142 fn scaffold_plugin_validates_name_like_startapp() {
2143 let tmp = tempfile::tempdir().expect("tempdir");
2144 assert!(matches!(
2145 scaffold_plugin("2cool", tmp.path(), None),
2146 Err(ScaffoldError::InvalidName(_))
2147 ));
2148 assert!(matches!(
2149 scaffold_plugin("foo bar", tmp.path(), None),
2150 Err(ScaffoldError::InvalidName(_))
2151 ));
2152 }
2153}