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::{Manifest, ModelSpec, Module};
7use gize_templates::{auth, crud, model, module, project, user};
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) -> 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(),
24            user::handler_rs(),
25            user::routes_rs(),
26        )
27    } else {
28        (
29            model::model_rs(model),
30            crud::dto_rs(model),
31            crud::error_rs(model),
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(format!("{dir}/repository.rs"), crud::repository_rs(model))
42        .create(format!("{dir}/service.rs"), crud::service_rs(model))
43        .create(format!("{dir}/handler.rs"), handler_rs)
44        .create(format!("{dir}/routes.rs"), routes_rs)
45        .create(format!("{dir}/tests.rs"), crud::tests_rs(model))
46}
47
48/// The generated `src/auth/mod.rs` skeleton (ADR-013), so `gize sync` can reconcile the auth
49/// module that the CRUD routes depend on.
50pub fn auth_mod_rs() -> String {
51    auth::mod_rs()
52}
53
54/// The code files (no migration) for a module reconstructed from its manifest entry, for
55/// `gize sync` (ADR-009 revision). The built-in `users` module keeps its special `model.rs`.
56pub fn module_code(module: &Module) -> Result<Plan> {
57    let model = module.model_spec()?;
58    let dir = format!("src/app/{}", module.name);
59    Ok(crud_files(&model, &dir, module.name == "users"))
60}
61
62/// The `CREATE TABLE` migration SQL for a module reconstructed from its manifest entry. The
63/// built-in `users` table keeps its special migration (`email UNIQUE`, `is_admin` default).
64pub fn module_migration_sql(module: &Module) -> Result<String> {
65    let model = module.model_spec()?;
66    Ok(if module.name == "users" {
67        user::migration_sql()
68    } else {
69        model::migration_sql(&model)
70    })
71}
72
73/// Plan for `gize new <name>`: a complete, compiling project skeleton (ADR-005).
74///
75/// When `with_user` is set (the default), a built-in `users` resource is scaffolded and
76/// wired in — model, CRUD and a migration with an `is_admin` flag — so a fresh project has
77/// authentication-ready data from the start. `timestamp` names the users migration and is
78/// injected (not read from the clock) to keep generation pure and tests deterministic.
79pub fn new_project(name: &str, with_user: bool, timestamp: &str) -> Plan {
80    let mut manifest = Manifest::new(name);
81    // Auth is generated for every project (ADR-013): the `src/auth` module is emitted below
82    // and write routes are guarded, so the manifest reflects it.
83    manifest.features.authentication = true;
84    if with_user {
85        // Record the built-in users module with its full shape so `gize sync` can
86        // reconcile/rebuild it from the manifest alone (ADR-009 revision).
87        manifest.upsert_module(Module {
88            name: "users".to_string(),
89            fields: user::spec().to_field_tokens(),
90            belongs_to: Vec::new(),
91        });
92    }
93
94    // Pre-wire `users` into app/mod.rs by running the same registry edit `make app` uses,
95    // keeping a single source of truth for the module/route wiring format.
96    let app_mod = if with_user {
97        registry::register_module(&project::app_mod_rs(), "users")
98            .expect("app_mod_rs template carries the gize markers")
99            .content
100    } else {
101        project::app_mod_rs()
102    };
103
104    let plan = Plan::new()
105        .create("Cargo.toml", project::cargo_toml(name))
106        .create("gize.toml", project::gize_toml(&manifest))
107        .create(".env.example", project::env_example(name))
108        .create(".gitignore", "/target\n.env\n")
109        .create("src/main.rs", project::main_rs())
110        .create("src/state.rs", project::state_rs())
111        .create("src/router.rs", project::router_rs())
112        .create("src/config/mod.rs", project::config_mod_rs())
113        .create("src/auth/mod.rs", auth::mod_rs())
114        .create("src/app/mod.rs", app_mod)
115        // Layout directories reserved by ADR-005 so the tree does not churn later.
116        .mkdir("src/database")
117        .mkdir("src/middleware")
118        .mkdir("src/shared")
119        .mkdir("migrations");
120
121    if with_user {
122        plan.extend(user_slice(timestamp))
123    } else {
124        plan
125    }
126}
127
128/// The built-in `users` vertical slice for a fresh project: the generic CRUD templates plus
129/// a password-hiding model and a users migration with an `is_admin` flag (see [`user`]).
130fn user_slice(timestamp: &str) -> Plan {
131    crud_files(&user::spec(), "src/app/users", true).create(
132        format!("migrations/{timestamp}_create_users.sql"),
133        user::migration_sql(),
134    )
135}
136
137/// Plan for `gize make app <name>`: a full, compiling module skeleton (ADR-005).
138///
139/// The module directory equals the module name verbatim (already snake_cased by the CLI).
140/// Registration of the module in `app/mod.rs` and `gize.toml` is handled by the command,
141/// not the plan, because those are edits to existing files (see [`crate::registry`]).
142pub fn make_app(module: &str) -> Plan {
143    let dir = format!("src/app/{module}");
144    Plan::new()
145        .create(format!("{dir}/mod.rs"), module::mod_rs(module))
146        .create(
147            format!("{dir}/model.rs"),
148            module::model_placeholder_rs(module),
149        )
150        .create(format!("{dir}/dto.rs"), module::dto_rs(module))
151        .create(
152            format!("{dir}/repository.rs"),
153            module::repository_rs(module),
154        )
155        .create(format!("{dir}/service.rs"), module::service_rs(module))
156        .create(format!("{dir}/error.rs"), module::error_rs(module))
157        .create(format!("{dir}/handler.rs"), module::handler_rs(module))
158        .create(format!("{dir}/routes.rs"), module::routes_rs(module))
159        .create(format!("{dir}/tests.rs"), module::tests_rs(module))
160}
161
162/// Plan for `gize make model <Name> field:Type ...`: a model struct + its migration.
163///
164/// The module directory is the pluralized snake_case of the model (matching the table and
165/// the `gize make app` convention, e.g. `User` -> `users`). `timestamp` is injected (not
166/// read from the clock) so generation stays pure and tests are deterministic.
167pub fn make_model(model: &ModelSpec, timestamp: &str) -> Plan {
168    let module = table_name(&model.name);
169    let table = &module;
170
171    Plan::new()
172        .create(format!("src/app/{module}/model.rs"), model::model_rs(model))
173        .create(
174            format!("migrations/{timestamp}_create_{table}.sql"),
175            model::migration_sql(model),
176        )
177}
178
179/// Plan for `gize make crud <Name> field:Type ...`: a full, compiling resource — model,
180/// DTOs, SQLx repository, service, Axum handlers, routes, error, tests, and the migration.
181/// Registration in `app/mod.rs` / `gize.toml` is handled by the command.
182pub fn make_crud(model: &ModelSpec, timestamp: &str) -> Plan {
183    let table = table_name(&model.name);
184    let dir = format!("src/app/{table}");
185
186    crud_files(model, &dir, false).create(
187        format!("migrations/{timestamp}_create_{table}.sql"),
188        model::migration_sql(model),
189    )
190}
191
192/// Plan for `gize make migration <name>`: a single blank, timestamped SQL file the developer
193/// fills in by hand. `name` is already snake_cased by the CLI; `timestamp` is injected so
194/// generation stays pure and tests are deterministic.
195pub fn make_migration(name: &str, timestamp: &str) -> Plan {
196    Plan::new().create(
197        format!("migrations/{timestamp}_{name}.sql"),
198        model::blank_migration_sql(name),
199    )
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn paths_of(plan: &Plan) -> Vec<String> {
207        plan.ops
208            .iter()
209            .map(|o| o.path.display().to_string())
210            .collect()
211    }
212
213    fn content_of<'a>(plan: &'a Plan, path: &str) -> &'a str {
214        plan.ops
215            .iter()
216            .find(|o| o.path.display().to_string() == path)
217            .map(|o| o.contents.as_str())
218            .unwrap_or_else(|| panic!("plan has no op for {path}"))
219    }
220
221    #[test]
222    fn new_project_plan_includes_core_files() {
223        let plan = new_project("shop", false, "20260704120000");
224        let paths = paths_of(&plan);
225        assert!(paths.contains(&"Cargo.toml".to_string()));
226        assert!(paths.contains(&"gize.toml".to_string()));
227        assert!(paths.contains(&"src/main.rs".to_string()));
228        assert!(paths.contains(&"src/app/mod.rs".to_string()));
229    }
230
231    #[test]
232    fn new_project_without_user_omits_users_slice() {
233        let plan = new_project("shop", false, "20260704120000");
234        let paths = paths_of(&plan);
235        assert!(!paths.iter().any(|p| p.starts_with("src/app/users/")));
236        assert!(!paths.iter().any(|p| p.ends_with("_create_users.sql")));
237        // app/mod.rs and gize.toml stay empty of the users module.
238        assert!(!content_of(&plan, "src/app/mod.rs").contains("mod users;"));
239        assert!(!content_of(&plan, "gize.toml").contains("users"));
240    }
241
242    #[test]
243    fn new_project_scaffolds_and_wires_users_by_default() {
244        let plan = new_project("shop", true, "20260704120000");
245        let paths = paths_of(&plan);
246        for file in ["mod.rs", "model.rs", "dto.rs", "repository.rs", "routes.rs"] {
247            assert!(
248                paths.contains(&format!("src/app/users/{file}")),
249                "missing users/{file}"
250            );
251        }
252        assert!(paths.contains(&"migrations/20260704120000_create_users.sql".to_string()));
253
254        // The users module is wired into app/mod.rs and listed in gize.toml.
255        let app_mod = content_of(&plan, "src/app/mod.rs");
256        assert!(app_mod.contains("mod users;"));
257        assert!(app_mod.contains(".merge(users::routes())"));
258        assert!(content_of(&plan, "gize.toml").contains("users"));
259
260        // The migration carries the admin flag and hides nothing schema-wise.
261        let migration = content_of(&plan, "migrations/20260704120000_create_users.sql");
262        assert!(migration.contains("is_admin BOOLEAN NOT NULL DEFAULT false"));
263    }
264
265    #[test]
266    fn make_model_plan_has_model_and_migration() {
267        let model = ModelSpec::parse("User", &["name:String".to_string()]).unwrap();
268        let plan = make_model(&model, "20260704120000");
269        let paths: Vec<_> = plan
270            .ops
271            .iter()
272            .map(|o| o.path.display().to_string())
273            .collect();
274        assert!(paths.contains(&"src/app/users/model.rs".to_string()));
275        assert!(paths.contains(&"migrations/20260704120000_create_users.sql".to_string()));
276    }
277
278    #[test]
279    fn make_crud_plan_has_slice_and_migration() {
280        let model = ModelSpec::parse("Product", &["name:String".to_string()]).unwrap();
281        let plan = make_crud(&model, "20260704120000");
282        let paths: Vec<_> = plan
283            .ops
284            .iter()
285            .map(|o| o.path.display().to_string())
286            .collect();
287        assert!(paths.contains(&"src/app/products/repository.rs".to_string()));
288        assert!(paths.contains(&"src/app/products/handler.rs".to_string()));
289        assert!(paths.contains(&"src/app/products/dto.rs".to_string()));
290        assert!(paths.contains(&"migrations/20260704120000_create_products.sql".to_string()));
291    }
292
293    #[test]
294    fn make_migration_plan_is_single_timestamped_file() {
295        let plan = make_migration("add_index_to_users", "20260704120000");
296        let paths: Vec<_> = plan
297            .ops
298            .iter()
299            .map(|o| o.path.display().to_string())
300            .collect();
301        assert_eq!(
302            paths,
303            vec!["migrations/20260704120000_add_index_to_users.sql".to_string()]
304        );
305    }
306
307    #[test]
308    fn make_app_plan_has_full_module() {
309        let plan = make_app("users");
310        let paths: Vec<_> = plan
311            .ops
312            .iter()
313            .map(|o| o.path.display().to_string())
314            .collect();
315        for file in [
316            "mod.rs",
317            "model.rs",
318            "dto.rs",
319            "repository.rs",
320            "service.rs",
321            "error.rs",
322            "handler.rs",
323            "routes.rs",
324            "tests.rs",
325        ] {
326            assert!(
327                paths.contains(&format!("src/app/users/{file}")),
328                "missing {file}"
329            );
330        }
331    }
332}