1use anyhow::Result;
5use gize_core::naming::table_name;
6use gize_core::{Dialect, Manifest, ModelSpec, Module};
7use gize_templates::{auth, crud, model, module, openapi, project, user};
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/api.ts", gize_admin::api_ts())
106 .create("admin/src/auth.tsx", gize_admin::auth_tsx())
107 .create("admin/src/App.tsx", gize_admin::app_tsx())
108 .create("admin/src/Resource.tsx", gize_admin::resource_tsx())
109}
110
111pub fn module_code(module: &Module, dialect: Dialect) -> Result<Plan> {
114 let model = module.model_spec()?;
115 let dir = format!("src/app/{}", module.name);
116 Ok(crud_files(&model, &dir, module.name == "users", dialect))
117}
118
119pub fn module_migration_sql(module: &Module, dialect: Dialect) -> Result<String> {
122 let model = module.model_spec()?;
123 Ok(if module.name == "users" {
124 user::migration_sql(dialect)
125 } else {
126 model::migration_sql(&model, dialect)
127 })
128}
129
130pub fn new_project(
138 name: &str,
139 with_user: bool,
140 with_openapi: bool,
141 dialect: Dialect,
142 timestamp: &str,
143) -> Plan {
144 let mut manifest = Manifest::new(name);
145 manifest.features.authentication = true;
148 manifest.features.openapi = with_openapi;
149 manifest.stack.database = match dialect {
151 Dialect::Postgres => "postgres".to_string(),
152 Dialect::Sqlite => "sqlite".to_string(),
153 };
154 if with_user {
155 manifest.upsert_module(Module {
158 name: "users".to_string(),
159 fields: user::spec().to_field_tokens(),
160 belongs_to: Vec::new(),
161 });
162 }
163
164 let mut app_mod = project::app_mod_rs();
167 for module in [(with_user, "users"), (with_openapi, "openapi")] {
168 if module.0 {
169 app_mod = registry::register_module(&app_mod, module.1)
170 .expect("app_mod_rs template carries the gize markers")
171 .content;
172 }
173 }
174
175 let mut plan = Plan::new()
176 .create("Cargo.toml", project::cargo_toml(name, dialect))
177 .create("gize.toml", project::gize_toml(&manifest))
178 .create(".env.example", project::env_example(name, dialect))
179 .create(".gitignore", "/target\n.env\n")
180 .create("src/main.rs", project::main_rs())
181 .create("src/state.rs", project::state_rs(dialect))
182 .create("src/router.rs", project::router_rs())
183 .create("src/config/mod.rs", project::config_mod_rs())
184 .create("src/auth/mod.rs", auth::mod_rs())
185 .create("src/app/mod.rs", app_mod)
186 .mkdir("src/database")
188 .mkdir("src/middleware")
189 .mkdir("src/shared")
190 .mkdir("migrations");
191
192 if with_user {
193 plan = plan.extend(user_slice(dialect, timestamp));
194 }
195 if with_openapi {
196 plan = plan
198 .extend(openapi_slice(&manifest).expect("fresh manifest yields a valid OpenAPI spec"));
199 }
200 plan
201}
202
203fn user_slice(dialect: Dialect, timestamp: &str) -> Plan {
206 crud_files(&user::spec(), "src/app/users", true, dialect).create(
207 format!("migrations/{timestamp}_create_users.sql"),
208 user::migration_sql(dialect),
209 )
210}
211
212pub fn make_app(module: &str) -> Plan {
218 let dir = format!("src/app/{module}");
219 Plan::new()
220 .create(format!("{dir}/mod.rs"), module::mod_rs(module))
221 .create(
222 format!("{dir}/model.rs"),
223 module::model_placeholder_rs(module),
224 )
225 .create(format!("{dir}/dto.rs"), module::dto_rs(module))
226 .create(
227 format!("{dir}/repository.rs"),
228 module::repository_rs(module),
229 )
230 .create(format!("{dir}/service.rs"), module::service_rs(module))
231 .create(format!("{dir}/error.rs"), module::error_rs(module))
232 .create(format!("{dir}/handler.rs"), module::handler_rs(module))
233 .create(format!("{dir}/routes.rs"), module::routes_rs(module))
234 .create(format!("{dir}/tests.rs"), module::tests_rs(module))
235}
236
237pub fn make_model(model: &ModelSpec, dialect: Dialect, timestamp: &str) -> Plan {
243 let module = table_name(&model.name);
244 let table = &module;
245
246 Plan::new()
247 .create(format!("src/app/{module}/model.rs"), model::model_rs(model))
248 .create(
249 format!("migrations/{timestamp}_create_{table}.sql"),
250 model::migration_sql(model, dialect),
251 )
252}
253
254pub fn make_crud(model: &ModelSpec, dialect: Dialect, timestamp: &str) -> Plan {
258 let table = table_name(&model.name);
259 let dir = format!("src/app/{table}");
260
261 crud_files(model, &dir, false, dialect).create(
262 format!("migrations/{timestamp}_create_{table}.sql"),
263 model::migration_sql(model, dialect),
264 )
265}
266
267pub fn make_migration(name: &str, timestamp: &str) -> Plan {
271 Plan::new().create(
272 format!("migrations/{timestamp}_{name}.sql"),
273 model::blank_migration_sql(name),
274 )
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 fn paths_of(plan: &Plan) -> Vec<String> {
282 plan.ops
283 .iter()
284 .map(|o| o.path.display().to_string())
285 .collect()
286 }
287
288 fn content_of<'a>(plan: &'a Plan, path: &str) -> &'a str {
289 plan.ops
290 .iter()
291 .find(|o| o.path.display().to_string() == path)
292 .map(|o| o.contents.as_str())
293 .unwrap_or_else(|| panic!("plan has no op for {path}"))
294 }
295
296 #[test]
297 fn new_project_plan_includes_core_files() {
298 let plan = new_project("shop", false, false, Dialect::Postgres, "20260704120000");
299 let paths = paths_of(&plan);
300 assert!(paths.contains(&"Cargo.toml".to_string()));
301 assert!(paths.contains(&"gize.toml".to_string()));
302 assert!(paths.contains(&"src/main.rs".to_string()));
303 assert!(paths.contains(&"src/app/mod.rs".to_string()));
304 }
305
306 #[test]
307 fn new_project_without_user_omits_users_slice() {
308 let plan = new_project("shop", false, false, Dialect::Postgres, "20260704120000");
309 let paths = paths_of(&plan);
310 assert!(!paths.iter().any(|p| p.starts_with("src/app/users/")));
311 assert!(!paths.iter().any(|p| p.ends_with("_create_users.sql")));
312 assert!(!content_of(&plan, "src/app/mod.rs").contains("mod users;"));
314 assert!(!content_of(&plan, "gize.toml").contains("users"));
315 }
316
317 #[test]
318 fn new_project_scaffolds_and_wires_users_by_default() {
319 let plan = new_project("shop", true, false, Dialect::Postgres, "20260704120000");
320 let paths = paths_of(&plan);
321 for file in ["mod.rs", "model.rs", "dto.rs", "repository.rs", "routes.rs"] {
322 assert!(
323 paths.contains(&format!("src/app/users/{file}")),
324 "missing users/{file}"
325 );
326 }
327 assert!(paths.contains(&"migrations/20260704120000_create_users.sql".to_string()));
328
329 let app_mod = content_of(&plan, "src/app/mod.rs");
331 assert!(app_mod.contains("mod users;"));
332 assert!(app_mod.contains(".merge(users::routes())"));
333 assert!(content_of(&plan, "gize.toml").contains("users"));
334
335 let migration = content_of(&plan, "migrations/20260704120000_create_users.sql");
337 assert!(migration.contains("is_admin BOOLEAN NOT NULL DEFAULT false"));
338 }
339
340 #[test]
341 fn make_model_plan_has_model_and_migration() {
342 let model = ModelSpec::parse("User", &["name:String".to_string()]).unwrap();
343 let plan = make_model(&model, Dialect::Postgres, "20260704120000");
344 let paths: Vec<_> = plan
345 .ops
346 .iter()
347 .map(|o| o.path.display().to_string())
348 .collect();
349 assert!(paths.contains(&"src/app/users/model.rs".to_string()));
350 assert!(paths.contains(&"migrations/20260704120000_create_users.sql".to_string()));
351 }
352
353 #[test]
354 fn make_crud_plan_has_slice_and_migration() {
355 let model = ModelSpec::parse("Product", &["name:String".to_string()]).unwrap();
356 let plan = make_crud(&model, Dialect::Postgres, "20260704120000");
357 let paths: Vec<_> = plan
358 .ops
359 .iter()
360 .map(|o| o.path.display().to_string())
361 .collect();
362 assert!(paths.contains(&"src/app/products/repository.rs".to_string()));
363 assert!(paths.contains(&"src/app/products/handler.rs".to_string()));
364 assert!(paths.contains(&"src/app/products/dto.rs".to_string()));
365 assert!(paths.contains(&"migrations/20260704120000_create_products.sql".to_string()));
366 }
367
368 #[test]
369 fn make_migration_plan_is_single_timestamped_file() {
370 let plan = make_migration("add_index_to_users", "20260704120000");
371 let paths: Vec<_> = plan
372 .ops
373 .iter()
374 .map(|o| o.path.display().to_string())
375 .collect();
376 assert_eq!(
377 paths,
378 vec!["migrations/20260704120000_add_index_to_users.sql".to_string()]
379 );
380 }
381
382 #[test]
383 fn new_project_with_openapi_includes_spec_and_module() {
384 let plan = new_project("blog", true, true, Dialect::Postgres, "20260704120000");
385 let paths: Vec<_> = plan
386 .ops
387 .iter()
388 .map(|o| o.path.display().to_string())
389 .collect();
390 assert!(paths.contains(&"src/app/openapi.rs".to_string()));
391 assert!(paths.contains(&"openapi.json".to_string()));
392 let spec = plan
394 .ops
395 .iter()
396 .find(|o| o.path.display().to_string() == "openapi.json")
397 .map(|o| o.contents.as_str())
398 .unwrap();
399 assert!(spec.contains("\"/users\""));
400 let app_mod = plan
401 .ops
402 .iter()
403 .find(|o| o.path.display().to_string() == "src/app/mod.rs")
404 .map(|o| o.contents.as_str())
405 .unwrap();
406 assert!(app_mod.contains("mod openapi;"));
407 assert!(app_mod.contains(".merge(openapi::routes())"));
408 }
409
410 #[test]
411 fn new_project_without_openapi_omits_it() {
412 let plan = new_project("blog", true, false, 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(&"openapi.json".to_string()));
419 assert!(!paths.iter().any(|p| p == "src/app/openapi.rs"));
420 }
421
422 #[test]
423 fn make_app_plan_has_full_module() {
424 let plan = make_app("users");
425 let paths: Vec<_> = plan
426 .ops
427 .iter()
428 .map(|o| o.path.display().to_string())
429 .collect();
430 for file in [
431 "mod.rs",
432 "model.rs",
433 "dto.rs",
434 "repository.rs",
435 "service.rs",
436 "error.rs",
437 "handler.rs",
438 "routes.rs",
439 "tests.rs",
440 ] {
441 assert!(
442 paths.contains(&format!("src/app/users/{file}")),
443 "missing {file}"
444 );
445 }
446 }
447}