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}
242
243pub fn scaffold_project(
291 name: &str,
292 parent_dir: &Path,
293 local_umbral_repo: Option<&Path>,
294) -> Result<ScaffoldReport, ScaffoldError> {
295 validate_name(name)?;
296
297 let root = parent_dir.join(name);
298 if root.exists() {
299 return Err(ScaffoldError::AlreadyExists(root));
300 }
301
302 fs::create_dir_all(&root)?;
303 fs::create_dir_all(root.join("src"))?;
304 fs::create_dir_all(root.join("src/views"))?;
305 fs::create_dir_all(root.join("src/seed"))?;
306 fs::create_dir_all(root.join("src/widgets"))?;
307 fs::create_dir_all(root.join("plugins"))?;
308 fs::create_dir_all(root.join("templates"))?;
309
310 let crate_name = rust_ident(name);
311 let mut files = Vec::new();
312
313 let version = env!("CARGO_PKG_VERSION");
317 let cargo_toml = format!(
318 r#"[package]
319name = "{name}"
320version = "0.1.0"
321edition = "2024"
322
323[dependencies]
324
325# ----- Framework core (always required) ------------------------------------
326umbral = "{version}"
327umbral-cli = "{version}"
328
329# ----- Active by default ---------------------------------------------------
330# What the generated `src/main.rs` wires in. Comment any of these out only
331# if you also remove the matching `.plugin(...)` line.
332umbral-auth = "{version}"
333umbral-sessions = "{version}"
334umbral-admin = "{version}"
335umbral-rest = "{version}"
336umbral-openapi = "{version}"
337umbral-security = "{version}"
338# Observability init helper (structured JSON logging). Enable the `otel`
339# feature to ALSO export OpenTelemetry traces over OTLP to a collector
340# (Jaeger/Tempo/Honeycomb): `umbral-logs = {{ version = "{version}", features = ["otel"] }}`.
341umbral-logs = "{version}"
342
343# ----- Available built-ins (uncomment + register in main.rs to enable) -----
344# umbral-playground = "{version}" # Interactive API playground UI (think mini-Postman) at /playground/.
345# umbral-tasks = "{version}" # DB-backed background task queue with a worker process.
346# umbral-permissions = "{version}" # ContentType + Group + Permission model.
347# umbral-rls = "{version}" # Postgres row-level security policy registration.
348# umbral-cache = "{version}" # Per-request caching helper.
349# umbral-email = "{version}" # SMTP + MIME email composer + sender.
350# umbral-storage = "{version}" # Unified storage: static-file serving (prod, whitenoise-equivalent) + uploaded-file storage (local FS + S3).
351# umbral-signals = "{version}" # Pre/post save/delete signal dispatch.
352# umbral-livereload = "{version}" # Dev-only browser live-reload (SSE push + file watcher). Add `.plugin(LiveReloadPlugin::new())`.
353
354# ----- Third-party + framework runtime deps --------------------------------
355tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
356tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
357serde = {{ version = "1", features = ["derive"] }}
358chrono = {{ version = "0.4", features = ["serde"] }}
359sqlx = {{ version = "0.8", features = ["macros", "sqlite", "postgres", "chrono", "runtime-tokio"] }}
360
361# Once you `umbral startapp <plugin>` or `umbral startplugin <plugin>`, add
362# the plugin crate here:
363# {crate_name}-posts = {{ path = "plugins/posts" }}
364"#
365 );
366 let cargo_toml = match local_umbral_repo {
373 Some(repo) => localize_deps(&cargo_toml, repo),
374 None => cargo_toml,
375 };
376 write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
377
378 let main_rs = format!(
382 r#"//! {name} — generated by `umbral startproject {name}`.
383//!
384//! This `main.rs` reads like a table of contents: the App builder lists
385//! every model, plugin, and route, and the per-concern submodules below
386//! own the detail. As the project grows you slot new handlers into
387//! `views/`, new seed steps into `seed/`, and new dashboard widgets into
388//! `widgets/` — `main.rs` stays a thin wiring layer.
389//!
390//! src/
391//! main.rs — App builder + route table + boot helpers (this file)
392//! views/ — HTTP handlers, one file per resource grouping
393//! seed/ — first-run data, `seed::all()` pins dependency order
394//! widgets/ — admin dashboard widgets, one file per kind
395//! ../plugins/ — local app plugins (`umbral startapp <name>`)
396//!
397//! Run with:
398//! cargo run -- migrate # apply pending migrations (run once after checkout)
399//! cargo run -- serve # boot the HTTP server
400//!
401//! Other management commands:
402//! cargo run -- makemigrations
403//! cargo run -- showmigrations
404//! cargo run -- createsuperuser
405
406// --- Per-concern modules (the table of contents) ---------------------------
407mod seed;
408mod views;
409mod widgets;
410
411use umbral::prelude::*;
412use umbral::web::{{SlashRedirect}};
413use umbral::migrate::MigrateError;
414use umbral_auth::{{AuthPlugin, AuthUser, login_required_html}};
415use umbral_sessions::SessionsPlugin;
416use umbral_admin::AdminPlugin;
417use umbral_rest::{{RestPlugin, ResourceConfig}};
418use umbral_openapi::OpenApiPlugin;
419use umbral_security::{{SecurityConfig, SecurityPlugin}};
420
421// ---------------------------------------------------------------------------
422// Models
423// ---------------------------------------------------------------------------
424
425/// A blog post. `author` is a FK to the built-in `AuthUser` model — the
426/// migration engine emits `REFERENCES "auth_user"("id")` automatically.
427#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]
428pub struct Post {{
429 pub id: i64,
430 pub title: String,
431 pub body: String,
432 pub published: bool,
433 pub author: ForeignKey<AuthUser>,
434 pub created_at: Option<chrono::DateTime<chrono::Utc>>,
435}}
436
437// ---------------------------------------------------------------------------
438// App wiring
439// ---------------------------------------------------------------------------
440
441#[tokio::main]
442async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {{
443 // Observability: structured logging + (under the `otel` feature on
444 // `umbral-logs`) OpenTelemetry OTLP trace export. Reads RUST_LOG,
445 // UMBRAL_LOG_FORMAT=json, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME.
446 // Keep the guard alive for the whole program: it flushes the OTLP
447 // exporter on drop so trailing spans aren't lost at exit.
448 let _obs = umbral_logs::observability::init(umbral_logs::ObservabilityConfig::from_env());
449
450 let settings = Settings::from_env()?;
451 let pool = umbral::db::connect(&settings.database_url).await?;
452
453 let app = App::builder()
454 .settings(settings)
455 .database("default", pool)
456
457 // --- Models ----------------------------------------------------------
458 // AuthUser and Session are contributed by their plugins below.
459 // List your own models here.
460 .model::<Post>()
461
462 // --- Plugins ---------------------------------------------------------
463 // Auth: user table, password hashing, createsuperuser command.
464 .plugin(AuthPlugin::<AuthUser>::default())
465 // Sessions: session table + cookie middleware.
466 .plugin(SessionsPlugin::default())
467 // Admin: auto CRUD UI at /admin/ for every registered model.
468 // The dashboard mounts one builtin widget from `widgets/` so a
469 // fresh admin isn't empty — add your own with `.dashboard_section`.
470 .plugin(
471 AdminPlugin::default()
472 .dashboard_section(widgets::cards::overview_section()),
473 )
474 // REST: JSON CRUD + filtering at /api/<table>/.
475 // The Post resource has query-string filtering enabled so
476 // GET /api/post/?published=true works out of the box.
477 .plugin(
478 RestPlugin::default()
479 .resource(ResourceConfig::new("post")),
480 )
481 // OpenAPI: Swagger UI at /openapi/ (override with
482 // `.at("/api/docs")` if you prefer a different mount).
483 .plugin(OpenApiPlugin::new())
484 // Security (on by default): CSRF + clickjacking/HSTS hardening
485 // headers across the app. `/api` is exempt so token-authenticated
486 // JSON clients can POST without a browser form CSRF cookie.
487 .plugin(SecurityPlugin::with_config(SecurityConfig {{
488 csrf_exempt_paths: vec!["/api".to_string()],
489 ..Default::default()
490 }}))
491
492 // --- Templates -------------------------------------------------------
493 .templates_dir("templates")
494 .not_found_template("404.html")
495 .server_error_template("500.html")
496
497 // Redirect /foo → /foo/ (append trailing slash).
498 .slash_redirect(SlashRedirect::Append)
499
500 // --- Routes ----------------------------------------------------------
501 // The Routes builder records each (method, path) pair as you
502 // declare it, so the dev-mode 404 panel surfaces them without
503 // a parallel declaration list. Handlers live in `views/`; this
504 // table is the URL conf — open `views/mod.rs` to see them all.
505 // Per-route middleware (here, login_required_html on /dashboard)
506 // goes through the explicit `.layered(method, path, mr)` form so
507 // the layer attaches just to that handler — not all routes.
508 .routes(
509 Routes::new()
510 // Public home page.
511 .get("/", views::public::home)
512 // API: list posts as JSON (no auth required — demo).
513 .get("/api/posts", views::public::api_list_posts)
514 // Dashboard: only reachable when logged in. The
515 // login_required_html("/login") layer issues a 302 to
516 // /login?next=/dashboard/ for anonymous visitors.
517 .layered(
518 "GET",
519 "/dashboard",
520 get(views::public::dashboard).layer(login_required_html("/login")),
521 ),
522 )
523 .build()?;
524
525 // Auto-migrate + seed on boot so `cargo run -- serve` Just Works
526 // against a fresh database — but only when we're actually starting
527 // the server. Running `cargo run -- makemigrations` or `migrate`
528 // from the CLI used to silently trigger `auto_migrate()` first and
529 // then report "no changes detected" (IMP-1 in bugs/tests/testBugs.md).
530 // The guard reads `std::env::args` before dispatch picks them apart
531 // so it matches whatever subcommand the user actually typed.
532 let argv: Vec<String> = std::env::args().collect();
533 let user_invoked_cli = argv.iter().skip(1).any(|a| !a.starts_with('-'));
534 if !user_invoked_cli {{
535 auto_migrate().await?;
536 // First-run data. `seed::all()` is idempotent — see seed/mod.rs.
537 seed::all().await?;
538 }}
539
540 umbral_cli::dispatch(app).await
541}}
542
543// ---------------------------------------------------------------------------
544// Boot helpers
545// ---------------------------------------------------------------------------
546
547/// Run `makemigrations` + `migrate` on boot. Demo-only convenience.
548async fn auto_migrate() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {{
549 match umbral::migrate::make().await {{
550 Ok(paths) => {{
551 for path in paths {{
552 eprintln!("auto-migrate: wrote {{}}", path.display());
553 }}
554 }}
555 Err(MigrateError::NoChanges) => {{}}
556 Err(err) => return Err(Box::new(err)),
557 }}
558 let n = umbral::migrate::run().await?;
559 if n > 0 {{
560 eprintln!("auto-migrate: applied {{n}} migration(s)");
561 }}
562 Ok(())
563}}
564"#
565 );
566 write_file(&root, "src/main.rs", &main_rs, &mut files)?;
567
568 let views_mod_rs = r#"//! HTTP handlers, split by concern — the re-export / discoverability
572//! layer. Open this file and you see the whole web surface in a few
573//! lines: one submodule per resource grouping.
574//!
575//! Submodules:
576//! - `public` — pages anyone can hit (home, JSON listings).
577//!
578//! Add `pub mod account;` here when auth-gated views land (dashboard,
579//! /me, staff-only pages), then re-export it below so `main.rs` keeps
580//! referencing handlers as `views::public::home` without caring which
581//! file owns each one. This is a recommended convention, not a rule —
582//! the router reads handlers directly, so you're free to restructure.
583
584pub mod public;
585
586use umbral::web::StatusCode;
587
588/// Convert any displayable error into a 500 response. Shared by every
589/// handler that bubbles a `?` through `.map_err(internal_error)`.
590pub(crate) fn internal_error<E: std::fmt::Display>(err: E) -> (StatusCode, String) {
591 (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
592}
593"#;
594 write_file(&root, "src/views/mod.rs", views_mod_rs, &mut files)?;
595
596 let views_public_rs = r#"//! Public storefront views — anyone can hit these, no auth required.
600//!
601//! Every handler hands a context to `umbral::templates::render` and wraps
602//! DB / template errors with the shared `internal_error` helper.
603
604use umbral::prelude::*;
605use umbral::templates::context;
606use umbral::web::{Html, Json, StatusCode};
607
608use super::internal_error;
609use crate::Post;
610use crate::post;
611
612/// Home page. Counts published posts and renders home.html.
613pub async fn home() -> Result<Html<String>, (StatusCode, String)> {
614 let post_count = Post::objects()
615 .filter(post::PUBLISHED.eq(true))
616 .count()
617 .await
618 .map_err(internal_error)?;
619
620 let body = umbral::templates::render("home.html", &context!(post_count)).map_err(internal_error)?;
621 Ok(Html(body))
622}
623
624/// JSON list of all posts — demonstrates the ORM QuerySet.
625pub async fn api_list_posts() -> Result<Json<Vec<Post>>, (StatusCode, String)> {
626 let posts = Post::objects()
627 .order_by(post::ID.desc())
628 .fetch()
629 .await
630 .map_err(internal_error)?;
631 Ok(Json(posts))
632}
633
634/// Dashboard: only reachable when logged in (see the `login_required_html`
635/// layer in `main.rs`). The `LoggedIn<AuthUser>` extractor supplies the
636/// current user — the layer already checked the session, so this is a
637/// cheap field read, not a second DB query.
638pub async fn dashboard(
639 user: umbral_auth::LoggedIn<umbral_auth::AuthUser>,
640) -> Result<Html<String>, (StatusCode, String)> {
641 // Demonstrates a transaction: fetch the user's post list atomically.
642 let user_id = user.id;
643 let my_posts = umbral::transaction(|tx| {
644 Box::pin(async move {
645 Post::objects()
646 .filter(post::AUTHOR.eq(user_id))
647 .on_tx(tx)
648 .fetch()
649 .await
650 })
651 })
652 .await
653 .map_err(internal_error)?;
654
655 let body =
656 umbral::templates::render("dashboard.html", &context!(user, my_posts)).map_err(internal_error)?;
657 Ok(Html(body))
658}
659"#;
660 write_file(&root, "src/views/public.rs", views_public_rs, &mut files)?;
661
662 let seed_mod_rs = r#"//! Seed orchestrator — the re-export / dependency-order layer. One
666//! file per concern keeps each step small and focused; `all()` pins
667//! the order in which they run.
668//!
669//! Submodules:
670//! - `credentials` — first-run dev superuser so you can log in to
671//! /admin/ without a manual `createsuperuser`.
672//!
673//! Add a `pub mod <concern>;` here for each new seed step, then call it
674//! from `all()` in dependency order (e.g. catalog rows before the orders
675//! that reference them). The order in `all()` doubles as documentation
676//! of which step depends on which.
677
678pub mod credentials;
679
680/// Run every seed step in the right order. Each step is idempotent
681/// (short-circuits on a non-empty table), so calling `all()` on a
682/// partially-seeded DB tops up the missing pieces without re-inserting.
683pub async fn all() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
684 credentials::test_credentials().await?;
685 Ok(())
686}
687"#;
688 write_file(&root, "src/seed/mod.rs", seed_mod_rs, &mut files)?;
689
690 let seed_credentials_rs = r#"//! First-run convenience: mints a dev superuser `admin` when no users
694//! exist yet — but ONLY in the Dev environment AND only when you opt in
695//! by exporting a password. There is deliberately NO hardcoded default
696//! password: a bare `./app` launch against an empty production database
697//! must never plant a known-credential admin account.
698//!
699//! To auto-seed the dev superuser:
700//!
701//! UMBRAL_DEV_ADMIN_PASSWORD=your-dev-password cargo run
702//!
703//! Otherwise the first boot prints guidance to run
704//! `cargo run -- createsuperuser` and seeds nothing. Idempotent —
705//! subsequent boots find the user and stay quiet.
706
707use umbral::Environment;
708use umbral_auth::AuthUser;
709
710/// Env var that opts a fresh install into the dev-superuser seed and
711/// supplies its password. Unset => no seed (print guidance instead).
712const DEV_ADMIN_PASSWORD_ENV: &str = "UMBRAL_DEV_ADMIN_PASSWORD";
713
714pub async fn test_credentials() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
715 // Never mint a dev superuser outside the Dev environment — belt and
716 // suspenders on top of the caller only running us on a bare launch.
717 if umbral::settings::get().environment != Environment::Dev {
718 return Ok(());
719 }
720
721 // Idempotent: bail out the moment any user exists.
722 if AuthUser::objects().count().await? > 0 {
723 return Ok(());
724 }
725
726 // Opt-in only: without an explicit password we plant nothing. This
727 // is what keeps a known `admin`/`admin` account off every fresh DB.
728 let password = match std::env::var(DEV_ADMIN_PASSWORD_ENV) {
729 Ok(p) if !p.is_empty() => p,
730 _ => {
731 eprintln!();
732 eprintln!("No users yet, and no dev superuser was seeded. To create one:");
733 eprintln!(" • interactive: cargo run -- createsuperuser");
734 eprintln!(" • auto on boot: set {DEV_ADMIN_PASSWORD_ENV}=... and restart");
735 eprintln!(" (Dev environment only; never seeds in Prod)");
736 eprintln!();
737 return Ok(());
738 }
739 };
740
741 umbral_auth::create_superuser("admin", "admin@example.com", &password)
742 .await
743 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
744
745 eprintln!();
746 eprintln!("======================================================================");
747 eprintln!(" DEV SUPERUSER seeded (Dev environment, {DEV_ADMIN_PASSWORD_ENV} set)");
748 eprintln!("----------------------------------------------------------------------");
749 eprintln!(" Username : admin");
750 eprintln!(" Password : (the value of {DEV_ADMIN_PASSWORD_ENV})");
751 eprintln!(" Log in : http://127.0.0.1:8000/admin/");
752 eprintln!(" Remove or edit src/seed/credentials.rs before shipping.");
753 eprintln!("======================================================================");
754 eprintln!();
755
756 Ok(())
757}
758"#;
759 write_file(
760 &root,
761 "src/seed/credentials.rs",
762 seed_credentials_rs,
763 &mut files,
764 )?;
765
766 let widgets_mod_rs = r#"//! Admin dashboard widgets — the re-export / discoverability layer,
770//! grouped by kind so each file stays small and focused on one
771//! rendering shape.
772//!
773//! Submodules:
774//! - `cards` — KPI tiles + dashboard sections.
775//!
776//! Add `pub mod charts;`, `pub mod tables;`, etc. as your dashboard
777//! grows, then re-export the builders so `main.rs` calls them as
778//! `widgets::cards::overview_section()` without knowing which file owns
779//! each one. A recommended convention — restructure freely.
780
781pub mod cards;
782"#;
783 write_file(&root, "src/widgets/mod.rs", widgets_mod_rs, &mut files)?;
784
785 let widgets_cards_rs = r#"//! Dashboard widget builders. This starter re-exports one framework
790//! builtin so a fresh `/admin/` dashboard isn't empty; replace it with
791//! your own KPI tiles as the app grows.
792//!
793//! A widget is a `Widget` value handed to `WidgetSection::widget(...)`.
794//! Each section becomes one row of tiles on the admin dashboard. See
795//! `documentation/docs/v0.0.1/admin/` and the `examples/shop/src/widgets`
796//! reference for the data-closure pattern that hits the ORM.
797
798use umbral_admin::WidgetSection;
799
800/// One dashboard section wiring two framework builtins: a model-count
801/// tile and a recent-users list. Mounted from `main.rs` via
802/// `.dashboard_section(widgets::cards::overview_section())`.
803pub fn overview_section() -> WidgetSection {
804 WidgetSection::new("Overview")
805 .subtitle("Framework-wide health + recent activity")
806 .widget(umbral_admin::builtin_total_models_widget().with_span(8, 2))
807 .widget(umbral_admin::builtin_recent_users_widget().with_span(4, 2))
808}
809"#;
810 write_file(&root, "src/widgets/cards.rs", widgets_cards_rs, &mut files)?;
811
812 write_file(&root, "plugins/.gitkeep", "", &mut files)?;
816 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";
817 write_file(&root, "plugins/README.md", plugins_readme, &mut files)?;
818
819 let dev_secret = random_dev_secret_key();
825 let umbral_toml = format!(
826 r#"# umbral settings for {name}.
827# Environment variables (UMBRAL_*) override these at runtime.
828# See umbral::settings for the full schema.
829
830database_url = "sqlite://{name}.db?mode=rwc"
831
832# Bind address for `cargo run -- serve`.
833# Override via UMBRAL_BIND_ADDR or the --addr flag.
834bind_addr = "127.0.0.1:8000"
835
836environment = "Dev"
837
838# A random dev-only key, unique to this project. CHANGE THIS IN PRODUCTION —
839# the framework errors at boot if a dev key is used with environment = "Prod".
840secret_key = "{dev_secret}"
841"#
842 );
843 write_file(&root, "umbral.toml", &umbral_toml, &mut files)?;
844
845 let dot_env = format!(
849 r#"# Working .env for {name}. Do not commit this file.
850# Generate a real secret key: openssl rand -hex 32
851UMBRAL_DATABASE_URL=sqlite://{name}.db?mode=rwc
852UMBRAL_BIND_ADDR=127.0.0.1:8000
853UMBRAL_SECRET_KEY={dev_secret}
854RUST_LOG=info,umbral=debug
855"#
856 );
857 write_file(&root, ".env", &dot_env, &mut files)?;
858
859 let env_example = r#"# Copy to `.env` and source from your shell, or use a tool like direnv.
863# Settings here override the umbral.toml values at runtime.
864#
865# UMBRAL_SECRET_KEY=$(openssl rand -hex 32)
866# UMBRAL_DATABASE_URL=sqlite://my.db?mode=rwc
867# UMBRAL_BIND_ADDR=0.0.0.0:8000
868# UMBRAL_ENVIRONMENT=prod
869# RUST_LOG=info,umbral=debug
870"#;
871 write_file(&root, ".env.example", env_example, &mut files)?;
872
873 let gitignore = format!("/target\n/{name}.db*\n.env\nCargo.lock\n");
877 write_file(&root, ".gitignore", &gitignore, &mut files)?;
878
879 let readme = format!(
883 r#"# {name}
884
885A blog-style demo generated by `umbral startproject {name}`.
886
887## What's in the project
888
889| File | What it shows |
890|---|---|
891| `src/main.rs` | App wiring: models, plugins, routes, auto-migrate |
892| `Post` model | `ForeignKey<AuthUser>`, ORM QuerySet, `#[derive(Model)]` |
893| `/` route | Template rendering with context |
894| `/api/posts` | JSON endpoint via the ORM |
895| `/dashboard` | `login_required_html("/login")` layer, `LoggedIn<AuthUser>` extractor, transaction |
896| `RestPlugin` | JSON CRUD at `/api/post/` with query-string filtering (`?published=true`) |
897| `AdminPlugin` | Auto CRUD UI at `/admin/` |
898| `OpenApiPlugin` | Swagger UI at `/openapi/` |
899| `SecurityPlugin` | CSRF middleware + hardening headers, with `/api` exempt for token clients |
900
901## Running
902
903```bash
904# First run — a bare `cargo run` (no subcommand) auto-migrates the
905# database and then starts the server. Passing an explicit subcommand
906# (like `serve`) SKIPS the auto-migrate, so `serve` alone assumes the
907# schema already exists.
908cargo run
909
910# Separate steps (production pattern) — migrate explicitly, then serve:
911cargo run -- migrate
912cargo run -- serve
913
914# Create a superuser to log in to the admin:
915cargo run -- createsuperuser
916
917# Explore the scaffold:
918cargo run -- showmigrations
919cargo run -- makemigrations
920```
921
922## Where to go next
923
924- Add a plugin: `umbral startapp posts`
925- Docs: https://umbral.dev/docs/v0.0.1/
926- ORM: /docs/v0.0.1/orm/models
927- Migrations: /docs/v0.0.1/migrations/managed-migrations
928- REST: /docs/v0.0.1/plugins/rest
929- Auth: /docs/v0.0.1/plugins/auth
930"#
931 );
932 write_file(&root, "README.md", &readme, &mut files)?;
933
934 let base_html = format!(
938 r#"<!doctype html>
939<html lang="en">
940<head>
941 <meta charset="utf-8">
942 <meta name="viewport" content="width=device-width, initial-scale=1">
943 <title>{{% block title %}}{name}{{% endblock %}}</title>
944 <!-- Tailwind CSS via the play CDN: DEV ONLY. This loads a third-party
945 script on every page and is versionless, so it can't take a
946 meaningful Subresource-Integrity (SRI) hash, and the SecurityPlugin's
947 Content-Security-Policy will block it. Before production, replace it
948 with a compiled/vendored CSS bundle you serve yourself. -->
949 <script src="https://cdn.tailwindcss.com"></script>
950</head>
951<body class="bg-gray-50 text-gray-900 min-h-screen">
952 <nav class="bg-white shadow px-6 py-3 flex items-center gap-6">
953 <a href="/" class="font-bold text-lg">{name}</a>
954 <a href="/dashboard" class="text-sm text-gray-600 hover:text-gray-900">Dashboard</a>
955 <a href="/admin/" class="text-sm text-gray-600 hover:text-gray-900">Admin</a>
956 <a href="/openapi/" class="text-sm text-gray-600 hover:text-gray-900">API docs</a>
957 </nav>
958 <main class="max-w-3xl mx-auto px-4 py-8">
959 {{% block content %}}{{% endblock %}}
960 </main>
961</body>
962</html>
963"#
964 );
965 write_file(&root, "templates/base.html", &base_html, &mut files)?;
966
967 let home_html = r#"{% extends "base.html" %}
971{% block title %}Home{% endblock %}
972{% block content %}
973 <h1 class="text-3xl font-bold mb-4">Welcome</h1>
974 <p class="text-gray-600 mb-6">
975 There are <strong>{{ post_count }}</strong> published post(s).
976 </p>
977 <div class="flex gap-4">
978 <a href="/api/post/" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
979 Browse posts (JSON)
980 </a>
981 <a href="/dashboard" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">
982 Dashboard (login required)
983 </a>
984 </div>
985{% endblock %}
986"#;
987 write_file(&root, "templates/home.html", home_html, &mut files)?;
988
989 let dashboard_html = r#"{% extends "base.html" %}
993{% block title %}Dashboard{% endblock %}
994{% block content %}
995 <h1 class="text-3xl font-bold mb-2">Dashboard</h1>
996 <p class="text-gray-500 mb-6">Logged in as <strong>{{ user.username }}</strong></p>
997
998 <h2 class="text-xl font-semibold mb-3">Your posts</h2>
999 {% if my_posts %}
1000 <ul class="space-y-2">
1001 {% for post in my_posts %}
1002 <li class="bg-white rounded shadow p-4">
1003 <span class="font-medium">{{ post.title }}</span>
1004 {% if post.published %}
1005 <span class="ml-2 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">published</span>
1006 {% else %}
1007 <span class="ml-2 text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded">draft</span>
1008 {% endif %}
1009 </li>
1010 {% endfor %}
1011 </ul>
1012 {% else %}
1013 <p class="text-gray-400">No posts yet.</p>
1014 {% endif %}
1015{% endblock %}
1016"#;
1017 write_file(
1018 &root,
1019 "templates/dashboard.html",
1020 dashboard_html,
1021 &mut files,
1022 )?;
1023
1024 let not_found_html = r#"{% extends "base.html" %}
1028{% block title %}Page not found{% endblock %}
1029{% block content %}
1030 <div class="text-center py-16">
1031 <h1 class="text-6xl font-bold text-gray-300 mb-4">404</h1>
1032 <p class="text-xl text-gray-600 mb-2">Page not found</p>
1033 <p class="text-gray-400 mb-8">The path <code class="bg-gray-100 px-1 rounded">{{ path }}</code> doesn't exist.</p>
1034 <a href="/" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Go home</a>
1035 </div>
1036{% endblock %}
1037"#;
1038 write_file(&root, "templates/404.html", not_found_html, &mut files)?;
1039
1040 let server_error_html = r#"{% extends "base.html" %}
1044{% block title %}Something went wrong{% endblock %}
1045{% block content %}
1046 <div class="text-center py-16">
1047 <h1 class="text-6xl font-bold text-gray-300 mb-4">500</h1>
1048 <p class="text-xl text-gray-600 mb-2">Something went wrong</p>
1049 <p class="text-gray-400 mb-8">We've been notified and are looking into it.</p>
1050 <a href="/" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Go home</a>
1051 </div>
1052{% endblock %}
1053"#;
1054 write_file(&root, "templates/500.html", server_error_html, &mut files)?;
1055
1056 let next_steps = vec![
1057 format!("cd {name}"),
1058 "cargo run -- migrate # apply schema migrations".to_string(),
1059 "cargo run -- serve # boot the HTTP server on http://127.0.0.1:8000".to_string(),
1060 "cargo run -- createsuperuser # create an admin login".to_string(),
1061 "umbral startapp <name> # scaffold a new plugin (app)".to_string(),
1062 ];
1063
1064 Ok(ScaffoldReport {
1065 root,
1066 files,
1067 next_steps,
1068 cargo_toml_registered: None,
1069 })
1070}
1071
1072pub fn scaffold_app(
1092 name: &str,
1093 project_root: &Path,
1094 local_umbral_repo: Option<&Path>,
1095) -> Result<ScaffoldReport, ScaffoldError> {
1096 validate_name(name)?;
1097
1098 let normalized = name.replace('-', "_");
1103 if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1104 return Err(ScaffoldError::ReservedName(name.to_string()));
1105 }
1106
1107 let plugins_dir = project_root.join("plugins");
1108 let root = plugins_dir.join(name);
1109 if root.exists() {
1110 return Err(ScaffoldError::AlreadyExists(root));
1111 }
1112
1113 fs::create_dir_all(&root)?;
1114 fs::create_dir_all(root.join("src"))?;
1115
1116 let crate_name = rust_ident(name);
1117 let pascal = pascal_case_from_ident(name);
1118 let mut files = Vec::new();
1119
1120 let version = env!("CARGO_PKG_VERSION");
1121 let cargo_toml = format!(
1122 r#"[package]
1123name = "{name}"
1124version = "0.1.0"
1125edition = "2024"
1126
1127[dependencies]
1128umbral = "{version}"
1129serde = {{ version = "1", features = ["derive"] }}
1130sqlx = {{ version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] }}
1131chrono = {{ version = "0.4", features = ["serde"] }}
1132"#
1133 );
1134 let cargo_toml = match local_umbral_repo {
1135 Some(repo) => localize_deps(&cargo_toml, repo),
1136 None => cargo_toml,
1137 };
1138 write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1139
1140 let lib_rs = format!(
1141 r#"//! {pascal}Plugin — generated by `umbral startapp {name}`.
1142//!
1143//! A plugin split one file per concern:
1144//!
1145//! src/
1146//! lib.rs — the `Plugin` impl: glues models + routes together (this file)
1147//! models.rs — `#[derive(Model)]` structs (this app's tables)
1148//! views.rs — HTTP handlers
1149//! urls.rs — the URL conf: maps paths to `views::` handlers
1150//!
1151//! Wire this into your App by adding to `src/main.rs`:
1152//!
1153//! ```ignore
1154//! .plugin({crate_name}::{pascal}Plugin::default())
1155//! ```
1156//!
1157//! See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for
1158//! what each `Plugin` method does. This layout is a recommended
1159//! convention — the framework only needs a type that impls `Plugin`.
1160
1161pub mod models;
1162pub mod urls;
1163pub mod views;
1164
1165use umbral::plugin::{{AppContext, Plugin, PluginError}};
1166use umbral::web::Router;
1167
1168#[derive(Debug, Default, Clone)]
1169pub struct {pascal}Plugin;
1170
1171impl Plugin for {pascal}Plugin {{
1172 fn name(&self) -> &'static str {{
1173 "{name}"
1174 }}
1175
1176 fn models(&self) -> Vec<umbral::migrate::ModelMeta> {{
1177 // Register every model the plugin owns so makemigrations
1178 // picks them up. Uncomment + extend once you've defined one
1179 // in src/models.rs.
1180 // vec![umbral::migrate::ModelMeta::for_::<models::Example>()]
1181 Vec::new()
1182 }}
1183
1184 fn routes(&self) -> Router {{
1185 // Routes live in `urls.rs` (this app's URL conf), one place to
1186 // see every path the plugin serves.
1187 urls::router()
1188 }}
1189
1190 fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1191 Ok(())
1192 }}
1193}}
1194"#
1195 );
1196 write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1197
1198 let models_rs = format!(
1202 r#"//! Models for the `{name}` plugin.
1203//!
1204//! Declare one `#[derive(umbral::orm::Model)]` struct per database
1205//! table. Once registered via `Plugin::models()` in lib.rs, the
1206//! migration engine picks them up on the next `makemigrations`.
1207//!
1208//! ```ignore
1209//! use chrono::{{DateTime, Utc}};
1210//! use serde::{{Deserialize, Serialize}};
1211//!
1212//! #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1213//! pub struct Example {{
1214//! pub id: i64,
1215//! #[umbral(string, max_length = 200)]
1216//! pub title: String,
1217//! #[umbral(noedit)]
1218//! pub created_at: DateTime<Utc>,
1219//! }}
1220//! ```
1221"#
1222 );
1223 write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1224
1225 let views_rs = format!(
1228 r#"//! HTTP handlers for the `{name}` plugin.
1229//!
1230//! Each handler is an axum handler — return anything that implements
1231//! `IntoResponse` (`Html<String>`, `Json<T>`, `&'static str`, a
1232//! `Result<_, (StatusCode, String)>`, …). Read this app's data through
1233//! the ORM (`models::*::objects()`), never raw SQL.
1234//!
1235//! Routes that reach these handlers are declared in `urls.rs`.
1236
1237/// Sample landing handler. `GET /{name}/` hits this; rewire the path in
1238/// `urls.rs`.
1239pub async fn index() -> &'static str {{
1240 "Hello from the {name} plugin"
1241}}
1242"#
1243 );
1244 write_file(&root, "src/views.rs", &views_rs, &mut files)?;
1245
1246 let urls_rs = format!(
1249 r#"//! URL conf for the `{name}` plugin — the route table.
1250//! `router()` returns the axum `Router` that
1251//! `Plugin::routes()` in lib.rs hands back to the framework.
1252//!
1253//! Convention: `/<name>/...` for HTML pages, `/api/<name>/...` for JSON.
1254//! Map each path to a handler in `views.rs` so this file reads as the
1255//! single index of everything the plugin serves.
1256
1257use umbral::web::{{Router, get}};
1258
1259use crate::views;
1260
1261/// Build this plugin's route table. Add one `.route(path, method(handler))`
1262/// line per endpoint.
1263pub fn router() -> Router {{
1264 Router::new().route("/{name}/", get(views::index))
1265}}
1266"#
1267 );
1268 write_file(&root, "src/urls.rs", &urls_rs, &mut files)?;
1269
1270 let project_cargo_toml = project_root.join("Cargo.toml");
1275 let cargo_toml_registered = if project_cargo_toml.is_file() {
1276 match register_dep_in_cargo_toml(&project_cargo_toml, name) {
1277 Ok(added) => Some(added),
1278 Err(_) => None,
1279 }
1280 } else {
1281 None
1282 };
1283
1284 let next_steps = vec![
1285 "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1286 format!(" .plugin({crate_name}::{pascal}Plugin::default())"),
1287 "(The plugin crate was auto-added to your project dependencies.)".to_string(),
1288 "Declare your first model in src/models.rs and uncomment the".to_string(),
1289 " `Plugin::models()` line in src/lib.rs.".to_string(),
1290 "Add handlers in src/views.rs and route them in src/urls.rs.".to_string(),
1291 ];
1292
1293 Ok(ScaffoldReport {
1294 root,
1295 files,
1296 next_steps,
1297 cargo_toml_registered,
1298 })
1299}
1300
1301pub fn scaffold_plugin(
1321 name: &str,
1322 project_root: &Path,
1323 local_umbral_repo: Option<&Path>,
1324) -> Result<ScaffoldReport, ScaffoldError> {
1325 validate_name(name)?;
1326
1327 let normalized = name.replace('-', "_");
1328 if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1329 return Err(ScaffoldError::ReservedName(name.to_string()));
1330 }
1331
1332 let plugins_dir = project_root.join("plugins");
1333 let root = plugins_dir.join(name);
1334 if root.exists() {
1335 return Err(ScaffoldError::AlreadyExists(root));
1336 }
1337
1338 fs::create_dir_all(&root)?;
1339 fs::create_dir_all(root.join("src"))?;
1340
1341 let crate_name = rust_ident(name);
1342 let pascal = pascal_case_from_ident(name);
1343 let mut files = Vec::new();
1344
1345 let version = env!("CARGO_PKG_VERSION");
1351 let cargo_toml = format!(
1352 r#"[package]
1353name = "{name}"
1354version = "0.1.0"
1355edition = "2024"
1356description = "A {crate_name} plugin for umbral."
1357
1358[dependencies]
1359umbral = "{version}"
1360serde = {{ version = "1", features = ["derive"] }}
1361sqlx = {{ version = "0.8", default-features = false, features = ["macros", "runtime-tokio"] }}
1362chrono = {{ version = "0.4", features = ["serde"] }}
1363async-trait = "0.1"
1364"#
1365 );
1366 let cargo_toml = match local_umbral_repo {
1367 Some(repo) => localize_deps(&cargo_toml, repo),
1368 None => cargo_toml,
1369 };
1370 write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1371
1372 let readme = format!(
1375 r#"# {name}
1376
1377A {crate_name} plugin for [umbral](https://github.com/dalmasonto/umbral).
1378
1379Generated by `umbral startplugin {name}`.
1380
1381## What's inside
1382
1383| File | Purpose |
1384|---|---|
1385| `src/lib.rs` | `{pascal}Plugin` struct + `impl Plugin` (registers models, routes, lifecycle hooks). |
1386| `src/models.rs` | One example model showing common field types (`#[umbral(...)]` attributes for `max_length`, `choices`, FK, defaults). |
1387| `src/handlers.rs` | One example axum handler showing how to read query params and return JSON. |
1388
1389## Wiring it in
1390
1391In your project's `Cargo.toml`:
1392
1393```toml
1394[dependencies]
1395{name} = {{ path = "plugins/{name}" }}
1396```
1397
1398In `src/main.rs`:
1399
1400```rust,ignore
1401let app = umbral::App::builder()
1402 .plugin({crate_name}::{pascal}Plugin::default())
1403 // ... your other plugins
1404 .build()?;
1405```
1406
1407Then:
1408
1409```sh
1410cargo run -- makemigrations # generates 0001_initial.json from your models
1411cargo run -- migrate # applies the schema
1412cargo run -- serve # boots the HTTP server
1413```
1414
1415## Next steps
1416
1417- Add your own models in `src/models.rs` (or split into a `models/` module).
1418- Add routes in `routes()` and handlers in `src/handlers.rs`.
1419- Use `on_ready(&AppContext)` for one-shot setup work (seed default rows, register signals).
1420- See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for the full trait surface.
1421"#
1422 );
1423 write_file(&root, "README.md", &readme, &mut files)?;
1424
1425 let lib_rs = format!(
1429 r#"//! {pascal}Plugin — a richer starter scaffold for distributable
1430//! umbral plugins. Generated by `umbral startplugin {name}`.
1431//!
1432//! Wire this into your App in `src/main.rs`:
1433//!
1434//! ```ignore
1435//! .plugin({crate_name}::{pascal}Plugin::default())
1436//! ```
1437//!
1438//! See `README.md` for the full file tour.
1439
1440pub mod handlers;
1441pub mod models;
1442
1443use async_trait::async_trait;
1444use umbral::migrate::ModelMeta;
1445use umbral::orm::Model;
1446use umbral::plugin::{{AppContext, Plugin, PluginError}};
1447use umbral::web::{{Router, get}};
1448
1449/// The plugin entry point. Register one instance per `App::builder()`.
1450#[derive(Debug, Default, Clone)]
1451pub struct {pascal}Plugin;
1452
1453#[async_trait]
1454impl Plugin for {pascal}Plugin {{
1455 fn name(&self) -> &'static str {{
1456 "{name}"
1457 }}
1458
1459 /// Models the framework's migration engine should track. Each
1460 /// returned [`ModelMeta`] becomes one row in the
1461 /// `umbral_migrations` tracking table once the initial migration
1462 /// applies.
1463 fn models(&self) -> Vec<ModelMeta> {{
1464 vec![models::{pascal}Item::meta()]
1465 }}
1466
1467 /// HTTP routes contributed by this plugin. The base path is
1468 /// up to you — convention is `/<name>/...` for HTML and
1469 /// `/api/<name>/...` for JSON.
1470 fn routes(&self) -> Router {{
1471 Router::new().route("/{name}/hello", get(handlers::hello))
1472 }}
1473
1474 /// One-shot setup after `App::build()` finishes. Use this for
1475 /// seeding default rows, registering signal handlers, or any
1476 /// work that needs the database available. Sync because the
1477 /// `Plugin` trait signature is sync (BUG-3 in bugs/tests/testBugs.md);
1478 /// reach into a runtime via `tokio::runtime::Handle::current()
1479 /// .block_on(...)` if you need to await something here.
1480 fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1481 Ok(())
1482 }}
1483}}
1484"#
1485 );
1486 write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1487
1488 let models_rs = format!(
1492 r#"//! Example model. Replace or extend with your own.
1493//!
1494//! What this demonstrates:
1495//! - `#[umbral(max_length = 200)]` — DDL `VARCHAR(200)` + admin form hint.
1496//! - `#[umbral(choices)]` on an enum — closed-set column with OpenAPI
1497//! `enum` and a Postgres `CHECK (col IN (...))` constraint.
1498//! - `Option<DateTime<Utc>>` — nullable timestamptz column.
1499//! - `#[umbral(noedit)]` — read-only on admin forms; not editable via
1500//! PUT/PATCH through the REST plugin.
1501
1502use chrono::{{DateTime, Utc}};
1503use serde::{{Deserialize, Serialize}};
1504
1505/// One {crate_name} item. Replace with whatever your plugin actually
1506/// stores.
1507#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1508pub struct {pascal}Item {{
1509 /// Auto-incrementing primary key.
1510 pub id: i64,
1511
1512 /// Display title. Capped at 200 chars; admin renders a single-line
1513 /// input.
1514 #[umbral(string, max_length = 200)]
1515 pub title: String,
1516
1517 /// Lifecycle state. The choices map 1:1 to enum variants; the
1518 /// migration engine emits a CHECK constraint, the admin renders a
1519 /// `<select>`, and the OpenAPI schema gets an `enum` array.
1520 pub status: {pascal}Status,
1521
1522 /// When the item was last published. Read-only on edit forms.
1523 #[umbral(noedit)]
1524 pub published_at: Option<DateTime<Utc>>,
1525}}
1526
1527/// Lifecycle state for [`{pascal}Item`].
1528#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
1529#[sqlx(rename_all = "lowercase")]
1530#[serde(rename_all = "lowercase")]
1531pub enum {pascal}Status {{
1532 Draft,
1533 Review,
1534 Published,
1535 Archived,
1536}}
1537"#
1538 );
1539 write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1540
1541 let handlers_rs = format!(
1544 r#"//! Example HTTP handlers. Replace or extend with your own.
1545//!
1546//! `GET /{name}/hello?name=world` returns `{{"greeting": "Hello, world!"}}`.
1547
1548use serde::{{Deserialize, Serialize}};
1549use umbral::web::{{Json, extract::Query}};
1550
1551#[derive(Debug, Deserialize, Default)]
1552pub struct HelloParams {{
1553 /// Who to greet. Defaults to "{name}" when omitted.
1554 #[serde(default)]
1555 pub name: Option<String>,
1556}}
1557
1558#[derive(Debug, Serialize)]
1559pub struct HelloResponse {{
1560 pub greeting: String,
1561}}
1562
1563pub async fn hello(Query(params): Query<HelloParams>) -> Json<HelloResponse> {{
1564 let who = params.name.as_deref().unwrap_or("{name}");
1565 Json(HelloResponse {{
1566 greeting: format!("Hello, {{who}}!"),
1567 }})
1568}}
1569"#
1570 );
1571 write_file(&root, "src/handlers.rs", &handlers_rs, &mut files)?;
1572
1573 let project_cargo_toml = project_root.join("Cargo.toml");
1575 let cargo_toml_registered = if project_cargo_toml.is_file() {
1576 match register_dep_in_cargo_toml(&project_cargo_toml, name) {
1577 Ok(added) => Some(added),
1578 Err(_) => None,
1579 }
1580 } else {
1581 None
1582 };
1583
1584 let next_steps = vec![
1585 "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1586 format!(" .plugin({crate_name}::{pascal}Plugin::default())"),
1587 "Generate + apply the initial migration:".to_string(),
1588 " cargo run -- makemigrations".to_string(),
1589 " cargo run -- migrate".to_string(),
1590 format!("Then visit http://127.0.0.1:8000/{name}/hello?name=you"),
1591 ];
1592
1593 Ok(ScaffoldReport {
1594 root,
1595 files,
1596 next_steps,
1597 cargo_toml_registered,
1598 })
1599}
1600
1601fn write_file(
1604 root: &Path,
1605 rel_path: &str,
1606 contents: &str,
1607 files: &mut Vec<PathBuf>,
1608) -> io::Result<()> {
1609 let full = root.join(rel_path);
1610 if let Some(parent) = full.parent() {
1611 fs::create_dir_all(parent)?;
1612 }
1613 fs::write(&full, contents)?;
1614 files.push(PathBuf::from(rel_path));
1615 Ok(())
1616}
1617
1618pub fn register_dep_in_cargo_toml(cargo_toml_path: &Path, name: &str) -> io::Result<bool> {
1634 let text = fs::read_to_string(cargo_toml_path)?;
1635
1636 let dep_key = format!("{name} =");
1639 if text.lines().any(|l| l.trim_start().starts_with(&dep_key)) {
1640 return Ok(false);
1642 }
1643
1644 let dep_line = format!("{name} = {{ path = \"plugins/{name}\" }}\n");
1649
1650 let mut out = String::with_capacity(text.len() + dep_line.len());
1651 let mut inserted = false;
1652
1653 for line in text.split_inclusive('\n') {
1654 out.push_str(line);
1655 if !inserted && line.trim() == "[dependencies]" {
1658 out.push_str(&dep_line);
1659 inserted = true;
1660 }
1661 }
1662
1663 if !inserted {
1664 if !out.ends_with('\n') {
1667 out.push('\n');
1668 }
1669 out.push_str("\n[dependencies]\n");
1670 out.push_str(&dep_line);
1671 }
1672
1673 fs::write(cargo_toml_path, &out)?;
1674 Ok(true)
1675}
1676
1677#[cfg(test)]
1678mod tests {
1679 use super::*;
1680
1681 #[test]
1682 fn validate_name_accepts_simple_identifiers() {
1683 assert!(validate_name("posts").is_ok());
1684 assert!(validate_name("blog_engine").is_ok());
1685 assert!(validate_name("blog-engine").is_ok());
1686 assert!(validate_name("api2").is_ok());
1687 }
1688
1689 #[test]
1690 fn validate_name_rejects_empty() {
1691 assert!(validate_name("").is_err());
1692 }
1693
1694 #[test]
1695 fn validate_name_rejects_leading_digit() {
1696 assert!(validate_name("2cool").is_err());
1697 }
1698
1699 #[test]
1700 fn validate_name_rejects_special_chars() {
1701 assert!(validate_name("foo bar").is_err());
1702 assert!(validate_name("foo!bar").is_err());
1703 assert!(validate_name("foo/bar").is_err());
1704 }
1705
1706 #[test]
1707 fn pascal_case_handles_kebab_and_snake() {
1708 assert_eq!(pascal_case_from_ident("posts"), "Posts");
1709 assert_eq!(pascal_case_from_ident("blog_engine"), "BlogEngine");
1710 assert_eq!(pascal_case_from_ident("blog-engine"), "BlogEngine");
1711 assert_eq!(pascal_case_from_ident("api2"), "Api2");
1712 }
1713
1714 #[test]
1715 fn rust_ident_replaces_hyphens() {
1716 assert_eq!(rust_ident("blog-engine"), "blog_engine");
1717 assert_eq!(rust_ident("posts"), "posts");
1718 }
1719
1720 #[test]
1721 fn scaffold_app_rejects_reserved_built_in_plugin_names() {
1722 let tmp = tempfile::tempdir().expect("tempdir");
1723 for name in RESERVED_PLUGIN_NAMES {
1724 let result = scaffold_app(name, tmp.path(), None);
1725 assert!(
1726 matches!(result, Err(ScaffoldError::ReservedName(_))),
1727 "expected ReservedName error for `{name}`, got: {result:?}",
1728 );
1729 assert!(
1730 !tmp.path().join("plugins").join(name).exists(),
1731 "directory must NOT be created when name is reserved: {name}",
1732 );
1733 }
1734 }
1735
1736 #[test]
1737 fn scaffold_app_rejects_reserved_name_with_hyphen_variant() {
1738 let tmp = tempfile::tempdir().expect("tempdir");
1743 let result = scaffold_app("auth", tmp.path(), None);
1747 assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
1748 }
1749
1750 #[test]
1751 fn scaffold_app_message_lists_reserved_names() {
1752 let err = ScaffoldError::ReservedName("auth".to_string());
1753 let msg = format!("{err}");
1754 assert!(msg.contains("`auth`"), "error names the offending input");
1755 assert!(
1756 msg.contains("admin") && msg.contains("sessions") && msg.contains("permissions"),
1757 "error lists the reserved set so the user can pick again: {msg}",
1758 );
1759 }
1760
1761 #[test]
1762 fn scaffold_app_already_exists_message_says_app() {
1763 let err = ScaffoldError::AlreadyExists(PathBuf::from("plugins/blog"));
1767 let msg = format!("{err}");
1768 assert!(msg.contains("app already exists"), "got: {msg}");
1769 assert!(msg.contains("plugins/blog"), "got: {msg}");
1770 }
1771
1772 #[test]
1777 fn scaffold_plugin_writes_richer_layout() {
1778 let tmp = tempfile::tempdir().expect("tempdir");
1779 let report = scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
1780
1781 let root = tmp.path().join("plugins").join("widgets");
1782 assert!(root.is_dir());
1783
1784 for rel in [
1786 "Cargo.toml",
1787 "README.md",
1788 "src/lib.rs",
1789 "src/models.rs",
1790 "src/handlers.rs",
1791 ] {
1792 assert!(
1793 root.join(rel).exists(),
1794 "missing expected file: {rel}; got {:?}",
1795 report.files,
1796 );
1797 }
1798 }
1799
1800 #[test]
1801 fn scaffold_plugin_lib_rs_references_sibling_modules() {
1802 let tmp = tempfile::tempdir().expect("tempdir");
1803 scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
1804 let lib = fs::read_to_string(tmp.path().join("plugins/widgets/src/lib.rs")).unwrap();
1805
1806 assert!(
1807 lib.contains("pub mod handlers;"),
1808 "lib.rs must publish handlers"
1809 );
1810 assert!(
1811 lib.contains("pub mod models;"),
1812 "lib.rs must publish models"
1813 );
1814 assert!(lib.contains("WidgetsPlugin"), "PascalCase plugin name");
1815 assert!(
1816 lib.contains("models::WidgetsItem::meta()"),
1817 "models() should register the example model",
1818 );
1819 assert!(
1820 lib.contains("/widgets/hello"),
1821 "routes() should register the example handler",
1822 );
1823 }
1824
1825 #[test]
1826 fn scaffold_plugin_models_rs_uses_real_umbral_attributes() {
1827 let tmp = tempfile::tempdir().expect("tempdir");
1828 scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
1829 let models = fs::read_to_string(tmp.path().join("plugins/widgets/src/models.rs")).unwrap();
1830
1831 assert!(
1832 models.contains("umbral::orm::Model"),
1833 "model derive must reference the framework's Model trait",
1834 );
1835 assert!(
1836 models.contains("max_length = 200"),
1837 "example model should demonstrate max_length",
1838 );
1839 assert!(
1840 models.contains("WidgetsStatus"),
1841 "example model should declare a Choice enum",
1842 );
1843 assert!(
1844 models.contains("noedit"),
1845 "example model should show the noedit attribute",
1846 );
1847 }
1848
1849 #[test]
1850 fn scaffold_plugin_rejects_reserved_built_in_plugin_names() {
1851 let tmp = tempfile::tempdir().expect("tempdir");
1852 for name in RESERVED_PLUGIN_NAMES {
1853 let result = scaffold_plugin(name, tmp.path(), None);
1854 assert!(
1855 matches!(result, Err(ScaffoldError::ReservedName(_))),
1856 "expected ReservedName error for `{name}`, got: {result:?}",
1857 );
1858 }
1859 }
1860
1861 #[test]
1862 fn scaffold_plugin_refuses_to_overwrite_existing_directory() {
1863 let tmp = tempfile::tempdir().expect("tempdir");
1864 scaffold_plugin("widgets", tmp.path(), None).expect("first scaffold ok");
1865 let result = scaffold_plugin("widgets", tmp.path(), None);
1866 assert!(matches!(result, Err(ScaffoldError::AlreadyExists(_))));
1867 }
1868
1869 #[test]
1875 fn scaffold_project_writes_per_concern_tree() {
1876 let tmp = tempfile::tempdir().expect("tempdir");
1877 let report = scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
1878
1879 let root = tmp.path().join("blog");
1880 assert!(root.is_dir());
1881
1882 for rel in [
1884 "src/main.rs",
1885 "src/views/mod.rs",
1886 "src/views/public.rs",
1887 "src/seed/mod.rs",
1888 "src/seed/credentials.rs",
1889 "src/widgets/mod.rs",
1890 "src/widgets/cards.rs",
1891 "plugins/.gitkeep",
1892 "plugins/README.md",
1893 ] {
1894 assert!(
1895 root.join(rel).exists(),
1896 "missing expected file: {rel}; got {:?}",
1897 report.files,
1898 );
1899 }
1900 }
1901
1902 #[test]
1903 fn scaffold_project_mod_files_carry_orchestrator_markers() {
1904 let tmp = tempfile::tempdir().expect("tempdir");
1905 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
1906 let root = tmp.path().join("blog");
1907
1908 let views_mod = fs::read_to_string(root.join("src/views/mod.rs")).unwrap();
1909 assert!(
1910 views_mod.contains("re-export"),
1911 "views/mod.rs should describe itself as the re-export layer",
1912 );
1913 assert!(
1914 views_mod.contains("fn internal_error"),
1915 "views/mod.rs must carry the shared internal_error helper",
1916 );
1917
1918 let seed_mod = fs::read_to_string(root.join("src/seed/mod.rs")).unwrap();
1919 assert!(
1920 seed_mod.contains("pub async fn all()"),
1921 "seed/mod.rs must declare the all() orchestrator",
1922 );
1923 assert!(
1924 seed_mod.contains("credentials::test_credentials()"),
1925 "seed::all() must call the credentials step",
1926 );
1927 assert!(
1928 seed_mod.contains("dependency order") || seed_mod.contains("order in which"),
1929 "seed/mod.rs should explain it pins dependency order",
1930 );
1931
1932 let credentials = fs::read_to_string(root.join("src/seed/credentials.rs")).unwrap();
1933 assert!(
1934 credentials.contains("fn test_credentials"),
1935 "credentials.rs must define the test_credentials seed",
1936 );
1937 assert!(
1938 credentials.contains("count().await? > 0"),
1939 "test_credentials must be idempotent (short-circuit on existing users)",
1940 );
1941
1942 let widgets_mod = fs::read_to_string(root.join("src/widgets/mod.rs")).unwrap();
1943 assert!(
1944 widgets_mod.contains("pub mod cards;"),
1945 "widgets/mod.rs must publish the cards submodule",
1946 );
1947
1948 let cards = fs::read_to_string(root.join("src/widgets/cards.rs")).unwrap();
1949 assert!(
1950 cards.contains("builtin_total_models_widget")
1951 || cards.contains("builtin_recent_users_widget"),
1952 "cards.rs should re-export a builtin widget so the dashboard isn't empty",
1953 );
1954 }
1955
1956 #[test]
1957 fn scaffold_project_main_declares_modules_and_mounts_security() {
1958 let tmp = tempfile::tempdir().expect("tempdir");
1959 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
1960 let main = fs::read_to_string(tmp.path().join("blog/src/main.rs")).unwrap();
1961
1962 assert!(
1964 main.contains("mod views;"),
1965 "main.rs must declare mod views"
1966 );
1967 assert!(main.contains("mod seed;"), "main.rs must declare mod seed");
1968 assert!(
1969 main.contains("mod widgets;"),
1970 "main.rs must declare mod widgets",
1971 );
1972
1973 assert!(
1975 main.contains("views::public::home"),
1976 "route table should wire views::public::home",
1977 );
1978 assert!(
1980 main.contains("seed::all().await"),
1981 "boot should run seed::all()",
1982 );
1983
1984 assert!(
1986 main.contains("SecurityPlugin"),
1987 "SecurityPlugin must be mounted by default",
1988 );
1989 }
1990
1991 #[test]
1992 fn scaffold_project_creates_empty_plugins_dir() {
1993 let tmp = tempfile::tempdir().expect("tempdir");
1994 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
1995 let readme = fs::read_to_string(tmp.path().join("blog/plugins/README.md")).unwrap();
1996 assert!(
1997 readme.contains("umbral startapp"),
1998 "plugins/README.md should point at `umbral startapp`",
1999 );
2000 }
2001
2002 #[test]
2007 fn scaffold_app_writes_per_concern_plugin_layout() {
2008 let tmp = tempfile::tempdir().expect("tempdir");
2009 let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2010
2011 let root = tmp.path().join("plugins").join("posts");
2012 assert!(root.is_dir());
2013
2014 for rel in [
2015 "Cargo.toml",
2016 "src/lib.rs",
2017 "src/models.rs",
2018 "src/views.rs",
2019 "src/urls.rs",
2020 ] {
2021 assert!(
2022 root.join(rel).exists(),
2023 "missing expected file: {rel}; got {:?}",
2024 report.files,
2025 );
2026 }
2027 }
2028
2029 #[test]
2030 fn scaffold_app_lib_wires_urls_and_views() {
2031 let tmp = tempfile::tempdir().expect("tempdir");
2032 scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2033 let root = tmp.path().join("plugins/posts");
2034
2035 let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
2036 assert!(
2037 lib.contains("pub mod models;"),
2038 "lib.rs must publish models"
2039 );
2040 assert!(lib.contains("pub mod views;"), "lib.rs must publish views");
2041 assert!(lib.contains("pub mod urls;"), "lib.rs must publish urls");
2042 assert!(
2043 lib.contains("urls::router()"),
2044 "routes() must return urls::router()",
2045 );
2046 assert!(lib.contains("PostsPlugin"), "PascalCase plugin name");
2047
2048 let urls = fs::read_to_string(root.join("src/urls.rs")).unwrap();
2049 assert!(
2050 urls.contains("pub fn router() -> Router"),
2051 "urls.rs must expose a router() returning a Router",
2052 );
2053 assert!(
2054 urls.contains("views::index"),
2055 "urls.rs route table should map to a views:: handler",
2056 );
2057
2058 let views = fs::read_to_string(root.join("src/views.rs")).unwrap();
2059 assert!(
2060 views.contains("pub async fn index"),
2061 "views.rs should ship a sample index handler",
2062 );
2063 }
2064
2065 #[test]
2066 fn scaffold_app_auto_registers_path_dep_in_project_cargo() {
2067 let tmp = tempfile::tempdir().expect("tempdir");
2068 let project_cargo = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nserde = \"1\"\n";
2070 fs::write(tmp.path().join("Cargo.toml"), project_cargo).unwrap();
2071
2072 let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2073 assert_eq!(
2074 report.cargo_toml_registered,
2075 Some(true),
2076 "the path dep should have been added",
2077 );
2078
2079 let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
2080 assert!(
2081 cargo.contains("posts = { path = \"plugins/posts\" }"),
2082 "project Cargo.toml must gain the plugin path dep; got:\n{cargo}",
2083 );
2084
2085 let second = register_dep_in_cargo_toml(&tmp.path().join("Cargo.toml"), "posts").unwrap();
2088 assert!(!second, "re-registering the same dep must be a no-op");
2089 }
2090
2091 #[test]
2092 fn scaffold_app_still_rejects_reserved_names() {
2093 let tmp = tempfile::tempdir().expect("tempdir");
2094 let result = scaffold_app("auth", tmp.path(), None);
2095 assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
2096 }
2097
2098 #[test]
2099 fn scaffold_plugin_validates_name_like_startapp() {
2100 let tmp = tempfile::tempdir().expect("tempdir");
2101 assert!(matches!(
2102 scaffold_plugin("2cool", tmp.path(), None),
2103 Err(ScaffoldError::InvalidName(_))
2104 ));
2105 assert!(matches!(
2106 scaffold_plugin("foo bar", tmp.path(), None),
2107 Err(ScaffoldError::InvalidName(_))
2108 ));
2109 }
2110}