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 (
1223 "static/favicon.svg",
1224 include_str!("../assets/scaffold/static/favicon.svg"),
1225 ),
1226 ] {
1227 write_file(&root, path, &fill(body), &mut files)?;
1228 }
1229
1230 let next_steps = vec![
1231 format!("cd {name}"),
1232 "cargo run -- migrate # apply schema migrations".to_string(),
1233 "cargo run -- serve # boot the HTTP server on http://127.0.0.1:8000".to_string(),
1234 "cargo run -- createsuperuser # create an admin login".to_string(),
1235 "umbral startplugin <name> # add a plugin to this project".to_string(),
1236 ];
1237
1238 Ok(ScaffoldReport {
1239 root,
1240 files,
1241 next_steps,
1242 cargo_toml_registered: None,
1243 registered: None,
1245 })
1246}
1247
1248pub fn scaffold_app(
1257 name: &str,
1258 project_root: &Path,
1259 local_umbral_repo: Option<&Path>,
1260) -> Result<ScaffoldReport, ScaffoldError> {
1261 scaffold_plugin(name, project_root, local_umbral_repo)
1262}
1263
1264pub fn scaffold_plugin(
1283 name: &str,
1284 project_root: &Path,
1285 local_umbral_repo: Option<&Path>,
1286) -> Result<ScaffoldReport, ScaffoldError> {
1287 let normalized = name.replace('-', "_");
1289 if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1290 return Err(ScaffoldError::ReservedName(name.to_string()));
1291 }
1292
1293 validate_name(name)?;
1294
1295 let plugins_dir = project_root.join("plugins");
1296 let root = plugins_dir.join(name);
1297 if root.exists() {
1298 return Err(ScaffoldError::AlreadyExists(root));
1299 }
1300
1301 fs::create_dir_all(&root)?;
1302 fs::create_dir_all(root.join("src"))?;
1303
1304 let crate_name = rust_ident(name);
1305 let pascal = pascal_case_from_ident(name);
1306 let mut files = Vec::new();
1307
1308 let version = env!("CARGO_PKG_VERSION");
1314 let cargo_toml = format!(
1315 r#"[package]
1316name = "{name}"
1317version = "0.1.0"
1318edition = "2024"
1319description = "A {crate_name} plugin for umbral."
1320
1321[dependencies]
1322umbral = "{version}"
1323serde = {{ version = "1", features = ["derive"] }}
1324sqlx = {{ version = "0.8", default-features = false, features = ["macros", "runtime-tokio"] }}
1325chrono = {{ version = "0.4", features = ["serde"] }}
1326async-trait = "0.1"
1327"#
1328 );
1329 let cargo_toml = match local_umbral_repo {
1330 Some(repo) => localize_deps(&cargo_toml, repo),
1331 None => cargo_toml,
1332 };
1333 write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1334
1335 let readme = format!(
1338 r#"# {name}
1339
1340A {crate_name} plugin for [umbral](https://github.com/dalmasonto/umbral).
1341
1342Generated by `umbral startplugin {name}`.
1343
1344## What's inside
1345
1346| File | Purpose |
1347|---|---|
1348| `src/lib.rs` | `{pascal}Plugin` struct + `impl Plugin` (registers models, routes, lifecycle hooks). |
1349| `src/models.rs` | One example model showing common field types (`#[umbral(...)]` attributes for `max_length`, `choices`, FK, defaults). |
1350| `src/handlers.rs` | One example axum handler showing how to read query params and return JSON. |
1351
1352## Wiring it in
1353
1354In your project's `Cargo.toml`:
1355
1356```toml
1357[dependencies]
1358{name} = {{ path = "plugins/{name}" }}
1359```
1360
1361In `src/main.rs`:
1362
1363```rust,ignore
1364let app = umbral::App::builder()
1365 .plugin({crate_name}::{pascal}Plugin::default())
1366 // ... your other plugins
1367 .build_deferred()?; // build_deferred + dispatch: lets `dispatch` fire
1368
1369umbral_cli::dispatch(app).await // on_ready AFTER a management command runs
1370```
1371
1372Then:
1373
1374```sh
1375cargo run -- makemigrations # generates 0001_initial.json from your models
1376cargo run -- migrate # applies the schema
1377cargo run -- serve # boots the HTTP server
1378```
1379
1380## Next steps
1381
1382- Add your own models in `src/models.rs` (or split into a `models/` module).
1383- Add routes in `routes()` and handlers in `src/handlers.rs`.
1384- Use `on_ready(&AppContext)` for one-shot setup work (seed default rows, register signals).
1385- See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for the full trait surface.
1386"#
1387 );
1388 write_file(&root, "README.md", &readme, &mut files)?;
1389
1390 let lib_rs = format!(
1394 r#"//! {pascal}Plugin — a distributable umbral plugin.
1395//!
1396//! Wire this into your App in `src/main.rs`:
1397//!
1398//! ```ignore
1399//! .plugin({crate_name}::{pascal}Plugin::default())
1400//! ```
1401//!
1402//! See `README.md` for the full file tour.
1403
1404pub mod handlers;
1405pub mod models;
1406
1407use async_trait::async_trait;
1408use umbral::migrate::ModelMeta;
1409use umbral::plugin::{{AppContext, Plugin, PluginError}};
1410use umbral::web::{{Router, get}};
1411
1412/// The plugin entry point. Register one instance per `App::builder()`.
1413#[derive(Debug, Default, Clone)]
1414pub struct {pascal}Plugin;
1415
1416#[async_trait]
1417impl Plugin for {pascal}Plugin {{
1418 fn name(&self) -> &'static str {{
1419 "{name}"
1420 }}
1421
1422 /// Models the framework's migration engine should track. Each
1423 /// returned [`ModelMeta`] becomes one row in the
1424 /// `umbral_migrations` tracking table once the initial migration
1425 /// applies.
1426 fn models(&self) -> Vec<ModelMeta> {{
1427 // One entry per model the plugin owns. `umbral::discovered_models!()`
1428 // finds every #[derive(Model)] in this crate automatically if you'd
1429 // rather not maintain the list by hand.
1430 vec![ModelMeta::for_::<models::{pascal}Item>()]
1431 }}
1432
1433 /// HTTP routes contributed by this plugin. The base path is
1434 /// up to you — convention is `/<name>/...` for HTML and
1435 /// `/api/<name>/...` for JSON.
1436 fn routes(&self) -> Router {{
1437 Router::new().route("/{name}/hello", get(handlers::hello))
1438 }}
1439
1440 /// One-shot setup after `App::build()` finishes. Use this for
1441 /// seeding default rows, registering signal handlers, or any
1442 /// work that needs the database available. Sync because the
1443 /// `Plugin` trait signature is sync (BUG-3 in bugs/tests/testBugs.md);
1444 /// reach into a runtime via `tokio::runtime::Handle::current()
1445 /// .block_on(...)` if you need to await something here.
1446 fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1447 Ok(())
1448 }}
1449}}
1450"#
1451 );
1452 write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1453
1454 let models_rs = format!(
1458 r#"//! Example model. Replace or extend with your own.
1459//!
1460//! What this demonstrates:
1461//! - `#[umbral(max_length = 200)]` — DDL `VARCHAR(200)` + admin form hint.
1462//! - `#[umbral(choices)]` on an enum — closed-set column with OpenAPI
1463//! `enum` and a Postgres `CHECK (col IN (...))` constraint.
1464//! - `Option<DateTime<Utc>>` — nullable timestamptz column.
1465//! - `#[umbral(noedit)]` — read-only on admin forms; not editable via
1466//! PUT/PATCH through the REST plugin.
1467
1468use chrono::{{DateTime, Utc}};
1469use serde::{{Deserialize, Serialize}};
1470
1471/// One {crate_name} item. Replace with whatever your plugin actually
1472/// stores.
1473#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1474pub struct {pascal}Item {{
1475 /// Auto-incrementing primary key.
1476 pub id: i64,
1477
1478 /// Display title. Capped at 200 chars; admin renders a single-line
1479 /// input.
1480 #[umbral(string, max_length = 200)]
1481 pub title: String,
1482
1483 /// Lifecycle state. `#[umbral(choices)]` maps the column 1:1 to the
1484 /// enum variants: the migration engine emits a CHECK constraint, the
1485 /// admin renders a `<select>`, and the OpenAPI schema gets an `enum`.
1486 #[umbral(choices)]
1487 pub status: {pascal}Status,
1488
1489 /// When the item was last published. Read-only on edit forms.
1490 #[umbral(noedit)]
1491 pub published_at: Option<DateTime<Utc>>,
1492}}
1493
1494/// Lifecycle state for [`{pascal}Item`]. The `Choices` derive teaches the
1495/// ORM the closed set; `rename_all` controls how variants serialize to the
1496/// stored string (`Draft` → `"draft"`).
1497#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, umbral::orm::Choices)]
1498#[choices(rename_all = "lowercase")]
1499pub enum {pascal}Status {{
1500 Draft,
1501 Review,
1502 Published,
1503 Archived,
1504}}
1505"#
1506 );
1507 write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1508
1509 let handlers_rs = format!(
1512 r#"//! Example HTTP handlers. Replace or extend with your own.
1513//!
1514//! `GET /{name}/hello?name=world` returns `{{"greeting": "Hello, world!"}}`.
1515
1516use serde::{{Deserialize, Serialize}};
1517use umbral::web::{{Json, Query}};
1518
1519#[derive(Debug, Deserialize, Default)]
1520pub struct HelloParams {{
1521 /// Who to greet. Defaults to "{name}" when omitted.
1522 #[serde(default)]
1523 pub name: Option<String>,
1524}}
1525
1526#[derive(Debug, Serialize)]
1527pub struct HelloResponse {{
1528 pub greeting: String,
1529}}
1530
1531pub async fn hello(Query(params): Query<HelloParams>) -> Json<HelloResponse> {{
1532 let who = params.name.as_deref().unwrap_or("{name}");
1533 Json(HelloResponse {{
1534 greeting: format!("Hello, {{who}}!"),
1535 }})
1536}}
1537"#
1538 );
1539 write_file(&root, "src/handlers.rs", &handlers_rs, &mut files)?;
1540
1541 let project_cargo_toml = project_root.join("Cargo.toml");
1543 let cargo_toml_registered = if project_cargo_toml.is_file() {
1544 register_dep_in_cargo_toml(&project_cargo_toml, name).ok()
1545 } else {
1546 None
1547 };
1548
1549 let next_steps = vec![
1550 "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1551 format!(" .plugin({crate_name}::{pascal}Plugin::default())"),
1552 "Generate + apply the initial migration:".to_string(),
1553 " cargo run -- makemigrations".to_string(),
1554 " cargo run -- migrate".to_string(),
1555 format!("Then visit http://127.0.0.1:8000/{name}/hello?name=you"),
1556 ];
1557
1558 Ok(ScaffoldReport {
1559 root,
1560 files,
1561 next_steps,
1562 cargo_toml_registered,
1563 registered: None,
1564 })
1565}
1566
1567pub use umbral::codegen::Target as CommandTarget;
1581
1582const MODS_MARKER: &str =
1584 "// umbral:startcommand — `umbral startcommand` declares new modules above this line.";
1585const REGISTRY_MARKER: &str =
1587 "// umbral:startcommand — `umbral startcommand` registers new commands above this line.";
1588
1589pub use umbral::codegen::discover_plugins;
1596
1597pub fn scaffold_command(
1627 name: &str,
1628 target: &CommandTarget,
1629 project_root: &Path,
1630) -> Result<ScaffoldReport, ScaffoldError> {
1631 if reserved_command_names().iter().any(|r| r == name) {
1634 return Err(ScaffoldError::ReservedCommandName(name.to_string()));
1635 }
1636
1637 validate_name(name)?;
1638
1639 let module = rust_ident(name);
1640 let pascal = pascal_case_from_ident(name);
1641 let struct_name = format!("{pascal}Command");
1642
1643 let resolved = umbral::codegen::resolve_target(project_root, target)?;
1648 let crate_root = resolved.crate_root.clone();
1649 let owner_file = resolved.owner_file.clone();
1650
1651 let mut files = Vec::new();
1652
1653 umbral::codegen::write_new_file(
1658 &crate_root,
1659 &format!("src/commands/{module}.rs"),
1660 &render_command_file(name, &struct_name, target),
1661 &mut files,
1662 )?;
1663
1664 let mod_rs = crate_root.join("src/commands/mod.rs");
1669 let mut next_steps: Vec<String> = Vec::new();
1670 if mod_rs.is_file() {
1671 let text = fs::read_to_string(&mod_rs)?;
1672 match append_to_registry(&text, &module, &struct_name) {
1673 Some(updated) => {
1674 fs::write(&mod_rs, updated)?;
1675 files.push(PathBuf::from("src/commands/mod.rs"));
1676 }
1677 None => {
1678 next_steps.push(
1682 "src/commands/mod.rs has no `umbral:startcommand` markers — add by hand:"
1683 .to_string(),
1684 );
1685 next_steps.push(format!(" pub mod {module};"));
1686 next_steps.push(format!(
1687 " ...and inside `all()`: Box::new({module}::{struct_name}),"
1688 ));
1689 }
1690 }
1691 } else {
1692 umbral::codegen::write_new_file(
1693 &crate_root,
1694 "src/commands/mod.rs",
1695 &render_registry_file(&module, &struct_name, target),
1696 &mut files,
1697 )?;
1698 }
1699
1700 let owner_text = fs::read_to_string(&owner_file)?;
1705 let wiring = match target {
1706 CommandTarget::Root => wire_registry_into_main(&owner_text),
1707 CommandTarget::Plugin(_) => wire_registry_into_plugin(&owner_text),
1708 };
1709 let registered = match wiring {
1713 Wiring::Updated { text, steps } => {
1714 fs::write(&owner_file, text)?;
1715 let complete = steps.is_empty();
1716 next_steps.extend(steps);
1717 complete
1718 }
1719 Wiring::AlreadyWired => true,
1720 Wiring::Manual(steps) => {
1721 next_steps.extend(steps);
1722 false
1723 }
1724 };
1725
1726 if registered {
1727 next_steps.push(format!("Run it: cargo run -- {name} --help"));
1728 } else {
1729 next_steps.push(format!(
1730 "Then run it: cargo run -- {name} --help (after the steps above — \
1731 it is NOT registered yet)"
1732 ));
1733 }
1734
1735 Ok(ScaffoldReport {
1736 root: crate_root,
1737 files,
1738 next_steps,
1739 cargo_toml_registered: None,
1740 registered: Some(registered),
1741 })
1742}
1743
1744enum Wiring {
1752 Updated { text: String, steps: Vec<String> },
1755 AlreadyWired,
1758 Manual(Vec<String>),
1761}
1762
1763fn wire_registry_into_main(text: &str) -> Wiring {
1770 let already_mod = text.lines().any(|l| l.trim() == "mod commands;");
1771 let already_registered = text.contains(".commands(commands::all())");
1772 if already_mod && already_registered {
1773 return Wiring::AlreadyWired;
1774 }
1775
1776 let mut out = text.to_string();
1777 let mut steps: Vec<String> = Vec::new();
1778
1779 if !already_mod {
1780 match umbral::codegen::declare_module(&out, "mod commands;") {
1783 Some(text) => out = text,
1784 None => steps.push("Add to src/main.rs: mod commands;".to_string()),
1785 }
1786 }
1787
1788 if !already_registered {
1789 match builder_terminal_line(&out) {
1790 Some(idx) => {
1791 let indent: String = out
1792 .lines()
1793 .nth(idx)
1794 .map(|l| l.chars().take_while(|c| c.is_whitespace()).collect())
1795 .unwrap_or_default();
1796 let call = format!(
1797 "{indent}// Project-owned management commands (`umbral startcommand`).\n\
1798 {indent}.commands(commands::all())"
1799 );
1800 out = insert_line_at_before(&out, idx, &call);
1801 }
1802 None => steps.push(
1803 "Add to the App::builder() chain in src/main.rs: .commands(commands::all())"
1804 .to_string(),
1805 ),
1806 }
1807 }
1808
1809 if out == text {
1810 if steps.is_empty() {
1811 Wiring::AlreadyWired
1812 } else {
1813 Wiring::Manual(steps)
1814 }
1815 } else {
1816 Wiring::Updated { text: out, steps }
1823 }
1824}
1825
1826fn builder_terminal_line(text: &str) -> Option<usize> {
1841 let builder_at = text.lines().position(|l| l.contains("App::builder()"))?;
1842 text.lines()
1843 .enumerate()
1844 .skip(builder_at)
1845 .find(|(_, l)| {
1846 let t = l.trim_start();
1847 t.starts_with(".build_deferred()") || t.starts_with(".build()")
1848 })
1849 .map(|(idx, _)| idx)
1850}
1851
1852fn wire_registry_into_plugin(text: &str) -> Wiring {
1861 let already_mod = text.lines().any(|l| l.trim() == "pub mod commands;");
1862 let has_commands_fn = text.contains("fn commands(");
1863 if already_mod && has_commands_fn {
1864 return Wiring::AlreadyWired;
1865 }
1866
1867 let mut out = text.to_string();
1868 let mut steps: Vec<String> = Vec::new();
1869
1870 if !already_mod {
1871 match umbral::codegen::declare_module(&out, "pub mod commands;") {
1872 Some(text) => out = text,
1873 None => steps.push("Add to src/lib.rs: pub mod commands;".to_string()),
1874 }
1875 }
1876
1877 if !has_commands_fn {
1878 match out
1886 .lines()
1887 .position(|l| l.starts_with("impl Plugin for ") && l.trim_end().ends_with('{'))
1888 {
1889 Some(idx) => {
1890 let method = "\n fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> {\n \
1891 // Every command in `src/commands/` — `umbral startcommand`\n \
1892 // appends to the registry in `commands/mod.rs`, so this line\n \
1893 // never needs to change again.\n \
1894 commands::all()\n }";
1895 out = insert_line_at(&out, idx, method);
1896 }
1897 None => steps.push(
1898 "Add to your `impl Plugin`: fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> { commands::all() }"
1899 .to_string(),
1900 ),
1901 }
1902 } else {
1903 steps.push(
1904 "Your plugin already has a `fn commands()` — make sure it returns \
1905 `commands::all()` (or extends it) so the new command is registered."
1906 .to_string(),
1907 );
1908 }
1909
1910 if out == text {
1911 if steps.is_empty() {
1914 Wiring::AlreadyWired
1915 } else {
1916 Wiring::Manual(steps)
1917 }
1918 } else {
1919 Wiring::Updated { text: out, steps }
1920 }
1921}
1922
1923fn insert_line_at(text: &str, idx: usize, line: &str) -> String {
1928 umbral::codegen::insert_line_after(text, idx, line)
1929}
1930
1931fn append_to_registry(text: &str, module: &str, struct_name: &str) -> Option<String> {
1938 let mod_line = format!("pub mod {module};");
1946 let entry = format!("Box::new({module}::{struct_name}),");
1947
1948 let mut out = text.to_string();
1949
1950 if !out.lines().any(|l| l.trim() == mod_line) {
1951 out = umbral::codegen::insert_before_marker(&out, MODS_MARKER, &mod_line)?;
1952 }
1953 if !out.lines().any(|l| l.trim() == entry) {
1954 out = umbral::codegen::insert_before_marker(
1955 &out,
1956 REGISTRY_MARKER,
1957 &format!(" {entry}"),
1958 )?;
1959 }
1960 Some(out)
1961}
1962
1963fn insert_line_at_before(text: &str, idx: usize, line: &str) -> String {
1967 umbral::codegen::insert_line_before(text, idx, line)
1968}
1969
1970fn render_registry_file(module: &str, struct_name: &str, target: &CommandTarget) -> String {
1972 let (owner, wiring) = match target {
1973 CommandTarget::Root => (
1974 "this project",
1975 "`main.rs` passes `all()` to `App::builder().commands(...)`.",
1976 ),
1977 CommandTarget::Plugin(_) => (
1978 "this plugin",
1979 "`lib.rs` returns `all()` from `Plugin::commands()`.",
1980 ),
1981 };
1982 format!(
1983 r#"//! Management commands owned by {owner} — one file per command,
1984//! and `all()` is the registry that hands them to the framework.
1985//!
1986//! {wiring}
1987//!
1988//! Rust can't discover a module by scanning this directory at runtime, so
1989//! `all()` IS the auto-detection: `umbral startcommand` appends to it for
1990//! you (that's what the marker comments below are for). You can also edit
1991//! it by hand — comment a command out and it stops existing, which is
1992//! harder to do with a magic registry you can't see.
1993
1994use umbral::cli::PluginCommand;
1995
1996pub mod {module};
1997{MODS_MARKER}
1998
1999/// Every command {owner} registers.
2000pub fn all() -> Vec<Box<dyn PluginCommand>> {{
2001 vec![
2002 Box::new({module}::{struct_name}),
2003 {REGISTRY_MARKER}
2004 ]
2005}}
2006"#
2007 )
2008}
2009
2010fn render_command_file(name: &str, struct_name: &str, target: &CommandTarget) -> String {
2014 let orm_note = match target {
2017 CommandTarget::Root => "// use crate::{Post, post};",
2018 CommandTarget::Plugin(_) => "// use crate::models::{Post, post};",
2019 };
2020 format!(
2021 r#"//! `{name}` — a management command.
2022//!
2023//! ```bash
2024//! cargo run -- {name} --help # what it takes
2025//! cargo run -- {name} hello --limit 5 --dry-run # a real run
2026//! umbral {name} hello --tag a --tag b # same thing, via the umbral CLI
2027//! ```
2028//!
2029//! Registered through `commands::all()` in `commands/mod.rs`. It runs against
2030//! a fully-built app: settings loaded, pool open, every model registered — so
2031//! the ORM works ambiently here, with no pool to thread through.
2032
2033use umbral::cli::{{CliError, PluginCommand, clap}};
2034
2035/// The `{name}` command.
2036///
2037/// A unit struct is enough when the command is stateless. It doesn't have to
2038/// be: the trait is object-safe over `&self`, so anything the command needs
2039/// configured (a prefix, a client, a channel) can live on the struct and be
2040/// passed in at registration — which is exactly why this is a trait and not a
2041/// bare `fn` pointer.
2042pub struct {struct_name};
2043
2044#[umbral::async_trait]
2045impl PluginCommand for {struct_name} {{
2046 /// Declare the command: its name, its help, and its arguments.
2047 ///
2048 /// This is plain `clap`, so everything clap can do is available here —
2049 /// value parsing and validation, defaults, conflicts, subcommands of your
2050 /// own. Note the import: `umbral::cli::clap`, the framework's own clap.
2051 /// Add `clap` to your Cargo.toml separately and a major-version bump on
2052 /// either side turns into a type mismatch a page long.
2053 fn command(&self) -> clap::Command {{
2054 clap::Command::new("{name}")
2055 // Shown next to the command in `umbral help`. Write it — a command
2056 // with no `about` lists as a dash and nobody discovers it.
2057 .about("TODO: one line on what {name} does")
2058 .long_about(
2059 "TODO: the longer story, shown on `{name} --help`. What it \
2060 changes, whether it's safe to re-run, what it needs first.",
2061 )
2062 // POSITIONAL argument — `{name} <slug>`. Required, so clap
2063 // rejects the call with a usage error if it's missing and `run`
2064 // never sees a half-formed invocation.
2065 .arg(
2066 clap::Arg::new("slug")
2067 .required(true)
2068 .help("The thing to operate on"),
2069 )
2070 // NAMED argument with a value and a default — `--limit 25` / `-l 25`.
2071 // `value_parser` is what makes it a `u64` on the other side rather
2072 // than a string you'd have to parse (and mis-parse) yourself.
2073 .arg(
2074 clap::Arg::new("limit")
2075 .long("limit")
2076 .short('l')
2077 .value_name("N")
2078 .value_parser(clap::value_parser!(u64))
2079 .default_value("25")
2080 .help("How many rows to touch at most"),
2081 )
2082 // REPEATABLE named argument — `--tag a --tag b` collects both.
2083 // `ArgAction::Append` is the difference between the second `--tag`
2084 // overwriting the first and the two accumulating.
2085 .arg(
2086 clap::Arg::new("tag")
2087 .long("tag")
2088 .value_name("TAG")
2089 .action(clap::ArgAction::Append)
2090 .help("Filter by tag. Repeat for more than one."),
2091 )
2092 // BOOLEAN flag — `--dry-run`, no value. `SetTrue` is what makes it
2093 // a flag rather than an option that demands a value.
2094 .arg(
2095 clap::Arg::new("dry-run")
2096 .long("dry-run")
2097 .action(clap::ArgAction::SetTrue)
2098 .help("Report what would change without writing anything"),
2099 )
2100 }}
2101
2102 /// Run the command. `matches` is this subcommand's own `ArgMatches` —
2103 /// clap has already validated it against `command()` above, so every
2104 /// `get_one` here is reading a value that exists and typechecked.
2105 async fn run(&self, matches: &clap::ArgMatches) -> Result<(), CliError> {{
2106 let slug = matches
2107 .get_one::<String>("slug")
2108 .expect("clap enforces `required(true)`");
2109 let limit = *matches
2110 .get_one::<u64>("limit")
2111 .expect("clap fills in `default_value`");
2112 let tags: Vec<&String> = matches
2113 .get_many::<String>("tag")
2114 .map(Iterator::collect)
2115 .unwrap_or_default();
2116 let dry_run = matches.get_flag("dry-run");
2117
2118 println!("{name}: slug={{slug}} limit={{limit}} tags={{tags:?}} dry_run={{dry_run}}");
2119
2120 // The app is already built by the time this runs, so the ORM is live:
2121 //
2122 {orm_note}
2123 //
2124 // let posts = Post::objects()
2125 // .filter(post::PUBLISHED.eq(true))
2126 // .limit(limit as i64)
2127 // .fetch()
2128 // .await?;
2129 //
2130 // if dry_run {{
2131 // println!("would touch {{}} post(s)", posts.len());
2132 // return Ok(());
2133 // }}
2134 //
2135 // `?` just works: `CliError` is a boxed error, so every umbral error
2136 // converts into it. Return `Err(...)` and the process exits non-zero,
2137 // which is what a CI step or a cron job is watching for.
2138
2139 Ok(())
2140 }}
2141}}
2142"#
2143 )
2144}
2145
2146fn write_file(
2149 root: &Path,
2150 rel_path: &str,
2151 contents: &str,
2152 files: &mut Vec<PathBuf>,
2153) -> Result<(), ScaffoldError> {
2154 umbral::codegen::write_new_file(root, rel_path, contents, files).map_err(Into::into)
2159}
2160
2161pub fn register_dep_in_cargo_toml(cargo_toml_path: &Path, name: &str) -> io::Result<bool> {
2177 umbral::codegen::ensure_dependency(
2184 cargo_toml_path,
2185 name,
2186 &format!("{{ path = \"plugins/{name}\" }}"),
2187 )
2188 .map_err(|e| match e {
2189 umbral::codegen::CodegenError::Io(e) => e,
2190 other => io::Error::new(io::ErrorKind::InvalidData, other.to_string()),
2191 })
2192}
2193#[cfg(test)]
2194mod tests {
2195 use super::*;
2196
2197 #[test]
2198 fn validate_name_accepts_simple_identifiers() {
2199 assert!(validate_name("posts").is_ok());
2200 assert!(validate_name("blog_engine").is_ok());
2201 assert!(validate_name("blog-engine").is_ok());
2202 assert!(validate_name("api2").is_ok());
2203 }
2204
2205 #[test]
2206 fn validate_name_rejects_empty() {
2207 assert!(validate_name("").is_err());
2208 }
2209
2210 #[test]
2211 fn validate_name_rejects_leading_digit() {
2212 assert!(validate_name("2cool").is_err());
2213 }
2214
2215 #[test]
2216 fn validate_name_rejects_special_chars() {
2217 assert!(validate_name("foo bar").is_err());
2218 assert!(validate_name("foo!bar").is_err());
2219 assert!(validate_name("foo/bar").is_err());
2220 }
2221
2222 #[test]
2223 fn pascal_case_handles_kebab_and_snake() {
2224 assert_eq!(pascal_case_from_ident("posts"), "Posts");
2225 assert_eq!(pascal_case_from_ident("blog_engine"), "BlogEngine");
2226 assert_eq!(pascal_case_from_ident("blog-engine"), "BlogEngine");
2227 assert_eq!(pascal_case_from_ident("api2"), "Api2");
2228 }
2229
2230 #[test]
2231 fn rust_ident_replaces_hyphens() {
2232 assert_eq!(rust_ident("blog-engine"), "blog_engine");
2233 assert_eq!(rust_ident("posts"), "posts");
2234 }
2235
2236 #[test]
2237 fn scaffold_app_rejects_reserved_built_in_plugin_names() {
2238 let tmp = tempfile::tempdir().expect("tempdir");
2239 for name in RESERVED_PLUGIN_NAMES {
2240 let result = scaffold_app(name, tmp.path(), None);
2241 assert!(
2242 matches!(result, Err(ScaffoldError::ReservedName(_))),
2243 "expected ReservedName error for `{name}`, got: {result:?}",
2244 );
2245 assert!(
2246 !tmp.path().join("plugins").join(name).exists(),
2247 "directory must NOT be created when name is reserved: {name}",
2248 );
2249 }
2250 }
2251
2252 #[test]
2253 fn scaffold_app_rejects_reserved_name_with_hyphen_variant() {
2254 let tmp = tempfile::tempdir().expect("tempdir");
2259 let result = scaffold_app("auth", tmp.path(), None);
2263 assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
2264 }
2265
2266 #[test]
2267 fn scaffold_app_message_lists_reserved_names() {
2268 let err = ScaffoldError::ReservedName("auth".to_string());
2269 let msg = format!("{err}");
2270 assert!(msg.contains("`auth`"), "error names the offending input");
2271 assert!(
2272 msg.contains("admin") && msg.contains("sessions") && msg.contains("permissions"),
2273 "error lists the reserved set so the user can pick again: {msg}",
2274 );
2275 }
2276
2277 #[test]
2278 fn scaffold_app_already_exists_message_says_app() {
2279 let err = ScaffoldError::AlreadyExists(PathBuf::from("plugins/blog"));
2283 let msg = format!("{err}");
2284 assert!(msg.contains("app already exists"), "got: {msg}");
2285 assert!(msg.contains("plugins/blog"), "got: {msg}");
2286 }
2287
2288 #[test]
2293 fn scaffold_plugin_writes_richer_layout() {
2294 let tmp = tempfile::tempdir().expect("tempdir");
2295 let report = scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2296
2297 let root = tmp.path().join("plugins").join("widgets");
2298 assert!(root.is_dir());
2299
2300 for rel in [
2302 "Cargo.toml",
2303 "README.md",
2304 "src/lib.rs",
2305 "src/models.rs",
2306 "src/handlers.rs",
2307 ] {
2308 assert!(
2309 root.join(rel).exists(),
2310 "missing expected file: {rel}; got {:?}",
2311 report.files,
2312 );
2313 }
2314 }
2315
2316 #[test]
2317 fn scaffold_plugin_lib_rs_references_sibling_modules() {
2318 let tmp = tempfile::tempdir().expect("tempdir");
2319 scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2320 let lib = fs::read_to_string(tmp.path().join("plugins/widgets/src/lib.rs")).unwrap();
2321
2322 assert!(
2323 lib.contains("pub mod handlers;"),
2324 "lib.rs must publish handlers"
2325 );
2326 assert!(
2327 lib.contains("pub mod models;"),
2328 "lib.rs must publish models"
2329 );
2330 assert!(lib.contains("WidgetsPlugin"), "PascalCase plugin name");
2331 assert!(
2332 lib.contains("ModelMeta::for_::<models::WidgetsItem>()"),
2333 "models() should register the example model",
2334 );
2335 assert!(
2336 lib.contains("/widgets/hello"),
2337 "routes() should register the example handler",
2338 );
2339 }
2340
2341 #[test]
2342 fn scaffold_plugin_models_rs_uses_real_umbral_attributes() {
2343 let tmp = tempfile::tempdir().expect("tempdir");
2344 scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2345 let models = fs::read_to_string(tmp.path().join("plugins/widgets/src/models.rs")).unwrap();
2346
2347 assert!(
2348 models.contains("umbral::orm::Model"),
2349 "model derive must reference the framework's Model trait",
2350 );
2351 assert!(
2352 models.contains("max_length = 200"),
2353 "example model should demonstrate max_length",
2354 );
2355 assert!(
2356 models.contains("WidgetsStatus"),
2357 "example model should declare a Choice enum",
2358 );
2359 assert!(
2364 models.contains("#[umbral(choices)]"),
2365 "the status field needs #[umbral(choices)] or the model won't compile",
2366 );
2367 assert!(
2368 models.contains("Choices"),
2369 "the enum needs the Choices derive, not a bare sqlx::Type",
2370 );
2371 assert!(
2372 models.contains("noedit"),
2373 "example model should show the noedit attribute",
2374 );
2375 }
2376
2377 #[test]
2378 fn scaffold_plugin_rejects_reserved_built_in_plugin_names() {
2379 let tmp = tempfile::tempdir().expect("tempdir");
2380 for name in RESERVED_PLUGIN_NAMES {
2381 let result = scaffold_plugin(name, tmp.path(), None);
2382 assert!(
2383 matches!(result, Err(ScaffoldError::ReservedName(_))),
2384 "expected ReservedName error for `{name}`, got: {result:?}",
2385 );
2386 }
2387 }
2388
2389 #[test]
2390 fn scaffold_plugin_refuses_to_overwrite_existing_directory() {
2391 let tmp = tempfile::tempdir().expect("tempdir");
2392 scaffold_plugin("widgets", tmp.path(), None).expect("first scaffold ok");
2393 let result = scaffold_plugin("widgets", tmp.path(), None);
2394 assert!(matches!(result, Err(ScaffoldError::AlreadyExists(_))));
2395 }
2396
2397 #[test]
2403 fn scaffold_project_writes_per_concern_tree() {
2404 let tmp = tempfile::tempdir().expect("tempdir");
2405 let report = scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2406
2407 let root = tmp.path().join("blog");
2408 assert!(root.is_dir());
2409
2410 for rel in [
2412 "src/main.rs",
2413 "src/views/mod.rs",
2414 "src/views/public.rs",
2415 "src/seed/mod.rs",
2416 "src/seed/credentials.rs",
2417 "src/widgets/mod.rs",
2418 "src/widgets/cards.rs",
2419 "plugins/.gitkeep",
2420 "plugins/README.md",
2421 ] {
2422 assert!(
2423 root.join(rel).exists(),
2424 "missing expected file: {rel}; got {:?}",
2425 report.files,
2426 );
2427 }
2428 }
2429
2430 #[test]
2431 fn scaffold_project_mod_files_carry_orchestrator_markers() {
2432 let tmp = tempfile::tempdir().expect("tempdir");
2433 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2434 let root = tmp.path().join("blog");
2435
2436 let views_mod = fs::read_to_string(root.join("src/views/mod.rs")).unwrap();
2437 assert!(
2438 views_mod.contains("re-export"),
2439 "views/mod.rs should describe itself as the re-export layer",
2440 );
2441 assert!(
2448 !views_mod.contains("fn internal_error"),
2449 "the scaffold must NOT generate an internal_error helper — handlers return \
2450 ApiError, which logs the cause and keeps it off the wire",
2451 );
2452 let views_public = fs::read_to_string(root.join("src/views/public.rs")).unwrap();
2453 assert!(
2454 views_public.contains("Result<Html<String>, ApiError>")
2455 && !views_public.contains("map_err(internal_error)"),
2456 "generated handlers must return ApiError and use a bare `?`",
2457 );
2458
2459 let seed_mod = fs::read_to_string(root.join("src/seed/mod.rs")).unwrap();
2460 assert!(
2461 seed_mod.contains("pub async fn all()"),
2462 "seed/mod.rs must declare the all() orchestrator",
2463 );
2464 assert!(
2465 seed_mod.contains("credentials::test_credentials()"),
2466 "seed::all() must call the credentials step",
2467 );
2468 assert!(
2469 seed_mod.contains("dependency order") || seed_mod.contains("order in which"),
2470 "seed/mod.rs should explain it pins dependency order",
2471 );
2472
2473 let credentials = fs::read_to_string(root.join("src/seed/credentials.rs")).unwrap();
2474 assert!(
2475 credentials.contains("fn test_credentials"),
2476 "credentials.rs must define the test_credentials seed",
2477 );
2478 assert!(
2479 credentials.contains("count().await? > 0"),
2480 "test_credentials must be idempotent (short-circuit on existing users)",
2481 );
2482
2483 let widgets_mod = fs::read_to_string(root.join("src/widgets/mod.rs")).unwrap();
2484 assert!(
2485 widgets_mod.contains("pub mod cards;"),
2486 "widgets/mod.rs must publish the cards submodule",
2487 );
2488
2489 let cards = fs::read_to_string(root.join("src/widgets/cards.rs")).unwrap();
2490 assert!(
2491 cards.contains("builtin_total_models_widget")
2492 || cards.contains("builtin_recent_users_widget"),
2493 "cards.rs should re-export a builtin widget so the dashboard isn't empty",
2494 );
2495 }
2496
2497 #[test]
2498 fn scaffold_project_main_declares_modules_and_mounts_security() {
2499 let tmp = tempfile::tempdir().expect("tempdir");
2500 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2501 let main = fs::read_to_string(tmp.path().join("blog/src/main.rs")).unwrap();
2502
2503 assert!(
2505 main.contains("mod views;"),
2506 "main.rs must declare mod views"
2507 );
2508 assert!(main.contains("mod seed;"), "main.rs must declare mod seed");
2509 assert!(
2510 main.contains("mod widgets;"),
2511 "main.rs must declare mod widgets",
2512 );
2513
2514 assert!(
2516 main.contains("views::public::home"),
2517 "route table should wire views::public::home",
2518 );
2519 assert!(
2522 main.contains(".seed_on_serve(seed::all)"),
2523 "boot should seed via .seed_on_serve(seed::all)",
2524 );
2525
2526 assert!(
2528 main.contains("SecurityPlugin"),
2529 "SecurityPlugin must be mounted by default",
2530 );
2531 }
2532
2533 #[test]
2534 fn scaffold_project_creates_empty_plugins_dir() {
2535 let tmp = tempfile::tempdir().expect("tempdir");
2536 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2537 let readme = fs::read_to_string(tmp.path().join("blog/plugins/README.md")).unwrap();
2538 assert!(
2539 readme.contains("umbral startplugin"),
2540 "plugins/README.md should point at the canonical `umbral startplugin`",
2541 );
2542 }
2543
2544 #[test]
2549 fn scaffold_app_forwards_to_the_plugin_generator() {
2550 let tmp = tempfile::tempdir().expect("tempdir");
2551 scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2552 let root = tmp.path().join("plugins/posts");
2553
2554 for rel in [
2556 "Cargo.toml",
2557 "README.md",
2558 "src/lib.rs",
2559 "src/models.rs",
2560 "src/handlers.rs",
2561 ] {
2562 assert!(root.join(rel).exists(), "missing expected file: {rel}");
2563 }
2564 let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
2565 assert!(lib.contains("pub mod models;"), "lib.rs publishes models");
2566 assert!(
2567 lib.contains("pub mod handlers;"),
2568 "lib.rs publishes handlers"
2569 );
2570 assert!(lib.contains("PostsPlugin"), "PascalCase plugin name");
2571 }
2572
2573 #[test]
2574 fn scaffold_app_auto_registers_path_dep_in_project_cargo() {
2575 let tmp = tempfile::tempdir().expect("tempdir");
2576 let project_cargo = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nserde = \"1\"\n";
2578 fs::write(tmp.path().join("Cargo.toml"), project_cargo).unwrap();
2579
2580 let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2581 assert_eq!(
2582 report.cargo_toml_registered,
2583 Some(true),
2584 "the path dep should have been added",
2585 );
2586
2587 let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
2588 assert!(
2589 cargo.contains("posts = { path = \"plugins/posts\" }"),
2590 "project Cargo.toml must gain the plugin path dep; got:\n{cargo}",
2591 );
2592
2593 let second = register_dep_in_cargo_toml(&tmp.path().join("Cargo.toml"), "posts").unwrap();
2596 assert!(!second, "re-registering the same dep must be a no-op");
2597 }
2598
2599 #[test]
2600 fn scaffold_app_still_rejects_reserved_names() {
2601 let tmp = tempfile::tempdir().expect("tempdir");
2602 let result = scaffold_app("auth", tmp.path(), None);
2603 assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
2604 }
2605
2606 #[test]
2607 fn scaffold_plugin_validates_name_like_startapp() {
2608 let tmp = tempfile::tempdir().expect("tempdir");
2609 assert!(matches!(
2610 scaffold_plugin("2cool", tmp.path(), None),
2611 Err(ScaffoldError::InvalidName(_))
2612 ));
2613 assert!(matches!(
2614 scaffold_plugin("foo bar", tmp.path(), None),
2615 Err(ScaffoldError::InvalidName(_))
2616 ));
2617 }
2618
2619 fn project(tmp: &tempfile::TempDir) -> PathBuf {
2628 scaffold_project("demo", tmp.path(), None).expect("scaffold_project");
2629 tmp.path().join("demo")
2630 }
2631
2632 fn read(root: &Path, rel: &str) -> String {
2633 fs::read_to_string(root.join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}"))
2634 }
2635
2636 #[test]
2637 fn startcommand_root_writes_the_command_and_wires_main() {
2638 let tmp = tempfile::tempdir().expect("tempdir");
2639 let root = project(&tmp);
2640
2641 let report =
2642 scaffold_command("backfill_slugs", &CommandTarget::Root, &root).expect("scaffold");
2643 assert!(
2644 report
2645 .files
2646 .contains(&PathBuf::from("src/commands/backfill_slugs.rs"))
2647 );
2648 assert!(report.files.contains(&PathBuf::from("src/commands/mod.rs")));
2649
2650 let cmd = read(&root, "src/commands/backfill_slugs.rs");
2652 assert!(cmd.contains("pub struct BackfillSlugsCommand;"), "{cmd}");
2653 assert!(
2654 cmd.contains("impl PluginCommand for BackfillSlugsCommand"),
2655 "{cmd}"
2656 );
2657 assert!(
2658 cmd.contains("use umbral::cli::{CliError, PluginCommand, clap};"),
2659 "the generated file must import the framework's clap, not its own: {cmd}"
2660 );
2661 assert!(
2662 cmd.contains(r#"clap::Command::new("backfill_slugs")"#),
2663 "{cmd}"
2664 );
2665
2666 let registry = read(&root, "src/commands/mod.rs");
2668 assert!(registry.contains("pub mod backfill_slugs;"), "{registry}");
2669 assert!(
2670 registry.contains("Box::new(backfill_slugs::BackfillSlugsCommand),"),
2671 "{registry}"
2672 );
2673
2674 let main_rs = read(&root, "src/main.rs");
2676 assert!(
2677 main_rs.contains("mod commands;"),
2678 "main.rs never declared the module: {main_rs}"
2679 );
2680 assert!(
2681 main_rs.contains(".commands(commands::all())"),
2682 "main.rs never registered the command registry: {main_rs}"
2683 );
2684 let reg = main_rs.find(".commands(commands::all())").unwrap();
2686 let build = main_rs.find(".build_deferred()").unwrap();
2687 assert!(
2688 reg < build,
2689 "`.commands(...)` landed after `.build_deferred()`, which doesn't compile"
2690 );
2691 }
2692
2693 #[test]
2699 fn startcommand_second_command_appends_and_leaves_main_alone() {
2700 let tmp = tempfile::tempdir().expect("tempdir");
2701 let root = project(&tmp);
2702
2703 scaffold_command("backfill_slugs", &CommandTarget::Root, &root).expect("first");
2704 let main_after_first = read(&root, "src/main.rs");
2705 scaffold_command("import-prices", &CommandTarget::Root, &root).expect("second");
2706 let main_after_second = read(&root, "src/main.rs");
2707
2708 assert_eq!(
2709 main_after_first, main_after_second,
2710 "the second startcommand edited main.rs again"
2711 );
2712
2713 let registry = read(&root, "src/commands/mod.rs");
2714 assert!(registry.contains("pub mod backfill_slugs;"), "{registry}");
2715 assert!(registry.contains("pub mod import_prices;"), "{registry}");
2718 assert!(
2719 registry.contains("Box::new(import_prices::ImportPricesCommand),"),
2720 "{registry}"
2721 );
2722 let cmd = read(&root, "src/commands/import_prices.rs");
2723 assert!(
2724 cmd.contains(r#"clap::Command::new("import-prices")"#),
2725 "the clap name should be what the user typed, hyphens and all: {cmd}"
2726 );
2727
2728 assert_eq!(
2729 main_after_second
2730 .matches(".commands(commands::all())")
2731 .count(),
2732 1,
2733 "main.rs registered the registry twice"
2734 );
2735 assert_eq!(
2736 main_after_second.matches("\nmod commands;").count(),
2737 1,
2738 "main.rs declared `mod commands;` twice"
2739 );
2740 }
2741
2742 #[test]
2743 fn startcommand_plugin_writes_the_command_and_wires_the_plugin() {
2744 let tmp = tempfile::tempdir().expect("tempdir");
2745 let root = project(&tmp);
2746 scaffold_app("blog", &root, None).expect("scaffold_app");
2747
2748 scaffold_command("reindex", &CommandTarget::Plugin("blog".to_string()), &root)
2749 .expect("scaffold");
2750
2751 let plugin_root = root.join("plugins/blog");
2752 let registry = read(&plugin_root, "src/commands/mod.rs");
2753 assert!(registry.contains("pub mod reindex;"), "{registry}");
2754 assert!(
2755 registry.contains("Box::new(reindex::ReindexCommand),"),
2756 "{registry}"
2757 );
2758
2759 let lib_rs = read(&plugin_root, "src/lib.rs");
2760 assert!(lib_rs.contains("pub mod commands;"), "{lib_rs}");
2761 assert!(
2762 lib_rs.contains("fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>>"),
2763 "the plugin never got a `Plugin::commands()` impl: {lib_rs}"
2764 );
2765 assert!(
2766 lib_rs.contains("commands::all()"),
2767 "the impl doesn't return the registry: {lib_rs}"
2768 );
2769 let impl_start = lib_rs.find("impl Plugin for BlogPlugin {").unwrap();
2771 let method = lib_rs.find("fn commands(&self)").unwrap();
2772 assert!(method > impl_start, "the method landed outside the impl");
2773 }
2774
2775 #[test]
2782 fn startcommand_rejects_a_rust_keyword_as_a_command_name() {
2783 let tmp = tempfile::tempdir().expect("tempdir");
2784 let root = project(&tmp);
2785 for kw in ["move", "type", "match"] {
2786 assert!(
2787 matches!(
2788 scaffold_command(kw, &CommandTarget::Root, &root),
2789 Err(ScaffoldError::InvalidName(_))
2790 ),
2791 "`{kw}` is a Rust keyword — `pub mod {kw};` does not parse"
2792 );
2793 }
2794 }
2795
2796 #[test]
2801 fn startcommand_rejects_a_builtin_command_name() {
2802 let tmp = tempfile::tempdir().expect("tempdir");
2803 let root = project(&tmp);
2804 for taken in ["migrate", "serve", "makemigrations", "dev"] {
2805 assert!(
2806 matches!(
2807 scaffold_command(taken, &CommandTarget::Root, &root),
2808 Err(ScaffoldError::ReservedCommandName(_))
2809 ),
2810 "`{taken}` is a built-in and must be rejected"
2811 );
2812 }
2813 }
2814
2815 #[test]
2819 fn startcommand_rejects_a_builtin_plugin_command_name() {
2820 let tmp = tempfile::tempdir().expect("tempdir");
2821 let root = project(&tmp);
2822 assert!(matches!(
2823 scaffold_command("createsuperuser", &CommandTarget::Root, &root),
2824 Err(ScaffoldError::ReservedCommandName(_))
2825 ));
2826 assert!(matches!(
2827 scaffold_command("tasks-worker", &CommandTarget::Root, &root),
2828 Err(ScaffoldError::ReservedCommandName(_))
2829 ));
2830 }
2831
2832 #[test]
2836 fn reserved_command_names_are_read_off_the_real_parser() {
2837 let names = reserved_command_names();
2838 for expected in ["migrate", "serve", "typegen", "squashmigrations", "help"] {
2839 assert!(
2840 names.iter().any(|n| n == expected),
2841 "`{expected}` missing from the reserved set: {names:?}"
2842 );
2843 }
2844 }
2845
2846 #[test]
2847 fn startcommand_rejects_an_unknown_plugin_and_lists_the_real_ones() {
2848 let tmp = tempfile::tempdir().expect("tempdir");
2849 let root = project(&tmp);
2850 scaffold_app("blog", &root, None).expect("scaffold_app");
2851
2852 let err = scaffold_command("reindex", &CommandTarget::Plugin("blgo".into()), &root)
2853 .expect_err("a typo'd plugin name must not scaffold anything");
2854 match err {
2855 ScaffoldError::NoSuchPlugin { asked, available } => {
2856 assert_eq!(asked, "blgo");
2857 assert_eq!(available, vec!["blog".to_string()]);
2858 }
2859 other => panic!("expected NoSuchPlugin, got {other:?}"),
2860 }
2861 }
2862
2863 #[test]
2864 fn startcommand_refuses_to_overwrite_an_existing_command() {
2865 let tmp = tempfile::tempdir().expect("tempdir");
2866 let root = project(&tmp);
2867 scaffold_command("reindex", &CommandTarget::Root, &root).expect("first");
2868 assert!(matches!(
2869 scaffold_command("reindex", &CommandTarget::Root, &root),
2870 Err(ScaffoldError::AlreadyExists(_))
2871 ));
2872 }
2873
2874 #[test]
2875 fn startcommand_outside_a_project_says_so() {
2876 let tmp = tempfile::tempdir().expect("tempdir");
2877 assert!(matches!(
2878 scaffold_command("reindex", &CommandTarget::Root, tmp.path()),
2879 Err(ScaffoldError::NotAProject(_))
2880 ));
2881 }
2882
2883 #[test]
2884 fn discover_plugins_lists_plugin_crates_only() {
2885 let tmp = tempfile::tempdir().expect("tempdir");
2886 let root = project(&tmp);
2887 assert!(discover_plugins(&root).is_empty());
2889
2890 scaffold_app("blog", &root, None).expect("scaffold_app");
2891 scaffold_app("shop", &root, None).expect("scaffold_app");
2892 fs::create_dir_all(root.join("plugins/notacrate")).unwrap();
2895
2896 assert_eq!(
2897 discover_plugins(&root),
2898 vec!["blog".to_string(), "shop".to_string()]
2899 );
2900 }
2901
2902 #[test]
2913 fn startcommand_does_not_splice_into_someone_elses_builder_chain() {
2914 let tmp = tempfile::tempdir().expect("tempdir");
2915 let root = project(&tmp);
2916
2917 let main_rs = root.join("src/main.rs");
2918 let original = read(&root, "src/main.rs");
2919 let with_client = original.replace(
2922 " let settings = Settings::from_env()?;",
2923 " let client = reqwest::Client::builder()\n\
2924 \x20 .timeout(Duration::from_secs(5))\n\
2925 \x20 .build()?;\n\n\
2926 \x20 let settings = Settings::from_env()?;",
2927 );
2928 assert_ne!(with_client, original, "fixture did not apply");
2929 fs::write(&main_rs, &with_client).unwrap();
2930
2931 scaffold_command("import_prices", &CommandTarget::Root, &root).expect("scaffold");
2932
2933 let after = read(&root, "src/main.rs");
2934 let commands_at = after.find(".commands(commands::all())").expect("wired");
2935 let client_build_at = after.find(".build()?;").expect("client chain still there");
2936 let app_builder_at = after.find("App::builder()").expect("app chain still there");
2937
2938 assert!(
2939 commands_at > client_build_at,
2940 "`.commands(...)` was spliced into the reqwest chain:\n{after}"
2941 );
2942 assert!(
2943 commands_at > app_builder_at,
2944 "`.commands(...)` landed outside the App::builder() chain:\n{after}"
2945 );
2946 }
2947
2948 #[test]
2951 fn startcommand_declines_when_there_is_no_app_builder_chain() {
2952 let tmp = tempfile::tempdir().expect("tempdir");
2953 let root = project(&tmp);
2954 fs::write(
2955 root.join("src/main.rs"),
2956 "mod seed;\n\nfn main() {\n println!(\"no app here\");\n}\n",
2957 )
2958 .unwrap();
2959
2960 let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("scaffold");
2961
2962 assert_eq!(
2963 report.registered,
2964 Some(false),
2965 "the tool must not claim a registration it could not perform"
2966 );
2967 let steps = report.next_steps.join("\n");
2968 assert!(steps.contains(".commands(commands::all())"), "{steps}");
2969 assert!(root.join("src/commands/backfill.rs").is_file());
2970 }
2971
2972 #[test]
2978 fn startcommand_keeps_the_module_declaration_it_managed_to_add() {
2979 let tmp = tempfile::tempdir().expect("tempdir");
2980 let root = project(&tmp);
2981 fs::write(
2984 root.join("src/main.rs"),
2985 "mod seed;\n\nfn main() {\n println!(\"no app\");\n}\n",
2986 )
2987 .unwrap();
2988
2989 let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("scaffold");
2990
2991 let after = read(&root, "src/main.rs");
2992 assert!(
2993 after.contains("mod commands;"),
2994 "the module declaration the tool made was thrown away: {after}"
2995 );
2996 assert_eq!(report.registered, Some(false));
2997 assert!(
2998 report
2999 .next_steps
3000 .join("\n")
3001 .contains(".commands(commands::all())"),
3002 "the user was not told the one step that remained"
3003 );
3004 }
3005
3006 #[test]
3009 fn startcommand_declines_a_plugin_impl_whose_brace_is_on_the_next_line() {
3010 let tmp = tempfile::tempdir().expect("tempdir");
3011 let root = project(&tmp);
3012 scaffold_app("blog", &root, None).expect("scaffold_app");
3013
3014 let lib_rs = root.join("plugins/blog/src/lib.rs");
3015 fs::write(
3016 &lib_rs,
3017 "pub mod models;\n\npub struct BlogPlugin;\n\n\
3018 impl Plugin for BlogPlugin\nwhere\n Self: Send,\n{\n \
3019 fn name(&self) -> &'static str {\n \"blog\"\n }\n}\n",
3020 )
3021 .unwrap();
3022
3023 let report = scaffold_command("reindex", &CommandTarget::Plugin("blog".to_string()), &root)
3024 .expect("scaffold");
3025
3026 let after = read(&root, "plugins/blog/src/lib.rs");
3027 assert!(
3029 after.contains("impl Plugin for BlogPlugin\nwhere\n Self: Send,\n{"),
3030 "the generator spliced a method into a multi-line impl header:\n{after}"
3031 );
3032 assert_eq!(report.registered, Some(false));
3033 assert!(
3034 report.next_steps.join("\n").contains("fn commands"),
3035 "the user was not told to add the method by hand"
3036 );
3037 }
3038
3039 #[test]
3044 fn startcommand_repairs_a_registry_missing_only_its_entry() {
3045 let tmp = tempfile::tempdir().expect("tempdir");
3046 let root = project(&tmp);
3047 scaffold_command("backfill", &CommandTarget::Root, &root).expect("first");
3048
3049 let mod_rs = root.join("src/commands/mod.rs");
3051 let text = read(&root, "src/commands/mod.rs")
3052 .lines()
3053 .filter(|l| !l.contains("Box::new(backfill::BackfillCommand)"))
3054 .collect::<Vec<_>>()
3055 .join("\n");
3056 fs::write(&mod_rs, format!("{text}\n")).unwrap();
3057 fs::remove_file(root.join("src/commands/backfill.rs")).unwrap();
3058
3059 let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("re-run");
3060
3061 let registry = read(&root, "src/commands/mod.rs");
3062 assert_eq!(
3063 registry.matches("pub mod backfill;").count(),
3064 1,
3065 "duplicate module declaration:\n{registry}"
3066 );
3067 assert!(
3068 registry.contains("Box::new(backfill::BackfillCommand),"),
3069 "the registry entry was never restored, but the command reports as \
3070 registered:\n{registry}"
3071 );
3072 assert_eq!(report.registered, Some(true));
3073 }
3074
3075 #[test]
3079 fn startcommand_reports_manual_steps_when_the_registry_markers_are_gone() {
3080 let tmp = tempfile::tempdir().expect("tempdir");
3081 let root = project(&tmp);
3082 scaffold_command("first", &CommandTarget::Root, &root).expect("first");
3083
3084 let mod_rs = root.join("src/commands/mod.rs");
3085 let mangled = read(&root, "src/commands/mod.rs")
3086 .lines()
3087 .filter(|l| !l.trim().starts_with("// umbral:startcommand"))
3088 .collect::<Vec<_>>()
3089 .join("\n");
3090 fs::write(&mod_rs, &mangled).unwrap();
3091
3092 let report = scaffold_command("second", &CommandTarget::Root, &root).expect("second");
3093
3094 assert_eq!(read(&root, "src/commands/mod.rs"), mangled);
3096 let steps = report.next_steps.join("\n");
3098 assert!(steps.contains("pub mod second;"), "{steps}");
3099 assert!(steps.contains("Box::new(second::SecondCommand)"), "{steps}");
3100 }
3101}