1use 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
12fn crud_files(model: &ModelSpec, dir: &str, is_user: bool, dialect: Dialect) -> Plan {
16 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
54pub fn auth_mod_rs() -> String {
57 auth::mod_rs()
58}
59
60pub fn openapi_module_rs() -> String {
63 openapi::module_rs()
64}
65
66pub 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
73pub 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
84pub fn admin_resources_ts(manifest: &Manifest) -> Result<String> {
87 gize_admin::resources_ts(manifest)
88}
89
90pub 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
118pub 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
126pub 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
137pub 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 manifest.features.authentication = true;
157 manifest.features.openapi = with_openapi;
158 manifest.features.websocket = with_ws;
159 manifest.api = api;
162 manifest.stack.database = dialect.sqlx_feature().to_string();
164 if with_user {
165 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 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 .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 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
224pub 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
235fn 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
244pub 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
269pub 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
286pub 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
299pub 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 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 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 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 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}