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