Skip to main content

gize_generator/
scaffold.rs

1//! High-level generators. Each returns a pure [`Plan`]; applying it is the [`Writer`]'s
2//! job. This separation keeps generation testable and makes `--dry-run` free.
3
4use anyhow::Result;
5use gize_core::naming::table_name;
6use gize_core::{Api, Dialect, Manifest, ModelSpec, Module};
7use gize_templates::{auth, crud, model, module, openapi, project, user, ws};
8
9use crate::plan::Plan;
10use crate::registry;
11
12/// The nine code files of a CRUD vertical slice (everything but the migration), shared by
13/// `gize new`'s built-in users slice, `gize make crud`, and `gize sync` so all three paths
14/// emit byte-identical code. When `is_user` is set, the password-hiding `model.rs` is used.
15fn crud_files(model: &ModelSpec, dir: &str, is_user: bool, dialect: Dialect) -> Plan {
16    // The users slice customizes the files that auth touches (password-hiding model, hashing
17    // handlers, register/login routes, an error type with `Unauthorized`); everything else is
18    // the generic CRUD template. See `gize_templates::user`.
19    let (model_rs, dto_rs, error_rs, handler_rs, routes_rs) = if is_user {
20        (
21            user::model_rs(),
22            user::dto_rs(),
23            user::error_rs(dialect),
24            user::handler_rs(dialect),
25            user::routes_rs(),
26        )
27    } else {
28        (
29            model::model_rs(model),
30            crud::dto_rs(model),
31            crud::error_rs(model, dialect),
32            crud::handler_rs(model),
33            crud::routes_rs(model),
34        )
35    };
36    Plan::new()
37        .create(format!("{dir}/mod.rs"), crud::mod_rs(model))
38        .create(format!("{dir}/model.rs"), model_rs)
39        .create(format!("{dir}/dto.rs"), dto_rs)
40        .create(format!("{dir}/error.rs"), error_rs)
41        .create(
42            format!("{dir}/repository.rs"),
43            crud::repository_rs(model, dialect),
44        )
45        .create(
46            format!("{dir}/service.rs"),
47            crud::service_rs(model, dialect),
48        )
49        .create(format!("{dir}/handler.rs"), handler_rs)
50        .create(format!("{dir}/routes.rs"), routes_rs)
51        .create(format!("{dir}/tests.rs"), crud::tests_rs(model))
52}
53
54/// The generated `src/auth/mod.rs` skeleton (ADR-013), so `gize sync` can reconcile the auth
55/// module that the CRUD routes depend on.
56pub fn auth_mod_rs() -> String {
57    auth::mod_rs()
58}
59
60/// The `src/app/openapi.rs` route module (ADR-010). Static (does not depend on the modules),
61/// so it can be reconciled drift-aware by `gize sync`.
62pub fn openapi_module_rs() -> String {
63    openapi::module_rs()
64}
65
66/// The `openapi.json` spec rendered from the manifest (ADR-010). This is a *derived* artifact
67/// — always regenerated from the manifest, never drift-protected.
68pub fn openapi_json(manifest: &Manifest) -> Result<String> {
69    let spec = gize_openapi::spec_json(manifest)?;
70    serde_json::to_string_pretty(&spec).map_err(Into::into)
71}
72
73/// The OpenAPI slice (ADR-010): the `src/app/openapi.rs` route module plus the generated
74/// `openapi.json`. Used by `gize new --openapi` and reconciled by `gize sync` when
75/// `features.openapi` is on. The module is registered in `app/mod.rs` by the command, like
76/// any module. The spec describes the *resource* routes only — the openapi module is wired
77/// but never listed in `[[module]]`, so it does not appear in its own spec.
78pub fn openapi_slice(manifest: &Manifest) -> Result<Plan> {
79    Ok(Plan::new()
80        .create("src/app/openapi.rs", openapi::module_rs())
81        .create("openapi.json", openapi_json(manifest)?))
82}
83
84/// The generated `admin/src/resources.ts` — the manifest-derived resource descriptors
85/// (ADR-006). A derived artifact, always regenerated from the current manifest.
86pub fn admin_resources_ts(manifest: &Manifest) -> Result<String> {
87    gize_admin::resources_ts(manifest)
88}
89
90/// The admin SPA **shell** (ADR-006): every file of the separate Vite + React + TypeScript app
91/// under `admin/` except the derived `src/resources.ts`. Static, so it is reconciled
92/// drift-aware by `gize sync`; the descriptors are written separately (see
93/// [`admin_resources_ts`]). Talks to the API through a Vite dev proxy — no backend changes.
94pub fn admin_shell_plan(manifest: &Manifest) -> Plan {
95    let project = &manifest.project.name;
96    Plan::new()
97        .create("admin/package.json", gize_admin::package_json(project))
98        .create("admin/vite.config.ts", gize_admin::vite_config())
99        .create("admin/tsconfig.json", gize_admin::tsconfig())
100        .create("admin/index.html", gize_admin::index_html())
101        .create("admin/.env.example", gize_admin::env_example())
102        .create("admin/.gitignore", gize_admin::gitignore())
103        .create("admin/src/main.tsx", gize_admin::main_tsx())
104        .create("admin/src/styles.css", gize_admin::styles_css())
105        .create("admin/src/theme.ts", gize_admin::theme_ts())
106        .create("admin/src/icons.tsx", gize_admin::icons_tsx())
107        .create("admin/src/toast.tsx", gize_admin::toast_tsx())
108        .create("admin/src/api.ts", gize_admin::api_ts())
109        .create("admin/src/auth.tsx", gize_admin::auth_tsx())
110        .create("admin/src/App.tsx", gize_admin::app_tsx())
111        .create("admin/src/Resource.tsx", gize_admin::resource_tsx())
112        .create(
113            "admin/src/ResourceForm.tsx",
114            gize_admin::resource_form_tsx(),
115        )
116}
117
118/// The code files (no migration) for a module reconstructed from its manifest entry, for
119/// `gize sync` (ADR-009 revision). The built-in `users` module keeps its special `model.rs`.
120pub fn module_code(module: &Module, dialect: Dialect) -> Result<Plan> {
121    let model = module.model_spec()?;
122    let dir = format!("src/app/{}", module.name);
123    Ok(crud_files(&model, &dir, module.name == "users", dialect))
124}
125
126/// The `CREATE TABLE` migration SQL for a module reconstructed from its manifest entry. The
127/// built-in `users` table keeps its special migration (`email UNIQUE`, `is_admin` default).
128pub fn module_migration_sql(module: &Module, dialect: Dialect) -> Result<String> {
129    let model = module.model_spec()?;
130    Ok(if module.name == "users" {
131        user::migration_sql(dialect)
132    } else {
133        model::migration_sql(&model, dialect)
134    })
135}
136
137/// Plan for `gize new <name>`: a complete, compiling project skeleton (ADR-005).
138///
139/// When `with_user` is set (the default), a built-in `users` resource is scaffolded and
140/// wired in — model, CRUD and a migration with an `is_admin` flag — so a fresh project has
141/// authentication-ready data from the start. When `with_openapi` is set, an OpenAPI spec +
142/// `/docs` UI are generated and wired (ADR-010). `timestamp` names the users migration and is
143/// injected (not read from the clock) to keep generation pure and tests deterministic.
144pub fn new_project(
145    name: &str,
146    with_user: bool,
147    with_openapi: bool,
148    with_ws: bool,
149    dialect: Dialect,
150    api: Option<Api>,
151    timestamp: &str,
152) -> Plan {
153    let mut manifest = Manifest::new(name);
154    // Auth is generated for every project (ADR-013): the `src/auth` module is emitted below
155    // and write routes are guarded, so the manifest reflects it.
156    manifest.features.authentication = true;
157    manifest.features.openapi = with_openapi;
158    manifest.features.websocket = with_ws;
159    // Record the API version prefix, if any, so the router nests routes under it and
160    // `gize sync` keeps the same mount (ADR-016).
161    manifest.api = api;
162    // Record the chosen database so `gize sync` regenerates against the same dialect (ADR-015).
163    manifest.stack.database = dialect.sqlx_feature().to_string();
164    if with_user {
165        // Record the built-in users module with its full shape so `gize sync` can
166        // reconcile/rebuild it from the manifest alone (ADR-009 revision).
167        manifest.upsert_module(Module {
168            name: "users".to_string(),
169            model: None,
170            fields: user::spec().to_field_tokens(),
171            belongs_to: Vec::new(),
172        });
173    }
174
175    // Pre-wire the built-in modules into app/mod.rs using the same registry edit `make app`
176    // uses, keeping a single source of truth for the module/route wiring format.
177    let mut app_mod = project::app_mod_rs();
178    for module in [
179        (with_user, "users"),
180        (with_openapi, "openapi"),
181        (with_ws, "ws"),
182    ] {
183        if module.0 {
184            app_mod = registry::register_module(&app_mod, module.1)
185                .expect("app_mod_rs template carries the gize markers")
186                .content;
187        }
188    }
189
190    let mut plan = Plan::new()
191        .create("Cargo.toml", project::cargo_toml(name, dialect, with_ws))
192        .create("gize.toml", project::gize_toml(&manifest))
193        .create(".env.example", project::env_example(name, dialect))
194        .create(".gitignore", "/target\n.env\n")
195        .create("src/main.rs", project::main_rs())
196        .create("src/state.rs", project::state_rs(dialect))
197        .create(
198            "src/router.rs",
199            project::router_rs(manifest.api.as_ref().map(Api::mount_path).as_deref()),
200        )
201        .create("src/config/mod.rs", project::config_mod_rs())
202        .create("src/auth/mod.rs", auth::mod_rs())
203        .create("src/app/mod.rs", app_mod)
204        // Layout directories reserved by ADR-005 so the tree does not churn later.
205        .mkdir("src/database")
206        .mkdir("src/middleware")
207        .mkdir("src/shared")
208        .mkdir("migrations");
209
210    if with_user {
211        plan = plan.extend(user_slice(dialect, timestamp));
212    }
213    if with_openapi {
214        // A fresh manifest is valid, so the spec renders; propagate any error defensively.
215        plan = plan
216            .extend(openapi_slice(&manifest).expect("fresh manifest yields a valid OpenAPI spec"));
217    }
218    if with_ws {
219        plan = plan.extend(ws_module_plan());
220    }
221    plan
222}
223
224/// The optional WebSocket module (ADR-018): a self-contained `src/app/ws/` with a typed echo
225/// endpoint. Static (does not depend on the modules), so `gize sync` reconciles it drift-aware
226/// when `features.websocket` is on, like the OpenAPI module.
227pub fn ws_module_plan() -> Plan {
228    Plan::new()
229        .create("src/app/ws/mod.rs", ws::mod_rs())
230        .create("src/app/ws/message.rs", ws::message_rs())
231        .create("src/app/ws/handler.rs", ws::handler_rs())
232        .create("src/app/ws/routes.rs", ws::routes_rs())
233}
234
235/// The built-in `users` vertical slice for a fresh project: the generic CRUD templates plus
236/// a password-hiding model and a users migration with an `is_admin` flag (see [`user`]).
237fn user_slice(dialect: Dialect, timestamp: &str) -> Plan {
238    crud_files(&user::spec(), "src/app/users", true, dialect).create(
239        format!("migrations/{timestamp}_create_users.sql"),
240        user::migration_sql(dialect),
241    )
242}
243
244/// Plan for `gize make app <name>`: a full, compiling module skeleton (ADR-005).
245///
246/// The module directory equals the module name verbatim (already snake_cased by the CLI).
247/// Registration of the module in `app/mod.rs` and `gize.toml` is handled by the command,
248/// not the plan, because those are edits to existing files (see [`crate::registry`]).
249pub fn make_app(module: &str) -> Plan {
250    let dir = format!("src/app/{module}");
251    Plan::new()
252        .create(format!("{dir}/mod.rs"), module::mod_rs(module))
253        .create(
254            format!("{dir}/model.rs"),
255            module::model_placeholder_rs(module),
256        )
257        .create(format!("{dir}/dto.rs"), module::dto_rs(module))
258        .create(
259            format!("{dir}/repository.rs"),
260            module::repository_rs(module),
261        )
262        .create(format!("{dir}/service.rs"), module::service_rs(module))
263        .create(format!("{dir}/error.rs"), module::error_rs(module))
264        .create(format!("{dir}/handler.rs"), module::handler_rs(module))
265        .create(format!("{dir}/routes.rs"), module::routes_rs(module))
266        .create(format!("{dir}/tests.rs"), module::tests_rs(module))
267}
268
269/// Plan for `gize make model <Name> field:Type ...`: a model struct + its migration.
270///
271/// The module directory is the pluralized snake_case of the model (matching the table and
272/// the `gize make app` convention, e.g. `User` -> `users`). `timestamp` is injected (not
273/// read from the clock) so generation stays pure and tests are deterministic.
274pub fn make_model(model: &ModelSpec, dialect: Dialect, timestamp: &str) -> Plan {
275    let module = table_name(&model.name);
276    let table = &module;
277
278    Plan::new()
279        .create(format!("src/app/{module}/model.rs"), model::model_rs(model))
280        .create(
281            format!("migrations/{timestamp}_create_{table}.sql"),
282            model::migration_sql(model, dialect),
283        )
284}
285
286/// Plan for `gize make crud <Name> field:Type ...`: a full, compiling resource — model,
287/// DTOs, SQLx repository, service, Axum handlers, routes, error, tests, and the migration.
288/// Registration in `app/mod.rs` / `gize.toml` is handled by the command.
289pub fn make_crud(model: &ModelSpec, dialect: Dialect, timestamp: &str) -> Plan {
290    let table = table_name(&model.name);
291    let dir = format!("src/app/{table}");
292
293    crud_files(model, &dir, false, dialect).create(
294        format!("migrations/{timestamp}_create_{table}.sql"),
295        model::migration_sql(model, dialect),
296    )
297}
298
299/// Plan for `gize make migration <name>`: a single blank, timestamped SQL file the developer
300/// fills in by hand. `name` is already snake_cased by the CLI; `timestamp` is injected so
301/// generation stays pure and tests are deterministic.
302pub fn make_migration(name: &str, timestamp: &str) -> Plan {
303    Plan::new().create(
304        format!("migrations/{timestamp}_{name}.sql"),
305        model::blank_migration_sql(name),
306    )
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn paths_of(plan: &Plan) -> Vec<String> {
314        plan.ops
315            .iter()
316            .map(|o| o.path.display().to_string())
317            .collect()
318    }
319
320    fn content_of<'a>(plan: &'a Plan, path: &str) -> &'a str {
321        plan.ops
322            .iter()
323            .find(|o| o.path.display().to_string() == path)
324            .map(|o| o.contents.as_str())
325            .unwrap_or_else(|| panic!("plan has no op for {path}"))
326    }
327
328    #[test]
329    fn new_project_plan_includes_core_files() {
330        let plan = new_project(
331            "shop",
332            false,
333            false,
334            false,
335            Dialect::Postgres,
336            None,
337            "20260704120000",
338        );
339        let paths = paths_of(&plan);
340        assert!(paths.contains(&"Cargo.toml".to_string()));
341        assert!(paths.contains(&"gize.toml".to_string()));
342        assert!(paths.contains(&"src/main.rs".to_string()));
343        assert!(paths.contains(&"src/app/mod.rs".to_string()));
344    }
345
346    #[test]
347    fn new_project_without_user_omits_users_slice() {
348        let plan = new_project(
349            "shop",
350            false,
351            false,
352            false,
353            Dialect::Postgres,
354            None,
355            "20260704120000",
356        );
357        let paths = paths_of(&plan);
358        assert!(!paths.iter().any(|p| p.starts_with("src/app/users/")));
359        assert!(!paths.iter().any(|p| p.ends_with("_create_users.sql")));
360        // app/mod.rs and gize.toml stay empty of the users module.
361        assert!(!content_of(&plan, "src/app/mod.rs").contains("mod users;"));
362        assert!(!content_of(&plan, "gize.toml").contains("users"));
363    }
364
365    #[test]
366    fn new_project_scaffolds_and_wires_users_by_default() {
367        let plan = new_project(
368            "shop",
369            true,
370            false,
371            false,
372            Dialect::Postgres,
373            None,
374            "20260704120000",
375        );
376        let paths = paths_of(&plan);
377        for file in ["mod.rs", "model.rs", "dto.rs", "repository.rs", "routes.rs"] {
378            assert!(
379                paths.contains(&format!("src/app/users/{file}")),
380                "missing users/{file}"
381            );
382        }
383        assert!(paths.contains(&"migrations/20260704120000_create_users.sql".to_string()));
384
385        // The users module is wired into app/mod.rs and listed in gize.toml.
386        let app_mod = content_of(&plan, "src/app/mod.rs");
387        assert!(app_mod.contains("mod users;"));
388        assert!(app_mod.contains(".merge(users::routes())"));
389        assert!(content_of(&plan, "gize.toml").contains("users"));
390
391        // The migration carries the admin flag and hides nothing schema-wise.
392        let migration = content_of(&plan, "migrations/20260704120000_create_users.sql");
393        assert!(migration.contains("is_admin BOOLEAN NOT NULL DEFAULT false"));
394    }
395
396    #[test]
397    fn make_model_plan_has_model_and_migration() {
398        let model = ModelSpec::parse("User", &["name:String".to_string()]).unwrap();
399        let plan = make_model(&model, Dialect::Postgres, "20260704120000");
400        let paths: Vec<_> = plan
401            .ops
402            .iter()
403            .map(|o| o.path.display().to_string())
404            .collect();
405        assert!(paths.contains(&"src/app/users/model.rs".to_string()));
406        assert!(paths.contains(&"migrations/20260704120000_create_users.sql".to_string()));
407    }
408
409    #[test]
410    fn make_crud_plan_has_slice_and_migration() {
411        let model = ModelSpec::parse("Product", &["name:String".to_string()]).unwrap();
412        let plan = make_crud(&model, Dialect::Postgres, "20260704120000");
413        let paths: Vec<_> = plan
414            .ops
415            .iter()
416            .map(|o| o.path.display().to_string())
417            .collect();
418        assert!(paths.contains(&"src/app/products/repository.rs".to_string()));
419        assert!(paths.contains(&"src/app/products/handler.rs".to_string()));
420        assert!(paths.contains(&"src/app/products/dto.rs".to_string()));
421        assert!(paths.contains(&"migrations/20260704120000_create_products.sql".to_string()));
422    }
423
424    #[test]
425    fn make_migration_plan_is_single_timestamped_file() {
426        let plan = make_migration("add_index_to_users", "20260704120000");
427        let paths: Vec<_> = plan
428            .ops
429            .iter()
430            .map(|o| o.path.display().to_string())
431            .collect();
432        assert_eq!(
433            paths,
434            vec!["migrations/20260704120000_add_index_to_users.sql".to_string()]
435        );
436    }
437
438    #[test]
439    fn new_project_with_openapi_includes_spec_and_module() {
440        let plan = new_project(
441            "blog",
442            true,
443            true,
444            false,
445            Dialect::Postgres,
446            None,
447            "20260704120000",
448        );
449        let paths: Vec<_> = plan
450            .ops
451            .iter()
452            .map(|o| o.path.display().to_string())
453            .collect();
454        assert!(paths.contains(&"src/app/openapi.rs".to_string()));
455        assert!(paths.contains(&"openapi.json".to_string()));
456        // The spec is valid JSON describing the users routes; the openapi module is wired.
457        let spec = plan
458            .ops
459            .iter()
460            .find(|o| o.path.display().to_string() == "openapi.json")
461            .map(|o| o.contents.as_str())
462            .unwrap();
463        assert!(spec.contains("\"/users\""));
464        let app_mod = plan
465            .ops
466            .iter()
467            .find(|o| o.path.display().to_string() == "src/app/mod.rs")
468            .map(|o| o.contents.as_str())
469            .unwrap();
470        assert!(app_mod.contains("mod openapi;"));
471        assert!(app_mod.contains(".merge(openapi::routes())"));
472    }
473
474    #[test]
475    fn new_project_without_openapi_omits_it() {
476        let plan = new_project(
477            "blog",
478            true,
479            false,
480            false,
481            Dialect::Postgres,
482            None,
483            "20260704120000",
484        );
485        let paths: Vec<_> = plan
486            .ops
487            .iter()
488            .map(|o| o.path.display().to_string())
489            .collect();
490        assert!(!paths.contains(&"openapi.json".to_string()));
491        assert!(!paths.iter().any(|p| p == "src/app/openapi.rs"));
492    }
493
494    #[test]
495    fn make_app_plan_has_full_module() {
496        let plan = make_app("users");
497        let paths: Vec<_> = plan
498            .ops
499            .iter()
500            .map(|o| o.path.display().to_string())
501            .collect();
502        for file in [
503            "mod.rs",
504            "model.rs",
505            "dto.rs",
506            "repository.rs",
507            "service.rs",
508            "error.rs",
509            "handler.rs",
510            "routes.rs",
511            "tests.rs",
512        ] {
513            assert!(
514                paths.contains(&format!("src/app/users/{file}")),
515                "missing {file}"
516            );
517        }
518    }
519}