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 ReservedCommandName(String),
44 NoSuchPlugin {
48 asked: String,
49 available: Vec<String>,
50 },
51 NotAProject(PathBuf),
54 Io(io::Error),
56}
57
58pub const RESERVED_PLUGIN_NAMES: &[&str] = &[
62 "admin",
68 "analytics",
69 "app",
70 "auth",
71 "cache",
72 "email",
73 "graphql",
74 "health",
75 "livereload",
76 "logs",
77 "oauth",
78 "openapi",
79 "permissions",
80 "playground",
81 "realtime",
82 "rest",
83 "rls",
84 "security",
85 "sessions",
86 "signals",
87 "static",
88 "storage",
89 "tasks",
90 "tenants",
91];
92
93pub const RESERVED_PLUGIN_COMMAND_NAMES: &[&str] = &[
102 "clearsessions",
103 "collectstatic",
104 "createsuperuser",
105 "gen-client",
106 "migrate_schemas",
107 "startauthentication",
108 "startpagination",
109 "startpermission",
110 "startthrottle",
111 "tasks-beat",
112 "tasks-worker",
113];
114
115pub fn reserved_command_names() -> Vec<String> {
128 let mut names = crate::builtin_command_names();
129 names.extend(RESERVED_PLUGIN_COMMAND_NAMES.iter().map(|s| s.to_string()));
130 names.sort();
131 names.dedup();
132 names
133}
134
135impl std::fmt::Display for ScaffoldError {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 match self {
138 Self::InvalidName(s) => write!(
139 f,
140 "invalid name `{s}`: must be ASCII alphanumeric, underscore or hyphen, not starting with a digit",
141 ),
142 Self::AlreadyExists(p) => write!(
143 f,
144 "an app already exists at `{}`; move it aside or pick a different name",
145 p.display()
146 ),
147 Self::ReservedName(s) => write!(
148 f,
149 "`{s}` is the name of a built-in umbral plugin; pick a different name to avoid conflicts at registration time. Reserved names: {}.",
150 RESERVED_PLUGIN_NAMES.join(", ")
151 ),
152 Self::ReservedCommandName(s) => write!(
153 f,
154 "`{s}` is already an umbral command; pick another name. A command you register \
155 is dispatched BEFORE the built-in of the same name, so this one would shadow \
156 it. Taken names: {}.",
157 reserved_command_names().join(", ")
158 ),
159 Self::NoSuchPlugin { asked, available } => {
160 if available.is_empty() {
161 write!(
162 f,
163 "no plugin named `{asked}` — this project has no `plugins/` directory yet. \
164 Create one with `umbral startplugin <name>`, or place the command at the \
165 project root with `--in root`."
166 )
167 } else {
168 write!(
169 f,
170 "no plugin named `{asked}`. Available: root, {}.",
171 available.join(", ")
172 )
173 }
174 }
175 Self::NotAProject(p) => write!(
176 f,
177 "`{}` doesn't look like an umbral project — no `src/main.rs`. cd into your \
178 project directory, or pass `--path <dir>`.",
179 p.display()
180 ),
181 Self::Io(e) => write!(f, "{e}"),
182 }
183 }
184}
185
186impl std::error::Error for ScaffoldError {}
187
188impl From<io::Error> for ScaffoldError {
189 fn from(e: io::Error) -> Self {
190 Self::Io(e)
191 }
192}
193
194impl From<umbral::codegen::CodegenError> for ScaffoldError {
199 fn from(e: umbral::codegen::CodegenError) -> Self {
200 use umbral::codegen::CodegenError as C;
201 match e {
202 C::InvalidName(s) => Self::InvalidName(s),
203 C::AlreadyExists(p) => Self::AlreadyExists(p),
204 C::NoSuchPlugin { asked, available } => Self::NoSuchPlugin { asked, available },
205 C::NotAProject(p) => Self::NotAProject(p),
206 C::Io(e) => Self::Io(e),
207 }
208 }
209}
210
211#[derive(Debug, Clone)]
214pub struct ScaffoldReport {
215 pub root: PathBuf,
218 pub files: Vec<PathBuf>,
220 pub next_steps: Vec<String>,
223 pub cargo_toml_registered: Option<bool>,
229 pub registered: Option<bool>,
241}
242
243fn validate_name(name: &str) -> Result<(), ScaffoldError> {
252 umbral::codegen::validate_ident(name).map_err(Into::into)
253}
254
255pub(crate) fn localize_deps(text: &str, umbral_repo: &Path) -> String {
273 let repo_str = umbral_repo.display().to_string();
274 let mut out = String::with_capacity(text.len());
275 for line in text.split_inclusive('\n') {
276 out.push_str(&rewrite_line(line, &repo_str));
277 }
278 out
279}
280
281fn rewrite_line(line: &str, repo: &str) -> String {
288 let body_start = line
292 .char_indices()
293 .find(|(_, c)| !matches!(*c, '#' | ' ' | '\t'))
294 .map(|(i, _)| i)
295 .unwrap_or(0);
296 let body = &line[body_start..];
297 let Some(eq_idx) = body.find('=') else {
298 return line.to_string();
299 };
300 let crate_name = body[..eq_idx].trim();
301 if !crate_name.starts_with("umbral") || crate_name.contains(|c: char| c.is_whitespace()) {
304 return line.to_string();
305 }
306 let after_eq = &body[eq_idx + 1..];
310 let spec_offset = after_eq.len() - after_eq.trim_start().len();
311 let spec = after_eq.trim_start();
312 let spec_len = if let Some(rest) = spec.strip_prefix('"') {
313 match rest.find('"') {
314 Some(i) => 1 + i + 1,
315 None => return line.to_string(),
316 }
317 } else if spec.starts_with('{') {
318 match spec.find('}') {
319 Some(i) => i + 1,
320 None => return line.to_string(),
321 }
322 } else {
323 return line.to_string();
324 };
325 let spec_start = body_start + eq_idx + 1 + spec_offset;
326 let spec_end = spec_start + spec_len;
327 let subdir = match crate_name {
328 "umbral" | "umbral-cli" | "umbral-core" | "umbral-macros" | "umbral-testing" => "crates",
329 _ => "plugins",
330 };
331 let path = format!("{repo}/{subdir}/{crate_name}");
332 let prefix = &line[..spec_start];
333 let suffix = &line[spec_end..];
334 format!("{prefix}{{ path = \"{path}\" }}{suffix}")
335}
336
337fn rust_ident(name: &str) -> String {
338 name.replace('-', "_")
339}
340
341fn random_dev_secret_key() -> String {
348 use std::hash::{BuildHasher, Hasher};
349 let seed = std::collections::hash_map::RandomState::new();
353 let mut out = String::with_capacity(64);
354 for i in 0..4u64 {
355 let mut h = seed.build_hasher();
356 h.write_u64(i);
357 h.write_u64(i.wrapping_mul(0x9E37_79B9_7F4A_7C15));
358 out.push_str(&format!("{:016x}", h.finish()));
359 }
360 out
361}
362const DOCS_URL: &str = "https://dalmasonto.github.io/umbral/docs/v0.0.1";
364
365fn find_umbral_checkout(start: &Path) -> Option<PathBuf> {
416 start
417 .ancestors()
418 .find(|d| d.join("crates/umbral-core/Cargo.toml").is_file())
419 .map(Path::to_path_buf)
420}
421
422fn warn_if_run_from_a_source_checkout(name: &str, parent_dir: &Path) {
436 let from_cwd = std::env::current_dir()
437 .ok()
438 .and_then(|d| find_umbral_checkout(&d));
439 let Some(repo) = from_cwd.or_else(|| find_umbral_checkout(parent_dir)) else {
440 return;
441 };
442 let version = env!("CARGO_PKG_VERSION");
443 let repo = repo.display();
444 eprintln!(
445 "warning: running `startproject` from an umbral source checkout ({repo}) without `--local`."
446 );
447 eprintln!();
448 eprintln!(
449 " The new project will depend on the PUBLISHED umbral {version}, while your checkout is on"
450 );
451 eprintln!(
452 " whatever you have got. Any framework surface you have added since {version} was released"
453 );
454 eprintln!(
455 " will be missing, and the generated project will fail to compile against it — looking for"
456 );
457 eprintln!(" all the world like a framework bug rather than a version skew.");
458 eprintln!();
459 eprintln!(" To build against this checkout instead:");
460 eprintln!();
461 eprintln!(" umbral startproject {name} --local {repo}");
462 eprintln!();
463}
464
465pub fn scaffold_project(
466 name: &str,
467 parent_dir: &Path,
468 local_umbral_repo: Option<&Path>,
469) -> Result<ScaffoldReport, ScaffoldError> {
470 validate_name(name)?;
471
472 if local_umbral_repo.is_none() {
473 warn_if_run_from_a_source_checkout(name, parent_dir);
474 }
475
476 let root = parent_dir.join(name);
477 if root.exists() {
478 return Err(ScaffoldError::AlreadyExists(root));
479 }
480
481 fs::create_dir_all(&root)?;
482 fs::create_dir_all(root.join("src"))?;
483 fs::create_dir_all(root.join("src/views"))?;
484 fs::create_dir_all(root.join("src/seed"))?;
485 fs::create_dir_all(root.join("src/widgets"))?;
486 fs::create_dir_all(root.join("plugins"))?;
487 fs::create_dir_all(root.join("templates"))?;
488
489 let crate_name = rust_ident(name);
490 let mut files = Vec::new();
491
492 let version = env!("CARGO_PKG_VERSION");
496 let cargo_toml = format!(
497 r#"[package]
498name = "{name}"
499version = "0.1.0"
500edition = "2024"
501
502[dependencies]
503
504# ----- Framework core (always required) ------------------------------------
505umbral = "{version}"
506umbral-cli = "{version}"
507
508# ----- Active by default ---------------------------------------------------
509# What the generated `src/main.rs` wires in. Comment any of these out only
510# if you also remove the matching `.plugin(...)` line.
511umbral-auth = "{version}"
512umbral-sessions = "{version}"
513umbral-admin = "{version}"
514umbral-rest = "{version}"
515umbral-openapi = "{version}"
516umbral-security = "{version}"
517# Observability init helper (structured JSON logging). Enable the `otel`
518# feature to ALSO export OpenTelemetry traces over OTLP to a collector
519# (Jaeger/Tempo/Honeycomb): `umbral-logs = {{ version = "{version}", features = ["otel"] }}`.
520umbral-logs = "{version}"
521# Serves ./static at /static — including the compiled Tailwind bundle this
522# project ships. Not optional: the SecurityPlugin's CSP blocks third-party
523# script/style CDNs, so an app must serve its own assets.
524umbral-storage = "{version}"
525
526# ----- Available built-ins (uncomment + register in main.rs to enable) -----
527# umbral-playground = "{version}" # Interactive API playground UI (think mini-Postman) at /playground/.
528# umbral-health = "{version}" # Liveness + readiness probes at /healthz and /ready. Zero config.
529# umbral-tasks = "{version}" # DB-backed background task queue with a worker process.
530# umbral-graphql = "{version}" # A real GraphQL API derived from your models. Expose per model.
531# umbral-realtime = "{version}" # Server-Sent Events + WebSocket push, with model-change subscriptions.
532# umbral-oauth = "{version}" # Social login / account connection (Google, GitHub). See auth/oauth docs.
533# umbral-permissions = "{version}" # ContentType + Group + Permission model.
534# umbral-tenants = "{version}" # Multi-tenant schema routing (Postgres).
535# umbral-rls = "{version}" # Postgres row-level security policy registration.
536# umbral-cache = "{version}" # Per-request caching helper.
537# umbral-email = "{version}" # SMTP + MIME email composer + sender.
538# umbral-analytics = "{version}" # Pageview / event analytics (needs an API key).
539# umbral-signals = "{version}" # Pre/post save/delete signal dispatch.
540# umbral-livereload = "{version}" # Dev-only browser live-reload (SSE push + file watcher). Add `.plugin(LiveReloadPlugin::new())`.
541
542# ----- Third-party + framework runtime deps --------------------------------
543tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
544tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
545serde = {{ version = "1", features = ["derive"] }}
546chrono = {{ version = "0.4", features = ["serde"] }}
547sqlx = {{ version = "0.8", features = ["macros", "sqlite", "postgres", "chrono", "runtime-tokio"] }}
548
549# Once you `umbral startapp <plugin>` or `umbral startplugin <plugin>`, add
550# the plugin crate here:
551# {crate_name}-posts = {{ path = "plugins/posts" }}
552"#
553 );
554 let cargo_toml = match local_umbral_repo {
561 Some(repo) => localize_deps(&cargo_toml, repo),
562 None => cargo_toml,
563 };
564 write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
565
566 let main_rs = format!(
570 r#"//! {name} — application entrypoint.
571//!
572//! This `main.rs` reads like a table of contents: the App builder lists
573//! every model, plugin, and route, and the per-concern submodules below
574//! own the detail. As the project grows you slot new handlers into
575//! `views/`, new seed steps into `seed/`, and new dashboard widgets into
576//! `widgets/` — `main.rs` stays a thin wiring layer.
577//!
578//! src/
579//! main.rs — App builder + route table + boot helpers (this file)
580//! views/ — HTTP handlers, one file per resource grouping
581//! seed/ — first-run data, `seed::all()` pins dependency order
582//! widgets/ — admin dashboard widgets, one file per kind
583//! ../plugins/ — local app plugins (`umbral startapp <name>`)
584//!
585//! Run with:
586//! cargo run -- migrate # apply pending migrations (run once after checkout)
587//! cargo run -- serve # boot the HTTP server
588//!
589//! Other management commands:
590//! cargo run -- makemigrations
591//! cargo run -- showmigrations
592//! cargo run -- createsuperuser
593
594// --- Per-concern modules (the table of contents) ---------------------------
595mod seed;
596mod views;
597mod widgets;
598
599use umbral::prelude::*;
600use umbral::web::{{SlashRedirect}};
601use umbral_auth::{{AuthPlugin, AuthUser, login_required_html}};
602use umbral_sessions::SessionsPlugin;
603use umbral_admin::AdminPlugin;
604use umbral_rest::{{RestPlugin, ResourceConfig}};
605use umbral_openapi::OpenApiPlugin;
606use umbral_security::SecurityPlugin;
607use umbral_storage::StoragePlugin;
608
609// ---------------------------------------------------------------------------
610// Models
611// ---------------------------------------------------------------------------
612
613/// A blog post. `author` is a FK to the built-in `AuthUser` model — the
614/// migration engine emits `REFERENCES "auth_user"("id")` automatically.
615#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]
616pub struct Post {{
617 pub id: i64,
618 pub title: String,
619 pub body: String,
620 pub published: bool,
621 pub author: ForeignKey<AuthUser>,
622 pub created_at: Option<chrono::DateTime<chrono::Utc>>,
623}}
624
625// ---------------------------------------------------------------------------
626// App wiring
627// ---------------------------------------------------------------------------
628
629#[tokio::main]
630async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {{
631 // Observability: structured logging + (under the `otel` feature on
632 // `umbral-logs`) OpenTelemetry OTLP trace export. Reads RUST_LOG,
633 // UMBRAL_LOG_FORMAT=json, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME.
634 // Keep the guard alive for the whole program: it flushes the OTLP
635 // exporter on drop so trailing spans aren't lost at exit.
636 let _obs = umbral_logs::observability::init(umbral_logs::ObservabilityConfig::from_env());
637
638 let settings = Settings::from_env()?;
639 let pool = umbral::db::connect(&settings.database_url).await?;
640
641 let app = App::builder()
642 .settings(settings)
643 .database("default", pool)
644
645 // --- Models ----------------------------------------------------------
646 // AuthUser and Session are contributed by their plugins below.
647 // List your own models here.
648 .model::<Post>()
649
650 // --- Plugins ---------------------------------------------------------
651 // Auth: user table, password hashing, createsuperuser command.
652 // `with_form_routes()` mounts the POST form-action routes under
653 // `/auth` (login / logout / signup / …) that the login page below
654 // submits to; `AuthPlugin::new()` is the no-turbofish constructor
655 // over the built-in AuthUser (gaps4 #45).
656 .plugin(AuthPlugin::new().with_form_routes())
657 // Sessions: session table + cookie middleware.
658 .plugin(SessionsPlugin::default())
659 // Admin: auto CRUD UI at /admin/ for every registered model.
660 // The dashboard mounts one builtin widget from `widgets/` so a
661 // fresh admin isn't empty — add your own with `.dashboard_section`.
662 .plugin(
663 AdminPlugin::default()
664 .dashboard_section(widgets::cards::overview_section()),
665 )
666 // REST: JSON CRUD + filtering at /api/<table>/.
667 // The Post resource has query-string filtering enabled so
668 // GET /api/post/?published=true works out of the box.
669 .plugin(
670 RestPlugin::default()
671 .resource(ResourceConfig::new("post")),
672 )
673 // OpenAPI: Swagger UI at /openapi/ (override with
674 // `.at("/api/docs")` if you prefer a different mount).
675 .plugin(OpenApiPlugin::new())
676 // Static files: serves ./static at /static, which is where the compiled
677 // Tailwind bundle lives. Use `{{ static('css/app.css') }}` in templates
678 // rather than a hardcoded path — in production it resolves through the
679 // hashed-asset manifest so you get cache-busting for free.
680 //
681 // The same plugin also gives you uploaded-file storage (local FS or S3)
682 // when you add a FileField / ImageField: `.media("/media", "./media")`.
683 .plugin(StoragePlugin::new().static_files("/static", "./static"))
684 // Security (on by default): CSRF + clickjacking/HSTS hardening
685 // headers across the app. `/api` is exempt so token-authenticated
686 // JSON clients can POST without a browser form CSRF cookie.
687 .plugin(SecurityPlugin::new().csrf_exempt(["/api"]))
688
689 // --- Templates -------------------------------------------------------
690 .templates_dir("templates")
691 .not_found_template("404.html")
692 .server_error_template("500.html")
693
694 // Redirect /foo → /foo/ (append trailing slash).
695 .slash_redirect(SlashRedirect::Append)
696
697 // --- Routes ----------------------------------------------------------
698 // The Routes builder records each (method, path) pair as you
699 // declare it, so the dev-mode 404 panel surfaces them without
700 // a parallel declaration list. Handlers live in `views/`; this
701 // table is the URL conf — open `views/mod.rs` to see them all.
702 // Per-route middleware (here, login_required_html on /dashboard)
703 // goes through the explicit `.layered(method, path, mr)` form so
704 // the layer attaches just to that handler — not all routes.
705 .routes(
706 Routes::new()
707 // Public home page.
708 .get("/", views::public::home)
709 // API: list posts as JSON (no auth required — demo).
710 .get("/api/posts", views::public::api_list_posts)
711 // Login page. The form POSTs to /auth/login (mounted by
712 // `with_form_routes()` above); on success it redirects to
713 // `?next`. This is the page login_required_html sends
714 // anonymous visitors to.
715 .get("/login", views::public::login)
716 // Dashboard: only reachable when logged in. The
717 // login_required_html("/login") layer issues a 302 to
718 // /login?next=/dashboard/ for anonymous visitors.
719 .layered(
720 "GET",
721 "/dashboard",
722 get(views::public::dashboard).layer(login_required_html("/login")),
723 ),
724 )
725 // Auto-migrate + seed on `serve` (gaps4 #47) so `cargo run -- serve`
726 // Just Works against a fresh database, and NEVER during
727 // `makemigrations` / `migrate` / any other subcommand. In Dev,
728 // auto_migrate_on_serve also autodetects (the makemigrations half);
729 // in Prod it only applies pending migrations. `seed::all()` is
730 // idempotent — see seed/mod.rs.
731 .auto_migrate_on_serve()
732 .seed_on_serve(seed::all)
733
734 // `build_deferred`, not `build`: it wires everything (pools, model
735 // registry, router, system checks) but leaves each plugin's `on_ready`
736 // hook unfired. Those hooks seed content and backfill rows, so they must
737 // not run during `migrate` — the command whose whole job is to create the
738 // tables they write to. `dispatch` fires them once it has read argv.
739 .build_deferred()?;
740
741 umbral_cli::dispatch(app).await
742}}
743
744"#
745 );
746 write_file(&root, "src/main.rs", &main_rs, &mut files)?;
747
748 let views_mod_rs = r#"//! HTTP handlers, split by concern — the re-export / discoverability
752//! layer. Open this file and you see the whole web surface in a few
753//! lines: one submodule per resource grouping.
754//!
755//! Submodules:
756//! - `public` — pages anyone can hit (home, JSON listings).
757//!
758//! Add `pub mod account;` here when auth-gated views land (dashboard,
759//! /me, staff-only pages), then re-export it below so `main.rs` keeps
760//! referencing handlers as `views::public::home` without caring which
761//! file owns each one. This is a recommended convention, not a rule —
762//! the router reads handlers directly, so you're free to restructure.
763
764pub mod public;
765
766// No `internal_error` helper, on purpose.
767//
768// Handlers return `Result<_, umbral::web::ApiError>` and use a bare `?`. ApiError
769// converts from sqlx / WriteError / TemplateError, logs the real cause server-side, and
770// returns an opaque 500 — so a missing table or a SQL fragment never reaches the browser.
771// The `(StatusCode, String)` + `err.to_string()` pattern does the opposite.
772"#;
773 write_file(&root, "src/views/mod.rs", views_mod_rs, &mut files)?;
774
775 let views_public_rs = r#"//! Public storefront views — anyone can hit these, no auth required.
779//!
780//! Every handler returns `Result<_, ApiError>` and lets `?` do the work. `ApiError`
781//! converts from a database error, a `WriteError` and a template error, so there is no
782//! per-handler error helper to write — and a 500 logs the real cause server-side while
783//! the client gets an opaque message. Never hand `err.to_string()` to a browser: that is
784//! how table names and SQL fragments end up on someone else's screen.
785
786use umbral::prelude::*;
787use umbral::templates::context;
788
789use crate::Post;
790use crate::post;
791
792/// Home page. Counts published posts and renders home.html.
793pub async fn home() -> Result<Html<String>, ApiError> {
794 let post_count = Post::objects()
795 .filter(post::PUBLISHED.eq(true))
796 .count()
797 .await?;
798
799 let body = umbral::templates::render("home.html", &context!(post_count))?;
800 Ok(Html(body))
801}
802
803/// JSON list of all posts — demonstrates the ORM QuerySet.
804pub async fn api_list_posts() -> Result<Json<Vec<Post>>, ApiError> {
805 let posts = Post::objects().order_by(post::ID.desc()).fetch().await?;
806 Ok(Json(posts))
807}
808
809/// Login page. Renders the form in `login.html`; the form POSTs to the
810/// auth plugin's `/auth/login` action. The `?next` query param (set by
811/// the `login_required_html` layer when it bounced an anonymous visitor
812/// here) is passed through to the template so a successful login returns
813/// the user to where they were headed.
814pub async fn login(umbral::web::Query(q): umbral::web::Query<LoginQuery>) -> Result<Html<String>, ApiError> {
815 let next = q.next.unwrap_or_else(|| "/dashboard".to_string());
816 let body = umbral::templates::render("login.html", &context!(next))?;
817 Ok(Html(body))
818}
819
820/// `?next=<path>` on the login page — where to return after signing in.
821#[derive(serde::Deserialize)]
822pub struct LoginQuery {
823 pub next: Option<String>,
824}
825
826/// Dashboard: only reachable when logged in (see the `login_required_html`
827/// layer in `main.rs`). The `LoggedIn<AuthUser>` extractor supplies the
828/// current user — the layer already checked the session, so this is a
829/// cheap field read, not a second DB query.
830pub async fn dashboard(
831 user: umbral_auth::LoggedIn<umbral_auth::AuthUser>,
832) -> Result<Html<String>, ApiError> {
833 // Demonstrates a transaction: fetch the user's post list atomically.
834 let user_id = user.id;
835 let my_posts = umbral::transaction(|tx| {
836 Box::pin(async move {
837 Post::objects()
838 .filter(post::AUTHOR.eq(user_id))
839 .on_tx(tx)
840 .fetch()
841 .await
842 })
843 })
844 .await?;
845
846 let body = umbral::templates::render("dashboard.html", &context!(user, my_posts))?;
847 Ok(Html(body))
848}
849"#;
850 write_file(&root, "src/views/public.rs", views_public_rs, &mut files)?;
851
852 let seed_mod_rs = r#"//! Seed orchestrator — the re-export / dependency-order layer. One
856//! file per concern keeps each step small and focused; `all()` pins
857//! the order in which they run.
858//!
859//! Submodules:
860//! - `credentials` — first-run dev superuser so you can log in to
861//! /admin/ without a manual `createsuperuser`.
862//!
863//! Add a `pub mod <concern>;` here for each new seed step, then call it
864//! from `all()` in dependency order (e.g. catalog rows before the orders
865//! that reference them). The order in `all()` doubles as documentation
866//! of which step depends on which.
867
868pub mod credentials;
869
870/// Run every seed step in the right order. Each step is idempotent
871/// (short-circuits on a non-empty table), so calling `all()` on a
872/// partially-seeded DB tops up the missing pieces without re-inserting.
873pub async fn all() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
874 credentials::test_credentials().await?;
875 Ok(())
876}
877"#;
878 write_file(&root, "src/seed/mod.rs", seed_mod_rs, &mut files)?;
879
880 let seed_credentials_rs = r#"//! First-run convenience: mints a dev superuser `admin` when no users
884//! exist yet — but ONLY in the Dev environment AND only when you opt in
885//! by exporting a password. There is deliberately NO hardcoded default
886//! password: a bare `./app` launch against an empty production database
887//! must never plant a known-credential admin account.
888//!
889//! To auto-seed the dev superuser:
890//!
891//! UMBRAL_DEV_ADMIN_PASSWORD=your-dev-password cargo run
892//!
893//! Otherwise the first boot prints guidance to run
894//! `cargo run -- createsuperuser` and seeds nothing. Idempotent —
895//! subsequent boots find the user and stay quiet.
896
897use umbral::Environment;
898use umbral_auth::AuthUser;
899
900/// Env var that opts a fresh install into the dev-superuser seed and
901/// supplies its password. Unset => no seed (print guidance instead).
902const DEV_ADMIN_PASSWORD_ENV: &str = "UMBRAL_DEV_ADMIN_PASSWORD";
903
904pub async fn test_credentials() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
905 // Never mint a dev superuser outside the Dev environment — belt and
906 // suspenders on top of the caller only running us on a bare launch.
907 if umbral::settings::get().environment != Environment::Dev {
908 return Ok(());
909 }
910
911 // Idempotent: bail out the moment any user exists.
912 if AuthUser::objects().count().await? > 0 {
913 return Ok(());
914 }
915
916 // Opt-in only: without an explicit password we plant nothing. This
917 // is what keeps a known `admin`/`admin` account off every fresh DB.
918 let password = match std::env::var(DEV_ADMIN_PASSWORD_ENV) {
919 Ok(p) if !p.is_empty() => p,
920 _ => {
921 eprintln!();
922 eprintln!("No users yet, and no dev superuser was seeded. To create one:");
923 eprintln!(" • interactive: cargo run -- createsuperuser");
924 eprintln!(" • auto on boot: set {DEV_ADMIN_PASSWORD_ENV}=... and restart");
925 eprintln!(" (Dev environment only; never seeds in Prod)");
926 eprintln!();
927 return Ok(());
928 }
929 };
930
931 umbral_auth::create_superuser("admin", "admin@example.com", &password)
932 .await
933 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
934
935 eprintln!();
936 eprintln!("======================================================================");
937 eprintln!(" DEV SUPERUSER seeded (Dev environment, {DEV_ADMIN_PASSWORD_ENV} set)");
938 eprintln!("----------------------------------------------------------------------");
939 eprintln!(" Username : admin");
940 eprintln!(" Password : (the value of {DEV_ADMIN_PASSWORD_ENV})");
941 eprintln!(" Log in : http://127.0.0.1:8000/admin/");
942 eprintln!(" Remove or edit src/seed/credentials.rs before shipping.");
943 eprintln!("======================================================================");
944 eprintln!();
945
946 Ok(())
947}
948"#;
949 write_file(
950 &root,
951 "src/seed/credentials.rs",
952 seed_credentials_rs,
953 &mut files,
954 )?;
955
956 let widgets_mod_rs = r#"//! Admin dashboard widgets — the re-export / discoverability layer,
960//! grouped by kind so each file stays small and focused on one
961//! rendering shape.
962//!
963//! Submodules:
964//! - `cards` — KPI tiles + dashboard sections.
965//!
966//! Add `pub mod charts;`, `pub mod tables;`, etc. as your dashboard
967//! grows, then re-export the builders so `main.rs` calls them as
968//! `widgets::cards::overview_section()` without knowing which file owns
969//! each one. A recommended convention — restructure freely.
970
971pub mod cards;
972"#;
973 write_file(&root, "src/widgets/mod.rs", widgets_mod_rs, &mut files)?;
974
975 let widgets_cards_rs = r#"//! Dashboard widget builders. This starter re-exports one framework
980//! builtin so a fresh `/admin/` dashboard isn't empty; replace it with
981//! your own KPI tiles as the app grows.
982//!
983//! A widget is a `Widget` value handed to `WidgetSection::widget(...)`.
984//! Each section becomes one row of tiles on the admin dashboard. See
985//! `documentation/docs/v0.0.1/admin/` and the `examples/shop/src/widgets`
986//! reference for the data-closure pattern that hits the ORM.
987
988use umbral_admin::WidgetSection;
989
990/// One dashboard section wiring two framework builtins: a model-count
991/// tile and a recent-users list. Mounted from `main.rs` via
992/// `.dashboard_section(widgets::cards::overview_section())`.
993pub fn overview_section() -> WidgetSection {
994 WidgetSection::new("Overview")
995 .subtitle("Framework-wide health + recent activity")
996 .widget(umbral_admin::builtin_total_models_widget().with_span(8, 2))
997 .widget(umbral_admin::builtin_recent_users_widget().with_span(4, 2))
998}
999"#;
1000 write_file(&root, "src/widgets/cards.rs", widgets_cards_rs, &mut files)?;
1001
1002 write_file(&root, "plugins/.gitkeep", "", &mut files)?;
1006 let plugins_readme = "# plugins/\n\nLocal plugins go here; create one with `umbral startplugin <name>`.\nEach is its own crate (`lib.rs` + `models.rs` + `handlers.rs`) and is\nauto-wired into this project's `Cargo.toml` `[dependencies]`.\n";
1007 write_file(&root, "plugins/README.md", plugins_readme, &mut files)?;
1008
1009 let dev_secret = random_dev_secret_key();
1015 let umbral_toml = format!(
1016 r#"# umbral settings for {name}.
1017# Environment variables (UMBRAL_*) override these at runtime.
1018# See umbral::settings for the full schema.
1019
1020database_url = "sqlite://{name}.db?mode=rwc"
1021
1022# Bind address for `cargo run -- serve`.
1023# Override via UMBRAL_BIND_ADDR or the --addr flag.
1024bind_addr = "127.0.0.1:8000"
1025
1026environment = "Dev"
1027
1028# A random dev-only key, unique to this project. CHANGE THIS IN PRODUCTION —
1029# the framework errors at boot if a dev key is used with environment = "Prod".
1030secret_key = "{dev_secret}"
1031"#
1032 );
1033 write_file(&root, "umbral.toml", &umbral_toml, &mut files)?;
1034
1035 let dot_env = format!(
1039 r#"# Working .env for {name}. Do not commit this file.
1040# Generate a real secret key: openssl rand -hex 32
1041UMBRAL_DATABASE_URL=sqlite://{name}.db?mode=rwc
1042UMBRAL_BIND_ADDR=127.0.0.1:8000
1043UMBRAL_SECRET_KEY={dev_secret}
1044RUST_LOG=info,umbral=debug
1045"#
1046 );
1047 write_file(&root, ".env", &dot_env, &mut files)?;
1048
1049 let env_example = r#"# Copy to `.env` and source from your shell, or use a tool like direnv.
1053# Settings here override the umbral.toml values at runtime.
1054#
1055# UMBRAL_SECRET_KEY=$(openssl rand -hex 32)
1056# UMBRAL_DATABASE_URL=sqlite://my.db?mode=rwc
1057# UMBRAL_BIND_ADDR=0.0.0.0:8000
1058# UMBRAL_ENVIRONMENT=prod
1059# RUST_LOG=info,umbral=debug
1060"#;
1061 write_file(&root, ".env.example", env_example, &mut files)?;
1062
1063 let gitignore = format!("/target\n/{name}.db*\n.env\nCargo.lock\n");
1067 write_file(&root, ".gitignore", &gitignore, &mut files)?;
1068
1069 let readme = format!(
1073 r#"# {name}
1074
1075Your umbral app.
1076
1077It starts with one model (`Post`), an admin, a JSON API and an OpenAPI browser, so there
1078is something running from the first `cargo run`. All of it is ordinary code in this
1079repository — rename it, gut it, replace it.
1080
1081## What's in the project
1082
1083| File | What it shows |
1084|---|---|
1085| `src/main.rs` | App wiring: models, plugins, routes, auto-migrate |
1086| `Post` model | `ForeignKey<AuthUser>`, ORM QuerySet, `#[derive(Model)]` |
1087| `/` route | Template rendering with context |
1088| `/api/posts` | JSON endpoint via the ORM |
1089| `/dashboard` | `login_required_html("/login")` layer, `LoggedIn<AuthUser>` extractor, transaction |
1090| `RestPlugin` | JSON CRUD at `/api/post/` with query-string filtering (`?published=true`) |
1091| `AdminPlugin` | Auto CRUD UI at `/admin/` |
1092| `OpenApiPlugin` | Swagger UI at `/openapi/` |
1093| `SecurityPlugin` | CSRF middleware + hardening headers, with `/api` exempt for token clients |
1094
1095## Running
1096
1097```bash
1098# First run — `serve` (or a bare `cargo run`, which defaults to serve)
1099# auto-migrates AND seeds against a fresh database before starting the
1100# server (auto_migrate_on_serve + seed_on_serve in main.rs). In dev it
1101# also autodetects model changes (the makemigrations half), so editing a
1102# model and re-running `serve` just works. Schema commands like `migrate`
1103# / `makemigrations` do NOT auto-migrate — they drive the flow themselves.
1104cargo run -- serve
1105
1106# Separate steps (production pattern) — migrate explicitly, then serve:
1107cargo run -- makemigrations # autodetect model changes into a migration file
1108cargo run -- migrate # apply pending migrations
1109cargo run -- serve
1110
1111# Create a superuser (the login page above uses these credentials):
1112cargo run -- createsuperuser
1113
1114# Inspect the schema:
1115cargo run -- showmigrations
1116
1117# Background tasks: run a worker alongside the server to drain the queue.
1118# Any #[umbral::task] handler you write is discovered automatically.
1119cargo run -- tasks-worker
1120```
1121
1122## Styling
1123
1124The pages use Tailwind, compiled to `static/css/app.css` and served by the
1125StoragePlugin at `/static`. That bundle ships **prebuilt**, so this project renders
1126correctly with no `npm install`.
1127
1128You only need Node once you edit a template and reach for a utility class that is not
1129already in the bundle:
1130
1131```bash
1132cd styles
1133npm install
1134npm run build # or: npm run watch
1135```
1136
1137The palette lives in `styles/input.css` as CSS variables (`--accent` is the violet).
1138Change them there and every page follows. There is deliberately no `cdn.tailwindcss.com`
1139script: it is versionless, it pulls a third party into every page load, and it is the
1140first thing a `default-src 'self'` Content-Security-Policy blocks.
1141
1142## Where to go next
1143
1144- Add a plugin: `umbral startplugin posts`
1145- Your first app: {docs}/getting-started/your-first-app
1146- Models & the ORM: {docs}/orm/models
1147- Migrations: {docs}/migrations/managed-migrations
1148- Admin: {docs}/plugins/admin
1149- REST: {docs}/rest/index
1150- Login & signup pages: {docs}/auth/login-and-signup-pages
1151- The Plugin trait: {docs}/plugins/the-plugin-trait
1152"#,
1153 docs = DOCS_URL,
1154 );
1155 write_file(&root, "README.md", &readme, &mut files)?;
1156
1157 let initial = name
1166 .chars()
1167 .next()
1168 .map(|c| c.to_uppercase().to_string())
1169 .unwrap_or_else(|| "U".to_string());
1170 let fill = |tpl: &str| -> String {
1171 tpl.replace("__PROJECT__", name)
1172 .replace("__INITIAL__", &initial)
1173 .replace("__DOCS__", DOCS_URL)
1174 };
1175
1176 for (path, body) in [
1177 (
1178 "templates/base.html",
1179 include_str!("../assets/scaffold/templates/base.html"),
1180 ),
1181 (
1182 "templates/home.html",
1183 include_str!("../assets/scaffold/templates/home.html"),
1184 ),
1185 (
1186 "templates/dashboard.html",
1187 include_str!("../assets/scaffold/templates/dashboard.html"),
1188 ),
1189 (
1190 "templates/login.html",
1191 include_str!("../assets/scaffold/templates/login.html"),
1192 ),
1193 (
1194 "templates/404.html",
1195 include_str!("../assets/scaffold/templates/404.html"),
1196 ),
1197 (
1198 "templates/500.html",
1199 include_str!("../assets/scaffold/templates/500.html"),
1200 ),
1201 (
1202 "styles/input.css",
1203 include_str!("../assets/scaffold/styles/input.css"),
1204 ),
1205 (
1206 "styles/tailwind.config.js",
1207 include_str!("../assets/scaffold/styles/tailwind.config.js"),
1208 ),
1209 (
1210 "styles/package.json",
1211 include_str!("../assets/scaffold/styles/package.json"),
1212 ),
1213 (
1217 "static/css/app.css",
1218 include_str!("../assets/scaffold/static/css/app.css"),
1219 ),
1220 ] {
1221 write_file(&root, path, &fill(body), &mut files)?;
1222 }
1223
1224 let next_steps = vec![
1225 format!("cd {name}"),
1226 "cargo run -- migrate # apply schema migrations".to_string(),
1227 "cargo run -- serve # boot the HTTP server on http://127.0.0.1:8000".to_string(),
1228 "cargo run -- createsuperuser # create an admin login".to_string(),
1229 "umbral startplugin <name> # add a plugin to this project".to_string(),
1230 ];
1231
1232 Ok(ScaffoldReport {
1233 root,
1234 files,
1235 next_steps,
1236 cargo_toml_registered: None,
1237 registered: None,
1239 })
1240}
1241
1242pub fn scaffold_app(
1251 name: &str,
1252 project_root: &Path,
1253 local_umbral_repo: Option<&Path>,
1254) -> Result<ScaffoldReport, ScaffoldError> {
1255 scaffold_plugin(name, project_root, local_umbral_repo)
1256}
1257
1258pub fn scaffold_plugin(
1277 name: &str,
1278 project_root: &Path,
1279 local_umbral_repo: Option<&Path>,
1280) -> Result<ScaffoldReport, ScaffoldError> {
1281 let normalized = name.replace('-', "_");
1283 if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1284 return Err(ScaffoldError::ReservedName(name.to_string()));
1285 }
1286
1287 validate_name(name)?;
1288
1289 let plugins_dir = project_root.join("plugins");
1290 let root = plugins_dir.join(name);
1291 if root.exists() {
1292 return Err(ScaffoldError::AlreadyExists(root));
1293 }
1294
1295 fs::create_dir_all(&root)?;
1296 fs::create_dir_all(root.join("src"))?;
1297
1298 let crate_name = rust_ident(name);
1299 let pascal = pascal_case_from_ident(name);
1300 let mut files = Vec::new();
1301
1302 let version = env!("CARGO_PKG_VERSION");
1308 let cargo_toml = format!(
1309 r#"[package]
1310name = "{name}"
1311version = "0.1.0"
1312edition = "2024"
1313description = "A {crate_name} plugin for umbral."
1314
1315[dependencies]
1316umbral = "{version}"
1317serde = {{ version = "1", features = ["derive"] }}
1318sqlx = {{ version = "0.8", default-features = false, features = ["macros", "runtime-tokio"] }}
1319chrono = {{ version = "0.4", features = ["serde"] }}
1320async-trait = "0.1"
1321"#
1322 );
1323 let cargo_toml = match local_umbral_repo {
1324 Some(repo) => localize_deps(&cargo_toml, repo),
1325 None => cargo_toml,
1326 };
1327 write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1328
1329 let readme = format!(
1332 r#"# {name}
1333
1334A {crate_name} plugin for [umbral](https://github.com/dalmasonto/umbral).
1335
1336Generated by `umbral startplugin {name}`.
1337
1338## What's inside
1339
1340| File | Purpose |
1341|---|---|
1342| `src/lib.rs` | `{pascal}Plugin` struct + `impl Plugin` (registers models, routes, lifecycle hooks). |
1343| `src/models.rs` | One example model showing common field types (`#[umbral(...)]` attributes for `max_length`, `choices`, FK, defaults). |
1344| `src/handlers.rs` | One example axum handler showing how to read query params and return JSON. |
1345
1346## Wiring it in
1347
1348In your project's `Cargo.toml`:
1349
1350```toml
1351[dependencies]
1352{name} = {{ path = "plugins/{name}" }}
1353```
1354
1355In `src/main.rs`:
1356
1357```rust,ignore
1358let app = umbral::App::builder()
1359 .plugin({crate_name}::{pascal}Plugin::default())
1360 // ... your other plugins
1361 .build_deferred()?; // build_deferred + dispatch: lets `dispatch` fire
1362
1363umbral_cli::dispatch(app).await // on_ready AFTER a management command runs
1364```
1365
1366Then:
1367
1368```sh
1369cargo run -- makemigrations # generates 0001_initial.json from your models
1370cargo run -- migrate # applies the schema
1371cargo run -- serve # boots the HTTP server
1372```
1373
1374## Next steps
1375
1376- Add your own models in `src/models.rs` (or split into a `models/` module).
1377- Add routes in `routes()` and handlers in `src/handlers.rs`.
1378- Use `on_ready(&AppContext)` for one-shot setup work (seed default rows, register signals).
1379- See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for the full trait surface.
1380"#
1381 );
1382 write_file(&root, "README.md", &readme, &mut files)?;
1383
1384 let lib_rs = format!(
1388 r#"//! {pascal}Plugin — a distributable umbral plugin.
1389//!
1390//! Wire this into your App in `src/main.rs`:
1391//!
1392//! ```ignore
1393//! .plugin({crate_name}::{pascal}Plugin::default())
1394//! ```
1395//!
1396//! See `README.md` for the full file tour.
1397
1398pub mod handlers;
1399pub mod models;
1400
1401use async_trait::async_trait;
1402use umbral::migrate::ModelMeta;
1403use umbral::plugin::{{AppContext, Plugin, PluginError}};
1404use umbral::web::{{Router, get}};
1405
1406/// The plugin entry point. Register one instance per `App::builder()`.
1407#[derive(Debug, Default, Clone)]
1408pub struct {pascal}Plugin;
1409
1410#[async_trait]
1411impl Plugin for {pascal}Plugin {{
1412 fn name(&self) -> &'static str {{
1413 "{name}"
1414 }}
1415
1416 /// Models the framework's migration engine should track. Each
1417 /// returned [`ModelMeta`] becomes one row in the
1418 /// `umbral_migrations` tracking table once the initial migration
1419 /// applies.
1420 fn models(&self) -> Vec<ModelMeta> {{
1421 // One entry per model the plugin owns. `umbral::discovered_models!()`
1422 // finds every #[derive(Model)] in this crate automatically if you'd
1423 // rather not maintain the list by hand.
1424 vec![ModelMeta::for_::<models::{pascal}Item>()]
1425 }}
1426
1427 /// HTTP routes contributed by this plugin. The base path is
1428 /// up to you — convention is `/<name>/...` for HTML and
1429 /// `/api/<name>/...` for JSON.
1430 fn routes(&self) -> Router {{
1431 Router::new().route("/{name}/hello", get(handlers::hello))
1432 }}
1433
1434 /// One-shot setup after `App::build()` finishes. Use this for
1435 /// seeding default rows, registering signal handlers, or any
1436 /// work that needs the database available. Sync because the
1437 /// `Plugin` trait signature is sync (BUG-3 in bugs/tests/testBugs.md);
1438 /// reach into a runtime via `tokio::runtime::Handle::current()
1439 /// .block_on(...)` if you need to await something here.
1440 fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1441 Ok(())
1442 }}
1443}}
1444"#
1445 );
1446 write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1447
1448 let models_rs = format!(
1452 r#"//! Example model. Replace or extend with your own.
1453//!
1454//! What this demonstrates:
1455//! - `#[umbral(max_length = 200)]` — DDL `VARCHAR(200)` + admin form hint.
1456//! - `#[umbral(choices)]` on an enum — closed-set column with OpenAPI
1457//! `enum` and a Postgres `CHECK (col IN (...))` constraint.
1458//! - `Option<DateTime<Utc>>` — nullable timestamptz column.
1459//! - `#[umbral(noedit)]` — read-only on admin forms; not editable via
1460//! PUT/PATCH through the REST plugin.
1461
1462use chrono::{{DateTime, Utc}};
1463use serde::{{Deserialize, Serialize}};
1464
1465/// One {crate_name} item. Replace with whatever your plugin actually
1466/// stores.
1467#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1468pub struct {pascal}Item {{
1469 /// Auto-incrementing primary key.
1470 pub id: i64,
1471
1472 /// Display title. Capped at 200 chars; admin renders a single-line
1473 /// input.
1474 #[umbral(string, max_length = 200)]
1475 pub title: String,
1476
1477 /// Lifecycle state. `#[umbral(choices)]` maps the column 1:1 to the
1478 /// enum variants: the migration engine emits a CHECK constraint, the
1479 /// admin renders a `<select>`, and the OpenAPI schema gets an `enum`.
1480 #[umbral(choices)]
1481 pub status: {pascal}Status,
1482
1483 /// When the item was last published. Read-only on edit forms.
1484 #[umbral(noedit)]
1485 pub published_at: Option<DateTime<Utc>>,
1486}}
1487
1488/// Lifecycle state for [`{pascal}Item`]. The `Choices` derive teaches the
1489/// ORM the closed set; `rename_all` controls how variants serialize to the
1490/// stored string (`Draft` → `"draft"`).
1491#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, umbral::orm::Choices)]
1492#[choices(rename_all = "lowercase")]
1493pub enum {pascal}Status {{
1494 Draft,
1495 Review,
1496 Published,
1497 Archived,
1498}}
1499"#
1500 );
1501 write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1502
1503 let handlers_rs = format!(
1506 r#"//! Example HTTP handlers. Replace or extend with your own.
1507//!
1508//! `GET /{name}/hello?name=world` returns `{{"greeting": "Hello, world!"}}`.
1509
1510use serde::{{Deserialize, Serialize}};
1511use umbral::web::{{Json, Query}};
1512
1513#[derive(Debug, Deserialize, Default)]
1514pub struct HelloParams {{
1515 /// Who to greet. Defaults to "{name}" when omitted.
1516 #[serde(default)]
1517 pub name: Option<String>,
1518}}
1519
1520#[derive(Debug, Serialize)]
1521pub struct HelloResponse {{
1522 pub greeting: String,
1523}}
1524
1525pub async fn hello(Query(params): Query<HelloParams>) -> Json<HelloResponse> {{
1526 let who = params.name.as_deref().unwrap_or("{name}");
1527 Json(HelloResponse {{
1528 greeting: format!("Hello, {{who}}!"),
1529 }})
1530}}
1531"#
1532 );
1533 write_file(&root, "src/handlers.rs", &handlers_rs, &mut files)?;
1534
1535 let project_cargo_toml = project_root.join("Cargo.toml");
1537 let cargo_toml_registered = if project_cargo_toml.is_file() {
1538 register_dep_in_cargo_toml(&project_cargo_toml, name).ok()
1539 } else {
1540 None
1541 };
1542
1543 let next_steps = vec![
1544 "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1545 format!(" .plugin({crate_name}::{pascal}Plugin::default())"),
1546 "Generate + apply the initial migration:".to_string(),
1547 " cargo run -- makemigrations".to_string(),
1548 " cargo run -- migrate".to_string(),
1549 format!("Then visit http://127.0.0.1:8000/{name}/hello?name=you"),
1550 ];
1551
1552 Ok(ScaffoldReport {
1553 root,
1554 files,
1555 next_steps,
1556 cargo_toml_registered,
1557 registered: None,
1558 })
1559}
1560
1561pub use umbral::codegen::Target as CommandTarget;
1575
1576const MODS_MARKER: &str =
1578 "// umbral:startcommand — `umbral startcommand` declares new modules above this line.";
1579const REGISTRY_MARKER: &str =
1581 "// umbral:startcommand — `umbral startcommand` registers new commands above this line.";
1582
1583pub use umbral::codegen::discover_plugins;
1590
1591pub fn scaffold_command(
1621 name: &str,
1622 target: &CommandTarget,
1623 project_root: &Path,
1624) -> Result<ScaffoldReport, ScaffoldError> {
1625 if reserved_command_names().iter().any(|r| r == name) {
1628 return Err(ScaffoldError::ReservedCommandName(name.to_string()));
1629 }
1630
1631 validate_name(name)?;
1632
1633 let module = rust_ident(name);
1634 let pascal = pascal_case_from_ident(name);
1635 let struct_name = format!("{pascal}Command");
1636
1637 let resolved = umbral::codegen::resolve_target(project_root, target)?;
1642 let crate_root = resolved.crate_root.clone();
1643 let owner_file = resolved.owner_file.clone();
1644
1645 let mut files = Vec::new();
1646
1647 umbral::codegen::write_new_file(
1652 &crate_root,
1653 &format!("src/commands/{module}.rs"),
1654 &render_command_file(name, &struct_name, target),
1655 &mut files,
1656 )?;
1657
1658 let mod_rs = crate_root.join("src/commands/mod.rs");
1663 let mut next_steps: Vec<String> = Vec::new();
1664 if mod_rs.is_file() {
1665 let text = fs::read_to_string(&mod_rs)?;
1666 match append_to_registry(&text, &module, &struct_name) {
1667 Some(updated) => {
1668 fs::write(&mod_rs, updated)?;
1669 files.push(PathBuf::from("src/commands/mod.rs"));
1670 }
1671 None => {
1672 next_steps.push(
1676 "src/commands/mod.rs has no `umbral:startcommand` markers — add by hand:"
1677 .to_string(),
1678 );
1679 next_steps.push(format!(" pub mod {module};"));
1680 next_steps.push(format!(
1681 " ...and inside `all()`: Box::new({module}::{struct_name}),"
1682 ));
1683 }
1684 }
1685 } else {
1686 umbral::codegen::write_new_file(
1687 &crate_root,
1688 "src/commands/mod.rs",
1689 &render_registry_file(&module, &struct_name, target),
1690 &mut files,
1691 )?;
1692 }
1693
1694 let owner_text = fs::read_to_string(&owner_file)?;
1699 let wiring = match target {
1700 CommandTarget::Root => wire_registry_into_main(&owner_text),
1701 CommandTarget::Plugin(_) => wire_registry_into_plugin(&owner_text),
1702 };
1703 let registered = match wiring {
1707 Wiring::Updated { text, steps } => {
1708 fs::write(&owner_file, text)?;
1709 let complete = steps.is_empty();
1710 next_steps.extend(steps);
1711 complete
1712 }
1713 Wiring::AlreadyWired => true,
1714 Wiring::Manual(steps) => {
1715 next_steps.extend(steps);
1716 false
1717 }
1718 };
1719
1720 if registered {
1721 next_steps.push(format!("Run it: cargo run -- {name} --help"));
1722 } else {
1723 next_steps.push(format!(
1724 "Then run it: cargo run -- {name} --help (after the steps above — \
1725 it is NOT registered yet)"
1726 ));
1727 }
1728
1729 Ok(ScaffoldReport {
1730 root: crate_root,
1731 files,
1732 next_steps,
1733 cargo_toml_registered: None,
1734 registered: Some(registered),
1735 })
1736}
1737
1738enum Wiring {
1746 Updated { text: String, steps: Vec<String> },
1749 AlreadyWired,
1752 Manual(Vec<String>),
1755}
1756
1757fn wire_registry_into_main(text: &str) -> Wiring {
1764 let already_mod = text.lines().any(|l| l.trim() == "mod commands;");
1765 let already_registered = text.contains(".commands(commands::all())");
1766 if already_mod && already_registered {
1767 return Wiring::AlreadyWired;
1768 }
1769
1770 let mut out = text.to_string();
1771 let mut steps: Vec<String> = Vec::new();
1772
1773 if !already_mod {
1774 match umbral::codegen::declare_module(&out, "mod commands;") {
1777 Some(text) => out = text,
1778 None => steps.push("Add to src/main.rs: mod commands;".to_string()),
1779 }
1780 }
1781
1782 if !already_registered {
1783 match builder_terminal_line(&out) {
1784 Some(idx) => {
1785 let indent: String = out
1786 .lines()
1787 .nth(idx)
1788 .map(|l| l.chars().take_while(|c| c.is_whitespace()).collect())
1789 .unwrap_or_default();
1790 let call = format!(
1791 "{indent}// Project-owned management commands (`umbral startcommand`).\n\
1792 {indent}.commands(commands::all())"
1793 );
1794 out = insert_line_at_before(&out, idx, &call);
1795 }
1796 None => steps.push(
1797 "Add to the App::builder() chain in src/main.rs: .commands(commands::all())"
1798 .to_string(),
1799 ),
1800 }
1801 }
1802
1803 if out == text {
1804 if steps.is_empty() {
1805 Wiring::AlreadyWired
1806 } else {
1807 Wiring::Manual(steps)
1808 }
1809 } else {
1810 Wiring::Updated { text: out, steps }
1817 }
1818}
1819
1820fn builder_terminal_line(text: &str) -> Option<usize> {
1835 let builder_at = text.lines().position(|l| l.contains("App::builder()"))?;
1836 text.lines()
1837 .enumerate()
1838 .skip(builder_at)
1839 .find(|(_, l)| {
1840 let t = l.trim_start();
1841 t.starts_with(".build_deferred()") || t.starts_with(".build()")
1842 })
1843 .map(|(idx, _)| idx)
1844}
1845
1846fn wire_registry_into_plugin(text: &str) -> Wiring {
1855 let already_mod = text.lines().any(|l| l.trim() == "pub mod commands;");
1856 let has_commands_fn = text.contains("fn commands(");
1857 if already_mod && has_commands_fn {
1858 return Wiring::AlreadyWired;
1859 }
1860
1861 let mut out = text.to_string();
1862 let mut steps: Vec<String> = Vec::new();
1863
1864 if !already_mod {
1865 match umbral::codegen::declare_module(&out, "pub mod commands;") {
1866 Some(text) => out = text,
1867 None => steps.push("Add to src/lib.rs: pub mod commands;".to_string()),
1868 }
1869 }
1870
1871 if !has_commands_fn {
1872 match out
1880 .lines()
1881 .position(|l| l.starts_with("impl Plugin for ") && l.trim_end().ends_with('{'))
1882 {
1883 Some(idx) => {
1884 let method = "\n fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> {\n \
1885 // Every command in `src/commands/` — `umbral startcommand`\n \
1886 // appends to the registry in `commands/mod.rs`, so this line\n \
1887 // never needs to change again.\n \
1888 commands::all()\n }";
1889 out = insert_line_at(&out, idx, method);
1890 }
1891 None => steps.push(
1892 "Add to your `impl Plugin`: fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> { commands::all() }"
1893 .to_string(),
1894 ),
1895 }
1896 } else {
1897 steps.push(
1898 "Your plugin already has a `fn commands()` — make sure it returns \
1899 `commands::all()` (or extends it) so the new command is registered."
1900 .to_string(),
1901 );
1902 }
1903
1904 if out == text {
1905 if steps.is_empty() {
1908 Wiring::AlreadyWired
1909 } else {
1910 Wiring::Manual(steps)
1911 }
1912 } else {
1913 Wiring::Updated { text: out, steps }
1914 }
1915}
1916
1917fn insert_line_at(text: &str, idx: usize, line: &str) -> String {
1922 umbral::codegen::insert_line_after(text, idx, line)
1923}
1924
1925fn append_to_registry(text: &str, module: &str, struct_name: &str) -> Option<String> {
1932 let mod_line = format!("pub mod {module};");
1940 let entry = format!("Box::new({module}::{struct_name}),");
1941
1942 let mut out = text.to_string();
1943
1944 if !out.lines().any(|l| l.trim() == mod_line) {
1945 out = umbral::codegen::insert_before_marker(&out, MODS_MARKER, &mod_line)?;
1946 }
1947 if !out.lines().any(|l| l.trim() == entry) {
1948 out = umbral::codegen::insert_before_marker(
1949 &out,
1950 REGISTRY_MARKER,
1951 &format!(" {entry}"),
1952 )?;
1953 }
1954 Some(out)
1955}
1956
1957fn insert_line_at_before(text: &str, idx: usize, line: &str) -> String {
1961 umbral::codegen::insert_line_before(text, idx, line)
1962}
1963
1964fn render_registry_file(module: &str, struct_name: &str, target: &CommandTarget) -> String {
1966 let (owner, wiring) = match target {
1967 CommandTarget::Root => (
1968 "this project",
1969 "`main.rs` passes `all()` to `App::builder().commands(...)`.",
1970 ),
1971 CommandTarget::Plugin(_) => (
1972 "this plugin",
1973 "`lib.rs` returns `all()` from `Plugin::commands()`.",
1974 ),
1975 };
1976 format!(
1977 r#"//! Management commands owned by {owner} — one file per command,
1978//! and `all()` is the registry that hands them to the framework.
1979//!
1980//! {wiring}
1981//!
1982//! Rust can't discover a module by scanning this directory at runtime, so
1983//! `all()` IS the auto-detection: `umbral startcommand` appends to it for
1984//! you (that's what the marker comments below are for). You can also edit
1985//! it by hand — comment a command out and it stops existing, which is
1986//! harder to do with a magic registry you can't see.
1987
1988use umbral::cli::PluginCommand;
1989
1990pub mod {module};
1991{MODS_MARKER}
1992
1993/// Every command {owner} registers.
1994pub fn all() -> Vec<Box<dyn PluginCommand>> {{
1995 vec![
1996 Box::new({module}::{struct_name}),
1997 {REGISTRY_MARKER}
1998 ]
1999}}
2000"#
2001 )
2002}
2003
2004fn render_command_file(name: &str, struct_name: &str, target: &CommandTarget) -> String {
2008 let orm_note = match target {
2011 CommandTarget::Root => "// use crate::{Post, post};",
2012 CommandTarget::Plugin(_) => "// use crate::models::{Post, post};",
2013 };
2014 format!(
2015 r#"//! `{name}` — a management command.
2016//!
2017//! ```bash
2018//! cargo run -- {name} --help # what it takes
2019//! cargo run -- {name} hello --limit 5 --dry-run # a real run
2020//! umbral {name} hello --tag a --tag b # same thing, via the umbral CLI
2021//! ```
2022//!
2023//! Registered through `commands::all()` in `commands/mod.rs`. It runs against
2024//! a fully-built app: settings loaded, pool open, every model registered — so
2025//! the ORM works ambiently here, with no pool to thread through.
2026
2027use umbral::cli::{{CliError, PluginCommand, clap}};
2028
2029/// The `{name}` command.
2030///
2031/// A unit struct is enough when the command is stateless. It doesn't have to
2032/// be: the trait is object-safe over `&self`, so anything the command needs
2033/// configured (a prefix, a client, a channel) can live on the struct and be
2034/// passed in at registration — which is exactly why this is a trait and not a
2035/// bare `fn` pointer.
2036pub struct {struct_name};
2037
2038#[umbral::async_trait]
2039impl PluginCommand for {struct_name} {{
2040 /// Declare the command: its name, its help, and its arguments.
2041 ///
2042 /// This is plain `clap`, so everything clap can do is available here —
2043 /// value parsing and validation, defaults, conflicts, subcommands of your
2044 /// own. Note the import: `umbral::cli::clap`, the framework's own clap.
2045 /// Add `clap` to your Cargo.toml separately and a major-version bump on
2046 /// either side turns into a type mismatch a page long.
2047 fn command(&self) -> clap::Command {{
2048 clap::Command::new("{name}")
2049 // Shown next to the command in `umbral help`. Write it — a command
2050 // with no `about` lists as a dash and nobody discovers it.
2051 .about("TODO: one line on what {name} does")
2052 .long_about(
2053 "TODO: the longer story, shown on `{name} --help`. What it \
2054 changes, whether it's safe to re-run, what it needs first.",
2055 )
2056 // POSITIONAL argument — `{name} <slug>`. Required, so clap
2057 // rejects the call with a usage error if it's missing and `run`
2058 // never sees a half-formed invocation.
2059 .arg(
2060 clap::Arg::new("slug")
2061 .required(true)
2062 .help("The thing to operate on"),
2063 )
2064 // NAMED argument with a value and a default — `--limit 25` / `-l 25`.
2065 // `value_parser` is what makes it a `u64` on the other side rather
2066 // than a string you'd have to parse (and mis-parse) yourself.
2067 .arg(
2068 clap::Arg::new("limit")
2069 .long("limit")
2070 .short('l')
2071 .value_name("N")
2072 .value_parser(clap::value_parser!(u64))
2073 .default_value("25")
2074 .help("How many rows to touch at most"),
2075 )
2076 // REPEATABLE named argument — `--tag a --tag b` collects both.
2077 // `ArgAction::Append` is the difference between the second `--tag`
2078 // overwriting the first and the two accumulating.
2079 .arg(
2080 clap::Arg::new("tag")
2081 .long("tag")
2082 .value_name("TAG")
2083 .action(clap::ArgAction::Append)
2084 .help("Filter by tag. Repeat for more than one."),
2085 )
2086 // BOOLEAN flag — `--dry-run`, no value. `SetTrue` is what makes it
2087 // a flag rather than an option that demands a value.
2088 .arg(
2089 clap::Arg::new("dry-run")
2090 .long("dry-run")
2091 .action(clap::ArgAction::SetTrue)
2092 .help("Report what would change without writing anything"),
2093 )
2094 }}
2095
2096 /// Run the command. `matches` is this subcommand's own `ArgMatches` —
2097 /// clap has already validated it against `command()` above, so every
2098 /// `get_one` here is reading a value that exists and typechecked.
2099 async fn run(&self, matches: &clap::ArgMatches) -> Result<(), CliError> {{
2100 let slug = matches
2101 .get_one::<String>("slug")
2102 .expect("clap enforces `required(true)`");
2103 let limit = *matches
2104 .get_one::<u64>("limit")
2105 .expect("clap fills in `default_value`");
2106 let tags: Vec<&String> = matches
2107 .get_many::<String>("tag")
2108 .map(Iterator::collect)
2109 .unwrap_or_default();
2110 let dry_run = matches.get_flag("dry-run");
2111
2112 println!("{name}: slug={{slug}} limit={{limit}} tags={{tags:?}} dry_run={{dry_run}}");
2113
2114 // The app is already built by the time this runs, so the ORM is live:
2115 //
2116 {orm_note}
2117 //
2118 // let posts = Post::objects()
2119 // .filter(post::PUBLISHED.eq(true))
2120 // .limit(limit as i64)
2121 // .fetch()
2122 // .await?;
2123 //
2124 // if dry_run {{
2125 // println!("would touch {{}} post(s)", posts.len());
2126 // return Ok(());
2127 // }}
2128 //
2129 // `?` just works: `CliError` is a boxed error, so every umbral error
2130 // converts into it. Return `Err(...)` and the process exits non-zero,
2131 // which is what a CI step or a cron job is watching for.
2132
2133 Ok(())
2134 }}
2135}}
2136"#
2137 )
2138}
2139
2140fn write_file(
2143 root: &Path,
2144 rel_path: &str,
2145 contents: &str,
2146 files: &mut Vec<PathBuf>,
2147) -> Result<(), ScaffoldError> {
2148 umbral::codegen::write_new_file(root, rel_path, contents, files).map_err(Into::into)
2153}
2154
2155pub fn register_dep_in_cargo_toml(cargo_toml_path: &Path, name: &str) -> io::Result<bool> {
2171 umbral::codegen::ensure_dependency(
2178 cargo_toml_path,
2179 name,
2180 &format!("{{ path = \"plugins/{name}\" }}"),
2181 )
2182 .map_err(|e| match e {
2183 umbral::codegen::CodegenError::Io(e) => e,
2184 other => io::Error::new(io::ErrorKind::InvalidData, other.to_string()),
2185 })
2186}
2187#[cfg(test)]
2188mod tests {
2189 use super::*;
2190
2191 #[test]
2192 fn validate_name_accepts_simple_identifiers() {
2193 assert!(validate_name("posts").is_ok());
2194 assert!(validate_name("blog_engine").is_ok());
2195 assert!(validate_name("blog-engine").is_ok());
2196 assert!(validate_name("api2").is_ok());
2197 }
2198
2199 #[test]
2200 fn validate_name_rejects_empty() {
2201 assert!(validate_name("").is_err());
2202 }
2203
2204 #[test]
2205 fn validate_name_rejects_leading_digit() {
2206 assert!(validate_name("2cool").is_err());
2207 }
2208
2209 #[test]
2210 fn validate_name_rejects_special_chars() {
2211 assert!(validate_name("foo bar").is_err());
2212 assert!(validate_name("foo!bar").is_err());
2213 assert!(validate_name("foo/bar").is_err());
2214 }
2215
2216 #[test]
2217 fn pascal_case_handles_kebab_and_snake() {
2218 assert_eq!(pascal_case_from_ident("posts"), "Posts");
2219 assert_eq!(pascal_case_from_ident("blog_engine"), "BlogEngine");
2220 assert_eq!(pascal_case_from_ident("blog-engine"), "BlogEngine");
2221 assert_eq!(pascal_case_from_ident("api2"), "Api2");
2222 }
2223
2224 #[test]
2225 fn rust_ident_replaces_hyphens() {
2226 assert_eq!(rust_ident("blog-engine"), "blog_engine");
2227 assert_eq!(rust_ident("posts"), "posts");
2228 }
2229
2230 #[test]
2231 fn scaffold_app_rejects_reserved_built_in_plugin_names() {
2232 let tmp = tempfile::tempdir().expect("tempdir");
2233 for name in RESERVED_PLUGIN_NAMES {
2234 let result = scaffold_app(name, tmp.path(), None);
2235 assert!(
2236 matches!(result, Err(ScaffoldError::ReservedName(_))),
2237 "expected ReservedName error for `{name}`, got: {result:?}",
2238 );
2239 assert!(
2240 !tmp.path().join("plugins").join(name).exists(),
2241 "directory must NOT be created when name is reserved: {name}",
2242 );
2243 }
2244 }
2245
2246 #[test]
2247 fn scaffold_app_rejects_reserved_name_with_hyphen_variant() {
2248 let tmp = tempfile::tempdir().expect("tempdir");
2253 let result = scaffold_app("auth", tmp.path(), None);
2257 assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
2258 }
2259
2260 #[test]
2261 fn scaffold_app_message_lists_reserved_names() {
2262 let err = ScaffoldError::ReservedName("auth".to_string());
2263 let msg = format!("{err}");
2264 assert!(msg.contains("`auth`"), "error names the offending input");
2265 assert!(
2266 msg.contains("admin") && msg.contains("sessions") && msg.contains("permissions"),
2267 "error lists the reserved set so the user can pick again: {msg}",
2268 );
2269 }
2270
2271 #[test]
2272 fn scaffold_app_already_exists_message_says_app() {
2273 let err = ScaffoldError::AlreadyExists(PathBuf::from("plugins/blog"));
2277 let msg = format!("{err}");
2278 assert!(msg.contains("app already exists"), "got: {msg}");
2279 assert!(msg.contains("plugins/blog"), "got: {msg}");
2280 }
2281
2282 #[test]
2287 fn scaffold_plugin_writes_richer_layout() {
2288 let tmp = tempfile::tempdir().expect("tempdir");
2289 let report = scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2290
2291 let root = tmp.path().join("plugins").join("widgets");
2292 assert!(root.is_dir());
2293
2294 for rel in [
2296 "Cargo.toml",
2297 "README.md",
2298 "src/lib.rs",
2299 "src/models.rs",
2300 "src/handlers.rs",
2301 ] {
2302 assert!(
2303 root.join(rel).exists(),
2304 "missing expected file: {rel}; got {:?}",
2305 report.files,
2306 );
2307 }
2308 }
2309
2310 #[test]
2311 fn scaffold_plugin_lib_rs_references_sibling_modules() {
2312 let tmp = tempfile::tempdir().expect("tempdir");
2313 scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2314 let lib = fs::read_to_string(tmp.path().join("plugins/widgets/src/lib.rs")).unwrap();
2315
2316 assert!(
2317 lib.contains("pub mod handlers;"),
2318 "lib.rs must publish handlers"
2319 );
2320 assert!(
2321 lib.contains("pub mod models;"),
2322 "lib.rs must publish models"
2323 );
2324 assert!(lib.contains("WidgetsPlugin"), "PascalCase plugin name");
2325 assert!(
2326 lib.contains("ModelMeta::for_::<models::WidgetsItem>()"),
2327 "models() should register the example model",
2328 );
2329 assert!(
2330 lib.contains("/widgets/hello"),
2331 "routes() should register the example handler",
2332 );
2333 }
2334
2335 #[test]
2336 fn scaffold_plugin_models_rs_uses_real_umbral_attributes() {
2337 let tmp = tempfile::tempdir().expect("tempdir");
2338 scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2339 let models = fs::read_to_string(tmp.path().join("plugins/widgets/src/models.rs")).unwrap();
2340
2341 assert!(
2342 models.contains("umbral::orm::Model"),
2343 "model derive must reference the framework's Model trait",
2344 );
2345 assert!(
2346 models.contains("max_length = 200"),
2347 "example model should demonstrate max_length",
2348 );
2349 assert!(
2350 models.contains("WidgetsStatus"),
2351 "example model should declare a Choice enum",
2352 );
2353 assert!(
2358 models.contains("#[umbral(choices)]"),
2359 "the status field needs #[umbral(choices)] or the model won't compile",
2360 );
2361 assert!(
2362 models.contains("Choices"),
2363 "the enum needs the Choices derive, not a bare sqlx::Type",
2364 );
2365 assert!(
2366 models.contains("noedit"),
2367 "example model should show the noedit attribute",
2368 );
2369 }
2370
2371 #[test]
2372 fn scaffold_plugin_rejects_reserved_built_in_plugin_names() {
2373 let tmp = tempfile::tempdir().expect("tempdir");
2374 for name in RESERVED_PLUGIN_NAMES {
2375 let result = scaffold_plugin(name, tmp.path(), None);
2376 assert!(
2377 matches!(result, Err(ScaffoldError::ReservedName(_))),
2378 "expected ReservedName error for `{name}`, got: {result:?}",
2379 );
2380 }
2381 }
2382
2383 #[test]
2384 fn scaffold_plugin_refuses_to_overwrite_existing_directory() {
2385 let tmp = tempfile::tempdir().expect("tempdir");
2386 scaffold_plugin("widgets", tmp.path(), None).expect("first scaffold ok");
2387 let result = scaffold_plugin("widgets", tmp.path(), None);
2388 assert!(matches!(result, Err(ScaffoldError::AlreadyExists(_))));
2389 }
2390
2391 #[test]
2397 fn scaffold_project_writes_per_concern_tree() {
2398 let tmp = tempfile::tempdir().expect("tempdir");
2399 let report = scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2400
2401 let root = tmp.path().join("blog");
2402 assert!(root.is_dir());
2403
2404 for rel in [
2406 "src/main.rs",
2407 "src/views/mod.rs",
2408 "src/views/public.rs",
2409 "src/seed/mod.rs",
2410 "src/seed/credentials.rs",
2411 "src/widgets/mod.rs",
2412 "src/widgets/cards.rs",
2413 "plugins/.gitkeep",
2414 "plugins/README.md",
2415 ] {
2416 assert!(
2417 root.join(rel).exists(),
2418 "missing expected file: {rel}; got {:?}",
2419 report.files,
2420 );
2421 }
2422 }
2423
2424 #[test]
2425 fn scaffold_project_mod_files_carry_orchestrator_markers() {
2426 let tmp = tempfile::tempdir().expect("tempdir");
2427 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2428 let root = tmp.path().join("blog");
2429
2430 let views_mod = fs::read_to_string(root.join("src/views/mod.rs")).unwrap();
2431 assert!(
2432 views_mod.contains("re-export"),
2433 "views/mod.rs should describe itself as the re-export layer",
2434 );
2435 assert!(
2442 !views_mod.contains("fn internal_error"),
2443 "the scaffold must NOT generate an internal_error helper — handlers return \
2444 ApiError, which logs the cause and keeps it off the wire",
2445 );
2446 let views_public = fs::read_to_string(root.join("src/views/public.rs")).unwrap();
2447 assert!(
2448 views_public.contains("Result<Html<String>, ApiError>")
2449 && !views_public.contains("map_err(internal_error)"),
2450 "generated handlers must return ApiError and use a bare `?`",
2451 );
2452
2453 let seed_mod = fs::read_to_string(root.join("src/seed/mod.rs")).unwrap();
2454 assert!(
2455 seed_mod.contains("pub async fn all()"),
2456 "seed/mod.rs must declare the all() orchestrator",
2457 );
2458 assert!(
2459 seed_mod.contains("credentials::test_credentials()"),
2460 "seed::all() must call the credentials step",
2461 );
2462 assert!(
2463 seed_mod.contains("dependency order") || seed_mod.contains("order in which"),
2464 "seed/mod.rs should explain it pins dependency order",
2465 );
2466
2467 let credentials = fs::read_to_string(root.join("src/seed/credentials.rs")).unwrap();
2468 assert!(
2469 credentials.contains("fn test_credentials"),
2470 "credentials.rs must define the test_credentials seed",
2471 );
2472 assert!(
2473 credentials.contains("count().await? > 0"),
2474 "test_credentials must be idempotent (short-circuit on existing users)",
2475 );
2476
2477 let widgets_mod = fs::read_to_string(root.join("src/widgets/mod.rs")).unwrap();
2478 assert!(
2479 widgets_mod.contains("pub mod cards;"),
2480 "widgets/mod.rs must publish the cards submodule",
2481 );
2482
2483 let cards = fs::read_to_string(root.join("src/widgets/cards.rs")).unwrap();
2484 assert!(
2485 cards.contains("builtin_total_models_widget")
2486 || cards.contains("builtin_recent_users_widget"),
2487 "cards.rs should re-export a builtin widget so the dashboard isn't empty",
2488 );
2489 }
2490
2491 #[test]
2492 fn scaffold_project_main_declares_modules_and_mounts_security() {
2493 let tmp = tempfile::tempdir().expect("tempdir");
2494 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2495 let main = fs::read_to_string(tmp.path().join("blog/src/main.rs")).unwrap();
2496
2497 assert!(
2499 main.contains("mod views;"),
2500 "main.rs must declare mod views"
2501 );
2502 assert!(main.contains("mod seed;"), "main.rs must declare mod seed");
2503 assert!(
2504 main.contains("mod widgets;"),
2505 "main.rs must declare mod widgets",
2506 );
2507
2508 assert!(
2510 main.contains("views::public::home"),
2511 "route table should wire views::public::home",
2512 );
2513 assert!(
2516 main.contains(".seed_on_serve(seed::all)"),
2517 "boot should seed via .seed_on_serve(seed::all)",
2518 );
2519
2520 assert!(
2522 main.contains("SecurityPlugin"),
2523 "SecurityPlugin must be mounted by default",
2524 );
2525 }
2526
2527 #[test]
2528 fn scaffold_project_creates_empty_plugins_dir() {
2529 let tmp = tempfile::tempdir().expect("tempdir");
2530 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2531 let readme = fs::read_to_string(tmp.path().join("blog/plugins/README.md")).unwrap();
2532 assert!(
2533 readme.contains("umbral startplugin"),
2534 "plugins/README.md should point at the canonical `umbral startplugin`",
2535 );
2536 }
2537
2538 #[test]
2543 fn scaffold_app_forwards_to_the_plugin_generator() {
2544 let tmp = tempfile::tempdir().expect("tempdir");
2545 scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2546 let root = tmp.path().join("plugins/posts");
2547
2548 for rel in [
2550 "Cargo.toml",
2551 "README.md",
2552 "src/lib.rs",
2553 "src/models.rs",
2554 "src/handlers.rs",
2555 ] {
2556 assert!(root.join(rel).exists(), "missing expected file: {rel}");
2557 }
2558 let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
2559 assert!(lib.contains("pub mod models;"), "lib.rs publishes models");
2560 assert!(
2561 lib.contains("pub mod handlers;"),
2562 "lib.rs publishes handlers"
2563 );
2564 assert!(lib.contains("PostsPlugin"), "PascalCase plugin name");
2565 }
2566
2567 #[test]
2568 fn scaffold_app_auto_registers_path_dep_in_project_cargo() {
2569 let tmp = tempfile::tempdir().expect("tempdir");
2570 let project_cargo = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nserde = \"1\"\n";
2572 fs::write(tmp.path().join("Cargo.toml"), project_cargo).unwrap();
2573
2574 let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2575 assert_eq!(
2576 report.cargo_toml_registered,
2577 Some(true),
2578 "the path dep should have been added",
2579 );
2580
2581 let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
2582 assert!(
2583 cargo.contains("posts = { path = \"plugins/posts\" }"),
2584 "project Cargo.toml must gain the plugin path dep; got:\n{cargo}",
2585 );
2586
2587 let second = register_dep_in_cargo_toml(&tmp.path().join("Cargo.toml"), "posts").unwrap();
2590 assert!(!second, "re-registering the same dep must be a no-op");
2591 }
2592
2593 #[test]
2594 fn scaffold_app_still_rejects_reserved_names() {
2595 let tmp = tempfile::tempdir().expect("tempdir");
2596 let result = scaffold_app("auth", tmp.path(), None);
2597 assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
2598 }
2599
2600 #[test]
2601 fn scaffold_plugin_validates_name_like_startapp() {
2602 let tmp = tempfile::tempdir().expect("tempdir");
2603 assert!(matches!(
2604 scaffold_plugin("2cool", tmp.path(), None),
2605 Err(ScaffoldError::InvalidName(_))
2606 ));
2607 assert!(matches!(
2608 scaffold_plugin("foo bar", tmp.path(), None),
2609 Err(ScaffoldError::InvalidName(_))
2610 ));
2611 }
2612
2613 fn project(tmp: &tempfile::TempDir) -> PathBuf {
2622 scaffold_project("demo", tmp.path(), None).expect("scaffold_project");
2623 tmp.path().join("demo")
2624 }
2625
2626 fn read(root: &Path, rel: &str) -> String {
2627 fs::read_to_string(root.join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}"))
2628 }
2629
2630 #[test]
2631 fn startcommand_root_writes_the_command_and_wires_main() {
2632 let tmp = tempfile::tempdir().expect("tempdir");
2633 let root = project(&tmp);
2634
2635 let report =
2636 scaffold_command("backfill_slugs", &CommandTarget::Root, &root).expect("scaffold");
2637 assert!(
2638 report
2639 .files
2640 .contains(&PathBuf::from("src/commands/backfill_slugs.rs"))
2641 );
2642 assert!(report.files.contains(&PathBuf::from("src/commands/mod.rs")));
2643
2644 let cmd = read(&root, "src/commands/backfill_slugs.rs");
2646 assert!(cmd.contains("pub struct BackfillSlugsCommand;"), "{cmd}");
2647 assert!(
2648 cmd.contains("impl PluginCommand for BackfillSlugsCommand"),
2649 "{cmd}"
2650 );
2651 assert!(
2652 cmd.contains("use umbral::cli::{CliError, PluginCommand, clap};"),
2653 "the generated file must import the framework's clap, not its own: {cmd}"
2654 );
2655 assert!(
2656 cmd.contains(r#"clap::Command::new("backfill_slugs")"#),
2657 "{cmd}"
2658 );
2659
2660 let registry = read(&root, "src/commands/mod.rs");
2662 assert!(registry.contains("pub mod backfill_slugs;"), "{registry}");
2663 assert!(
2664 registry.contains("Box::new(backfill_slugs::BackfillSlugsCommand),"),
2665 "{registry}"
2666 );
2667
2668 let main_rs = read(&root, "src/main.rs");
2670 assert!(
2671 main_rs.contains("mod commands;"),
2672 "main.rs never declared the module: {main_rs}"
2673 );
2674 assert!(
2675 main_rs.contains(".commands(commands::all())"),
2676 "main.rs never registered the command registry: {main_rs}"
2677 );
2678 let reg = main_rs.find(".commands(commands::all())").unwrap();
2680 let build = main_rs.find(".build_deferred()").unwrap();
2681 assert!(
2682 reg < build,
2683 "`.commands(...)` landed after `.build_deferred()`, which doesn't compile"
2684 );
2685 }
2686
2687 #[test]
2693 fn startcommand_second_command_appends_and_leaves_main_alone() {
2694 let tmp = tempfile::tempdir().expect("tempdir");
2695 let root = project(&tmp);
2696
2697 scaffold_command("backfill_slugs", &CommandTarget::Root, &root).expect("first");
2698 let main_after_first = read(&root, "src/main.rs");
2699 scaffold_command("import-prices", &CommandTarget::Root, &root).expect("second");
2700 let main_after_second = read(&root, "src/main.rs");
2701
2702 assert_eq!(
2703 main_after_first, main_after_second,
2704 "the second startcommand edited main.rs again"
2705 );
2706
2707 let registry = read(&root, "src/commands/mod.rs");
2708 assert!(registry.contains("pub mod backfill_slugs;"), "{registry}");
2709 assert!(registry.contains("pub mod import_prices;"), "{registry}");
2712 assert!(
2713 registry.contains("Box::new(import_prices::ImportPricesCommand),"),
2714 "{registry}"
2715 );
2716 let cmd = read(&root, "src/commands/import_prices.rs");
2717 assert!(
2718 cmd.contains(r#"clap::Command::new("import-prices")"#),
2719 "the clap name should be what the user typed, hyphens and all: {cmd}"
2720 );
2721
2722 assert_eq!(
2723 main_after_second
2724 .matches(".commands(commands::all())")
2725 .count(),
2726 1,
2727 "main.rs registered the registry twice"
2728 );
2729 assert_eq!(
2730 main_after_second.matches("\nmod commands;").count(),
2731 1,
2732 "main.rs declared `mod commands;` twice"
2733 );
2734 }
2735
2736 #[test]
2737 fn startcommand_plugin_writes_the_command_and_wires_the_plugin() {
2738 let tmp = tempfile::tempdir().expect("tempdir");
2739 let root = project(&tmp);
2740 scaffold_app("blog", &root, None).expect("scaffold_app");
2741
2742 scaffold_command("reindex", &CommandTarget::Plugin("blog".to_string()), &root)
2743 .expect("scaffold");
2744
2745 let plugin_root = root.join("plugins/blog");
2746 let registry = read(&plugin_root, "src/commands/mod.rs");
2747 assert!(registry.contains("pub mod reindex;"), "{registry}");
2748 assert!(
2749 registry.contains("Box::new(reindex::ReindexCommand),"),
2750 "{registry}"
2751 );
2752
2753 let lib_rs = read(&plugin_root, "src/lib.rs");
2754 assert!(lib_rs.contains("pub mod commands;"), "{lib_rs}");
2755 assert!(
2756 lib_rs.contains("fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>>"),
2757 "the plugin never got a `Plugin::commands()` impl: {lib_rs}"
2758 );
2759 assert!(
2760 lib_rs.contains("commands::all()"),
2761 "the impl doesn't return the registry: {lib_rs}"
2762 );
2763 let impl_start = lib_rs.find("impl Plugin for BlogPlugin {").unwrap();
2765 let method = lib_rs.find("fn commands(&self)").unwrap();
2766 assert!(method > impl_start, "the method landed outside the impl");
2767 }
2768
2769 #[test]
2776 fn startcommand_rejects_a_rust_keyword_as_a_command_name() {
2777 let tmp = tempfile::tempdir().expect("tempdir");
2778 let root = project(&tmp);
2779 for kw in ["move", "type", "match"] {
2780 assert!(
2781 matches!(
2782 scaffold_command(kw, &CommandTarget::Root, &root),
2783 Err(ScaffoldError::InvalidName(_))
2784 ),
2785 "`{kw}` is a Rust keyword — `pub mod {kw};` does not parse"
2786 );
2787 }
2788 }
2789
2790 #[test]
2795 fn startcommand_rejects_a_builtin_command_name() {
2796 let tmp = tempfile::tempdir().expect("tempdir");
2797 let root = project(&tmp);
2798 for taken in ["migrate", "serve", "makemigrations", "dev"] {
2799 assert!(
2800 matches!(
2801 scaffold_command(taken, &CommandTarget::Root, &root),
2802 Err(ScaffoldError::ReservedCommandName(_))
2803 ),
2804 "`{taken}` is a built-in and must be rejected"
2805 );
2806 }
2807 }
2808
2809 #[test]
2813 fn startcommand_rejects_a_builtin_plugin_command_name() {
2814 let tmp = tempfile::tempdir().expect("tempdir");
2815 let root = project(&tmp);
2816 assert!(matches!(
2817 scaffold_command("createsuperuser", &CommandTarget::Root, &root),
2818 Err(ScaffoldError::ReservedCommandName(_))
2819 ));
2820 assert!(matches!(
2821 scaffold_command("tasks-worker", &CommandTarget::Root, &root),
2822 Err(ScaffoldError::ReservedCommandName(_))
2823 ));
2824 }
2825
2826 #[test]
2830 fn reserved_command_names_are_read_off_the_real_parser() {
2831 let names = reserved_command_names();
2832 for expected in ["migrate", "serve", "typegen", "squashmigrations", "help"] {
2833 assert!(
2834 names.iter().any(|n| n == expected),
2835 "`{expected}` missing from the reserved set: {names:?}"
2836 );
2837 }
2838 }
2839
2840 #[test]
2841 fn startcommand_rejects_an_unknown_plugin_and_lists_the_real_ones() {
2842 let tmp = tempfile::tempdir().expect("tempdir");
2843 let root = project(&tmp);
2844 scaffold_app("blog", &root, None).expect("scaffold_app");
2845
2846 let err = scaffold_command("reindex", &CommandTarget::Plugin("blgo".into()), &root)
2847 .expect_err("a typo'd plugin name must not scaffold anything");
2848 match err {
2849 ScaffoldError::NoSuchPlugin { asked, available } => {
2850 assert_eq!(asked, "blgo");
2851 assert_eq!(available, vec!["blog".to_string()]);
2852 }
2853 other => panic!("expected NoSuchPlugin, got {other:?}"),
2854 }
2855 }
2856
2857 #[test]
2858 fn startcommand_refuses_to_overwrite_an_existing_command() {
2859 let tmp = tempfile::tempdir().expect("tempdir");
2860 let root = project(&tmp);
2861 scaffold_command("reindex", &CommandTarget::Root, &root).expect("first");
2862 assert!(matches!(
2863 scaffold_command("reindex", &CommandTarget::Root, &root),
2864 Err(ScaffoldError::AlreadyExists(_))
2865 ));
2866 }
2867
2868 #[test]
2869 fn startcommand_outside_a_project_says_so() {
2870 let tmp = tempfile::tempdir().expect("tempdir");
2871 assert!(matches!(
2872 scaffold_command("reindex", &CommandTarget::Root, tmp.path()),
2873 Err(ScaffoldError::NotAProject(_))
2874 ));
2875 }
2876
2877 #[test]
2878 fn discover_plugins_lists_plugin_crates_only() {
2879 let tmp = tempfile::tempdir().expect("tempdir");
2880 let root = project(&tmp);
2881 assert!(discover_plugins(&root).is_empty());
2883
2884 scaffold_app("blog", &root, None).expect("scaffold_app");
2885 scaffold_app("shop", &root, None).expect("scaffold_app");
2886 fs::create_dir_all(root.join("plugins/notacrate")).unwrap();
2889
2890 assert_eq!(
2891 discover_plugins(&root),
2892 vec!["blog".to_string(), "shop".to_string()]
2893 );
2894 }
2895
2896 #[test]
2907 fn startcommand_does_not_splice_into_someone_elses_builder_chain() {
2908 let tmp = tempfile::tempdir().expect("tempdir");
2909 let root = project(&tmp);
2910
2911 let main_rs = root.join("src/main.rs");
2912 let original = read(&root, "src/main.rs");
2913 let with_client = original.replace(
2916 " let settings = Settings::from_env()?;",
2917 " let client = reqwest::Client::builder()\n\
2918 \x20 .timeout(Duration::from_secs(5))\n\
2919 \x20 .build()?;\n\n\
2920 \x20 let settings = Settings::from_env()?;",
2921 );
2922 assert_ne!(with_client, original, "fixture did not apply");
2923 fs::write(&main_rs, &with_client).unwrap();
2924
2925 scaffold_command("import_prices", &CommandTarget::Root, &root).expect("scaffold");
2926
2927 let after = read(&root, "src/main.rs");
2928 let commands_at = after.find(".commands(commands::all())").expect("wired");
2929 let client_build_at = after.find(".build()?;").expect("client chain still there");
2930 let app_builder_at = after.find("App::builder()").expect("app chain still there");
2931
2932 assert!(
2933 commands_at > client_build_at,
2934 "`.commands(...)` was spliced into the reqwest chain:\n{after}"
2935 );
2936 assert!(
2937 commands_at > app_builder_at,
2938 "`.commands(...)` landed outside the App::builder() chain:\n{after}"
2939 );
2940 }
2941
2942 #[test]
2945 fn startcommand_declines_when_there_is_no_app_builder_chain() {
2946 let tmp = tempfile::tempdir().expect("tempdir");
2947 let root = project(&tmp);
2948 fs::write(
2949 root.join("src/main.rs"),
2950 "mod seed;\n\nfn main() {\n println!(\"no app here\");\n}\n",
2951 )
2952 .unwrap();
2953
2954 let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("scaffold");
2955
2956 assert_eq!(
2957 report.registered,
2958 Some(false),
2959 "the tool must not claim a registration it could not perform"
2960 );
2961 let steps = report.next_steps.join("\n");
2962 assert!(steps.contains(".commands(commands::all())"), "{steps}");
2963 assert!(root.join("src/commands/backfill.rs").is_file());
2964 }
2965
2966 #[test]
2972 fn startcommand_keeps_the_module_declaration_it_managed_to_add() {
2973 let tmp = tempfile::tempdir().expect("tempdir");
2974 let root = project(&tmp);
2975 fs::write(
2978 root.join("src/main.rs"),
2979 "mod seed;\n\nfn main() {\n println!(\"no app\");\n}\n",
2980 )
2981 .unwrap();
2982
2983 let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("scaffold");
2984
2985 let after = read(&root, "src/main.rs");
2986 assert!(
2987 after.contains("mod commands;"),
2988 "the module declaration the tool made was thrown away: {after}"
2989 );
2990 assert_eq!(report.registered, Some(false));
2991 assert!(
2992 report
2993 .next_steps
2994 .join("\n")
2995 .contains(".commands(commands::all())"),
2996 "the user was not told the one step that remained"
2997 );
2998 }
2999
3000 #[test]
3003 fn startcommand_declines_a_plugin_impl_whose_brace_is_on_the_next_line() {
3004 let tmp = tempfile::tempdir().expect("tempdir");
3005 let root = project(&tmp);
3006 scaffold_app("blog", &root, None).expect("scaffold_app");
3007
3008 let lib_rs = root.join("plugins/blog/src/lib.rs");
3009 fs::write(
3010 &lib_rs,
3011 "pub mod models;\n\npub struct BlogPlugin;\n\n\
3012 impl Plugin for BlogPlugin\nwhere\n Self: Send,\n{\n \
3013 fn name(&self) -> &'static str {\n \"blog\"\n }\n}\n",
3014 )
3015 .unwrap();
3016
3017 let report = scaffold_command("reindex", &CommandTarget::Plugin("blog".to_string()), &root)
3018 .expect("scaffold");
3019
3020 let after = read(&root, "plugins/blog/src/lib.rs");
3021 assert!(
3023 after.contains("impl Plugin for BlogPlugin\nwhere\n Self: Send,\n{"),
3024 "the generator spliced a method into a multi-line impl header:\n{after}"
3025 );
3026 assert_eq!(report.registered, Some(false));
3027 assert!(
3028 report.next_steps.join("\n").contains("fn commands"),
3029 "the user was not told to add the method by hand"
3030 );
3031 }
3032
3033 #[test]
3038 fn startcommand_repairs_a_registry_missing_only_its_entry() {
3039 let tmp = tempfile::tempdir().expect("tempdir");
3040 let root = project(&tmp);
3041 scaffold_command("backfill", &CommandTarget::Root, &root).expect("first");
3042
3043 let mod_rs = root.join("src/commands/mod.rs");
3045 let text = read(&root, "src/commands/mod.rs")
3046 .lines()
3047 .filter(|l| !l.contains("Box::new(backfill::BackfillCommand)"))
3048 .collect::<Vec<_>>()
3049 .join("\n");
3050 fs::write(&mod_rs, format!("{text}\n")).unwrap();
3051 fs::remove_file(root.join("src/commands/backfill.rs")).unwrap();
3052
3053 let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("re-run");
3054
3055 let registry = read(&root, "src/commands/mod.rs");
3056 assert_eq!(
3057 registry.matches("pub mod backfill;").count(),
3058 1,
3059 "duplicate module declaration:\n{registry}"
3060 );
3061 assert!(
3062 registry.contains("Box::new(backfill::BackfillCommand),"),
3063 "the registry entry was never restored, but the command reports as \
3064 registered:\n{registry}"
3065 );
3066 assert_eq!(report.registered, Some(true));
3067 }
3068
3069 #[test]
3073 fn startcommand_reports_manual_steps_when_the_registry_markers_are_gone() {
3074 let tmp = tempfile::tempdir().expect("tempdir");
3075 let root = project(&tmp);
3076 scaffold_command("first", &CommandTarget::Root, &root).expect("first");
3077
3078 let mod_rs = root.join("src/commands/mod.rs");
3079 let mangled = read(&root, "src/commands/mod.rs")
3080 .lines()
3081 .filter(|l| !l.trim().starts_with("// umbral:startcommand"))
3082 .collect::<Vec<_>>()
3083 .join("\n");
3084 fs::write(&mod_rs, &mangled).unwrap();
3085
3086 let report = scaffold_command("second", &CommandTarget::Root, &root).expect("second");
3087
3088 assert_eq!(read(&root, "src/commands/mod.rs"), mangled);
3090 let steps = report.next_steps.join("\n");
3092 assert!(steps.contains("pub mod second;"), "{steps}");
3093 assert!(steps.contains("Box::new(second::SecondCommand)"), "{steps}");
3094 }
3095}