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",
63 "app",
64 "auth",
65 "cache",
66 "email",
67 "openapi",
68 "permissions",
69 "rest",
70 "rls",
71 "security",
72 "sessions",
73 "signals",
74 "static",
75 "tasks",
76];
77
78pub const RESERVED_PLUGIN_COMMAND_NAMES: &[&str] = &[
87 "clearsessions",
88 "collectstatic",
89 "createsuperuser",
90 "gen-client",
91 "migrate_schemas",
92 "startauthentication",
93 "startpagination",
94 "startpermission",
95 "startthrottle",
96 "tasks-beat",
97 "tasks-worker",
98];
99
100pub fn reserved_command_names() -> Vec<String> {
113 let mut names = crate::builtin_command_names();
114 names.extend(RESERVED_PLUGIN_COMMAND_NAMES.iter().map(|s| s.to_string()));
115 names.sort();
116 names.dedup();
117 names
118}
119
120impl std::fmt::Display for ScaffoldError {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 match self {
123 Self::InvalidName(s) => write!(
124 f,
125 "invalid name `{s}`: must be ASCII alphanumeric, underscore or hyphen, not starting with a digit",
126 ),
127 Self::AlreadyExists(p) => write!(
128 f,
129 "an app already exists at `{}`; move it aside or pick a different name",
130 p.display()
131 ),
132 Self::ReservedName(s) => write!(
133 f,
134 "`{s}` is the name of a built-in umbral plugin; pick a different name to avoid conflicts at registration time. Reserved names: {}.",
135 RESERVED_PLUGIN_NAMES.join(", ")
136 ),
137 Self::ReservedCommandName(s) => write!(
138 f,
139 "`{s}` is already an umbral command; pick another name. A command you register \
140 is dispatched BEFORE the built-in of the same name, so this one would shadow \
141 it. Taken names: {}.",
142 reserved_command_names().join(", ")
143 ),
144 Self::NoSuchPlugin { asked, available } => {
145 if available.is_empty() {
146 write!(
147 f,
148 "no plugin named `{asked}` — this project has no `plugins/` directory yet. \
149 Create one with `umbral startapp <name>`, or place the command at the \
150 project root with `--in root`."
151 )
152 } else {
153 write!(
154 f,
155 "no plugin named `{asked}`. Available: root, {}.",
156 available.join(", ")
157 )
158 }
159 }
160 Self::NotAProject(p) => write!(
161 f,
162 "`{}` doesn't look like an umbral project — no `src/main.rs`. cd into your \
163 project directory, or pass `--path <dir>`.",
164 p.display()
165 ),
166 Self::Io(e) => write!(f, "{e}"),
167 }
168 }
169}
170
171impl std::error::Error for ScaffoldError {}
172
173impl From<io::Error> for ScaffoldError {
174 fn from(e: io::Error) -> Self {
175 Self::Io(e)
176 }
177}
178
179impl From<umbral::codegen::CodegenError> for ScaffoldError {
184 fn from(e: umbral::codegen::CodegenError) -> Self {
185 use umbral::codegen::CodegenError as C;
186 match e {
187 C::InvalidName(s) => Self::InvalidName(s),
188 C::AlreadyExists(p) => Self::AlreadyExists(p),
189 C::NoSuchPlugin { asked, available } => Self::NoSuchPlugin { asked, available },
190 C::NotAProject(p) => Self::NotAProject(p),
191 C::Io(e) => Self::Io(e),
192 }
193 }
194}
195
196#[derive(Debug, Clone)]
199pub struct ScaffoldReport {
200 pub root: PathBuf,
203 pub files: Vec<PathBuf>,
205 pub next_steps: Vec<String>,
208 pub cargo_toml_registered: Option<bool>,
214 pub registered: Option<bool>,
226}
227
228fn validate_name(name: &str) -> Result<(), ScaffoldError> {
237 umbral::codegen::validate_ident(name).map_err(Into::into)
238}
239
240pub(crate) fn localize_deps(text: &str, umbral_repo: &Path) -> String {
258 let repo_str = umbral_repo.display().to_string();
259 let mut out = String::with_capacity(text.len());
260 for line in text.split_inclusive('\n') {
261 out.push_str(&rewrite_line(line, &repo_str));
262 }
263 out
264}
265
266fn rewrite_line(line: &str, repo: &str) -> String {
273 let body_start = line
277 .char_indices()
278 .find(|(_, c)| !matches!(*c, '#' | ' ' | '\t'))
279 .map(|(i, _)| i)
280 .unwrap_or(0);
281 let body = &line[body_start..];
282 let Some(eq_idx) = body.find('=') else {
283 return line.to_string();
284 };
285 let crate_name = body[..eq_idx].trim();
286 if !crate_name.starts_with("umbral") || crate_name.contains(|c: char| c.is_whitespace()) {
289 return line.to_string();
290 }
291 let after_eq = &body[eq_idx + 1..];
295 let spec_offset = after_eq.len() - after_eq.trim_start().len();
296 let spec = after_eq.trim_start();
297 let spec_len = if let Some(rest) = spec.strip_prefix('"') {
298 match rest.find('"') {
299 Some(i) => 1 + i + 1,
300 None => return line.to_string(),
301 }
302 } else if spec.starts_with('{') {
303 match spec.find('}') {
304 Some(i) => i + 1,
305 None => return line.to_string(),
306 }
307 } else {
308 return line.to_string();
309 };
310 let spec_start = body_start + eq_idx + 1 + spec_offset;
311 let spec_end = spec_start + spec_len;
312 let subdir = match crate_name {
313 "umbral" | "umbral-cli" | "umbral-core" | "umbral-macros" | "umbral-testing" => "crates",
314 _ => "plugins",
315 };
316 let path = format!("{repo}/{subdir}/{crate_name}");
317 let prefix = &line[..spec_start];
318 let suffix = &line[spec_end..];
319 format!("{prefix}{{ path = \"{path}\" }}{suffix}")
320}
321
322fn rust_ident(name: &str) -> String {
323 name.replace('-', "_")
324}
325
326fn random_dev_secret_key() -> String {
333 use std::hash::{BuildHasher, Hasher};
334 let seed = std::collections::hash_map::RandomState::new();
338 let mut out = String::with_capacity(64);
339 for i in 0..4u64 {
340 let mut h = seed.build_hasher();
341 h.write_u64(i);
342 h.write_u64(i.wrapping_mul(0x9E37_79B9_7F4A_7C15));
343 out.push_str(&format!("{:016x}", h.finish()));
344 }
345 out
346}
347const DOCS_URL: &str = "https://dalmasonto.github.io/umbral/docs/v0.0.1";
349
350fn find_umbral_checkout(start: &Path) -> Option<PathBuf> {
401 start
402 .ancestors()
403 .find(|d| d.join("crates/umbral-core/Cargo.toml").is_file())
404 .map(Path::to_path_buf)
405}
406
407fn warn_if_run_from_a_source_checkout(name: &str, parent_dir: &Path) {
421 let from_cwd = std::env::current_dir()
422 .ok()
423 .and_then(|d| find_umbral_checkout(&d));
424 let Some(repo) = from_cwd.or_else(|| find_umbral_checkout(parent_dir)) else {
425 return;
426 };
427 let version = env!("CARGO_PKG_VERSION");
428 let repo = repo.display();
429 eprintln!(
430 "warning: running `startproject` from an umbral source checkout ({repo}) without `--local`."
431 );
432 eprintln!();
433 eprintln!(
434 " The new project will depend on the PUBLISHED umbral {version}, while your checkout is on"
435 );
436 eprintln!(
437 " whatever you have got. Any framework surface you have added since {version} was released"
438 );
439 eprintln!(
440 " will be missing, and the generated project will fail to compile against it — looking for"
441 );
442 eprintln!(" all the world like a framework bug rather than a version skew.");
443 eprintln!();
444 eprintln!(" To build against this checkout instead:");
445 eprintln!();
446 eprintln!(" umbral startproject {name} --local {repo}");
447 eprintln!();
448}
449
450pub fn scaffold_project(
451 name: &str,
452 parent_dir: &Path,
453 local_umbral_repo: Option<&Path>,
454) -> Result<ScaffoldReport, ScaffoldError> {
455 validate_name(name)?;
456
457 if local_umbral_repo.is_none() {
458 warn_if_run_from_a_source_checkout(name, parent_dir);
459 }
460
461 let root = parent_dir.join(name);
462 if root.exists() {
463 return Err(ScaffoldError::AlreadyExists(root));
464 }
465
466 fs::create_dir_all(&root)?;
467 fs::create_dir_all(root.join("src"))?;
468 fs::create_dir_all(root.join("src/views"))?;
469 fs::create_dir_all(root.join("src/seed"))?;
470 fs::create_dir_all(root.join("src/widgets"))?;
471 fs::create_dir_all(root.join("plugins"))?;
472 fs::create_dir_all(root.join("templates"))?;
473
474 let crate_name = rust_ident(name);
475 let mut files = Vec::new();
476
477 let version = env!("CARGO_PKG_VERSION");
481 let cargo_toml = format!(
482 r#"[package]
483name = "{name}"
484version = "0.1.0"
485edition = "2024"
486
487[dependencies]
488
489# ----- Framework core (always required) ------------------------------------
490umbral = "{version}"
491umbral-cli = "{version}"
492
493# ----- Active by default ---------------------------------------------------
494# What the generated `src/main.rs` wires in. Comment any of these out only
495# if you also remove the matching `.plugin(...)` line.
496umbral-auth = "{version}"
497umbral-sessions = "{version}"
498umbral-admin = "{version}"
499umbral-rest = "{version}"
500umbral-openapi = "{version}"
501umbral-security = "{version}"
502# Observability init helper (structured JSON logging). Enable the `otel`
503# feature to ALSO export OpenTelemetry traces over OTLP to a collector
504# (Jaeger/Tempo/Honeycomb): `umbral-logs = {{ version = "{version}", features = ["otel"] }}`.
505umbral-logs = "{version}"
506# Serves ./static at /static — including the compiled Tailwind bundle this
507# project ships. Not optional: the SecurityPlugin's CSP blocks third-party
508# script/style CDNs, so an app must serve its own assets.
509umbral-storage = "{version}"
510
511# ----- Available built-ins (uncomment + register in main.rs to enable) -----
512# umbral-playground = "{version}" # Interactive API playground UI (think mini-Postman) at /playground/.
513# umbral-tasks = "{version}" # DB-backed background task queue with a worker process.
514# umbral-permissions = "{version}" # ContentType + Group + Permission model.
515# umbral-rls = "{version}" # Postgres row-level security policy registration.
516# umbral-cache = "{version}" # Per-request caching helper.
517# umbral-email = "{version}" # SMTP + MIME email composer + sender.
518# umbral-signals = "{version}" # Pre/post save/delete signal dispatch.
519# umbral-livereload = "{version}" # Dev-only browser live-reload (SSE push + file watcher). Add `.plugin(LiveReloadPlugin::new())`.
520
521# ----- Third-party + framework runtime deps --------------------------------
522tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
523tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
524serde = {{ version = "1", features = ["derive"] }}
525chrono = {{ version = "0.4", features = ["serde"] }}
526sqlx = {{ version = "0.8", features = ["macros", "sqlite", "postgres", "chrono", "runtime-tokio"] }}
527
528# Once you `umbral startapp <plugin>` or `umbral startplugin <plugin>`, add
529# the plugin crate here:
530# {crate_name}-posts = {{ path = "plugins/posts" }}
531"#
532 );
533 let cargo_toml = match local_umbral_repo {
540 Some(repo) => localize_deps(&cargo_toml, repo),
541 None => cargo_toml,
542 };
543 write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
544
545 let main_rs = format!(
549 r#"//! {name} — application entrypoint.
550//!
551//! This `main.rs` reads like a table of contents: the App builder lists
552//! every model, plugin, and route, and the per-concern submodules below
553//! own the detail. As the project grows you slot new handlers into
554//! `views/`, new seed steps into `seed/`, and new dashboard widgets into
555//! `widgets/` — `main.rs` stays a thin wiring layer.
556//!
557//! src/
558//! main.rs — App builder + route table + boot helpers (this file)
559//! views/ — HTTP handlers, one file per resource grouping
560//! seed/ — first-run data, `seed::all()` pins dependency order
561//! widgets/ — admin dashboard widgets, one file per kind
562//! ../plugins/ — local app plugins (`umbral startapp <name>`)
563//!
564//! Run with:
565//! cargo run -- migrate # apply pending migrations (run once after checkout)
566//! cargo run -- serve # boot the HTTP server
567//!
568//! Other management commands:
569//! cargo run -- makemigrations
570//! cargo run -- showmigrations
571//! cargo run -- createsuperuser
572
573// --- Per-concern modules (the table of contents) ---------------------------
574mod seed;
575mod views;
576mod widgets;
577
578use umbral::prelude::*;
579use umbral::web::{{SlashRedirect}};
580use umbral::migrate::MigrateError;
581use umbral_auth::{{AuthPlugin, AuthUser, login_required_html}};
582use umbral_sessions::SessionsPlugin;
583use umbral_admin::AdminPlugin;
584use umbral_rest::{{RestPlugin, ResourceConfig}};
585use umbral_openapi::OpenApiPlugin;
586use umbral_security::{{SecurityConfig, SecurityPlugin}};
587use umbral_storage::StoragePlugin;
588
589// ---------------------------------------------------------------------------
590// Models
591// ---------------------------------------------------------------------------
592
593/// A blog post. `author` is a FK to the built-in `AuthUser` model — the
594/// migration engine emits `REFERENCES "auth_user"("id")` automatically.
595#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]
596pub struct Post {{
597 pub id: i64,
598 pub title: String,
599 pub body: String,
600 pub published: bool,
601 pub author: ForeignKey<AuthUser>,
602 pub created_at: Option<chrono::DateTime<chrono::Utc>>,
603}}
604
605// ---------------------------------------------------------------------------
606// App wiring
607// ---------------------------------------------------------------------------
608
609#[tokio::main]
610async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {{
611 // Observability: structured logging + (under the `otel` feature on
612 // `umbral-logs`) OpenTelemetry OTLP trace export. Reads RUST_LOG,
613 // UMBRAL_LOG_FORMAT=json, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME.
614 // Keep the guard alive for the whole program: it flushes the OTLP
615 // exporter on drop so trailing spans aren't lost at exit.
616 let _obs = umbral_logs::observability::init(umbral_logs::ObservabilityConfig::from_env());
617
618 let settings = Settings::from_env()?;
619 let pool = umbral::db::connect(&settings.database_url).await?;
620
621 let app = App::builder()
622 .settings(settings)
623 .database("default", pool)
624
625 // --- Models ----------------------------------------------------------
626 // AuthUser and Session are contributed by their plugins below.
627 // List your own models here.
628 .model::<Post>()
629
630 // --- Plugins ---------------------------------------------------------
631 // Auth: user table, password hashing, createsuperuser command.
632 .plugin(AuthPlugin::<AuthUser>::default())
633 // Sessions: session table + cookie middleware.
634 .plugin(SessionsPlugin::default())
635 // Admin: auto CRUD UI at /admin/ for every registered model.
636 // The dashboard mounts one builtin widget from `widgets/` so a
637 // fresh admin isn't empty — add your own with `.dashboard_section`.
638 .plugin(
639 AdminPlugin::default()
640 .dashboard_section(widgets::cards::overview_section()),
641 )
642 // REST: JSON CRUD + filtering at /api/<table>/.
643 // The Post resource has query-string filtering enabled so
644 // GET /api/post/?published=true works out of the box.
645 .plugin(
646 RestPlugin::default()
647 .resource(ResourceConfig::new("post")),
648 )
649 // OpenAPI: Swagger UI at /openapi/ (override with
650 // `.at("/api/docs")` if you prefer a different mount).
651 .plugin(OpenApiPlugin::new())
652 // Static files: serves ./static at /static, which is where the compiled
653 // Tailwind bundle lives. Use `{{ static('css/app.css') }}` in templates
654 // rather than a hardcoded path — in production it resolves through the
655 // hashed-asset manifest so you get cache-busting for free.
656 //
657 // The same plugin also gives you uploaded-file storage (local FS or S3)
658 // when you add a FileField / ImageField: `.media("/media", "./media")`.
659 .plugin(StoragePlugin::new().static_files("/static", "./static"))
660 // Security (on by default): CSRF + clickjacking/HSTS hardening
661 // headers across the app. `/api` is exempt so token-authenticated
662 // JSON clients can POST without a browser form CSRF cookie.
663 .plugin(SecurityPlugin::with_config(SecurityConfig {{
664 csrf_exempt_paths: vec!["/api".to_string()],
665 ..Default::default()
666 }}))
667
668 // --- Templates -------------------------------------------------------
669 .templates_dir("templates")
670 .not_found_template("404.html")
671 .server_error_template("500.html")
672
673 // Redirect /foo → /foo/ (append trailing slash).
674 .slash_redirect(SlashRedirect::Append)
675
676 // --- Routes ----------------------------------------------------------
677 // The Routes builder records each (method, path) pair as you
678 // declare it, so the dev-mode 404 panel surfaces them without
679 // a parallel declaration list. Handlers live in `views/`; this
680 // table is the URL conf — open `views/mod.rs` to see them all.
681 // Per-route middleware (here, login_required_html on /dashboard)
682 // goes through the explicit `.layered(method, path, mr)` form so
683 // the layer attaches just to that handler — not all routes.
684 .routes(
685 Routes::new()
686 // Public home page.
687 .get("/", views::public::home)
688 // API: list posts as JSON (no auth required — demo).
689 .get("/api/posts", views::public::api_list_posts)
690 // Dashboard: only reachable when logged in. The
691 // login_required_html("/login") layer issues a 302 to
692 // /login?next=/dashboard/ for anonymous visitors.
693 .layered(
694 "GET",
695 "/dashboard",
696 get(views::public::dashboard).layer(login_required_html("/login")),
697 ),
698 )
699 // `build_deferred`, not `build`: it wires everything (pools, model
700 // registry, router, system checks) but leaves each plugin's `on_ready`
701 // hook unfired. Those hooks seed content and backfill rows, so they must
702 // not run during `migrate` — the command whose whole job is to create the
703 // tables they write to. `dispatch` fires them once it has read argv.
704 .build_deferred()?;
705
706 // Auto-migrate + seed on boot so `cargo run -- serve` Just Works
707 // against a fresh database — but only when we're actually starting
708 // the server. Running `cargo run -- makemigrations` or `migrate`
709 // from the CLI used to silently trigger `auto_migrate()` first and
710 // then report "no changes detected" (IMP-1 in bugs/tests/testBugs.md).
711 // The guard reads `std::env::args` before dispatch picks them apart
712 // so it matches whatever subcommand the user actually typed.
713 let argv: Vec<String> = std::env::args().collect();
714 let user_invoked_cli = argv.iter().skip(1).any(|a| !a.starts_with('-'));
715 if !user_invoked_cli {{
716 auto_migrate().await?;
717 // First-run data. `seed::all()` is idempotent — see seed/mod.rs.
718 seed::all().await?;
719 }}
720
721 umbral_cli::dispatch(app).await
722}}
723
724// ---------------------------------------------------------------------------
725// Boot helpers
726// ---------------------------------------------------------------------------
727
728/// Run `makemigrations` + `migrate` on boot. Demo-only convenience.
729async fn auto_migrate() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {{
730 match umbral::migrate::make().await {{
731 Ok(paths) => {{
732 for path in paths {{
733 eprintln!("auto-migrate: wrote {{}}", path.display());
734 }}
735 }}
736 Err(MigrateError::NoChanges) => {{}}
737 Err(err) => return Err(Box::new(err)),
738 }}
739 let n = umbral::migrate::run().await?;
740 if n > 0 {{
741 eprintln!("auto-migrate: applied {{n}} migration(s)");
742 }}
743 Ok(())
744}}
745"#
746 );
747 write_file(&root, "src/main.rs", &main_rs, &mut files)?;
748
749 let views_mod_rs = r#"//! HTTP handlers, split by concern — the re-export / discoverability
753//! layer. Open this file and you see the whole web surface in a few
754//! lines: one submodule per resource grouping.
755//!
756//! Submodules:
757//! - `public` — pages anyone can hit (home, JSON listings).
758//!
759//! Add `pub mod account;` here when auth-gated views land (dashboard,
760//! /me, staff-only pages), then re-export it below so `main.rs` keeps
761//! referencing handlers as `views::public::home` without caring which
762//! file owns each one. This is a recommended convention, not a rule —
763//! the router reads handlers directly, so you're free to restructure.
764
765pub mod public;
766
767// No `internal_error` helper, on purpose.
768//
769// Handlers return `Result<_, umbral::web::ApiError>` and use a bare `?`. ApiError
770// converts from sqlx / WriteError / TemplateError, logs the real cause server-side, and
771// returns an opaque 500 — so a missing table or a SQL fragment never reaches the browser.
772// The `(StatusCode, String)` + `err.to_string()` pattern does the opposite.
773"#;
774 write_file(&root, "src/views/mod.rs", views_mod_rs, &mut files)?;
775
776 let views_public_rs = r#"//! Public storefront views — anyone can hit these, no auth required.
780//!
781//! Every handler returns `Result<_, ApiError>` and lets `?` do the work. `ApiError`
782//! converts from a database error, a `WriteError` and a template error, so there is no
783//! per-handler error helper to write — and a 500 logs the real cause server-side while
784//! the client gets an opaque message. Never hand `err.to_string()` to a browser: that is
785//! how table names and SQL fragments end up on someone else's screen.
786
787use umbral::prelude::*;
788use umbral::templates::context;
789
790use crate::Post;
791use crate::post;
792
793/// Home page. Counts published posts and renders home.html.
794pub async fn home() -> Result<Html<String>, ApiError> {
795 let post_count = Post::objects()
796 .filter(post::PUBLISHED.eq(true))
797 .count()
798 .await?;
799
800 let body = umbral::templates::render("home.html", &context!(post_count))?;
801 Ok(Html(body))
802}
803
804/// JSON list of all posts — demonstrates the ORM QuerySet.
805pub async fn api_list_posts() -> Result<Json<Vec<Post>>, ApiError> {
806 let posts = Post::objects().order_by(post::ID.desc()).fetch().await?;
807 Ok(Json(posts))
808}
809
810/// Dashboard: only reachable when logged in (see the `login_required_html`
811/// layer in `main.rs`). The `LoggedIn<AuthUser>` extractor supplies the
812/// current user — the layer already checked the session, so this is a
813/// cheap field read, not a second DB query.
814pub async fn dashboard(
815 user: umbral_auth::LoggedIn<umbral_auth::AuthUser>,
816) -> Result<Html<String>, ApiError> {
817 // Demonstrates a transaction: fetch the user's post list atomically.
818 let user_id = user.id;
819 let my_posts = umbral::transaction(|tx| {
820 Box::pin(async move {
821 Post::objects()
822 .filter(post::AUTHOR.eq(user_id))
823 .on_tx(tx)
824 .fetch()
825 .await
826 })
827 })
828 .await?;
829
830 let body = umbral::templates::render("dashboard.html", &context!(user, my_posts))?;
831 Ok(Html(body))
832}
833"#;
834 write_file(&root, "src/views/public.rs", views_public_rs, &mut files)?;
835
836 let seed_mod_rs = r#"//! Seed orchestrator — the re-export / dependency-order layer. One
840//! file per concern keeps each step small and focused; `all()` pins
841//! the order in which they run.
842//!
843//! Submodules:
844//! - `credentials` — first-run dev superuser so you can log in to
845//! /admin/ without a manual `createsuperuser`.
846//!
847//! Add a `pub mod <concern>;` here for each new seed step, then call it
848//! from `all()` in dependency order (e.g. catalog rows before the orders
849//! that reference them). The order in `all()` doubles as documentation
850//! of which step depends on which.
851
852pub mod credentials;
853
854/// Run every seed step in the right order. Each step is idempotent
855/// (short-circuits on a non-empty table), so calling `all()` on a
856/// partially-seeded DB tops up the missing pieces without re-inserting.
857pub async fn all() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
858 credentials::test_credentials().await?;
859 Ok(())
860}
861"#;
862 write_file(&root, "src/seed/mod.rs", seed_mod_rs, &mut files)?;
863
864 let seed_credentials_rs = r#"//! First-run convenience: mints a dev superuser `admin` when no users
868//! exist yet — but ONLY in the Dev environment AND only when you opt in
869//! by exporting a password. There is deliberately NO hardcoded default
870//! password: a bare `./app` launch against an empty production database
871//! must never plant a known-credential admin account.
872//!
873//! To auto-seed the dev superuser:
874//!
875//! UMBRAL_DEV_ADMIN_PASSWORD=your-dev-password cargo run
876//!
877//! Otherwise the first boot prints guidance to run
878//! `cargo run -- createsuperuser` and seeds nothing. Idempotent —
879//! subsequent boots find the user and stay quiet.
880
881use umbral::Environment;
882use umbral_auth::AuthUser;
883
884/// Env var that opts a fresh install into the dev-superuser seed and
885/// supplies its password. Unset => no seed (print guidance instead).
886const DEV_ADMIN_PASSWORD_ENV: &str = "UMBRAL_DEV_ADMIN_PASSWORD";
887
888pub async fn test_credentials() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
889 // Never mint a dev superuser outside the Dev environment — belt and
890 // suspenders on top of the caller only running us on a bare launch.
891 if umbral::settings::get().environment != Environment::Dev {
892 return Ok(());
893 }
894
895 // Idempotent: bail out the moment any user exists.
896 if AuthUser::objects().count().await? > 0 {
897 return Ok(());
898 }
899
900 // Opt-in only: without an explicit password we plant nothing. This
901 // is what keeps a known `admin`/`admin` account off every fresh DB.
902 let password = match std::env::var(DEV_ADMIN_PASSWORD_ENV) {
903 Ok(p) if !p.is_empty() => p,
904 _ => {
905 eprintln!();
906 eprintln!("No users yet, and no dev superuser was seeded. To create one:");
907 eprintln!(" • interactive: cargo run -- createsuperuser");
908 eprintln!(" • auto on boot: set {DEV_ADMIN_PASSWORD_ENV}=... and restart");
909 eprintln!(" (Dev environment only; never seeds in Prod)");
910 eprintln!();
911 return Ok(());
912 }
913 };
914
915 umbral_auth::create_superuser("admin", "admin@example.com", &password)
916 .await
917 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
918
919 eprintln!();
920 eprintln!("======================================================================");
921 eprintln!(" DEV SUPERUSER seeded (Dev environment, {DEV_ADMIN_PASSWORD_ENV} set)");
922 eprintln!("----------------------------------------------------------------------");
923 eprintln!(" Username : admin");
924 eprintln!(" Password : (the value of {DEV_ADMIN_PASSWORD_ENV})");
925 eprintln!(" Log in : http://127.0.0.1:8000/admin/");
926 eprintln!(" Remove or edit src/seed/credentials.rs before shipping.");
927 eprintln!("======================================================================");
928 eprintln!();
929
930 Ok(())
931}
932"#;
933 write_file(
934 &root,
935 "src/seed/credentials.rs",
936 seed_credentials_rs,
937 &mut files,
938 )?;
939
940 let widgets_mod_rs = r#"//! Admin dashboard widgets — the re-export / discoverability layer,
944//! grouped by kind so each file stays small and focused on one
945//! rendering shape.
946//!
947//! Submodules:
948//! - `cards` — KPI tiles + dashboard sections.
949//!
950//! Add `pub mod charts;`, `pub mod tables;`, etc. as your dashboard
951//! grows, then re-export the builders so `main.rs` calls them as
952//! `widgets::cards::overview_section()` without knowing which file owns
953//! each one. A recommended convention — restructure freely.
954
955pub mod cards;
956"#;
957 write_file(&root, "src/widgets/mod.rs", widgets_mod_rs, &mut files)?;
958
959 let widgets_cards_rs = r#"//! Dashboard widget builders. This starter re-exports one framework
964//! builtin so a fresh `/admin/` dashboard isn't empty; replace it with
965//! your own KPI tiles as the app grows.
966//!
967//! A widget is a `Widget` value handed to `WidgetSection::widget(...)`.
968//! Each section becomes one row of tiles on the admin dashboard. See
969//! `documentation/docs/v0.0.1/admin/` and the `examples/shop/src/widgets`
970//! reference for the data-closure pattern that hits the ORM.
971
972use umbral_admin::WidgetSection;
973
974/// One dashboard section wiring two framework builtins: a model-count
975/// tile and a recent-users list. Mounted from `main.rs` via
976/// `.dashboard_section(widgets::cards::overview_section())`.
977pub fn overview_section() -> WidgetSection {
978 WidgetSection::new("Overview")
979 .subtitle("Framework-wide health + recent activity")
980 .widget(umbral_admin::builtin_total_models_widget().with_span(8, 2))
981 .widget(umbral_admin::builtin_recent_users_widget().with_span(4, 2))
982}
983"#;
984 write_file(&root, "src/widgets/cards.rs", widgets_cards_rs, &mut files)?;
985
986 write_file(&root, "plugins/.gitkeep", "", &mut files)?;
990 let plugins_readme = "# plugins/\n\nLocal app plugins go here; create one with `umbral startapp <name>`.\nEach is its own crate (`lib/models/views/urls`) and is\nauto-wired into this project's `Cargo.toml` `[dependencies]`.\n";
991 write_file(&root, "plugins/README.md", plugins_readme, &mut files)?;
992
993 let dev_secret = random_dev_secret_key();
999 let umbral_toml = format!(
1000 r#"# umbral settings for {name}.
1001# Environment variables (UMBRAL_*) override these at runtime.
1002# See umbral::settings for the full schema.
1003
1004database_url = "sqlite://{name}.db?mode=rwc"
1005
1006# Bind address for `cargo run -- serve`.
1007# Override via UMBRAL_BIND_ADDR or the --addr flag.
1008bind_addr = "127.0.0.1:8000"
1009
1010environment = "Dev"
1011
1012# A random dev-only key, unique to this project. CHANGE THIS IN PRODUCTION —
1013# the framework errors at boot if a dev key is used with environment = "Prod".
1014secret_key = "{dev_secret}"
1015"#
1016 );
1017 write_file(&root, "umbral.toml", &umbral_toml, &mut files)?;
1018
1019 let dot_env = format!(
1023 r#"# Working .env for {name}. Do not commit this file.
1024# Generate a real secret key: openssl rand -hex 32
1025UMBRAL_DATABASE_URL=sqlite://{name}.db?mode=rwc
1026UMBRAL_BIND_ADDR=127.0.0.1:8000
1027UMBRAL_SECRET_KEY={dev_secret}
1028RUST_LOG=info,umbral=debug
1029"#
1030 );
1031 write_file(&root, ".env", &dot_env, &mut files)?;
1032
1033 let env_example = r#"# Copy to `.env` and source from your shell, or use a tool like direnv.
1037# Settings here override the umbral.toml values at runtime.
1038#
1039# UMBRAL_SECRET_KEY=$(openssl rand -hex 32)
1040# UMBRAL_DATABASE_URL=sqlite://my.db?mode=rwc
1041# UMBRAL_BIND_ADDR=0.0.0.0:8000
1042# UMBRAL_ENVIRONMENT=prod
1043# RUST_LOG=info,umbral=debug
1044"#;
1045 write_file(&root, ".env.example", env_example, &mut files)?;
1046
1047 let gitignore = format!("/target\n/{name}.db*\n.env\nCargo.lock\n");
1051 write_file(&root, ".gitignore", &gitignore, &mut files)?;
1052
1053 let readme = format!(
1057 r#"# {name}
1058
1059Your umbral app.
1060
1061It starts with one model (`Post`), an admin, a JSON API and an OpenAPI browser, so there
1062is something running from the first `cargo run`. All of it is ordinary code in this
1063repository — rename it, gut it, replace it.
1064
1065## What's in the project
1066
1067| File | What it shows |
1068|---|---|
1069| `src/main.rs` | App wiring: models, plugins, routes, auto-migrate |
1070| `Post` model | `ForeignKey<AuthUser>`, ORM QuerySet, `#[derive(Model)]` |
1071| `/` route | Template rendering with context |
1072| `/api/posts` | JSON endpoint via the ORM |
1073| `/dashboard` | `login_required_html("/login")` layer, `LoggedIn<AuthUser>` extractor, transaction |
1074| `RestPlugin` | JSON CRUD at `/api/post/` with query-string filtering (`?published=true`) |
1075| `AdminPlugin` | Auto CRUD UI at `/admin/` |
1076| `OpenApiPlugin` | Swagger UI at `/openapi/` |
1077| `SecurityPlugin` | CSRF middleware + hardening headers, with `/api` exempt for token clients |
1078
1079## Running
1080
1081```bash
1082# First run — a bare `cargo run` (no subcommand) auto-migrates the
1083# database and then starts the server. Passing an explicit subcommand
1084# (like `serve`) SKIPS the auto-migrate, so `serve` alone assumes the
1085# schema already exists.
1086cargo run
1087
1088# Separate steps (production pattern) — migrate explicitly, then serve:
1089cargo run -- migrate
1090cargo run -- serve
1091
1092# Create a superuser to log in to the admin:
1093cargo run -- createsuperuser
1094
1095# Inspect the schema:
1096cargo run -- showmigrations
1097cargo run -- makemigrations
1098```
1099
1100## Styling
1101
1102The pages use Tailwind, compiled to `static/css/app.css` and served by the
1103StoragePlugin at `/static`. That bundle ships **prebuilt**, so this project renders
1104correctly with no `npm install`.
1105
1106You only need Node once you edit a template and reach for a utility class that is not
1107already in the bundle:
1108
1109```bash
1110cd styles
1111npm install
1112npm run build # or: npm run watch
1113```
1114
1115The palette lives in `styles/input.css` as CSS variables (`--accent` is the violet).
1116Change them there and every page follows. There is deliberately no `cdn.tailwindcss.com`
1117script: it is versionless, it pulls a third party into every page load, and it is the
1118first thing a `default-src 'self'` Content-Security-Policy blocks.
1119
1120## Where to go next
1121
1122- Add a plugin: `umbral startapp posts`
1123- Your first app: {docs}/getting-started/your-first-app
1124- Models & the ORM: {docs}/orm/models
1125- Migrations: {docs}/migrations/managed-migrations
1126- Admin: {docs}/plugins/admin
1127- REST: {docs}/rest/index
1128- Login & signup pages: {docs}/auth/login-and-signup-pages
1129- The Plugin trait: {docs}/plugins/the-plugin-trait
1130"#,
1131 docs = DOCS_URL,
1132 );
1133 write_file(&root, "README.md", &readme, &mut files)?;
1134
1135 let initial = name
1144 .chars()
1145 .next()
1146 .map(|c| c.to_uppercase().to_string())
1147 .unwrap_or_else(|| "U".to_string());
1148 let fill = |tpl: &str| -> String {
1149 tpl.replace("__PROJECT__", name)
1150 .replace("__INITIAL__", &initial)
1151 .replace("__DOCS__", DOCS_URL)
1152 };
1153
1154 for (path, body) in [
1155 (
1156 "templates/base.html",
1157 include_str!("../assets/scaffold/templates/base.html"),
1158 ),
1159 (
1160 "templates/home.html",
1161 include_str!("../assets/scaffold/templates/home.html"),
1162 ),
1163 (
1164 "templates/dashboard.html",
1165 include_str!("../assets/scaffold/templates/dashboard.html"),
1166 ),
1167 (
1168 "templates/404.html",
1169 include_str!("../assets/scaffold/templates/404.html"),
1170 ),
1171 (
1172 "templates/500.html",
1173 include_str!("../assets/scaffold/templates/500.html"),
1174 ),
1175 (
1176 "styles/input.css",
1177 include_str!("../assets/scaffold/styles/input.css"),
1178 ),
1179 (
1180 "styles/tailwind.config.js",
1181 include_str!("../assets/scaffold/styles/tailwind.config.js"),
1182 ),
1183 (
1184 "styles/package.json",
1185 include_str!("../assets/scaffold/styles/package.json"),
1186 ),
1187 (
1191 "static/css/app.css",
1192 include_str!("../assets/scaffold/static/css/app.css"),
1193 ),
1194 ] {
1195 write_file(&root, path, &fill(body), &mut files)?;
1196 }
1197
1198 let next_steps = vec![
1199 format!("cd {name}"),
1200 "cargo run -- migrate # apply schema migrations".to_string(),
1201 "cargo run -- serve # boot the HTTP server on http://127.0.0.1:8000".to_string(),
1202 "cargo run -- createsuperuser # create an admin login".to_string(),
1203 "umbral startapp <name> # add another app to this project".to_string(),
1204 ];
1205
1206 Ok(ScaffoldReport {
1207 root,
1208 files,
1209 next_steps,
1210 cargo_toml_registered: None,
1211 registered: None,
1213 })
1214}
1215
1216pub fn scaffold_app(
1236 name: &str,
1237 project_root: &Path,
1238 local_umbral_repo: Option<&Path>,
1239) -> Result<ScaffoldReport, ScaffoldError> {
1240 let normalized = name.replace('-', "_");
1251 if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1252 return Err(ScaffoldError::ReservedName(name.to_string()));
1253 }
1254
1255 validate_name(name)?;
1256
1257 let plugins_dir = project_root.join("plugins");
1258 let root = plugins_dir.join(name);
1259 if root.exists() {
1260 return Err(ScaffoldError::AlreadyExists(root));
1261 }
1262
1263 fs::create_dir_all(&root)?;
1264 fs::create_dir_all(root.join("src"))?;
1265
1266 let crate_name = rust_ident(name);
1267 let pascal = pascal_case_from_ident(name);
1268 let mut files = Vec::new();
1269
1270 let version = env!("CARGO_PKG_VERSION");
1271 let cargo_toml = format!(
1272 r#"[package]
1273name = "{name}"
1274version = "0.1.0"
1275edition = "2024"
1276
1277[dependencies]
1278umbral = "{version}"
1279serde = {{ version = "1", features = ["derive"] }}
1280sqlx = {{ version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] }}
1281chrono = {{ version = "0.4", features = ["serde"] }}
1282"#
1283 );
1284 let cargo_toml = match local_umbral_repo {
1285 Some(repo) => localize_deps(&cargo_toml, repo),
1286 None => cargo_toml,
1287 };
1288 write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1289
1290 let lib_rs = format!(
1291 r#"//! {pascal}Plugin — generated by `umbral startapp {name}`.
1292//!
1293//! A plugin split one file per concern:
1294//!
1295//! src/
1296//! lib.rs — the `Plugin` impl: glues models + routes together (this file)
1297//! models.rs — `#[derive(Model)]` structs (this app's tables)
1298//! views.rs — HTTP handlers
1299//! urls.rs — the URL conf: maps paths to `views::` handlers
1300//!
1301//! Wire this into your App by adding to `src/main.rs`:
1302//!
1303//! ```ignore
1304//! .plugin({crate_name}::{pascal}Plugin::default())
1305//! ```
1306//!
1307//! See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for
1308//! what each `Plugin` method does. This layout is a recommended
1309//! convention — the framework only needs a type that impls `Plugin`.
1310
1311pub mod models;
1312pub mod urls;
1313pub mod views;
1314
1315use umbral::plugin::{{AppContext, Plugin, PluginError}};
1316use umbral::web::Router;
1317
1318#[derive(Debug, Default, Clone)]
1319pub struct {pascal}Plugin;
1320
1321impl Plugin for {pascal}Plugin {{
1322 fn name(&self) -> &'static str {{
1323 "{name}"
1324 }}
1325
1326 fn models(&self) -> Vec<umbral::migrate::ModelMeta> {{
1327 // Register every model the plugin owns so makemigrations
1328 // picks them up. Uncomment + extend once you've defined one
1329 // in src/models.rs.
1330 // vec![umbral::migrate::ModelMeta::for_::<models::Example>()]
1331 Vec::new()
1332 }}
1333
1334 fn routes(&self) -> Router {{
1335 // Routes live in `urls.rs` (this app's URL conf), one place to
1336 // see every path the plugin serves.
1337 urls::router()
1338 }}
1339
1340 fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1341 Ok(())
1342 }}
1343}}
1344"#
1345 );
1346 write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1347
1348 let models_rs = format!(
1352 r#"//! Models for the `{name}` plugin.
1353//!
1354//! Declare one `#[derive(umbral::orm::Model)]` struct per database
1355//! table. Once registered via `Plugin::models()` in lib.rs, the
1356//! migration engine picks them up on the next `makemigrations`.
1357//!
1358//! ```ignore
1359//! use chrono::{{DateTime, Utc}};
1360//! use serde::{{Deserialize, Serialize}};
1361//!
1362//! #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1363//! pub struct Example {{
1364//! pub id: i64,
1365//! #[umbral(string, max_length = 200)]
1366//! pub title: String,
1367//! #[umbral(noedit)]
1368//! pub created_at: DateTime<Utc>,
1369//! }}
1370//! ```
1371"#
1372 );
1373 write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1374
1375 let views_rs = format!(
1378 r#"//! HTTP handlers for the `{name}` plugin.
1379//!
1380//! Each handler is an axum handler — return anything that implements
1381//! `IntoResponse` (`Html<String>`, `Json<T>`, `&'static str`, a
1382//! `Result<_, (StatusCode, String)>`, …). Read this app's data through
1383//! the ORM (`models::*::objects()`), never raw SQL.
1384//!
1385//! Routes that reach these handlers are declared in `urls.rs`.
1386
1387/// Sample landing handler. `GET /{name}/` hits this; rewire the path in
1388/// `urls.rs`.
1389pub async fn index() -> &'static str {{
1390 "Hello from the {name} plugin"
1391}}
1392"#
1393 );
1394 write_file(&root, "src/views.rs", &views_rs, &mut files)?;
1395
1396 let urls_rs = format!(
1399 r#"//! URL conf for the `{name}` plugin — the route table.
1400//! `router()` returns the axum `Router` that
1401//! `Plugin::routes()` in lib.rs hands back to the framework.
1402//!
1403//! Convention: `/<name>/...` for HTML pages, `/api/<name>/...` for JSON.
1404//! Map each path to a handler in `views.rs` so this file reads as the
1405//! single index of everything the plugin serves.
1406
1407use umbral::web::{{Router, get}};
1408
1409use crate::views;
1410
1411/// Build this plugin's route table. Add one `.route(path, method(handler))`
1412/// line per endpoint.
1413pub fn router() -> Router {{
1414 Router::new().route("/{name}/", get(views::index))
1415}}
1416"#
1417 );
1418 write_file(&root, "src/urls.rs", &urls_rs, &mut files)?;
1419
1420 let project_cargo_toml = project_root.join("Cargo.toml");
1425 let cargo_toml_registered = if project_cargo_toml.is_file() {
1426 register_dep_in_cargo_toml(&project_cargo_toml, name).ok()
1427 } else {
1428 None
1429 };
1430
1431 let next_steps = vec![
1432 "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1433 format!(" .plugin({crate_name}::{pascal}Plugin::default())"),
1434 "(The plugin crate was auto-added to your project dependencies.)".to_string(),
1435 "Declare your first model in src/models.rs and uncomment the".to_string(),
1436 " `Plugin::models()` line in src/lib.rs.".to_string(),
1437 "Add handlers in src/views.rs and route them in src/urls.rs.".to_string(),
1438 ];
1439
1440 Ok(ScaffoldReport {
1441 root,
1442 files,
1443 next_steps,
1444 cargo_toml_registered,
1445 registered: None,
1446 })
1447}
1448
1449pub fn scaffold_plugin(
1469 name: &str,
1470 project_root: &Path,
1471 local_umbral_repo: Option<&Path>,
1472) -> Result<ScaffoldReport, ScaffoldError> {
1473 let normalized = name.replace('-', "_");
1475 if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1476 return Err(ScaffoldError::ReservedName(name.to_string()));
1477 }
1478
1479 validate_name(name)?;
1480
1481 let plugins_dir = project_root.join("plugins");
1482 let root = plugins_dir.join(name);
1483 if root.exists() {
1484 return Err(ScaffoldError::AlreadyExists(root));
1485 }
1486
1487 fs::create_dir_all(&root)?;
1488 fs::create_dir_all(root.join("src"))?;
1489
1490 let crate_name = rust_ident(name);
1491 let pascal = pascal_case_from_ident(name);
1492 let mut files = Vec::new();
1493
1494 let version = env!("CARGO_PKG_VERSION");
1500 let cargo_toml = format!(
1501 r#"[package]
1502name = "{name}"
1503version = "0.1.0"
1504edition = "2024"
1505description = "A {crate_name} plugin for umbral."
1506
1507[dependencies]
1508umbral = "{version}"
1509serde = {{ version = "1", features = ["derive"] }}
1510sqlx = {{ version = "0.8", default-features = false, features = ["macros", "runtime-tokio"] }}
1511chrono = {{ version = "0.4", features = ["serde"] }}
1512async-trait = "0.1"
1513"#
1514 );
1515 let cargo_toml = match local_umbral_repo {
1516 Some(repo) => localize_deps(&cargo_toml, repo),
1517 None => cargo_toml,
1518 };
1519 write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1520
1521 let readme = format!(
1524 r#"# {name}
1525
1526A {crate_name} plugin for [umbral](https://github.com/dalmasonto/umbral).
1527
1528Generated by `umbral startplugin {name}`.
1529
1530## What's inside
1531
1532| File | Purpose |
1533|---|---|
1534| `src/lib.rs` | `{pascal}Plugin` struct + `impl Plugin` (registers models, routes, lifecycle hooks). |
1535| `src/models.rs` | One example model showing common field types (`#[umbral(...)]` attributes for `max_length`, `choices`, FK, defaults). |
1536| `src/handlers.rs` | One example axum handler showing how to read query params and return JSON. |
1537
1538## Wiring it in
1539
1540In your project's `Cargo.toml`:
1541
1542```toml
1543[dependencies]
1544{name} = {{ path = "plugins/{name}" }}
1545```
1546
1547In `src/main.rs`:
1548
1549```rust,ignore
1550let app = umbral::App::builder()
1551 .plugin({crate_name}::{pascal}Plugin::default())
1552 // ... your other plugins
1553 .build()?;
1554```
1555
1556Then:
1557
1558```sh
1559cargo run -- makemigrations # generates 0001_initial.json from your models
1560cargo run -- migrate # applies the schema
1561cargo run -- serve # boots the HTTP server
1562```
1563
1564## Next steps
1565
1566- Add your own models in `src/models.rs` (or split into a `models/` module).
1567- Add routes in `routes()` and handlers in `src/handlers.rs`.
1568- Use `on_ready(&AppContext)` for one-shot setup work (seed default rows, register signals).
1569- See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for the full trait surface.
1570"#
1571 );
1572 write_file(&root, "README.md", &readme, &mut files)?;
1573
1574 let lib_rs = format!(
1578 r#"//! {pascal}Plugin — a distributable umbral plugin.
1579//!
1580//! Wire this into your App in `src/main.rs`:
1581//!
1582//! ```ignore
1583//! .plugin({crate_name}::{pascal}Plugin::default())
1584//! ```
1585//!
1586//! See `README.md` for the full file tour.
1587
1588pub mod handlers;
1589pub mod models;
1590
1591use async_trait::async_trait;
1592use umbral::migrate::ModelMeta;
1593use umbral::orm::Model;
1594use umbral::plugin::{{AppContext, Plugin, PluginError}};
1595use umbral::web::{{Router, get}};
1596
1597/// The plugin entry point. Register one instance per `App::builder()`.
1598#[derive(Debug, Default, Clone)]
1599pub struct {pascal}Plugin;
1600
1601#[async_trait]
1602impl Plugin for {pascal}Plugin {{
1603 fn name(&self) -> &'static str {{
1604 "{name}"
1605 }}
1606
1607 /// Models the framework's migration engine should track. Each
1608 /// returned [`ModelMeta`] becomes one row in the
1609 /// `umbral_migrations` tracking table once the initial migration
1610 /// applies.
1611 fn models(&self) -> Vec<ModelMeta> {{
1612 vec![models::{pascal}Item::meta()]
1613 }}
1614
1615 /// HTTP routes contributed by this plugin. The base path is
1616 /// up to you — convention is `/<name>/...` for HTML and
1617 /// `/api/<name>/...` for JSON.
1618 fn routes(&self) -> Router {{
1619 Router::new().route("/{name}/hello", get(handlers::hello))
1620 }}
1621
1622 /// One-shot setup after `App::build()` finishes. Use this for
1623 /// seeding default rows, registering signal handlers, or any
1624 /// work that needs the database available. Sync because the
1625 /// `Plugin` trait signature is sync (BUG-3 in bugs/tests/testBugs.md);
1626 /// reach into a runtime via `tokio::runtime::Handle::current()
1627 /// .block_on(...)` if you need to await something here.
1628 fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1629 Ok(())
1630 }}
1631}}
1632"#
1633 );
1634 write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1635
1636 let models_rs = format!(
1640 r#"//! Example model. Replace or extend with your own.
1641//!
1642//! What this demonstrates:
1643//! - `#[umbral(max_length = 200)]` — DDL `VARCHAR(200)` + admin form hint.
1644//! - `#[umbral(choices)]` on an enum — closed-set column with OpenAPI
1645//! `enum` and a Postgres `CHECK (col IN (...))` constraint.
1646//! - `Option<DateTime<Utc>>` — nullable timestamptz column.
1647//! - `#[umbral(noedit)]` — read-only on admin forms; not editable via
1648//! PUT/PATCH through the REST plugin.
1649
1650use chrono::{{DateTime, Utc}};
1651use serde::{{Deserialize, Serialize}};
1652
1653/// One {crate_name} item. Replace with whatever your plugin actually
1654/// stores.
1655#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1656pub struct {pascal}Item {{
1657 /// Auto-incrementing primary key.
1658 pub id: i64,
1659
1660 /// Display title. Capped at 200 chars; admin renders a single-line
1661 /// input.
1662 #[umbral(string, max_length = 200)]
1663 pub title: String,
1664
1665 /// Lifecycle state. The choices map 1:1 to enum variants; the
1666 /// migration engine emits a CHECK constraint, the admin renders a
1667 /// `<select>`, and the OpenAPI schema gets an `enum` array.
1668 pub status: {pascal}Status,
1669
1670 /// When the item was last published. Read-only on edit forms.
1671 #[umbral(noedit)]
1672 pub published_at: Option<DateTime<Utc>>,
1673}}
1674
1675/// Lifecycle state for [`{pascal}Item`].
1676#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
1677#[sqlx(rename_all = "lowercase")]
1678#[serde(rename_all = "lowercase")]
1679pub enum {pascal}Status {{
1680 Draft,
1681 Review,
1682 Published,
1683 Archived,
1684}}
1685"#
1686 );
1687 write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1688
1689 let handlers_rs = format!(
1692 r#"//! Example HTTP handlers. Replace or extend with your own.
1693//!
1694//! `GET /{name}/hello?name=world` returns `{{"greeting": "Hello, world!"}}`.
1695
1696use serde::{{Deserialize, Serialize}};
1697use umbral::web::{{Json, extract::Query}};
1698
1699#[derive(Debug, Deserialize, Default)]
1700pub struct HelloParams {{
1701 /// Who to greet. Defaults to "{name}" when omitted.
1702 #[serde(default)]
1703 pub name: Option<String>,
1704}}
1705
1706#[derive(Debug, Serialize)]
1707pub struct HelloResponse {{
1708 pub greeting: String,
1709}}
1710
1711pub async fn hello(Query(params): Query<HelloParams>) -> Json<HelloResponse> {{
1712 let who = params.name.as_deref().unwrap_or("{name}");
1713 Json(HelloResponse {{
1714 greeting: format!("Hello, {{who}}!"),
1715 }})
1716}}
1717"#
1718 );
1719 write_file(&root, "src/handlers.rs", &handlers_rs, &mut files)?;
1720
1721 let project_cargo_toml = project_root.join("Cargo.toml");
1723 let cargo_toml_registered = if project_cargo_toml.is_file() {
1724 register_dep_in_cargo_toml(&project_cargo_toml, name).ok()
1725 } else {
1726 None
1727 };
1728
1729 let next_steps = vec![
1730 "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1731 format!(" .plugin({crate_name}::{pascal}Plugin::default())"),
1732 "Generate + apply the initial migration:".to_string(),
1733 " cargo run -- makemigrations".to_string(),
1734 " cargo run -- migrate".to_string(),
1735 format!("Then visit http://127.0.0.1:8000/{name}/hello?name=you"),
1736 ];
1737
1738 Ok(ScaffoldReport {
1739 root,
1740 files,
1741 next_steps,
1742 cargo_toml_registered,
1743 registered: None,
1744 })
1745}
1746
1747pub use umbral::codegen::Target as CommandTarget;
1761
1762const MODS_MARKER: &str =
1764 "// umbral:startcommand — `umbral startcommand` declares new modules above this line.";
1765const REGISTRY_MARKER: &str =
1767 "// umbral:startcommand — `umbral startcommand` registers new commands above this line.";
1768
1769pub use umbral::codegen::discover_plugins;
1776
1777pub fn scaffold_command(
1807 name: &str,
1808 target: &CommandTarget,
1809 project_root: &Path,
1810) -> Result<ScaffoldReport, ScaffoldError> {
1811 if reserved_command_names().iter().any(|r| r == name) {
1814 return Err(ScaffoldError::ReservedCommandName(name.to_string()));
1815 }
1816
1817 validate_name(name)?;
1818
1819 let module = rust_ident(name);
1820 let pascal = pascal_case_from_ident(name);
1821 let struct_name = format!("{pascal}Command");
1822
1823 let resolved = umbral::codegen::resolve_target(project_root, target)?;
1828 let crate_root = resolved.crate_root.clone();
1829 let owner_file = resolved.owner_file.clone();
1830
1831 let mut files = Vec::new();
1832
1833 umbral::codegen::write_new_file(
1838 &crate_root,
1839 &format!("src/commands/{module}.rs"),
1840 &render_command_file(name, &struct_name, target),
1841 &mut files,
1842 )?;
1843
1844 let mod_rs = crate_root.join("src/commands/mod.rs");
1849 let mut next_steps: Vec<String> = Vec::new();
1850 if mod_rs.is_file() {
1851 let text = fs::read_to_string(&mod_rs)?;
1852 match append_to_registry(&text, &module, &struct_name) {
1853 Some(updated) => {
1854 fs::write(&mod_rs, updated)?;
1855 files.push(PathBuf::from("src/commands/mod.rs"));
1856 }
1857 None => {
1858 next_steps.push(
1862 "src/commands/mod.rs has no `umbral:startcommand` markers — add by hand:"
1863 .to_string(),
1864 );
1865 next_steps.push(format!(" pub mod {module};"));
1866 next_steps.push(format!(
1867 " ...and inside `all()`: Box::new({module}::{struct_name}),"
1868 ));
1869 }
1870 }
1871 } else {
1872 umbral::codegen::write_new_file(
1873 &crate_root,
1874 "src/commands/mod.rs",
1875 &render_registry_file(&module, &struct_name, target),
1876 &mut files,
1877 )?;
1878 }
1879
1880 let owner_text = fs::read_to_string(&owner_file)?;
1885 let wiring = match target {
1886 CommandTarget::Root => wire_registry_into_main(&owner_text),
1887 CommandTarget::Plugin(_) => wire_registry_into_plugin(&owner_text),
1888 };
1889 let registered = match wiring {
1893 Wiring::Updated { text, steps } => {
1894 fs::write(&owner_file, text)?;
1895 let complete = steps.is_empty();
1896 next_steps.extend(steps);
1897 complete
1898 }
1899 Wiring::AlreadyWired => true,
1900 Wiring::Manual(steps) => {
1901 next_steps.extend(steps);
1902 false
1903 }
1904 };
1905
1906 if registered {
1907 next_steps.push(format!("Run it: cargo run -- {name} --help"));
1908 } else {
1909 next_steps.push(format!(
1910 "Then run it: cargo run -- {name} --help (after the steps above — \
1911 it is NOT registered yet)"
1912 ));
1913 }
1914
1915 Ok(ScaffoldReport {
1916 root: crate_root,
1917 files,
1918 next_steps,
1919 cargo_toml_registered: None,
1920 registered: Some(registered),
1921 })
1922}
1923
1924enum Wiring {
1932 Updated { text: String, steps: Vec<String> },
1935 AlreadyWired,
1938 Manual(Vec<String>),
1941}
1942
1943fn wire_registry_into_main(text: &str) -> Wiring {
1950 let already_mod = text.lines().any(|l| l.trim() == "mod commands;");
1951 let already_registered = text.contains(".commands(commands::all())");
1952 if already_mod && already_registered {
1953 return Wiring::AlreadyWired;
1954 }
1955
1956 let mut out = text.to_string();
1957 let mut steps: Vec<String> = Vec::new();
1958
1959 if !already_mod {
1960 match umbral::codegen::declare_module(&out, "mod commands;") {
1963 Some(text) => out = text,
1964 None => steps.push("Add to src/main.rs: mod commands;".to_string()),
1965 }
1966 }
1967
1968 if !already_registered {
1969 match builder_terminal_line(&out) {
1970 Some(idx) => {
1971 let indent: String = out
1972 .lines()
1973 .nth(idx)
1974 .map(|l| l.chars().take_while(|c| c.is_whitespace()).collect())
1975 .unwrap_or_default();
1976 let call = format!(
1977 "{indent}// Project-owned management commands (`umbral startcommand`).\n\
1978 {indent}.commands(commands::all())"
1979 );
1980 out = insert_line_at_before(&out, idx, &call);
1981 }
1982 None => steps.push(
1983 "Add to the App::builder() chain in src/main.rs: .commands(commands::all())"
1984 .to_string(),
1985 ),
1986 }
1987 }
1988
1989 if out == text {
1990 if steps.is_empty() {
1991 Wiring::AlreadyWired
1992 } else {
1993 Wiring::Manual(steps)
1994 }
1995 } else {
1996 Wiring::Updated { text: out, steps }
2003 }
2004}
2005
2006fn builder_terminal_line(text: &str) -> Option<usize> {
2021 let builder_at = text.lines().position(|l| l.contains("App::builder()"))?;
2022 text.lines()
2023 .enumerate()
2024 .skip(builder_at)
2025 .find(|(_, l)| {
2026 let t = l.trim_start();
2027 t.starts_with(".build_deferred()") || t.starts_with(".build()")
2028 })
2029 .map(|(idx, _)| idx)
2030}
2031
2032fn wire_registry_into_plugin(text: &str) -> Wiring {
2041 let already_mod = text.lines().any(|l| l.trim() == "pub mod commands;");
2042 let has_commands_fn = text.contains("fn commands(");
2043 if already_mod && has_commands_fn {
2044 return Wiring::AlreadyWired;
2045 }
2046
2047 let mut out = text.to_string();
2048 let mut steps: Vec<String> = Vec::new();
2049
2050 if !already_mod {
2051 match umbral::codegen::declare_module(&out, "pub mod commands;") {
2052 Some(text) => out = text,
2053 None => steps.push("Add to src/lib.rs: pub mod commands;".to_string()),
2054 }
2055 }
2056
2057 if !has_commands_fn {
2058 match out
2066 .lines()
2067 .position(|l| l.starts_with("impl Plugin for ") && l.trim_end().ends_with('{'))
2068 {
2069 Some(idx) => {
2070 let method = "\n fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> {\n \
2071 // Every command in `src/commands/` — `umbral startcommand`\n \
2072 // appends to the registry in `commands/mod.rs`, so this line\n \
2073 // never needs to change again.\n \
2074 commands::all()\n }";
2075 out = insert_line_at(&out, idx, method);
2076 }
2077 None => steps.push(
2078 "Add to your `impl Plugin`: fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> { commands::all() }"
2079 .to_string(),
2080 ),
2081 }
2082 } else {
2083 steps.push(
2084 "Your plugin already has a `fn commands()` — make sure it returns \
2085 `commands::all()` (or extends it) so the new command is registered."
2086 .to_string(),
2087 );
2088 }
2089
2090 if out == text {
2091 if steps.is_empty() {
2094 Wiring::AlreadyWired
2095 } else {
2096 Wiring::Manual(steps)
2097 }
2098 } else {
2099 Wiring::Updated { text: out, steps }
2100 }
2101}
2102
2103fn insert_line_at(text: &str, idx: usize, line: &str) -> String {
2108 umbral::codegen::insert_line_after(text, idx, line)
2109}
2110
2111fn append_to_registry(text: &str, module: &str, struct_name: &str) -> Option<String> {
2118 let mod_line = format!("pub mod {module};");
2126 let entry = format!("Box::new({module}::{struct_name}),");
2127
2128 let mut out = text.to_string();
2129
2130 if !out.lines().any(|l| l.trim() == mod_line) {
2131 out = umbral::codegen::insert_before_marker(&out, MODS_MARKER, &mod_line)?;
2132 }
2133 if !out.lines().any(|l| l.trim() == entry) {
2134 out = umbral::codegen::insert_before_marker(
2135 &out,
2136 REGISTRY_MARKER,
2137 &format!(" {entry}"),
2138 )?;
2139 }
2140 Some(out)
2141}
2142
2143fn insert_line_at_before(text: &str, idx: usize, line: &str) -> String {
2147 umbral::codegen::insert_line_before(text, idx, line)
2148}
2149
2150fn render_registry_file(module: &str, struct_name: &str, target: &CommandTarget) -> String {
2152 let (owner, wiring) = match target {
2153 CommandTarget::Root => (
2154 "this project",
2155 "`main.rs` passes `all()` to `App::builder().commands(...)`.",
2156 ),
2157 CommandTarget::Plugin(_) => (
2158 "this plugin",
2159 "`lib.rs` returns `all()` from `Plugin::commands()`.",
2160 ),
2161 };
2162 format!(
2163 r#"//! Management commands owned by {owner} — one file per command,
2164//! and `all()` is the registry that hands them to the framework.
2165//!
2166//! {wiring}
2167//!
2168//! Rust can't discover a module by scanning this directory at runtime, so
2169//! `all()` IS the auto-detection: `umbral startcommand` appends to it for
2170//! you (that's what the marker comments below are for). You can also edit
2171//! it by hand — comment a command out and it stops existing, which is
2172//! harder to do with a magic registry you can't see.
2173
2174use umbral::cli::PluginCommand;
2175
2176pub mod {module};
2177{MODS_MARKER}
2178
2179/// Every command {owner} registers.
2180pub fn all() -> Vec<Box<dyn PluginCommand>> {{
2181 vec![
2182 Box::new({module}::{struct_name}),
2183 {REGISTRY_MARKER}
2184 ]
2185}}
2186"#
2187 )
2188}
2189
2190fn render_command_file(name: &str, struct_name: &str, target: &CommandTarget) -> String {
2194 let orm_note = match target {
2197 CommandTarget::Root => "// use crate::{Post, post};",
2198 CommandTarget::Plugin(_) => "// use crate::models::{Post, post};",
2199 };
2200 format!(
2201 r#"//! `{name}` — a management command.
2202//!
2203//! ```bash
2204//! cargo run -- {name} --help # what it takes
2205//! cargo run -- {name} hello --limit 5 --dry-run # a real run
2206//! umbral {name} hello --tag a --tag b # same thing, via the umbral CLI
2207//! ```
2208//!
2209//! Registered through `commands::all()` in `commands/mod.rs`. It runs against
2210//! a fully-built app: settings loaded, pool open, every model registered — so
2211//! the ORM works ambiently here, with no pool to thread through.
2212
2213use umbral::cli::{{CliError, PluginCommand, clap}};
2214
2215/// The `{name}` command.
2216///
2217/// A unit struct is enough when the command is stateless. It doesn't have to
2218/// be: the trait is object-safe over `&self`, so anything the command needs
2219/// configured (a prefix, a client, a channel) can live on the struct and be
2220/// passed in at registration — which is exactly why this is a trait and not a
2221/// bare `fn` pointer.
2222pub struct {struct_name};
2223
2224#[umbral::async_trait]
2225impl PluginCommand for {struct_name} {{
2226 /// Declare the command: its name, its help, and its arguments.
2227 ///
2228 /// This is plain `clap`, so everything clap can do is available here —
2229 /// value parsing and validation, defaults, conflicts, subcommands of your
2230 /// own. Note the import: `umbral::cli::clap`, the framework's own clap.
2231 /// Add `clap` to your Cargo.toml separately and a major-version bump on
2232 /// either side turns into a type mismatch a page long.
2233 fn command(&self) -> clap::Command {{
2234 clap::Command::new("{name}")
2235 // Shown next to the command in `umbral help`. Write it — a command
2236 // with no `about` lists as a dash and nobody discovers it.
2237 .about("TODO: one line on what {name} does")
2238 .long_about(
2239 "TODO: the longer story, shown on `{name} --help`. What it \
2240 changes, whether it's safe to re-run, what it needs first.",
2241 )
2242 // POSITIONAL argument — `{name} <slug>`. Required, so clap
2243 // rejects the call with a usage error if it's missing and `run`
2244 // never sees a half-formed invocation.
2245 .arg(
2246 clap::Arg::new("slug")
2247 .required(true)
2248 .help("The thing to operate on"),
2249 )
2250 // NAMED argument with a value and a default — `--limit 25` / `-l 25`.
2251 // `value_parser` is what makes it a `u64` on the other side rather
2252 // than a string you'd have to parse (and mis-parse) yourself.
2253 .arg(
2254 clap::Arg::new("limit")
2255 .long("limit")
2256 .short('l')
2257 .value_name("N")
2258 .value_parser(clap::value_parser!(u64))
2259 .default_value("25")
2260 .help("How many rows to touch at most"),
2261 )
2262 // REPEATABLE named argument — `--tag a --tag b` collects both.
2263 // `ArgAction::Append` is the difference between the second `--tag`
2264 // overwriting the first and the two accumulating.
2265 .arg(
2266 clap::Arg::new("tag")
2267 .long("tag")
2268 .value_name("TAG")
2269 .action(clap::ArgAction::Append)
2270 .help("Filter by tag. Repeat for more than one."),
2271 )
2272 // BOOLEAN flag — `--dry-run`, no value. `SetTrue` is what makes it
2273 // a flag rather than an option that demands a value.
2274 .arg(
2275 clap::Arg::new("dry-run")
2276 .long("dry-run")
2277 .action(clap::ArgAction::SetTrue)
2278 .help("Report what would change without writing anything"),
2279 )
2280 }}
2281
2282 /// Run the command. `matches` is this subcommand's own `ArgMatches` —
2283 /// clap has already validated it against `command()` above, so every
2284 /// `get_one` here is reading a value that exists and typechecked.
2285 async fn run(&self, matches: &clap::ArgMatches) -> Result<(), CliError> {{
2286 let slug = matches
2287 .get_one::<String>("slug")
2288 .expect("clap enforces `required(true)`");
2289 let limit = *matches
2290 .get_one::<u64>("limit")
2291 .expect("clap fills in `default_value`");
2292 let tags: Vec<&String> = matches
2293 .get_many::<String>("tag")
2294 .map(Iterator::collect)
2295 .unwrap_or_default();
2296 let dry_run = matches.get_flag("dry-run");
2297
2298 println!("{name}: slug={{slug}} limit={{limit}} tags={{tags:?}} dry_run={{dry_run}}");
2299
2300 // The app is already built by the time this runs, so the ORM is live:
2301 //
2302 {orm_note}
2303 //
2304 // let posts = Post::objects()
2305 // .filter(post::PUBLISHED.eq(true))
2306 // .limit(limit as i64)
2307 // .fetch()
2308 // .await?;
2309 //
2310 // if dry_run {{
2311 // println!("would touch {{}} post(s)", posts.len());
2312 // return Ok(());
2313 // }}
2314 //
2315 // `?` just works: `CliError` is a boxed error, so every umbral error
2316 // converts into it. Return `Err(...)` and the process exits non-zero,
2317 // which is what a CI step or a cron job is watching for.
2318
2319 Ok(())
2320 }}
2321}}
2322"#
2323 )
2324}
2325
2326fn write_file(
2329 root: &Path,
2330 rel_path: &str,
2331 contents: &str,
2332 files: &mut Vec<PathBuf>,
2333) -> Result<(), ScaffoldError> {
2334 umbral::codegen::write_new_file(root, rel_path, contents, files).map_err(Into::into)
2339}
2340
2341pub fn register_dep_in_cargo_toml(cargo_toml_path: &Path, name: &str) -> io::Result<bool> {
2357 umbral::codegen::ensure_dependency(
2364 cargo_toml_path,
2365 name,
2366 &format!("{{ path = \"plugins/{name}\" }}"),
2367 )
2368 .map_err(|e| match e {
2369 umbral::codegen::CodegenError::Io(e) => e,
2370 other => io::Error::new(io::ErrorKind::InvalidData, other.to_string()),
2371 })
2372}
2373#[cfg(test)]
2374mod tests {
2375 use super::*;
2376
2377 #[test]
2378 fn validate_name_accepts_simple_identifiers() {
2379 assert!(validate_name("posts").is_ok());
2380 assert!(validate_name("blog_engine").is_ok());
2381 assert!(validate_name("blog-engine").is_ok());
2382 assert!(validate_name("api2").is_ok());
2383 }
2384
2385 #[test]
2386 fn validate_name_rejects_empty() {
2387 assert!(validate_name("").is_err());
2388 }
2389
2390 #[test]
2391 fn validate_name_rejects_leading_digit() {
2392 assert!(validate_name("2cool").is_err());
2393 }
2394
2395 #[test]
2396 fn validate_name_rejects_special_chars() {
2397 assert!(validate_name("foo bar").is_err());
2398 assert!(validate_name("foo!bar").is_err());
2399 assert!(validate_name("foo/bar").is_err());
2400 }
2401
2402 #[test]
2403 fn pascal_case_handles_kebab_and_snake() {
2404 assert_eq!(pascal_case_from_ident("posts"), "Posts");
2405 assert_eq!(pascal_case_from_ident("blog_engine"), "BlogEngine");
2406 assert_eq!(pascal_case_from_ident("blog-engine"), "BlogEngine");
2407 assert_eq!(pascal_case_from_ident("api2"), "Api2");
2408 }
2409
2410 #[test]
2411 fn rust_ident_replaces_hyphens() {
2412 assert_eq!(rust_ident("blog-engine"), "blog_engine");
2413 assert_eq!(rust_ident("posts"), "posts");
2414 }
2415
2416 #[test]
2417 fn scaffold_app_rejects_reserved_built_in_plugin_names() {
2418 let tmp = tempfile::tempdir().expect("tempdir");
2419 for name in RESERVED_PLUGIN_NAMES {
2420 let result = scaffold_app(name, tmp.path(), None);
2421 assert!(
2422 matches!(result, Err(ScaffoldError::ReservedName(_))),
2423 "expected ReservedName error for `{name}`, got: {result:?}",
2424 );
2425 assert!(
2426 !tmp.path().join("plugins").join(name).exists(),
2427 "directory must NOT be created when name is reserved: {name}",
2428 );
2429 }
2430 }
2431
2432 #[test]
2433 fn scaffold_app_rejects_reserved_name_with_hyphen_variant() {
2434 let tmp = tempfile::tempdir().expect("tempdir");
2439 let result = scaffold_app("auth", tmp.path(), None);
2443 assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
2444 }
2445
2446 #[test]
2447 fn scaffold_app_message_lists_reserved_names() {
2448 let err = ScaffoldError::ReservedName("auth".to_string());
2449 let msg = format!("{err}");
2450 assert!(msg.contains("`auth`"), "error names the offending input");
2451 assert!(
2452 msg.contains("admin") && msg.contains("sessions") && msg.contains("permissions"),
2453 "error lists the reserved set so the user can pick again: {msg}",
2454 );
2455 }
2456
2457 #[test]
2458 fn scaffold_app_already_exists_message_says_app() {
2459 let err = ScaffoldError::AlreadyExists(PathBuf::from("plugins/blog"));
2463 let msg = format!("{err}");
2464 assert!(msg.contains("app already exists"), "got: {msg}");
2465 assert!(msg.contains("plugins/blog"), "got: {msg}");
2466 }
2467
2468 #[test]
2473 fn scaffold_plugin_writes_richer_layout() {
2474 let tmp = tempfile::tempdir().expect("tempdir");
2475 let report = scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2476
2477 let root = tmp.path().join("plugins").join("widgets");
2478 assert!(root.is_dir());
2479
2480 for rel in [
2482 "Cargo.toml",
2483 "README.md",
2484 "src/lib.rs",
2485 "src/models.rs",
2486 "src/handlers.rs",
2487 ] {
2488 assert!(
2489 root.join(rel).exists(),
2490 "missing expected file: {rel}; got {:?}",
2491 report.files,
2492 );
2493 }
2494 }
2495
2496 #[test]
2497 fn scaffold_plugin_lib_rs_references_sibling_modules() {
2498 let tmp = tempfile::tempdir().expect("tempdir");
2499 scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2500 let lib = fs::read_to_string(tmp.path().join("plugins/widgets/src/lib.rs")).unwrap();
2501
2502 assert!(
2503 lib.contains("pub mod handlers;"),
2504 "lib.rs must publish handlers"
2505 );
2506 assert!(
2507 lib.contains("pub mod models;"),
2508 "lib.rs must publish models"
2509 );
2510 assert!(lib.contains("WidgetsPlugin"), "PascalCase plugin name");
2511 assert!(
2512 lib.contains("models::WidgetsItem::meta()"),
2513 "models() should register the example model",
2514 );
2515 assert!(
2516 lib.contains("/widgets/hello"),
2517 "routes() should register the example handler",
2518 );
2519 }
2520
2521 #[test]
2522 fn scaffold_plugin_models_rs_uses_real_umbral_attributes() {
2523 let tmp = tempfile::tempdir().expect("tempdir");
2524 scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2525 let models = fs::read_to_string(tmp.path().join("plugins/widgets/src/models.rs")).unwrap();
2526
2527 assert!(
2528 models.contains("umbral::orm::Model"),
2529 "model derive must reference the framework's Model trait",
2530 );
2531 assert!(
2532 models.contains("max_length = 200"),
2533 "example model should demonstrate max_length",
2534 );
2535 assert!(
2536 models.contains("WidgetsStatus"),
2537 "example model should declare a Choice enum",
2538 );
2539 assert!(
2540 models.contains("noedit"),
2541 "example model should show the noedit attribute",
2542 );
2543 }
2544
2545 #[test]
2546 fn scaffold_plugin_rejects_reserved_built_in_plugin_names() {
2547 let tmp = tempfile::tempdir().expect("tempdir");
2548 for name in RESERVED_PLUGIN_NAMES {
2549 let result = scaffold_plugin(name, tmp.path(), None);
2550 assert!(
2551 matches!(result, Err(ScaffoldError::ReservedName(_))),
2552 "expected ReservedName error for `{name}`, got: {result:?}",
2553 );
2554 }
2555 }
2556
2557 #[test]
2558 fn scaffold_plugin_refuses_to_overwrite_existing_directory() {
2559 let tmp = tempfile::tempdir().expect("tempdir");
2560 scaffold_plugin("widgets", tmp.path(), None).expect("first scaffold ok");
2561 let result = scaffold_plugin("widgets", tmp.path(), None);
2562 assert!(matches!(result, Err(ScaffoldError::AlreadyExists(_))));
2563 }
2564
2565 #[test]
2571 fn scaffold_project_writes_per_concern_tree() {
2572 let tmp = tempfile::tempdir().expect("tempdir");
2573 let report = scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2574
2575 let root = tmp.path().join("blog");
2576 assert!(root.is_dir());
2577
2578 for rel in [
2580 "src/main.rs",
2581 "src/views/mod.rs",
2582 "src/views/public.rs",
2583 "src/seed/mod.rs",
2584 "src/seed/credentials.rs",
2585 "src/widgets/mod.rs",
2586 "src/widgets/cards.rs",
2587 "plugins/.gitkeep",
2588 "plugins/README.md",
2589 ] {
2590 assert!(
2591 root.join(rel).exists(),
2592 "missing expected file: {rel}; got {:?}",
2593 report.files,
2594 );
2595 }
2596 }
2597
2598 #[test]
2599 fn scaffold_project_mod_files_carry_orchestrator_markers() {
2600 let tmp = tempfile::tempdir().expect("tempdir");
2601 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2602 let root = tmp.path().join("blog");
2603
2604 let views_mod = fs::read_to_string(root.join("src/views/mod.rs")).unwrap();
2605 assert!(
2606 views_mod.contains("re-export"),
2607 "views/mod.rs should describe itself as the re-export layer",
2608 );
2609 assert!(
2616 !views_mod.contains("fn internal_error"),
2617 "the scaffold must NOT generate an internal_error helper — handlers return \
2618 ApiError, which logs the cause and keeps it off the wire",
2619 );
2620 let views_public = fs::read_to_string(root.join("src/views/public.rs")).unwrap();
2621 assert!(
2622 views_public.contains("Result<Html<String>, ApiError>")
2623 && !views_public.contains("map_err(internal_error)"),
2624 "generated handlers must return ApiError and use a bare `?`",
2625 );
2626
2627 let seed_mod = fs::read_to_string(root.join("src/seed/mod.rs")).unwrap();
2628 assert!(
2629 seed_mod.contains("pub async fn all()"),
2630 "seed/mod.rs must declare the all() orchestrator",
2631 );
2632 assert!(
2633 seed_mod.contains("credentials::test_credentials()"),
2634 "seed::all() must call the credentials step",
2635 );
2636 assert!(
2637 seed_mod.contains("dependency order") || seed_mod.contains("order in which"),
2638 "seed/mod.rs should explain it pins dependency order",
2639 );
2640
2641 let credentials = fs::read_to_string(root.join("src/seed/credentials.rs")).unwrap();
2642 assert!(
2643 credentials.contains("fn test_credentials"),
2644 "credentials.rs must define the test_credentials seed",
2645 );
2646 assert!(
2647 credentials.contains("count().await? > 0"),
2648 "test_credentials must be idempotent (short-circuit on existing users)",
2649 );
2650
2651 let widgets_mod = fs::read_to_string(root.join("src/widgets/mod.rs")).unwrap();
2652 assert!(
2653 widgets_mod.contains("pub mod cards;"),
2654 "widgets/mod.rs must publish the cards submodule",
2655 );
2656
2657 let cards = fs::read_to_string(root.join("src/widgets/cards.rs")).unwrap();
2658 assert!(
2659 cards.contains("builtin_total_models_widget")
2660 || cards.contains("builtin_recent_users_widget"),
2661 "cards.rs should re-export a builtin widget so the dashboard isn't empty",
2662 );
2663 }
2664
2665 #[test]
2666 fn scaffold_project_main_declares_modules_and_mounts_security() {
2667 let tmp = tempfile::tempdir().expect("tempdir");
2668 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2669 let main = fs::read_to_string(tmp.path().join("blog/src/main.rs")).unwrap();
2670
2671 assert!(
2673 main.contains("mod views;"),
2674 "main.rs must declare mod views"
2675 );
2676 assert!(main.contains("mod seed;"), "main.rs must declare mod seed");
2677 assert!(
2678 main.contains("mod widgets;"),
2679 "main.rs must declare mod widgets",
2680 );
2681
2682 assert!(
2684 main.contains("views::public::home"),
2685 "route table should wire views::public::home",
2686 );
2687 assert!(
2689 main.contains("seed::all().await"),
2690 "boot should run seed::all()",
2691 );
2692
2693 assert!(
2695 main.contains("SecurityPlugin"),
2696 "SecurityPlugin must be mounted by default",
2697 );
2698 }
2699
2700 #[test]
2701 fn scaffold_project_creates_empty_plugins_dir() {
2702 let tmp = tempfile::tempdir().expect("tempdir");
2703 scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2704 let readme = fs::read_to_string(tmp.path().join("blog/plugins/README.md")).unwrap();
2705 assert!(
2706 readme.contains("umbral startapp"),
2707 "plugins/README.md should point at `umbral startapp`",
2708 );
2709 }
2710
2711 #[test]
2716 fn scaffold_app_writes_per_concern_plugin_layout() {
2717 let tmp = tempfile::tempdir().expect("tempdir");
2718 let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2719
2720 let root = tmp.path().join("plugins").join("posts");
2721 assert!(root.is_dir());
2722
2723 for rel in [
2724 "Cargo.toml",
2725 "src/lib.rs",
2726 "src/models.rs",
2727 "src/views.rs",
2728 "src/urls.rs",
2729 ] {
2730 assert!(
2731 root.join(rel).exists(),
2732 "missing expected file: {rel}; got {:?}",
2733 report.files,
2734 );
2735 }
2736 }
2737
2738 #[test]
2739 fn scaffold_app_lib_wires_urls_and_views() {
2740 let tmp = tempfile::tempdir().expect("tempdir");
2741 scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2742 let root = tmp.path().join("plugins/posts");
2743
2744 let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
2745 assert!(
2746 lib.contains("pub mod models;"),
2747 "lib.rs must publish models"
2748 );
2749 assert!(lib.contains("pub mod views;"), "lib.rs must publish views");
2750 assert!(lib.contains("pub mod urls;"), "lib.rs must publish urls");
2751 assert!(
2752 lib.contains("urls::router()"),
2753 "routes() must return urls::router()",
2754 );
2755 assert!(lib.contains("PostsPlugin"), "PascalCase plugin name");
2756
2757 let urls = fs::read_to_string(root.join("src/urls.rs")).unwrap();
2758 assert!(
2759 urls.contains("pub fn router() -> Router"),
2760 "urls.rs must expose a router() returning a Router",
2761 );
2762 assert!(
2763 urls.contains("views::index"),
2764 "urls.rs route table should map to a views:: handler",
2765 );
2766
2767 let views = fs::read_to_string(root.join("src/views.rs")).unwrap();
2768 assert!(
2769 views.contains("pub async fn index"),
2770 "views.rs should ship a sample index handler",
2771 );
2772 }
2773
2774 #[test]
2775 fn scaffold_app_auto_registers_path_dep_in_project_cargo() {
2776 let tmp = tempfile::tempdir().expect("tempdir");
2777 let project_cargo = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nserde = \"1\"\n";
2779 fs::write(tmp.path().join("Cargo.toml"), project_cargo).unwrap();
2780
2781 let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2782 assert_eq!(
2783 report.cargo_toml_registered,
2784 Some(true),
2785 "the path dep should have been added",
2786 );
2787
2788 let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
2789 assert!(
2790 cargo.contains("posts = { path = \"plugins/posts\" }"),
2791 "project Cargo.toml must gain the plugin path dep; got:\n{cargo}",
2792 );
2793
2794 let second = register_dep_in_cargo_toml(&tmp.path().join("Cargo.toml"), "posts").unwrap();
2797 assert!(!second, "re-registering the same dep must be a no-op");
2798 }
2799
2800 #[test]
2801 fn scaffold_app_still_rejects_reserved_names() {
2802 let tmp = tempfile::tempdir().expect("tempdir");
2803 let result = scaffold_app("auth", tmp.path(), None);
2804 assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
2805 }
2806
2807 #[test]
2808 fn scaffold_plugin_validates_name_like_startapp() {
2809 let tmp = tempfile::tempdir().expect("tempdir");
2810 assert!(matches!(
2811 scaffold_plugin("2cool", tmp.path(), None),
2812 Err(ScaffoldError::InvalidName(_))
2813 ));
2814 assert!(matches!(
2815 scaffold_plugin("foo bar", tmp.path(), None),
2816 Err(ScaffoldError::InvalidName(_))
2817 ));
2818 }
2819
2820 fn project(tmp: &tempfile::TempDir) -> PathBuf {
2829 scaffold_project("demo", tmp.path(), None).expect("scaffold_project");
2830 tmp.path().join("demo")
2831 }
2832
2833 fn read(root: &Path, rel: &str) -> String {
2834 fs::read_to_string(root.join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}"))
2835 }
2836
2837 #[test]
2838 fn startcommand_root_writes_the_command_and_wires_main() {
2839 let tmp = tempfile::tempdir().expect("tempdir");
2840 let root = project(&tmp);
2841
2842 let report =
2843 scaffold_command("backfill_slugs", &CommandTarget::Root, &root).expect("scaffold");
2844 assert!(
2845 report
2846 .files
2847 .contains(&PathBuf::from("src/commands/backfill_slugs.rs"))
2848 );
2849 assert!(report.files.contains(&PathBuf::from("src/commands/mod.rs")));
2850
2851 let cmd = read(&root, "src/commands/backfill_slugs.rs");
2853 assert!(cmd.contains("pub struct BackfillSlugsCommand;"), "{cmd}");
2854 assert!(
2855 cmd.contains("impl PluginCommand for BackfillSlugsCommand"),
2856 "{cmd}"
2857 );
2858 assert!(
2859 cmd.contains("use umbral::cli::{CliError, PluginCommand, clap};"),
2860 "the generated file must import the framework's clap, not its own: {cmd}"
2861 );
2862 assert!(
2863 cmd.contains(r#"clap::Command::new("backfill_slugs")"#),
2864 "{cmd}"
2865 );
2866
2867 let registry = read(&root, "src/commands/mod.rs");
2869 assert!(registry.contains("pub mod backfill_slugs;"), "{registry}");
2870 assert!(
2871 registry.contains("Box::new(backfill_slugs::BackfillSlugsCommand),"),
2872 "{registry}"
2873 );
2874
2875 let main_rs = read(&root, "src/main.rs");
2877 assert!(
2878 main_rs.contains("mod commands;"),
2879 "main.rs never declared the module: {main_rs}"
2880 );
2881 assert!(
2882 main_rs.contains(".commands(commands::all())"),
2883 "main.rs never registered the command registry: {main_rs}"
2884 );
2885 let reg = main_rs.find(".commands(commands::all())").unwrap();
2887 let build = main_rs.find(".build_deferred()").unwrap();
2888 assert!(
2889 reg < build,
2890 "`.commands(...)` landed after `.build_deferred()`, which doesn't compile"
2891 );
2892 }
2893
2894 #[test]
2900 fn startcommand_second_command_appends_and_leaves_main_alone() {
2901 let tmp = tempfile::tempdir().expect("tempdir");
2902 let root = project(&tmp);
2903
2904 scaffold_command("backfill_slugs", &CommandTarget::Root, &root).expect("first");
2905 let main_after_first = read(&root, "src/main.rs");
2906 scaffold_command("import-prices", &CommandTarget::Root, &root).expect("second");
2907 let main_after_second = read(&root, "src/main.rs");
2908
2909 assert_eq!(
2910 main_after_first, main_after_second,
2911 "the second startcommand edited main.rs again"
2912 );
2913
2914 let registry = read(&root, "src/commands/mod.rs");
2915 assert!(registry.contains("pub mod backfill_slugs;"), "{registry}");
2916 assert!(registry.contains("pub mod import_prices;"), "{registry}");
2919 assert!(
2920 registry.contains("Box::new(import_prices::ImportPricesCommand),"),
2921 "{registry}"
2922 );
2923 let cmd = read(&root, "src/commands/import_prices.rs");
2924 assert!(
2925 cmd.contains(r#"clap::Command::new("import-prices")"#),
2926 "the clap name should be what the user typed, hyphens and all: {cmd}"
2927 );
2928
2929 assert_eq!(
2930 main_after_second
2931 .matches(".commands(commands::all())")
2932 .count(),
2933 1,
2934 "main.rs registered the registry twice"
2935 );
2936 assert_eq!(
2937 main_after_second.matches("\nmod commands;").count(),
2938 1,
2939 "main.rs declared `mod commands;` twice"
2940 );
2941 }
2942
2943 #[test]
2944 fn startcommand_plugin_writes_the_command_and_wires_the_plugin() {
2945 let tmp = tempfile::tempdir().expect("tempdir");
2946 let root = project(&tmp);
2947 scaffold_app("blog", &root, None).expect("scaffold_app");
2948
2949 scaffold_command("reindex", &CommandTarget::Plugin("blog".to_string()), &root)
2950 .expect("scaffold");
2951
2952 let plugin_root = root.join("plugins/blog");
2953 let registry = read(&plugin_root, "src/commands/mod.rs");
2954 assert!(registry.contains("pub mod reindex;"), "{registry}");
2955 assert!(
2956 registry.contains("Box::new(reindex::ReindexCommand),"),
2957 "{registry}"
2958 );
2959
2960 let lib_rs = read(&plugin_root, "src/lib.rs");
2961 assert!(lib_rs.contains("pub mod commands;"), "{lib_rs}");
2962 assert!(
2963 lib_rs.contains("fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>>"),
2964 "the plugin never got a `Plugin::commands()` impl: {lib_rs}"
2965 );
2966 assert!(
2967 lib_rs.contains("commands::all()"),
2968 "the impl doesn't return the registry: {lib_rs}"
2969 );
2970 let impl_start = lib_rs.find("impl Plugin for BlogPlugin {").unwrap();
2972 let method = lib_rs.find("fn commands(&self)").unwrap();
2973 assert!(method > impl_start, "the method landed outside the impl");
2974 }
2975
2976 #[test]
2983 fn startcommand_rejects_a_rust_keyword_as_a_command_name() {
2984 let tmp = tempfile::tempdir().expect("tempdir");
2985 let root = project(&tmp);
2986 for kw in ["move", "type", "match"] {
2987 assert!(
2988 matches!(
2989 scaffold_command(kw, &CommandTarget::Root, &root),
2990 Err(ScaffoldError::InvalidName(_))
2991 ),
2992 "`{kw}` is a Rust keyword — `pub mod {kw};` does not parse"
2993 );
2994 }
2995 }
2996
2997 #[test]
3002 fn startcommand_rejects_a_builtin_command_name() {
3003 let tmp = tempfile::tempdir().expect("tempdir");
3004 let root = project(&tmp);
3005 for taken in ["migrate", "serve", "makemigrations", "dev"] {
3006 assert!(
3007 matches!(
3008 scaffold_command(taken, &CommandTarget::Root, &root),
3009 Err(ScaffoldError::ReservedCommandName(_))
3010 ),
3011 "`{taken}` is a built-in and must be rejected"
3012 );
3013 }
3014 }
3015
3016 #[test]
3020 fn startcommand_rejects_a_builtin_plugin_command_name() {
3021 let tmp = tempfile::tempdir().expect("tempdir");
3022 let root = project(&tmp);
3023 assert!(matches!(
3024 scaffold_command("createsuperuser", &CommandTarget::Root, &root),
3025 Err(ScaffoldError::ReservedCommandName(_))
3026 ));
3027 assert!(matches!(
3028 scaffold_command("tasks-worker", &CommandTarget::Root, &root),
3029 Err(ScaffoldError::ReservedCommandName(_))
3030 ));
3031 }
3032
3033 #[test]
3037 fn reserved_command_names_are_read_off_the_real_parser() {
3038 let names = reserved_command_names();
3039 for expected in ["migrate", "serve", "typegen", "squashmigrations", "help"] {
3040 assert!(
3041 names.iter().any(|n| n == expected),
3042 "`{expected}` missing from the reserved set: {names:?}"
3043 );
3044 }
3045 }
3046
3047 #[test]
3048 fn startcommand_rejects_an_unknown_plugin_and_lists_the_real_ones() {
3049 let tmp = tempfile::tempdir().expect("tempdir");
3050 let root = project(&tmp);
3051 scaffold_app("blog", &root, None).expect("scaffold_app");
3052
3053 let err = scaffold_command("reindex", &CommandTarget::Plugin("blgo".into()), &root)
3054 .expect_err("a typo'd plugin name must not scaffold anything");
3055 match err {
3056 ScaffoldError::NoSuchPlugin { asked, available } => {
3057 assert_eq!(asked, "blgo");
3058 assert_eq!(available, vec!["blog".to_string()]);
3059 }
3060 other => panic!("expected NoSuchPlugin, got {other:?}"),
3061 }
3062 }
3063
3064 #[test]
3065 fn startcommand_refuses_to_overwrite_an_existing_command() {
3066 let tmp = tempfile::tempdir().expect("tempdir");
3067 let root = project(&tmp);
3068 scaffold_command("reindex", &CommandTarget::Root, &root).expect("first");
3069 assert!(matches!(
3070 scaffold_command("reindex", &CommandTarget::Root, &root),
3071 Err(ScaffoldError::AlreadyExists(_))
3072 ));
3073 }
3074
3075 #[test]
3076 fn startcommand_outside_a_project_says_so() {
3077 let tmp = tempfile::tempdir().expect("tempdir");
3078 assert!(matches!(
3079 scaffold_command("reindex", &CommandTarget::Root, tmp.path()),
3080 Err(ScaffoldError::NotAProject(_))
3081 ));
3082 }
3083
3084 #[test]
3085 fn discover_plugins_lists_plugin_crates_only() {
3086 let tmp = tempfile::tempdir().expect("tempdir");
3087 let root = project(&tmp);
3088 assert!(discover_plugins(&root).is_empty());
3090
3091 scaffold_app("blog", &root, None).expect("scaffold_app");
3092 scaffold_app("shop", &root, None).expect("scaffold_app");
3093 fs::create_dir_all(root.join("plugins/notacrate")).unwrap();
3096
3097 assert_eq!(
3098 discover_plugins(&root),
3099 vec!["blog".to_string(), "shop".to_string()]
3100 );
3101 }
3102
3103 #[test]
3114 fn startcommand_does_not_splice_into_someone_elses_builder_chain() {
3115 let tmp = tempfile::tempdir().expect("tempdir");
3116 let root = project(&tmp);
3117
3118 let main_rs = root.join("src/main.rs");
3119 let original = read(&root, "src/main.rs");
3120 let with_client = original.replace(
3123 " let settings = Settings::from_env()?;",
3124 " let client = reqwest::Client::builder()\n\
3125 \x20 .timeout(Duration::from_secs(5))\n\
3126 \x20 .build()?;\n\n\
3127 \x20 let settings = Settings::from_env()?;",
3128 );
3129 assert_ne!(with_client, original, "fixture did not apply");
3130 fs::write(&main_rs, &with_client).unwrap();
3131
3132 scaffold_command("import_prices", &CommandTarget::Root, &root).expect("scaffold");
3133
3134 let after = read(&root, "src/main.rs");
3135 let commands_at = after.find(".commands(commands::all())").expect("wired");
3136 let client_build_at = after.find(".build()?;").expect("client chain still there");
3137 let app_builder_at = after.find("App::builder()").expect("app chain still there");
3138
3139 assert!(
3140 commands_at > client_build_at,
3141 "`.commands(...)` was spliced into the reqwest chain:\n{after}"
3142 );
3143 assert!(
3144 commands_at > app_builder_at,
3145 "`.commands(...)` landed outside the App::builder() chain:\n{after}"
3146 );
3147 }
3148
3149 #[test]
3152 fn startcommand_declines_when_there_is_no_app_builder_chain() {
3153 let tmp = tempfile::tempdir().expect("tempdir");
3154 let root = project(&tmp);
3155 fs::write(
3156 root.join("src/main.rs"),
3157 "mod seed;\n\nfn main() {\n println!(\"no app here\");\n}\n",
3158 )
3159 .unwrap();
3160
3161 let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("scaffold");
3162
3163 assert_eq!(
3164 report.registered,
3165 Some(false),
3166 "the tool must not claim a registration it could not perform"
3167 );
3168 let steps = report.next_steps.join("\n");
3169 assert!(steps.contains(".commands(commands::all())"), "{steps}");
3170 assert!(root.join("src/commands/backfill.rs").is_file());
3171 }
3172
3173 #[test]
3179 fn startcommand_keeps_the_module_declaration_it_managed_to_add() {
3180 let tmp = tempfile::tempdir().expect("tempdir");
3181 let root = project(&tmp);
3182 fs::write(
3185 root.join("src/main.rs"),
3186 "mod seed;\n\nfn main() {\n println!(\"no app\");\n}\n",
3187 )
3188 .unwrap();
3189
3190 let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("scaffold");
3191
3192 let after = read(&root, "src/main.rs");
3193 assert!(
3194 after.contains("mod commands;"),
3195 "the module declaration the tool made was thrown away: {after}"
3196 );
3197 assert_eq!(report.registered, Some(false));
3198 assert!(
3199 report
3200 .next_steps
3201 .join("\n")
3202 .contains(".commands(commands::all())"),
3203 "the user was not told the one step that remained"
3204 );
3205 }
3206
3207 #[test]
3210 fn startcommand_declines_a_plugin_impl_whose_brace_is_on_the_next_line() {
3211 let tmp = tempfile::tempdir().expect("tempdir");
3212 let root = project(&tmp);
3213 scaffold_app("blog", &root, None).expect("scaffold_app");
3214
3215 let lib_rs = root.join("plugins/blog/src/lib.rs");
3216 fs::write(
3217 &lib_rs,
3218 "pub mod models;\n\npub struct BlogPlugin;\n\n\
3219 impl Plugin for BlogPlugin\nwhere\n Self: Send,\n{\n \
3220 fn name(&self) -> &'static str {\n \"blog\"\n }\n}\n",
3221 )
3222 .unwrap();
3223
3224 let report = scaffold_command("reindex", &CommandTarget::Plugin("blog".to_string()), &root)
3225 .expect("scaffold");
3226
3227 let after = read(&root, "plugins/blog/src/lib.rs");
3228 assert!(
3230 after.contains("impl Plugin for BlogPlugin\nwhere\n Self: Send,\n{"),
3231 "the generator spliced a method into a multi-line impl header:\n{after}"
3232 );
3233 assert_eq!(report.registered, Some(false));
3234 assert!(
3235 report.next_steps.join("\n").contains("fn commands"),
3236 "the user was not told to add the method by hand"
3237 );
3238 }
3239
3240 #[test]
3245 fn startcommand_repairs_a_registry_missing_only_its_entry() {
3246 let tmp = tempfile::tempdir().expect("tempdir");
3247 let root = project(&tmp);
3248 scaffold_command("backfill", &CommandTarget::Root, &root).expect("first");
3249
3250 let mod_rs = root.join("src/commands/mod.rs");
3252 let text = read(&root, "src/commands/mod.rs")
3253 .lines()
3254 .filter(|l| !l.contains("Box::new(backfill::BackfillCommand)"))
3255 .collect::<Vec<_>>()
3256 .join("\n");
3257 fs::write(&mod_rs, format!("{text}\n")).unwrap();
3258 fs::remove_file(root.join("src/commands/backfill.rs")).unwrap();
3259
3260 let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("re-run");
3261
3262 let registry = read(&root, "src/commands/mod.rs");
3263 assert_eq!(
3264 registry.matches("pub mod backfill;").count(),
3265 1,
3266 "duplicate module declaration:\n{registry}"
3267 );
3268 assert!(
3269 registry.contains("Box::new(backfill::BackfillCommand),"),
3270 "the registry entry was never restored, but the command reports as \
3271 registered:\n{registry}"
3272 );
3273 assert_eq!(report.registered, Some(true));
3274 }
3275
3276 #[test]
3280 fn startcommand_reports_manual_steps_when_the_registry_markers_are_gone() {
3281 let tmp = tempfile::tempdir().expect("tempdir");
3282 let root = project(&tmp);
3283 scaffold_command("first", &CommandTarget::Root, &root).expect("first");
3284
3285 let mod_rs = root.join("src/commands/mod.rs");
3286 let mangled = read(&root, "src/commands/mod.rs")
3287 .lines()
3288 .filter(|l| !l.trim().starts_with("// umbral:startcommand"))
3289 .collect::<Vec<_>>()
3290 .join("\n");
3291 fs::write(&mod_rs, &mangled).unwrap();
3292
3293 let report = scaffold_command("second", &CommandTarget::Root, &root).expect("second");
3294
3295 assert_eq!(read(&root, "src/commands/mod.rs"), mangled);
3297 let steps = report.next_steps.join("\n");
3299 assert!(steps.contains("pub mod second;"), "{steps}");
3300 assert!(steps.contains("Box::new(second::SecondCommand)"), "{steps}");
3301 }
3302}