1use 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
12fn crud_files(model: &ModelSpec, dir: &str, is_user: bool) -> 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(),
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
48pub fn auth_mod_rs() -> String {
51 auth::mod_rs()
52}
53
54pub 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
62pub 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
73pub fn new_project(name: &str, with_user: bool, timestamp: &str) -> Plan {
80 let mut manifest = Manifest::new(name);
81 manifest.features.authentication = true;
84 if with_user {
85 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 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 .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
128fn 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
137pub 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
162pub 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
179pub 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
192pub 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 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 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 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}