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