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