1use anyhow::{Context, Result};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::cli::{DbBackend, ResourceArgs, ResourceIdType};
8use crate::commands::add::{array_value, wire_database_in_main};
9use crate::{ensure_dir, print_info, print_success, print_warning, write_file};
10
11pub fn run(args: ResourceArgs) -> Result<()> {
12 let project_dir = PathBuf::from(&args.path);
13 let src_dir = project_dir.join("src");
14 if !src_dir.exists() {
15 return Err(anyhow::anyhow!("src/ not found in {}", project_dir.display()));
16 }
17
18 let resource_name = normalize_name(&args.name);
19 let resource_pascal = to_pascal_case(&resource_name);
20 let resource_plural = pluralize(&resource_name);
21
22 let cargo_path = project_dir.join("Cargo.toml");
23 let has_openapi = has_feature(&cargo_path, "openapi");
24 let has_database = has_feature(&cargo_path, "database");
25
26 let routes_dir = src_dir.join("routes");
27 ensure_dir(&routes_dir)
28 .with_context(|| format!("Failed to create {}", routes_dir.display()))?;
29
30 let resource_path = routes_dir.join(format!("{}.rs", resource_name));
31 let contents = render_resource_module(
32 &resource_pascal,
33 &resource_name,
34 &resource_plural,
35 args.with_tests,
36 has_openapi,
37 args.db,
38 args.repo,
39 args.service,
40 args.id_type,
41 args.paginate,
42 args.search,
43 );
44 write_file_with_force(&resource_path, &contents, false)?;
45
46 if args.wire {
47 wire_routes_mod(&routes_dir, &resource_name)?;
48 wire_main_rs(&src_dir, &resource_name, &resource_pascal)?;
49 if has_openapi {
50 wire_openapi_docs(&src_dir, &resource_name, &resource_plural)?;
51 }
52 } else {
53 print_info("Next steps: add the module to routes/mod.rs and register it in main.rs");
54 }
55
56 if args.repo && !args.db {
57 return Err(anyhow::anyhow!(
58 "Repository scaffolding requires --db (run `tideway resource {} --db --repo`)",
59 resource_name
60 ));
61 }
62
63 if args.repo_tests && !args.repo {
64 return Err(anyhow::anyhow!(
65 "Repository tests require --repo (run `tideway resource {} --db --repo --repo-tests`)",
66 resource_name
67 ));
68 }
69
70 if args.service && !args.repo {
71 return Err(anyhow::anyhow!(
72 "Service scaffolding requires --repo (run `tideway resource {} --db --repo --service`)",
73 resource_name
74 ));
75 }
76
77 if args.search && !args.paginate {
78 return Err(anyhow::anyhow!(
79 "Search requires --paginate (run `tideway resource {} --db --paginate --search`)",
80 resource_name
81 ));
82 }
83
84 if args.search && !args.db {
85 return Err(anyhow::anyhow!(
86 "Search requires --db (run `tideway resource {} --db --paginate --search`)",
87 resource_name
88 ));
89 }
90
91 if args.paginate && !args.db {
92 return Err(anyhow::anyhow!(
93 "Pagination requires --db (run `tideway resource {} --db --paginate`)",
94 resource_name
95 ));
96 }
97
98 if args.db {
99 if !has_database {
100 return Err(anyhow::anyhow!(
101 "Database scaffolding requires the Tideway `database` feature (run `tideway add database`)"
102 ));
103 }
104 if !has_dependency(&cargo_path, "sea-orm") {
105 return Err(anyhow::anyhow!(
106 "SeaORM dependency not found (run `tideway add database`)"
107 ));
108 }
109 if matches!(args.id_type, ResourceIdType::Uuid) && !has_dependency(&cargo_path, "uuid") {
110 if args.add_uuid {
111 add_uuid_dependency(&cargo_path)?;
112 print_success("Added uuid dependency to Cargo.toml");
113 } else {
114 return Err(anyhow::anyhow!(
115 "UUID id type requires the uuid dependency (rerun with --add-uuid)"
116 ));
117 }
118 }
119 let backend = resolve_db_backend(&project_dir, args.db_backend)?;
120 match backend {
121 DbBackend::SeaOrm => generate_sea_orm_scaffold(
122 &project_dir,
123 &resource_name,
124 &resource_plural,
125 args.id_type,
126 )?,
127 DbBackend::Auto => {
128 return Err(anyhow::anyhow!(
129 "Unable to detect database backend (use --db-backend)"
130 ));
131 }
132 }
133
134 if args.repo {
135 generate_repository(
136 &project_dir,
137 &resource_name,
138 args.id_type,
139 args.paginate,
140 args.search,
141 )?;
142 if args.repo_tests {
143 let project_name = project_name_from_cargo(&cargo_path, &project_dir);
144 generate_repository_tests(&project_dir, &project_name, &resource_name, args.id_type)?;
145 }
146 if args.service {
147 generate_service(
148 &project_dir,
149 &resource_name,
150 args.id_type,
151 args.paginate,
152 args.search,
153 )?;
154 }
155 }
156
157 if args.wire {
158 wire_database_in_main(&project_dir)?;
159 wire_entities_in_main(&src_dir)?;
160 if args.repo {
161 wire_repositories_in_main(&src_dir)?;
162 }
163 if args.service {
164 wire_services_in_main(&src_dir)?;
165 }
166 } else {
167 print_info("Next steps: wire database into main.rs (tideway add database --wire)");
168 }
169 }
170
171 if args.with_tests {
172 print_info("Added unit tests to the resource module");
173 }
174
175 print_success(&format!("Generated {} resource", resource_name));
176 Ok(())
177}
178
179fn resolve_db_backend(project_dir: &Path, backend: DbBackend) -> Result<DbBackend> {
180 match backend {
181 DbBackend::Auto => detect_db_backend(project_dir),
182 DbBackend::SeaOrm => Ok(DbBackend::SeaOrm),
183 }
184}
185
186fn detect_db_backend(project_dir: &Path) -> Result<DbBackend> {
187 let cargo_path = project_dir.join("Cargo.toml");
188 let contents = fs::read_to_string(&cargo_path)
189 .with_context(|| format!("Failed to read {}", cargo_path.display()))?;
190 let doc = contents
191 .parse::<toml_edit::DocumentMut>()
192 .context("Failed to parse Cargo.toml")?;
193
194 let deps = doc.get("dependencies");
195 let has_sea_orm = deps
196 .and_then(|deps| deps.get("sea-orm"))
197 .is_some();
198 let has_tideway_db = deps
199 .and_then(|deps| deps.get("tideway"))
200 .and_then(|item| item.get("features"))
201 .and_then(|item| item.as_array())
202 .map(|arr| arr.iter().any(|v| v.as_str() == Some("database")))
203 .unwrap_or(false);
204
205 if has_sea_orm || has_tideway_db {
206 Ok(DbBackend::SeaOrm)
207 } else {
208 Err(anyhow::anyhow!(
209 "Could not detect database backend (add sea-orm or pass --db-backend)"
210 ))
211 }
212}
213
214fn render_resource_module(
215 resource_pascal: &str,
216 resource_name: &str,
217 resource_plural: &str,
218 with_tests: bool,
219 has_openapi: bool,
220 with_db: bool,
221 with_repo: bool,
222 with_service: bool,
223 id_type: ResourceIdType,
224 paginate: bool,
225 search: bool,
226) -> String {
227 let body_extractor = "Json(body): Json<CreateRequest>";
228 let id_type_str = if matches!(id_type, ResourceIdType::Uuid) {
229 "uuid::Uuid"
230 } else {
231 "i32"
232 };
233 let uuid_import = if matches!(id_type, ResourceIdType::Uuid) {
234 "use uuid::Uuid;\n"
235 } else {
236 ""
237 };
238 let create_id_field = if matches!(id_type, ResourceIdType::Uuid) {
239 " id: Set(Uuid::new_v4()),\n"
240 } else {
241 ""
242 };
243 let tests_block = if with_tests {
244 format!(
245 r#"
246
247#[cfg(test)]
248mod tests {{
249 use super::*;
250 use tideway::testing::{{get, post}};
251 use tideway::App;
252
253 #[tokio::test]
254 async fn list_{resource_plural}_ok() {{
255 let app = App::new()
256 .register_module({resource_pascal}Module)
257 .into_router();
258
259 get(app, "/api/{resource_plural}")
260 .execute()
261 .await
262 .assert_ok();
263 }}
264
265 #[tokio::test]
266 async fn create_{resource_name}_ok() {{
267 let app = App::new()
268 .register_module({resource_pascal}Module)
269 .into_router();
270
271 post(app, "/api/{resource_plural}")
272 .with_json(&serde_json::json!({{ "name": "Example" }}))
273 .execute()
274 .await
275 .assert_ok();
276 }}
277}}
278"#,
279 resource_pascal = resource_pascal,
280 resource_name = resource_name,
281 resource_plural = resource_plural,
282 )
283 } else {
284 String::new()
285 };
286 let mut openapi_import = String::new();
287 if has_openapi {
288 openapi_import.push_str("use utoipa::ToSchema;\n");
289 if paginate {
290 openapi_import.push_str("use utoipa::IntoParams;\n");
291 }
292 }
293
294 let openapi_schema = if has_openapi {
295 "#[derive(ToSchema)]"
296 } else {
297 ""
298 };
299
300 let openapi_paths = if has_openapi {
301 format!(
302 r#"
303#[cfg(feature = "openapi")]
304mod openapi_docs {{
305 use super::*;
306 use utoipa::OpenApi;
307
308 #[derive(OpenApi)]
309 #[openapi(
310 paths(
311 list_{resource_plural},
312 get_{resource_name},
313 create_{resource_name},
314 update_{resource_name},
315 delete_{resource_name}
316 ),
317 components(schemas({resource_pascal}, CreateRequest, UpdateRequest))
318 )]
319 pub struct {resource_pascal}Api;
320}}
321"#,
322 resource_pascal = resource_pascal,
323 resource_name = resource_name,
324 resource_plural = resource_plural,
325 )
326 } else {
327 String::new()
328 };
329
330 let openapi_attrs = if has_openapi {
331 format!(
332 r#"
333#[cfg_attr(feature = "openapi", utoipa::path(
334 get,
335 path = "/api/{resource_plural}",
336 {pagination_params}
337 responses((status = 200, body = [{resource_pascal}]))
338))]
339"#,
340 resource_plural = resource_plural,
341 resource_pascal = resource_pascal,
342 pagination_params = if paginate { "params(PaginationParams)," } else { "" },
343 )
344 } else {
345 String::new()
346 };
347
348 let openapi_attrs_get = if has_openapi {
349 format!(
350 r#"
351#[cfg_attr(feature = "openapi", utoipa::path(
352 get,
353 path = "/api/{resource_plural}/{{id}}",
354 responses((status = 200, body = {resource_pascal}))
355))]
356"#,
357 resource_plural = resource_plural,
358 resource_pascal = resource_pascal,
359 )
360 } else {
361 String::new()
362 };
363
364 let openapi_attrs_create = if has_openapi {
365 format!(
366 r#"
367#[cfg_attr(feature = "openapi", utoipa::path(
368 post,
369 path = "/api/{resource_plural}",
370 request_body = CreateRequest,
371 responses((status = 200, body = MessageResponse))
372))]
373"#,
374 resource_plural = resource_plural,
375 )
376 } else {
377 String::new()
378 };
379
380 let openapi_attrs_update = if has_openapi {
381 format!(
382 r#"
383#[cfg_attr(feature = "openapi", utoipa::path(
384 put,
385 path = "/api/{resource_plural}/{{id}}",
386 request_body = UpdateRequest,
387 responses((status = 200, body = MessageResponse))
388))]
389"#,
390 resource_plural = resource_plural,
391 )
392 } else {
393 String::new()
394 };
395
396 let openapi_attrs_delete = if has_openapi {
397 format!(
398 r#"
399#[cfg_attr(feature = "openapi", utoipa::path(
400 delete,
401 path = "/api/{resource_plural}/{{id}}",
402 responses((status = 200, body = MessageResponse))
403))]
404"#,
405 resource_plural = resource_plural,
406 )
407 } else {
408 String::new()
409 };
410
411 let extract_import = if with_db {
412 if paginate {
413 "extract::{Path, Query, State}, "
414 } else {
415 "extract::{Path, State}, "
416 }
417 } else {
418 ""
419 };
420 let sea_orm_imports = if with_db {
421 match (paginate, search) {
422 (true, true) => "use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set};\n",
423 (true, false) => "use sea_orm::{ActiveModelTrait, EntityTrait, QuerySelect, Set};\n",
424 (false, true) => "use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};\n",
425 (false, false) => "use sea_orm::{ActiveModelTrait, EntityTrait, Set};\n",
426 }
427 } else {
428 ""
429 };
430 let entities_import = if with_db {
431 format!("use crate::entities::{resource_name};\n")
432 } else {
433 String::new()
434 };
435 let repositories_import = if with_repo {
436 format!("use crate::repositories::{resource_name}::{resource_pascal}Repository;\n")
437 } else {
438 String::new()
439 };
440 let services_import = if with_service {
441 format!("use crate::services::{resource_name}::{resource_pascal}Service;\n")
442 } else {
443 String::new()
444 };
445
446 let pagination_struct = if paginate {
447 let attrs = if has_openapi {
448 "#[cfg_attr(feature = \"openapi\", derive(IntoParams))]"
449 } else {
450 ""
451 };
452 let mut struct_body = format!(
453 r#"
454#[derive(Deserialize)]
455{attrs}
456pub struct PaginationParams {{
457 pub limit: Option<u64>,
458 pub offset: Option<u64>,
459"#,
460 attrs = attrs
461 );
462 if search {
463 struct_body.push_str(" pub q: Option<String>,\n");
464 }
465 struct_body.push_str("}\n");
466 struct_body
467 } else {
468 String::new()
469 };
470 let list_params = if paginate {
471 "Query(params): Query<PaginationParams>"
472 } else {
473 ""
474 };
475 let list_param_prefix = if paginate { ", " } else { "" };
476 let list_args = if paginate {
477 if search {
478 "params.limit, params.offset, params.q"
479 } else {
480 "params.limit, params.offset"
481 }
482 } else {
483 ""
484 };
485 let pagination_query = if paginate {
486 let search_query = if search {
487 format!(
488 " if let Some(q) = params.q.as_deref() {{ query = query.filter({resource_name}::Column::Name.contains(q)); }}\n",
489 resource_name = resource_name
490 )
491 } else {
492 String::new()
493 };
494 format!(
495 " if let Some(limit) = params.limit {{ query = query.limit(limit); }}\n if let Some(offset) = params.offset {{ query = query.offset(offset); }}\n{search_query}",
496 search_query = search_query
497 )
498 } else {
499 String::new()
500 };
501
502 let handlers = if with_db && with_repo && with_service {
503 format!(
504 r#"
505{openapi_attrs}
506async fn list_{resource_plural}(State(ctx): State<AppContext>{list_param_prefix}{list_params}) -> Result<Json<Vec<{resource_pascal}>>> {{
507 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
508 let service = {resource_pascal}Service::new(repo);
509 let models = service.list({list_args}).await?;
510 let items = models
511 .into_iter()
512 .map(|model| {resource_pascal} {{
513 id: model.id.to_string(),
514 name: model.name,
515 }})
516 .collect();
517 Ok(Json(items))
518}}
519
520{openapi_attrs_get}
521async fn get_{resource_name}(
522 State(ctx): State<AppContext>,
523 Path(id): Path<{id_type_str}>,
524) -> Result<Json<{resource_pascal}>> {{
525 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
526 let service = {resource_pascal}Service::new(repo);
527 let model = service
528 .get(id)
529 .await?
530 .ok_or_else(|| tideway::TidewayError::not_found("{resource_pascal} not found"))?;
531 Ok(Json({resource_pascal} {{
532 id: model.id.to_string(),
533 name: model.name,
534 }}))
535}}
536
537{openapi_attrs_create}
538async fn create_{resource_name}(
539 State(ctx): State<AppContext>,
540 {body_extractor},
541) -> Result<MessageResponse> {{
542 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
543 let service = {resource_pascal}Service::new(repo);
544 service.create(body.name).await?;
545 Ok(MessageResponse::success("Created"))
546}}
547
548{openapi_attrs_update}
549async fn update_{resource_name}(
550 State(ctx): State<AppContext>,
551 Path(id): Path<{id_type_str}>,
552 Json(body): Json<UpdateRequest>,
553) -> Result<MessageResponse> {{
554 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
555 let service = {resource_pascal}Service::new(repo);
556 service.update(id, body.name).await?;
557 Ok(MessageResponse::success("Updated"))
558}}
559
560{openapi_attrs_delete}
561async fn delete_{resource_name}(
562 State(ctx): State<AppContext>,
563 Path(id): Path<{id_type_str}>,
564) -> Result<MessageResponse> {{
565 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
566 let service = {resource_pascal}Service::new(repo);
567 service.delete(id).await?;
568 Ok(MessageResponse::success("Deleted"))
569}}
570"#,
571 resource_pascal = resource_pascal,
572 resource_name = resource_name,
573 resource_plural = resource_plural,
574 openapi_attrs = openapi_attrs,
575 openapi_attrs_get = openapi_attrs_get,
576 openapi_attrs_create = openapi_attrs_create,
577 openapi_attrs_update = openapi_attrs_update,
578 openapi_attrs_delete = openapi_attrs_delete,
579 body_extractor = body_extractor,
580 id_type_str = id_type_str,
581 list_param_prefix = list_param_prefix,
582 list_params = list_params,
583 list_args = list_args,
584 )
585 } else if with_db && with_repo {
586 format!(
587 r#"
588{openapi_attrs}
589async fn list_{resource_plural}(State(ctx): State<AppContext>{list_param_prefix}{list_params}) -> Result<Json<Vec<{resource_pascal}>>> {{
590 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
591 let models = repo.list({list_args}).await?;
592 let items = models
593 .into_iter()
594 .map(|model| {resource_pascal} {{
595 id: model.id.to_string(),
596 name: model.name,
597 }})
598 .collect();
599 Ok(Json(items))
600}}
601
602{openapi_attrs_get}
603async fn get_{resource_name}(
604 State(ctx): State<AppContext>,
605 Path(id): Path<{id_type_str}>,
606) -> Result<Json<{resource_pascal}>> {{
607 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
608 let model = repo
609 .get(id)
610 .await?
611 .ok_or_else(|| tideway::TidewayError::not_found("{resource_pascal} not found"))?;
612 Ok(Json({resource_pascal} {{
613 id: model.id.to_string(),
614 name: model.name,
615 }}))
616}}
617
618{openapi_attrs_create}
619async fn create_{resource_name}(
620 State(ctx): State<AppContext>,
621 {body_extractor},
622) -> Result<MessageResponse> {{
623 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
624 repo.create(body.name).await?;
625 Ok(MessageResponse::success("Created"))
626}}
627
628{openapi_attrs_update}
629async fn update_{resource_name}(
630 State(ctx): State<AppContext>,
631 Path(id): Path<{id_type_str}>,
632 Json(body): Json<UpdateRequest>,
633) -> Result<MessageResponse> {{
634 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
635 repo.update(id, body.name).await?;
636 Ok(MessageResponse::success("Updated"))
637}}
638
639{openapi_attrs_delete}
640async fn delete_{resource_name}(
641 State(ctx): State<AppContext>,
642 Path(id): Path<{id_type_str}>,
643) -> Result<MessageResponse> {{
644 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
645 repo.delete(id).await?;
646 Ok(MessageResponse::success("Deleted"))
647}}
648"#,
649 resource_pascal = resource_pascal,
650 resource_name = resource_name,
651 resource_plural = resource_plural,
652 openapi_attrs = openapi_attrs,
653 openapi_attrs_get = openapi_attrs_get,
654 openapi_attrs_create = openapi_attrs_create,
655 openapi_attrs_update = openapi_attrs_update,
656 openapi_attrs_delete = openapi_attrs_delete,
657 body_extractor = body_extractor,
658 id_type_str = id_type_str,
659 list_param_prefix = list_param_prefix,
660 list_params = list_params,
661 list_args = list_args,
662 )
663 } else if with_db {
664 format!(
665 r#"
666{openapi_attrs}
667async fn list_{resource_plural}(State(ctx): State<AppContext>{list_param_prefix}{list_params}) -> Result<Json<Vec<{resource_pascal}>>> {{
668 let db = ctx.sea_orm_connection()?;
669 let mut query = {resource_name}::Entity::find();
670{pagination_query}
671 let models = query.all(&db).await?;
672 let items = models
673 .into_iter()
674 .map(|model| {resource_pascal} {{
675 id: model.id.to_string(),
676 name: model.name,
677 }})
678 .collect();
679 Ok(Json(items))
680}}
681
682{openapi_attrs_get}
683async fn get_{resource_name}(
684 State(ctx): State<AppContext>,
685 Path(id): Path<{id_type_str}>,
686) -> Result<Json<{resource_pascal}>> {{
687 let db = ctx.sea_orm_connection()?;
688 let model = {resource_name}::Entity::find_by_id(id).one(&db).await?;
689 let model = model.ok_or_else(|| tideway::TidewayError::not_found("{resource_pascal} not found"))?;
690 Ok(Json({resource_pascal} {{
691 id: model.id.to_string(),
692 name: model.name,
693 }}))
694}}
695
696{openapi_attrs_create}
697async fn create_{resource_name}(
698 State(ctx): State<AppContext>,
699 {body_extractor},
700) -> Result<MessageResponse> {{
701 let db = ctx.sea_orm_connection()?;
702 let active = {resource_name}::ActiveModel {{
703{create_id_field}
704 name: Set(body.name),
705 ..Default::default()
706 }};
707 active.insert(&db).await?;
708 Ok(MessageResponse::success("Created"))
709}}
710
711{openapi_attrs_update}
712async fn update_{resource_name}(
713 State(ctx): State<AppContext>,
714 Path(id): Path<{id_type_str}>,
715 Json(body): Json<UpdateRequest>,
716) -> Result<MessageResponse> {{
717 let db = ctx.sea_orm_connection()?;
718 let model = {resource_name}::Entity::find_by_id(id).one(&db).await?;
719 let model = model.ok_or_else(|| tideway::TidewayError::not_found("{resource_pascal} not found"))?;
720 let mut active: {resource_name}::ActiveModel = model.into();
721 if let Some(name) = body.name {{
722 active.name = Set(name);
723 }}
724 active.update(&db).await?;
725 Ok(MessageResponse::success("Updated"))
726}}
727
728{openapi_attrs_delete}
729async fn delete_{resource_name}(
730 State(ctx): State<AppContext>,
731 Path(id): Path<{id_type_str}>,
732) -> Result<MessageResponse> {{
733 let db = ctx.sea_orm_connection()?;
734 {resource_name}::Entity::delete_by_id(id).exec(&db).await?;
735 Ok(MessageResponse::success("Deleted"))
736}}
737"#,
738 resource_pascal = resource_pascal,
739 resource_name = resource_name,
740 resource_plural = resource_plural,
741 openapi_attrs = openapi_attrs,
742 openapi_attrs_get = openapi_attrs_get,
743 openapi_attrs_create = openapi_attrs_create,
744 openapi_attrs_update = openapi_attrs_update,
745 openapi_attrs_delete = openapi_attrs_delete,
746 body_extractor = body_extractor,
747 id_type_str = id_type_str,
748 list_params = list_params,
749 list_param_prefix = list_param_prefix,
750 pagination_query = pagination_query,
751 )
752 } else {
753 format!(
754 r#"
755{openapi_attrs}
756async fn list_{resource_plural}() -> Json<Vec<{resource_pascal}>> {{
757 Json(Vec::new())
758}}
759
760{openapi_attrs_get}
761async fn get_{resource_name}() -> Result<Json<{resource_pascal}>> {{
762 Ok(Json({resource_pascal} {{
763 id: "demo".to_string(),
764 name: "{resource_pascal}".to_string(),
765 }}))
766}}
767
768{openapi_attrs_create}
769async fn create_{resource_name}({body_extractor}) -> Result<MessageResponse> {{
770 Ok(MessageResponse::success(format!("Created {{}}", body.name)))
771}}
772
773{openapi_attrs_update}
774async fn update_{resource_name}({body_extractor}) -> Result<MessageResponse> {{
775 let name = body.name.unwrap_or_else(|| "{resource_pascal}".to_string());
776 Ok(MessageResponse::success(format!("Updated {{}}", name)))
777}}
778
779{openapi_attrs_delete}
780async fn delete_{resource_name}() -> Result<MessageResponse> {{
781 Ok(MessageResponse::success("Deleted"))
782}}
783"#,
784 resource_pascal = resource_pascal,
785 resource_name = resource_name,
786 resource_plural = resource_plural,
787 openapi_attrs = openapi_attrs,
788 openapi_attrs_get = openapi_attrs_get,
789 openapi_attrs_create = openapi_attrs_create,
790 openapi_attrs_update = openapi_attrs_update,
791 openapi_attrs_delete = openapi_attrs_delete,
792 body_extractor = body_extractor,
793 )
794 };
795
796 format!(
797 r#"//! {resource_pascal} routes.
798
799use axum::{{routing::{{delete, get, post, put}}, {extract_import}Json, Router}};
800use serde::{{Deserialize, Serialize}};
801use tideway::{{AppContext, MessageResponse, Result, RouteModule}};
802{openapi_import}
803{sea_orm_imports}
804{uuid_import}
805{entities_import}
806{repositories_import}
807{services_import}
808
809pub struct {resource_pascal}Module;
810
811impl RouteModule for {resource_pascal}Module {{
812 fn routes(&self) -> Router<AppContext> {{
813 Router::new()
814 .route("/", get(list_{resource_plural}).post(create_{resource_name}))
815 .route("/:id", get(get_{resource_name}).put(update_{resource_name}).delete(delete_{resource_name}))
816 }}
817
818 fn prefix(&self) -> Option<&str> {{
819 Some("/api/{resource_plural}")
820 }}
821}}
822
823#[derive(Debug, Serialize)]
824{openapi_schema}
825pub struct {resource_pascal} {{
826 pub id: String,
827 pub name: String,
828}}
829
830#[derive(Deserialize)]
831{openapi_schema}
832pub struct CreateRequest {{
833 pub name: String,
834}}
835
836#[derive(Deserialize)]
837{openapi_schema}
838pub struct UpdateRequest {{
839 pub name: Option<String>,
840}}
841
842{pagination_struct}
843{handlers}
844{tests_block}
845{openapi_paths}
846"#,
847 resource_pascal = resource_pascal,
848 resource_name = resource_name,
849 resource_plural = resource_plural,
850 tests_block = tests_block,
851 openapi_import = openapi_import,
852 openapi_schema = openapi_schema,
853 openapi_paths = openapi_paths,
854 pagination_struct = pagination_struct,
855 handlers = handlers,
856 extract_import = extract_import,
857 sea_orm_imports = sea_orm_imports,
858 uuid_import = uuid_import,
859 entities_import = entities_import,
860 repositories_import = repositories_import,
861 services_import = services_import,
862 )
863}
864
865fn wire_openapi_docs(src_dir: &Path, resource_name: &str, resource_plural: &str) -> Result<()> {
866 let docs_path = src_dir.join("openapi_docs.rs");
867 let paths = [
868 format!("crate::routes::{resource_name}::list_{resource_plural}"),
869 format!("crate::routes::{resource_name}::get_{resource_name}"),
870 format!("crate::routes::{resource_name}::create_{resource_name}"),
871 format!("crate::routes::{resource_name}::update_{resource_name}"),
872 format!("crate::routes::{resource_name}::delete_{resource_name}"),
873 ];
874
875 if !docs_path.exists() {
876 let contents = render_openapi_docs_file(&paths);
877 write_file_with_force(&docs_path, &contents, false)?;
878 print_success("Created src/openapi_docs.rs");
879 return Ok(());
880 }
881
882 let mut contents = fs::read_to_string(&docs_path)
883 .with_context(|| format!("Failed to read {}", docs_path.display()))?;
884
885 if !contents.contains("openapi_doc!") || !contents.contains("paths(") {
886 print_warning("Could not find OpenAPI doc paths; skipping openapi_docs.rs update");
887 return Ok(());
888 }
889
890 if paths.iter().all(|path| contents.contains(path)) {
891 return Ok(());
892 }
893
894 let mut lines = contents.lines().map(|line| line.to_string()).collect::<Vec<_>>();
895 let mut start = None;
896 let mut end = None;
897
898 for (idx, line) in lines.iter().enumerate() {
899 if start.is_none() && line.contains("paths(") {
900 start = Some(idx);
901 continue;
902 }
903 if start.is_some() && line.trim_start().starts_with(")") {
904 end = Some(idx);
905 break;
906 }
907 }
908
909 let (start, mut end) = match (start, end) {
910 (Some(start), Some(end)) if end > start => (start, end),
911 _ => {
912 print_warning("Could not locate OpenAPI paths block; skipping openapi_docs.rs update");
913 return Ok(());
914 }
915 };
916
917 let base_indent = lines[start]
918 .chars()
919 .take_while(|c| c.is_whitespace())
920 .collect::<String>();
921 let entry_indent = format!("{base_indent} ");
922
923 for path in paths {
924 if contents.contains(&path) {
925 continue;
926 }
927 lines.insert(end, format!("{entry_indent}{path},"));
928 end += 1;
929 }
930
931 contents = lines.join("\n");
932 if !contents.ends_with('\n') {
933 contents.push('\n');
934 }
935 write_file(&docs_path, &contents)
936 .with_context(|| format!("Failed to write {}", docs_path.display()))?;
937 print_success("Updated src/openapi_docs.rs");
938 Ok(())
939}
940
941fn wire_entities_in_main(src_dir: &Path) -> Result<()> {
942 let main_path = src_dir.join("main.rs");
943 if !main_path.exists() {
944 print_warning("src/main.rs not found; skipping entities wiring");
945 return Ok(());
946 }
947
948 let mut contents = fs::read_to_string(&main_path)
949 .with_context(|| format!("Failed to read {}", main_path.display()))?;
950
951 if contents.contains("mod entities;") {
952 return Ok(());
953 }
954
955 if contents.contains("mod routes;") {
956 contents = contents.replace("mod routes;\n", "mod routes;\nmod entities;\n");
957 } else {
958 contents = format!("mod entities;\n{}", contents);
959 }
960
961 write_file(&main_path, &contents)
962 .with_context(|| format!("Failed to write {}", main_path.display()))?;
963 print_success("Added mod entities to src/main.rs");
964 Ok(())
965}
966
967fn wire_repositories_in_main(src_dir: &Path) -> Result<()> {
968 let main_path = src_dir.join("main.rs");
969 if !main_path.exists() {
970 print_warning("src/main.rs not found; skipping repositories wiring");
971 return Ok(());
972 }
973
974 let mut contents = fs::read_to_string(&main_path)
975 .with_context(|| format!("Failed to read {}", main_path.display()))?;
976
977 if contents.contains("mod repositories;") {
978 return Ok(());
979 }
980
981 if contents.contains("mod routes;") {
982 contents = contents.replace("mod routes;\n", "mod routes;\nmod repositories;\n");
983 } else {
984 contents = format!("mod repositories;\n{}", contents);
985 }
986
987 write_file(&main_path, &contents)
988 .with_context(|| format!("Failed to write {}", main_path.display()))?;
989 print_success("Added mod repositories to src/main.rs");
990 Ok(())
991}
992
993fn wire_services_in_main(src_dir: &Path) -> Result<()> {
994 let main_path = src_dir.join("main.rs");
995 if !main_path.exists() {
996 print_warning("src/main.rs not found; skipping services wiring");
997 return Ok(());
998 }
999
1000 let mut contents = fs::read_to_string(&main_path)
1001 .with_context(|| format!("Failed to read {}", main_path.display()))?;
1002
1003 if contents.contains("mod services;") {
1004 return Ok(());
1005 }
1006
1007 if contents.contains("mod routes;") {
1008 contents = contents.replace("mod routes;\n", "mod routes;\nmod services;\n");
1009 } else {
1010 contents = format!("mod services;\n{}", contents);
1011 }
1012
1013 write_file(&main_path, &contents)
1014 .with_context(|| format!("Failed to write {}", main_path.display()))?;
1015 print_success("Added mod services to src/main.rs");
1016 Ok(())
1017}
1018
1019fn render_openapi_docs_file(paths: &[String]) -> String {
1020 let mut output = String::new();
1021 output.push_str("#[cfg(feature = \"openapi\")]\n");
1022 output.push_str("tideway::openapi_doc!(\n");
1023 output.push_str(" pub(crate) ApiDoc,\n");
1024 output.push_str(" paths(\n");
1025 for path in paths {
1026 output.push_str(" ");
1027 output.push_str(path);
1028 output.push_str(",\n");
1029 }
1030 output.push_str(" )\n");
1031 output.push_str(");\n");
1032 output
1033}
1034
1035fn generate_sea_orm_scaffold(
1036 project_dir: &Path,
1037 resource_name: &str,
1038 resource_plural: &str,
1039 id_type: ResourceIdType,
1040) -> Result<()> {
1041 let src_dir = project_dir.join("src");
1042 let entities_dir = src_dir.join("entities");
1043 ensure_dir(&entities_dir)
1044 .with_context(|| format!("Failed to create {}", entities_dir.display()))?;
1045
1046 let entities_mod = entities_dir.join("mod.rs");
1047 if !entities_mod.exists() {
1048 let contents = "//! Database entities.\n\n";
1049 write_file_with_force(&entities_mod, contents, false)?;
1050 print_success("Created src/entities/mod.rs");
1051 }
1052 wire_entities_mod(&entities_mod, resource_name)?;
1053
1054 let entity_path = entities_dir.join(format!("{}.rs", resource_name));
1055 let entity_contents = render_sea_orm_entity(resource_name, resource_plural, id_type);
1056 write_file_with_force(&entity_path, &entity_contents, false)?;
1057
1058 let migration_root = project_dir.join("migration");
1059 let migration_src = migration_root.join("src");
1060 if !migration_src.exists() {
1061 ensure_dir(&migration_src)
1062 .with_context(|| format!("Failed to create {}", migration_src.display()))?;
1063 }
1064 if !migration_root.join("Cargo.toml").exists() {
1065 print_warning("migration/Cargo.toml not found (run `sea-orm-cli migrate init` if needed)");
1066 }
1067
1068 let (migration_mod, migration_file) =
1069 next_migration_name(&migration_src, resource_plural)?;
1070 let migration_contents = render_sea_orm_migration(resource_plural, id_type);
1071 let migration_path = migration_src.join(&migration_file);
1072 write_file_with_force(&migration_path, &migration_contents, false)?;
1073
1074 let migration_lib = migration_src.join("lib.rs");
1075 if !migration_lib.exists() {
1076 let contents = render_migration_lib(&migration_mod);
1077 write_file_with_force(&migration_lib, &contents, false)?;
1078 print_success("Created migration/src/lib.rs");
1079 } else {
1080 update_migration_lib(&migration_lib, &migration_mod)?;
1081 }
1082
1083 print_success("Generated SeaORM entity + migration");
1084 Ok(())
1085}
1086
1087fn wire_entities_mod(mod_path: &Path, resource_name: &str) -> Result<()> {
1088 let mut contents = fs::read_to_string(mod_path)
1089 .with_context(|| format!("Failed to read {}", mod_path.display()))?;
1090 let mod_line = format!("pub mod {};", resource_name);
1091 if !contents.contains(&mod_line) {
1092 contents.push_str(&format!("\n{}\n", mod_line));
1093 write_file(mod_path, &contents)
1094 .with_context(|| format!("Failed to write {}", mod_path.display()))?;
1095 }
1096 Ok(())
1097}
1098
1099fn render_sea_orm_entity(
1100 resource_name: &str,
1101 resource_plural: &str,
1102 id_type: ResourceIdType,
1103) -> String {
1104 let resource_pascal = to_pascal_case(resource_name);
1105 let id_field = if matches!(id_type, ResourceIdType::Uuid) {
1106 " #[sea_orm(primary_key, auto_increment = false)]\n pub id: Uuid,\n"
1107 } else {
1108 " #[sea_orm(primary_key, auto_increment = true)]\n pub id: i32,\n"
1109 };
1110 let uuid_import = if matches!(id_type, ResourceIdType::Uuid) {
1111 "use uuid::Uuid;\n"
1112 } else {
1113 ""
1114 };
1115 format!(
1116 r#"//! SeaORM entity for {resource_pascal}.
1117
1118use sea_orm::entity::prelude::*;
1119{uuid_import}
1120
1121#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
1122#[sea_orm(table_name = "{resource_plural}")]
1123pub struct Model {{
1124{id_field}
1125 pub name: String,
1126}}
1127
1128#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
1129pub enum Relation {{}}
1130
1131impl ActiveModelBehavior for ActiveModel {{}}
1132"#,
1133 resource_pascal = resource_pascal,
1134 resource_plural = resource_plural,
1135 id_field = id_field,
1136 uuid_import = uuid_import
1137 )
1138}
1139
1140fn next_migration_name(migration_src: &Path, resource_plural: &str) -> Result<(String, String)> {
1141 let mut max_num = 0u64;
1142 let mut width = 3usize;
1143
1144 if migration_src.exists() {
1145 for entry in fs::read_dir(migration_src)
1146 .with_context(|| format!("Failed to read {}", migration_src.display()))?
1147 {
1148 let entry = entry?;
1149 let Some(name) = entry.file_name().to_str().map(|s| s.to_string()) else {
1150 continue;
1151 };
1152 if !name.starts_with('m') || !name.ends_with(".rs") {
1153 continue;
1154 }
1155 let stem = name.trim_end_matches(".rs");
1156 let number_part = stem
1157 .trim_start_matches('m')
1158 .split('_')
1159 .next()
1160 .unwrap_or("");
1161 if number_part.chars().all(|c| c.is_ascii_digit()) && !number_part.is_empty() {
1162 if let Ok(num) = number_part.parse::<u64>() {
1163 max_num = max_num.max(num);
1164 width = width.max(number_part.len());
1165 }
1166 }
1167 }
1168 }
1169
1170 let next = max_num + 1;
1171 let prefix = format!("m{:0width$}", next, width = width);
1172 let mod_name = format!("{prefix}_create_{resource_plural}");
1173 let file_name = format!("{mod_name}.rs");
1174 Ok((mod_name, file_name))
1175}
1176
1177fn render_sea_orm_migration(resource_plural: &str, id_type: ResourceIdType) -> String {
1178 let table_enum = to_pascal_case(resource_plural);
1179 let id_column = if matches!(id_type, ResourceIdType::Uuid) {
1180 format!(
1181 "ColumnDef::new({table_enum}::Id)\n .uuid()\n .not_null()\n .primary_key()"
1182 )
1183 } else {
1184 format!(
1185 "ColumnDef::new({table_enum}::Id)\n .integer()\n .not_null()\n .auto_increment()\n .primary_key()"
1186 )
1187 };
1188 format!(
1189 r#"use sea_orm_migration::prelude::*;
1190
1191#[derive(DeriveMigrationName)]
1192pub struct Migration;
1193
1194#[async_trait::async_trait]
1195impl MigrationTrait for Migration {{
1196 async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
1197 manager
1198 .create_table(
1199 Table::create()
1200 .table({table_enum}::Table)
1201 .if_not_exists()
1202 .col(
1203 {id_column},
1204 )
1205 .col(ColumnDef::new({table_enum}::Name).string().not_null())
1206 .to_owned(),
1207 )
1208 .await
1209 }}
1210
1211 async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
1212 manager
1213 .drop_table(Table::drop().table({table_enum}::Table).to_owned())
1214 .await
1215 }}
1216}}
1217
1218#[derive(Iden)]
1219enum {table_enum} {{
1220 Table,
1221 Id,
1222 Name,
1223}}
1224"#,
1225 table_enum = table_enum,
1226 id_column = id_column
1227 )
1228}
1229
1230fn render_migration_lib(mod_name: &str) -> String {
1231 format!(
1232 r#"//! Database migrations.
1233
1234pub use sea_orm_migration::prelude::*;
1235
1236mod {mod_name};
1237
1238pub struct Migrator;
1239
1240#[async_trait::async_trait]
1241impl MigratorTrait for Migrator {{
1242 fn migrations() -> Vec<Box<dyn MigrationTrait>> {{
1243 vec![Box::new({mod_name}::Migration)]
1244 }}
1245}}
1246"#,
1247 mod_name = mod_name
1248 )
1249}
1250
1251fn update_migration_lib(path: &Path, mod_name: &str) -> Result<()> {
1252 let mut contents = fs::read_to_string(path)
1253 .with_context(|| format!("Failed to read {}", path.display()))?;
1254
1255 let mod_line = format!("mod {};", mod_name);
1256 if !contents.contains(&mod_line) {
1257 let mut lines = contents.lines().map(|line| line.to_string()).collect::<Vec<_>>();
1258 let mut insert_at = None;
1259 for (idx, line) in lines.iter().enumerate() {
1260 if line.trim_start().starts_with("mod ") {
1261 insert_at = Some(idx + 1);
1262 }
1263 }
1264 let insert_at = insert_at.unwrap_or_else(|| {
1265 let prelude_line = lines
1266 .iter()
1267 .position(|line| line.contains("sea_orm_migration::prelude"))
1268 .map(|idx| idx + 1)
1269 .unwrap_or(0);
1270 prelude_line
1271 });
1272 lines.insert(insert_at, mod_line);
1273 contents = lines.join("\n");
1274 if !contents.ends_with('\n') {
1275 contents.push('\n');
1276 }
1277 }
1278
1279 if !contents.contains(&format!("{}::Migration", mod_name)) {
1280 let mut lines = contents.lines().map(|line| line.to_string()).collect::<Vec<_>>();
1281 let mut vec_start = None;
1282 let mut vec_end = None;
1283 for (idx, line) in lines.iter().enumerate() {
1284 if vec_start.is_none() && line.contains("vec![") {
1285 vec_start = Some(idx);
1286 continue;
1287 }
1288 if vec_start.is_some() && line.trim_start().starts_with(']') {
1289 vec_end = Some(idx);
1290 break;
1291 }
1292 }
1293 if let (Some(start), Some(end)) = (vec_start, vec_end) {
1294 let base_indent = lines[start]
1295 .chars()
1296 .take_while(|c| c.is_whitespace())
1297 .collect::<String>();
1298 let entry_indent = format!("{base_indent} ");
1299 lines.insert(end, format!("{entry_indent}Box::new({}::Migration),", mod_name));
1300 contents = lines.join("\n");
1301 if !contents.ends_with('\n') {
1302 contents.push('\n');
1303 }
1304 } else {
1305 print_warning("Could not find migrations vector in migration/src/lib.rs");
1306 }
1307 }
1308
1309 write_file(path, &contents)
1310 .with_context(|| format!("Failed to write {}", path.display()))?;
1311 Ok(())
1312}
1313fn wire_routes_mod(routes_dir: &Path, resource_name: &str) -> Result<()> {
1314 let mod_path = routes_dir.join("mod.rs");
1315 if !mod_path.exists() {
1316 print_warning("routes/mod.rs not found; skipping auto wiring");
1317 return Ok(());
1318 }
1319
1320 let mut contents = fs::read_to_string(&mod_path)
1321 .with_context(|| format!("Failed to read {}", mod_path.display()))?;
1322 let mod_line = format!("pub mod {};", resource_name);
1323 if !contents.contains(&mod_line) {
1324 contents.push_str(&format!("\n{}\n", mod_line));
1325 write_file(&mod_path, &contents)
1326 .with_context(|| format!("Failed to write {}", mod_path.display()))?;
1327 }
1328 Ok(())
1329}
1330
1331fn generate_repository(
1332 project_dir: &Path,
1333 resource_name: &str,
1334 id_type: ResourceIdType,
1335 paginate: bool,
1336 search: bool,
1337) -> Result<()> {
1338 let src_dir = project_dir.join("src");
1339 let repos_dir = src_dir.join("repositories");
1340 ensure_dir(&repos_dir)
1341 .with_context(|| format!("Failed to create {}", repos_dir.display()))?;
1342
1343 let repos_mod = repos_dir.join("mod.rs");
1344 if !repos_mod.exists() {
1345 let contents = "//! Repository layer.\n\n";
1346 write_file_with_force(&repos_mod, contents, false)?;
1347 print_success("Created src/repositories/mod.rs");
1348 }
1349 wire_repositories_mod(&repos_mod, resource_name)?;
1350
1351 let repo_path = repos_dir.join(format!("{}.rs", resource_name));
1352 let repo_contents = render_repository(resource_name, id_type, paginate, search);
1353 write_file_with_force(&repo_path, &repo_contents, false)?;
1354 print_success("Generated repository");
1355 Ok(())
1356}
1357
1358fn wire_repositories_mod(mod_path: &Path, resource_name: &str) -> Result<()> {
1359 let mut contents = fs::read_to_string(mod_path)
1360 .with_context(|| format!("Failed to read {}", mod_path.display()))?;
1361 let mod_line = format!("pub mod {};", resource_name);
1362 if !contents.contains(&mod_line) {
1363 contents.push_str(&format!("\n{}\n", mod_line));
1364 write_file(mod_path, &contents)
1365 .with_context(|| format!("Failed to write {}", mod_path.display()))?;
1366 }
1367 Ok(())
1368}
1369
1370fn render_repository(
1371 resource_name: &str,
1372 id_type: ResourceIdType,
1373 paginate: bool,
1374 search: bool,
1375) -> String {
1376 let resource_pascal = to_pascal_case(resource_name);
1377 let (id_type_str, uuid_import) = if matches!(id_type, ResourceIdType::Uuid) {
1378 ("uuid::Uuid", "use uuid::Uuid;\n")
1379 } else {
1380 ("i32", "")
1381 };
1382 let create_id_field = if matches!(id_type, ResourceIdType::Uuid) {
1383 " id: Set(Uuid::new_v4()),\n"
1384 } else {
1385 ""
1386 };
1387 let list_signature = if paginate {
1388 if search {
1389 format!("pub async fn list(&self, limit: Option<u64>, offset: Option<u64>, search: Option<String>) -> Result<Vec<{resource_name}::Model>> {{")
1390 } else {
1391 format!("pub async fn list(&self, limit: Option<u64>, offset: Option<u64>) -> Result<Vec<{resource_name}::Model>> {{")
1392 }
1393 } else {
1394 format!("pub async fn list(&self) -> Result<Vec<{resource_name}::Model>> {{")
1395 };
1396 let list_params = if paginate {
1397 let search_query = if search {
1398 format!(" if let Some(search) = search.as_deref() {{ query = query.filter({resource_name}::Column::Name.contains(search)); }}\n")
1399 } else {
1400 String::new()
1401 };
1402 format!(
1403 " let mut query = {resource_name}::Entity::find();\n if let Some(limit) = limit {{ query = query.limit(limit); }}\n if let Some(offset) = offset {{ query = query.offset(offset); }}\n{search_query} Ok(query.all(&self.db).await?)",
1404 search_query = search_query,
1405 )
1406 } else {
1407 format!(" Ok({resource_name}::Entity::find().all(&self.db).await?)")
1408 };
1409 let sea_orm_imports = match (paginate, search) {
1410 (true, true) => "use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set};",
1411 (true, false) => "use sea_orm::{ActiveModelTrait, EntityTrait, QuerySelect, Set};",
1412 (false, true) => "use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};",
1413 (false, false) => "use sea_orm::{ActiveModelTrait, EntityTrait, Set};",
1414 };
1415 format!(
1416 r#"{sea_orm_imports}
1417use tideway::Result;
1418{uuid_import}
1419
1420use crate::entities::{resource_name};
1421
1422pub struct {resource_pascal}Repository {{
1423 db: sea_orm::DatabaseConnection,
1424}}
1425
1426impl {resource_pascal}Repository {{
1427 pub fn new(db: sea_orm::DatabaseConnection) -> Self {{
1428 Self {{ db }}
1429 }}
1430
1431 {list_signature}
1432{list_params}
1433 }}
1434
1435 pub async fn get(&self, id: {id_type}) -> Result<Option<{resource_name}::Model>> {{
1436 Ok({resource_name}::Entity::find_by_id(id).one(&self.db).await?)
1437 }}
1438
1439 pub async fn create(&self, name: String) -> Result<{resource_name}::Model> {{
1440 let active = {resource_name}::ActiveModel {{
1441{create_id_field}
1442 name: Set(name),
1443 ..Default::default()
1444 }};
1445 Ok(active.insert(&self.db).await?)
1446 }}
1447
1448 pub async fn update(&self, id: {id_type}, name: Option<String>) -> Result<{resource_name}::Model> {{
1449 let model = {resource_name}::Entity::find_by_id(id).one(&self.db).await?;
1450 let model =
1451 model.ok_or_else(|| tideway::TidewayError::not_found("{resource_pascal} not found"))?;
1452 let mut active: {resource_name}::ActiveModel = model.into();
1453 if let Some(name) = name {{
1454 active.name = Set(name);
1455 }}
1456 Ok(active.update(&self.db).await?)
1457 }}
1458
1459 pub async fn delete(&self, id: {id_type}) -> Result<()> {{
1460 {resource_name}::Entity::delete_by_id(id)
1461 .exec(&self.db)
1462 .await?;
1463 Ok(())
1464 }}
1465}}
1466"#,
1467 resource_name = resource_name,
1468 resource_pascal = resource_pascal,
1469 id_type = id_type_str,
1470 uuid_import = uuid_import,
1471 create_id_field = create_id_field,
1472 list_signature = list_signature,
1473 list_params = list_params,
1474 sea_orm_imports = sea_orm_imports
1475 )
1476}
1477
1478fn generate_service(
1479 project_dir: &Path,
1480 resource_name: &str,
1481 id_type: ResourceIdType,
1482 paginate: bool,
1483 search: bool,
1484) -> Result<()> {
1485 let src_dir = project_dir.join("src");
1486 let services_dir = src_dir.join("services");
1487 ensure_dir(&services_dir)
1488 .with_context(|| format!("Failed to create {}", services_dir.display()))?;
1489
1490 let services_mod = services_dir.join("mod.rs");
1491 if !services_mod.exists() {
1492 let contents = "//! Service layer.\n\n";
1493 write_file_with_force(&services_mod, contents, false)?;
1494 print_success("Created src/services/mod.rs");
1495 }
1496 wire_services_mod(&services_mod, resource_name)?;
1497
1498 let service_path = services_dir.join(format!("{}.rs", resource_name));
1499 let service_contents = render_service(resource_name, id_type, paginate, search);
1500 write_file_with_force(&service_path, &service_contents, false)?;
1501 print_success("Generated service");
1502 Ok(())
1503}
1504
1505fn wire_services_mod(mod_path: &Path, resource_name: &str) -> Result<()> {
1506 let mut contents = fs::read_to_string(mod_path)
1507 .with_context(|| format!("Failed to read {}", mod_path.display()))?;
1508 let mod_line = format!("pub mod {};", resource_name);
1509 if !contents.contains(&mod_line) {
1510 contents.push_str(&format!("\n{}\n", mod_line));
1511 write_file(mod_path, &contents)
1512 .with_context(|| format!("Failed to write {}", mod_path.display()))?;
1513 }
1514 Ok(())
1515}
1516
1517fn render_service(
1518 resource_name: &str,
1519 id_type: ResourceIdType,
1520 paginate: bool,
1521 search: bool,
1522) -> String {
1523 let resource_pascal = to_pascal_case(resource_name);
1524 let id_type_str = if matches!(id_type, ResourceIdType::Uuid) {
1525 "uuid::Uuid"
1526 } else {
1527 "i32"
1528 };
1529 let uuid_import = if matches!(id_type, ResourceIdType::Uuid) {
1530 "use uuid::Uuid;\n"
1531 } else {
1532 ""
1533 };
1534 let list_signature = if paginate {
1535 if search {
1536 format!("pub async fn list(&self, limit: Option<u64>, offset: Option<u64>, search: Option<String>) -> Result<Vec<crate::entities::{resource_name}::Model>> {{")
1537 } else {
1538 format!("pub async fn list(&self, limit: Option<u64>, offset: Option<u64>) -> Result<Vec<crate::entities::{resource_name}::Model>> {{")
1539 }
1540 } else {
1541 format!("pub async fn list(&self) -> Result<Vec<crate::entities::{resource_name}::Model>> {{")
1542 };
1543 let list_body = if paginate {
1544 if search {
1545 " self.repo.list(limit, offset, search).await"
1546 } else {
1547 " self.repo.list(limit, offset).await"
1548 }
1549 } else {
1550 " self.repo.list().await"
1551 };
1552 format!(
1553 r#"use tideway::Result;
1554{uuid_import}
1555
1556use crate::repositories::{resource_name}::{resource_pascal}Repository;
1557
1558pub struct {resource_pascal}Service {{
1559 repo: {resource_pascal}Repository,
1560}}
1561
1562impl {resource_pascal}Service {{
1563 pub fn new(repo: {resource_pascal}Repository) -> Self {{
1564 Self {{ repo }}
1565 }}
1566
1567 {list_signature}
1568{list_body}
1569 }}
1570
1571 pub async fn get(&self, id: {id_type}) -> Result<Option<crate::entities::{resource_name}::Model>> {{
1572 self.repo.get(id).await
1573 }}
1574
1575 pub async fn create(&self, name: String) -> Result<crate::entities::{resource_name}::Model> {{
1576 self.repo.create(name).await
1577 }}
1578
1579 pub async fn update(
1580 &self,
1581 id: {id_type},
1582 name: Option<String>,
1583 ) -> Result<crate::entities::{resource_name}::Model> {{
1584 self.repo.update(id, name).await
1585 }}
1586
1587 pub async fn delete(&self, id: {id_type}) -> Result<()> {{
1588 self.repo.delete(id).await
1589 }}
1590}}
1591"#,
1592 resource_name = resource_name,
1593 resource_pascal = resource_pascal,
1594 id_type = id_type_str,
1595 uuid_import = uuid_import,
1596 list_signature = list_signature,
1597 list_body = list_body
1598 )
1599}
1600
1601fn generate_repository_tests(
1602 project_dir: &Path,
1603 project_name: &str,
1604 resource_name: &str,
1605 id_type: ResourceIdType,
1606) -> Result<()> {
1607 let tests_dir = project_dir.join("tests");
1608 ensure_dir(&tests_dir)
1609 .with_context(|| format!("Failed to create {}", tests_dir.display()))?;
1610
1611 let file_path = tests_dir.join(format!("repository_{}.rs", resource_name));
1612 let contents = render_repository_tests(project_name, resource_name, id_type);
1613 write_file_with_force(&file_path, &contents, false)?;
1614 print_success("Generated repository tests");
1615 Ok(())
1616}
1617
1618fn render_repository_tests(
1619 project_name: &str,
1620 resource_name: &str,
1621 _id_type: ResourceIdType,
1622) -> String {
1623 let resource_pascal = to_pascal_case(resource_name);
1624 format!(
1625 r#"use sea_orm::Database;
1626use tideway::Result;
1627
1628use {project_name}::repositories::{resource_name}::{resource_pascal}Repository;
1629
1630#[tokio::test]
1631#[ignore = "Requires DATABASE_URL and existing migrations"]
1632async fn repository_crud_smoke() -> Result<()> {{
1633 let database_url = std::env::var("DATABASE_URL")
1634 .expect("DATABASE_URL is required for repository tests");
1635 let db = Database::connect(&database_url).await?;
1636 let repo = {resource_pascal}Repository::new(db);
1637
1638 let created = repo.create("Example".to_string()).await?;
1639 let _ = repo.list().await?;
1640 repo.delete(created.id).await?;
1641 Ok(())
1642}}
1643"#,
1644 project_name = project_name,
1645 resource_name = resource_name,
1646 resource_pascal = resource_pascal
1647 )
1648}
1649
1650fn wire_main_rs(src_dir: &Path, resource_name: &str, resource_pascal: &str) -> Result<()> {
1651 let main_path = src_dir.join("main.rs");
1652 if !main_path.exists() {
1653 print_warning("src/main.rs not found; skipping auto wiring");
1654 return Ok(());
1655 }
1656
1657 let mut contents = fs::read_to_string(&main_path)
1658 .with_context(|| format!("Failed to read {}", main_path.display()))?;
1659
1660 let register_line = format!(
1661 ".register_module(routes::{}::{}Module)",
1662 resource_name, resource_pascal
1663 );
1664 if contents.contains(®ister_line) {
1665 return Ok(());
1666 }
1667
1668 if let Some(pos) = contents.find(".register_module(") {
1669 let line_end = contents[pos..]
1670 .find('\n')
1671 .map(|idx| pos + idx)
1672 .unwrap_or(contents.len());
1673 contents.insert_str(line_end + 1, &format!(" {}\n", register_line));
1674 } else {
1675 print_warning("Could not find register_module call in main.rs");
1676 }
1677
1678 write_file(&main_path, &contents)
1679 .with_context(|| format!("Failed to write {}", main_path.display()))?;
1680 Ok(())
1681}
1682
1683fn write_file_with_force(path: &Path, contents: &str, force: bool) -> Result<()> {
1684 if path.exists() && !force {
1685 print_warning(&format!(
1686 "Skipping {} (use --force to overwrite)",
1687 path.display()
1688 ));
1689 return Ok(());
1690 }
1691 if let Some(parent) = path.parent() {
1692 ensure_dir(parent)
1693 .with_context(|| format!("Failed to create {}", parent.display()))?;
1694 }
1695 write_file(path, contents)
1696 .with_context(|| format!("Failed to write {}", path.display()))?;
1697 Ok(())
1698}
1699
1700fn normalize_name(name: &str) -> String {
1701 name.trim().to_lowercase().replace('-', "_")
1702}
1703
1704fn pluralize(name: &str) -> String {
1705 if name.ends_with('s') {
1706 format!("{}es", name)
1707 } else {
1708 format!("{}s", name)
1709 }
1710}
1711
1712fn to_pascal_case(s: &str) -> String {
1713 s.split('_')
1714 .filter(|part| !part.is_empty())
1715 .map(|word| {
1716 let mut chars = word.chars();
1717 match chars.next() {
1718 None => String::new(),
1719 Some(first) => first.to_uppercase().chain(chars).collect(),
1720 }
1721 })
1722 .collect()
1723}
1724
1725fn has_feature(cargo_path: &Path, feature: &str) -> bool {
1726 let Ok(contents) = fs::read_to_string(cargo_path) else {
1727 return false;
1728 };
1729 let Ok(doc) = contents.parse::<toml_edit::DocumentMut>() else {
1730 return false;
1731 };
1732
1733 let Some(tideway) = doc
1734 .get("dependencies")
1735 .and_then(|deps| deps.get("tideway"))
1736 else {
1737 return false;
1738 };
1739
1740 let Some(features) = tideway.get("features").and_then(|item| item.as_array()) else {
1741 return false;
1742 };
1743
1744 features.iter().any(|v| v.as_str() == Some(feature))
1745}
1746
1747fn has_dependency(cargo_path: &Path, dependency: &str) -> bool {
1748 let Ok(contents) = fs::read_to_string(cargo_path) else {
1749 return false;
1750 };
1751 let Ok(doc) = contents.parse::<toml_edit::DocumentMut>() else {
1752 return false;
1753 };
1754
1755 doc.get("dependencies")
1756 .and_then(|deps| deps.get(dependency))
1757 .is_some()
1758}
1759
1760fn add_uuid_dependency(cargo_path: &Path) -> Result<()> {
1761 let contents = fs::read_to_string(cargo_path)
1762 .with_context(|| format!("Failed to read {}", cargo_path.display()))?;
1763 let mut doc = contents.parse::<toml_edit::DocumentMut>()?;
1764 let deps = doc["dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1765 let deps_table = deps
1766 .as_table_mut()
1767 .context("dependencies should be a table")?;
1768
1769 let mut table = toml_edit::InlineTable::new();
1770 table.get_or_insert("version", "1");
1771 table.get_or_insert("features", array_value(&["v4"]));
1772 deps_table.insert(
1773 "uuid",
1774 toml_edit::Item::Value(toml_edit::Value::InlineTable(table)),
1775 );
1776
1777 write_file(cargo_path, &doc.to_string())
1778 .with_context(|| format!("Failed to write {}", cargo_path.display()))?;
1779 Ok(())
1780}
1781
1782fn project_name_from_cargo(cargo_path: &Path, project_dir: &Path) -> String {
1783 let Ok(contents) = fs::read_to_string(cargo_path) else {
1784 return fallback_project_name(project_dir);
1785 };
1786 let Ok(doc) = contents.parse::<toml_edit::DocumentMut>() else {
1787 return fallback_project_name(project_dir);
1788 };
1789
1790 doc.get("package")
1791 .and_then(|pkg| pkg.get("name"))
1792 .and_then(|value| value.as_str())
1793 .map(|name| name.replace('-', "_"))
1794 .unwrap_or_else(|| fallback_project_name(project_dir))
1795}
1796
1797fn fallback_project_name(project_dir: &Path) -> String {
1798 project_dir
1799 .file_name()
1800 .and_then(|n| n.to_str())
1801 .unwrap_or("my_app")
1802 .replace('-', "_")
1803}