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 if search {
422 "use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};\n"
423 } else {
424 "use sea_orm::{ActiveModelTrait, EntityTrait, Set};\n"
425 }
426 } else {
427 ""
428 };
429 let entities_import = if with_db {
430 format!("use crate::entities::{resource_name};\n")
431 } else {
432 String::new()
433 };
434 let repositories_import = if with_repo {
435 format!("use crate::repositories::{resource_name}::{resource_pascal}Repository;\n")
436 } else {
437 String::new()
438 };
439 let services_import = if with_service {
440 format!("use crate::services::{resource_name}::{resource_pascal}Service;\n")
441 } else {
442 String::new()
443 };
444
445 let pagination_struct = if paginate {
446 let attrs = if has_openapi {
447 "#[cfg_attr(feature = \"openapi\", derive(IntoParams))]"
448 } else {
449 ""
450 };
451 let mut struct_body = format!(
452 r#"
453#[derive(Deserialize)]
454{attrs}
455pub struct PaginationParams {{
456 pub limit: Option<u64>,
457 pub offset: Option<u64>,
458"#,
459 attrs = attrs
460 );
461 if search {
462 struct_body.push_str(" pub q: Option<String>,\n");
463 }
464 struct_body.push_str("}\n");
465 struct_body
466 } else {
467 String::new()
468 };
469 let list_params = if paginate {
470 "Query(params): Query<PaginationParams>"
471 } else {
472 ""
473 };
474 let list_param_prefix = if paginate { ", " } else { "" };
475 let list_args = if paginate {
476 if search {
477 "params.limit, params.offset, params.q"
478 } else {
479 "params.limit, params.offset"
480 }
481 } else {
482 ""
483 };
484 let pagination_query = if paginate {
485 let search_query = if search {
486 format!(
487 " if let Some(q) = params.q.as_deref() {{ query = query.filter({resource_name}::Column::Name.contains(q)); }}\n",
488 resource_name = resource_name
489 )
490 } else {
491 String::new()
492 };
493 format!(
494 " if let Some(limit) = params.limit {{ query = query.limit(limit); }}\n if let Some(offset) = params.offset {{ query = query.offset(offset); }}\n{search_query}",
495 search_query = search_query
496 )
497 } else {
498 String::new()
499 };
500
501 let handlers = if with_db && with_repo && with_service {
502 format!(
503 r#"
504{openapi_attrs}
505async fn list_{resource_plural}(State(ctx): State<AppContext>{list_param_prefix}{list_params}) -> Result<Json<Vec<{resource_pascal}>>> {{
506 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
507 let service = {resource_pascal}Service::new(repo);
508 let models = service.list({list_args}).await?;
509 let items = models
510 .into_iter()
511 .map(|model| {resource_pascal} {{
512 id: model.id.to_string(),
513 name: model.name,
514 }})
515 .collect();
516 Ok(Json(items))
517}}
518
519{openapi_attrs_get}
520async fn get_{resource_name}(
521 State(ctx): State<AppContext>,
522 Path(id): Path<{id_type_str}>,
523) -> Result<Json<{resource_pascal}>> {{
524 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
525 let service = {resource_pascal}Service::new(repo);
526 let model = service
527 .get(id)
528 .await?
529 .ok_or_else(|| tideway::TidewayError::not_found("{resource_pascal} not found"))?;
530 Ok(Json({resource_pascal} {{
531 id: model.id.to_string(),
532 name: model.name,
533 }}))
534}}
535
536{openapi_attrs_create}
537async fn create_{resource_name}(
538 State(ctx): State<AppContext>,
539 {body_extractor},
540) -> Result<MessageResponse> {{
541 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
542 let service = {resource_pascal}Service::new(repo);
543 service.create(body.name).await?;
544 Ok(MessageResponse::success("Created"))
545}}
546
547{openapi_attrs_update}
548async fn update_{resource_name}(
549 State(ctx): State<AppContext>,
550 Path(id): Path<{id_type_str}>,
551 Json(body): Json<UpdateRequest>,
552) -> Result<MessageResponse> {{
553 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
554 let service = {resource_pascal}Service::new(repo);
555 service.update(id, body.name).await?;
556 Ok(MessageResponse::success("Updated"))
557}}
558
559{openapi_attrs_delete}
560async fn delete_{resource_name}(
561 State(ctx): State<AppContext>,
562 Path(id): Path<{id_type_str}>,
563) -> Result<MessageResponse> {{
564 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
565 let service = {resource_pascal}Service::new(repo);
566 service.delete(id).await?;
567 Ok(MessageResponse::success("Deleted"))
568}}
569"#,
570 resource_pascal = resource_pascal,
571 resource_name = resource_name,
572 resource_plural = resource_plural,
573 openapi_attrs = openapi_attrs,
574 openapi_attrs_get = openapi_attrs_get,
575 openapi_attrs_create = openapi_attrs_create,
576 openapi_attrs_update = openapi_attrs_update,
577 openapi_attrs_delete = openapi_attrs_delete,
578 body_extractor = body_extractor,
579 id_type_str = id_type_str,
580 list_param_prefix = list_param_prefix,
581 list_params = list_params,
582 list_args = list_args,
583 )
584 } else if with_db && with_repo {
585 format!(
586 r#"
587{openapi_attrs}
588async fn list_{resource_plural}(State(ctx): State<AppContext>{list_param_prefix}{list_params}) -> Result<Json<Vec<{resource_pascal}>>> {{
589 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
590 let models = repo.list({list_args}).await?;
591 let items = models
592 .into_iter()
593 .map(|model| {resource_pascal} {{
594 id: model.id.to_string(),
595 name: model.name,
596 }})
597 .collect();
598 Ok(Json(items))
599}}
600
601{openapi_attrs_get}
602async fn get_{resource_name}(
603 State(ctx): State<AppContext>,
604 Path(id): Path<{id_type_str}>,
605) -> Result<Json<{resource_pascal}>> {{
606 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
607 let model = repo
608 .get(id)
609 .await?
610 .ok_or_else(|| tideway::TidewayError::not_found("{resource_pascal} not found"))?;
611 Ok(Json({resource_pascal} {{
612 id: model.id.to_string(),
613 name: model.name,
614 }}))
615}}
616
617{openapi_attrs_create}
618async fn create_{resource_name}(
619 State(ctx): State<AppContext>,
620 {body_extractor},
621) -> Result<MessageResponse> {{
622 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
623 repo.create(body.name).await?;
624 Ok(MessageResponse::success("Created"))
625}}
626
627{openapi_attrs_update}
628async fn update_{resource_name}(
629 State(ctx): State<AppContext>,
630 Path(id): Path<{id_type_str}>,
631 Json(body): Json<UpdateRequest>,
632) -> Result<MessageResponse> {{
633 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
634 repo.update(id, body.name).await?;
635 Ok(MessageResponse::success("Updated"))
636}}
637
638{openapi_attrs_delete}
639async fn delete_{resource_name}(
640 State(ctx): State<AppContext>,
641 Path(id): Path<{id_type_str}>,
642) -> Result<MessageResponse> {{
643 let repo = {resource_pascal}Repository::new(ctx.sea_orm_connection()?);
644 repo.delete(id).await?;
645 Ok(MessageResponse::success("Deleted"))
646}}
647"#,
648 resource_pascal = resource_pascal,
649 resource_name = resource_name,
650 resource_plural = resource_plural,
651 openapi_attrs = openapi_attrs,
652 openapi_attrs_get = openapi_attrs_get,
653 openapi_attrs_create = openapi_attrs_create,
654 openapi_attrs_update = openapi_attrs_update,
655 openapi_attrs_delete = openapi_attrs_delete,
656 body_extractor = body_extractor,
657 id_type_str = id_type_str,
658 list_param_prefix = list_param_prefix,
659 list_params = list_params,
660 list_args = list_args,
661 )
662 } else if with_db {
663 format!(
664 r#"
665{openapi_attrs}
666async fn list_{resource_plural}(State(ctx): State<AppContext>{list_param_prefix}{list_params}) -> Result<Json<Vec<{resource_pascal}>>> {{
667 let db = ctx.sea_orm_connection()?;
668 let mut query = {resource_name}::Entity::find();
669{pagination_query}
670 let models = query.all(&db).await?;
671 let items = models
672 .into_iter()
673 .map(|model| {resource_pascal} {{
674 id: model.id.to_string(),
675 name: model.name,
676 }})
677 .collect();
678 Ok(Json(items))
679}}
680
681{openapi_attrs_get}
682async fn get_{resource_name}(
683 State(ctx): State<AppContext>,
684 Path(id): Path<{id_type_str}>,
685) -> Result<Json<{resource_pascal}>> {{
686 let db = ctx.sea_orm_connection()?;
687 let model = {resource_name}::Entity::find_by_id(id).one(&db).await?;
688 let model = model.ok_or_else(|| tideway::TidewayError::not_found("{resource_pascal} not found"))?;
689 Ok(Json({resource_pascal} {{
690 id: model.id.to_string(),
691 name: model.name,
692 }}))
693}}
694
695{openapi_attrs_create}
696async fn create_{resource_name}(
697 State(ctx): State<AppContext>,
698 {body_extractor},
699) -> Result<MessageResponse> {{
700 let db = ctx.sea_orm_connection()?;
701 let active = {resource_name}::ActiveModel {{
702{create_id_field}
703 name: Set(body.name),
704 ..Default::default()
705 }};
706 active.insert(&db).await?;
707 Ok(MessageResponse::success("Created"))
708}}
709
710{openapi_attrs_update}
711async fn update_{resource_name}(
712 State(ctx): State<AppContext>,
713 Path(id): Path<{id_type_str}>,
714 Json(body): Json<UpdateRequest>,
715) -> Result<MessageResponse> {{
716 let db = ctx.sea_orm_connection()?;
717 let model = {resource_name}::Entity::find_by_id(id).one(&db).await?;
718 let model = model.ok_or_else(|| tideway::TidewayError::not_found("{resource_pascal} not found"))?;
719 let mut active: {resource_name}::ActiveModel = model.into();
720 if let Some(name) = body.name {{
721 active.name = Set(name);
722 }}
723 active.update(&db).await?;
724 Ok(MessageResponse::success("Updated"))
725}}
726
727{openapi_attrs_delete}
728async fn delete_{resource_name}(
729 State(ctx): State<AppContext>,
730 Path(id): Path<{id_type_str}>,
731) -> Result<MessageResponse> {{
732 let db = ctx.sea_orm_connection()?;
733 {resource_name}::Entity::delete_by_id(id).exec(&db).await?;
734 Ok(MessageResponse::success("Deleted"))
735}}
736"#,
737 resource_pascal = resource_pascal,
738 resource_name = resource_name,
739 resource_plural = resource_plural,
740 openapi_attrs = openapi_attrs,
741 openapi_attrs_get = openapi_attrs_get,
742 openapi_attrs_create = openapi_attrs_create,
743 openapi_attrs_update = openapi_attrs_update,
744 openapi_attrs_delete = openapi_attrs_delete,
745 body_extractor = body_extractor,
746 id_type_str = id_type_str,
747 list_params = list_params,
748 list_param_prefix = list_param_prefix,
749 pagination_query = pagination_query,
750 )
751 } else {
752 format!(
753 r#"
754{openapi_attrs}
755async fn list_{resource_plural}() -> Json<Vec<{resource_pascal}>> {{
756 Json(Vec::new())
757}}
758
759{openapi_attrs_get}
760async fn get_{resource_name}() -> Result<Json<{resource_pascal}>> {{
761 Ok(Json({resource_pascal} {{
762 id: "demo".to_string(),
763 name: "{resource_pascal}".to_string(),
764 }}))
765}}
766
767{openapi_attrs_create}
768async fn create_{resource_name}({body_extractor}) -> Result<MessageResponse> {{
769 Ok(MessageResponse::success(format!("Created {{}}", body.name)))
770}}
771
772{openapi_attrs_update}
773async fn update_{resource_name}({body_extractor}) -> Result<MessageResponse> {{
774 let name = body.name.unwrap_or_else(|| "{resource_pascal}".to_string());
775 Ok(MessageResponse::success(format!("Updated {{}}", name)))
776}}
777
778{openapi_attrs_delete}
779async fn delete_{resource_name}() -> Result<MessageResponse> {{
780 Ok(MessageResponse::success("Deleted"))
781}}
782"#,
783 resource_pascal = resource_pascal,
784 resource_name = resource_name,
785 resource_plural = resource_plural,
786 openapi_attrs = openapi_attrs,
787 openapi_attrs_get = openapi_attrs_get,
788 openapi_attrs_create = openapi_attrs_create,
789 openapi_attrs_update = openapi_attrs_update,
790 openapi_attrs_delete = openapi_attrs_delete,
791 body_extractor = body_extractor,
792 )
793 };
794
795 format!(
796 r#"//! {resource_pascal} routes.
797
798use axum::{{routing::{{delete, get, post, put}}, {extract_import}Json, Router}};
799use serde::{{Deserialize, Serialize}};
800use tideway::{{AppContext, MessageResponse, Result, RouteModule}};
801{openapi_import}
802{sea_orm_imports}
803{uuid_import}
804{entities_import}
805{repositories_import}
806{services_import}
807
808pub struct {resource_pascal}Module;
809
810impl RouteModule for {resource_pascal}Module {{
811 fn routes(&self) -> Router<AppContext> {{
812 Router::new()
813 .route("/", get(list_{resource_plural}).post(create_{resource_name}))
814 .route("/:id", get(get_{resource_name}).put(update_{resource_name}).delete(delete_{resource_name}))
815 }}
816
817 fn prefix(&self) -> Option<&str> {{
818 Some("/api/{resource_plural}")
819 }}
820}}
821
822#[derive(Debug, Serialize)]
823{openapi_schema}
824pub struct {resource_pascal} {{
825 pub id: String,
826 pub name: String,
827}}
828
829#[derive(Deserialize)]
830{openapi_schema}
831pub struct CreateRequest {{
832 pub name: String,
833}}
834
835#[derive(Deserialize)]
836{openapi_schema}
837pub struct UpdateRequest {{
838 pub name: Option<String>,
839}}
840
841{pagination_struct}
842{handlers}
843{tests_block}
844{openapi_paths}
845"#,
846 resource_pascal = resource_pascal,
847 resource_name = resource_name,
848 resource_plural = resource_plural,
849 tests_block = tests_block,
850 openapi_import = openapi_import,
851 openapi_schema = openapi_schema,
852 openapi_paths = openapi_paths,
853 pagination_struct = pagination_struct,
854 handlers = handlers,
855 extract_import = extract_import,
856 sea_orm_imports = sea_orm_imports,
857 uuid_import = uuid_import,
858 entities_import = entities_import,
859 repositories_import = repositories_import,
860 services_import = services_import,
861 )
862}
863
864fn wire_openapi_docs(src_dir: &Path, resource_name: &str, resource_plural: &str) -> Result<()> {
865 let docs_path = src_dir.join("openapi_docs.rs");
866 let paths = [
867 format!("crate::routes::{resource_name}::list_{resource_plural}"),
868 format!("crate::routes::{resource_name}::get_{resource_name}"),
869 format!("crate::routes::{resource_name}::create_{resource_name}"),
870 format!("crate::routes::{resource_name}::update_{resource_name}"),
871 format!("crate::routes::{resource_name}::delete_{resource_name}"),
872 ];
873
874 if !docs_path.exists() {
875 let contents = render_openapi_docs_file(&paths);
876 write_file_with_force(&docs_path, &contents, false)?;
877 print_success("Created src/openapi_docs.rs");
878 return Ok(());
879 }
880
881 let mut contents = fs::read_to_string(&docs_path)
882 .with_context(|| format!("Failed to read {}", docs_path.display()))?;
883
884 if !contents.contains("openapi_doc!") || !contents.contains("paths(") {
885 print_warning("Could not find OpenAPI doc paths; skipping openapi_docs.rs update");
886 return Ok(());
887 }
888
889 if paths.iter().all(|path| contents.contains(path)) {
890 return Ok(());
891 }
892
893 let mut lines = contents.lines().map(|line| line.to_string()).collect::<Vec<_>>();
894 let mut start = None;
895 let mut end = None;
896
897 for (idx, line) in lines.iter().enumerate() {
898 if start.is_none() && line.contains("paths(") {
899 start = Some(idx);
900 continue;
901 }
902 if start.is_some() && line.trim_start().starts_with(")") {
903 end = Some(idx);
904 break;
905 }
906 }
907
908 let (start, mut end) = match (start, end) {
909 (Some(start), Some(end)) if end > start => (start, end),
910 _ => {
911 print_warning("Could not locate OpenAPI paths block; skipping openapi_docs.rs update");
912 return Ok(());
913 }
914 };
915
916 let base_indent = lines[start]
917 .chars()
918 .take_while(|c| c.is_whitespace())
919 .collect::<String>();
920 let entry_indent = format!("{base_indent} ");
921
922 for path in paths {
923 if contents.contains(&path) {
924 continue;
925 }
926 lines.insert(end, format!("{entry_indent}{path},"));
927 end += 1;
928 }
929
930 contents = lines.join("\n");
931 if !contents.ends_with('\n') {
932 contents.push('\n');
933 }
934 write_file(&docs_path, &contents)
935 .with_context(|| format!("Failed to write {}", docs_path.display()))?;
936 print_success("Updated src/openapi_docs.rs");
937 Ok(())
938}
939
940fn wire_entities_in_main(src_dir: &Path) -> Result<()> {
941 let main_path = src_dir.join("main.rs");
942 if !main_path.exists() {
943 print_warning("src/main.rs not found; skipping entities wiring");
944 return Ok(());
945 }
946
947 let mut contents = fs::read_to_string(&main_path)
948 .with_context(|| format!("Failed to read {}", main_path.display()))?;
949
950 if contents.contains("mod entities;") {
951 return Ok(());
952 }
953
954 if contents.contains("mod routes;") {
955 contents = contents.replace("mod routes;\n", "mod routes;\nmod entities;\n");
956 } else {
957 contents = format!("mod entities;\n{}", contents);
958 }
959
960 write_file(&main_path, &contents)
961 .with_context(|| format!("Failed to write {}", main_path.display()))?;
962 print_success("Added mod entities to src/main.rs");
963 Ok(())
964}
965
966fn wire_repositories_in_main(src_dir: &Path) -> Result<()> {
967 let main_path = src_dir.join("main.rs");
968 if !main_path.exists() {
969 print_warning("src/main.rs not found; skipping repositories wiring");
970 return Ok(());
971 }
972
973 let mut contents = fs::read_to_string(&main_path)
974 .with_context(|| format!("Failed to read {}", main_path.display()))?;
975
976 if contents.contains("mod repositories;") {
977 return Ok(());
978 }
979
980 if contents.contains("mod routes;") {
981 contents = contents.replace("mod routes;\n", "mod routes;\nmod repositories;\n");
982 } else {
983 contents = format!("mod repositories;\n{}", contents);
984 }
985
986 write_file(&main_path, &contents)
987 .with_context(|| format!("Failed to write {}", main_path.display()))?;
988 print_success("Added mod repositories to src/main.rs");
989 Ok(())
990}
991
992fn wire_services_in_main(src_dir: &Path) -> Result<()> {
993 let main_path = src_dir.join("main.rs");
994 if !main_path.exists() {
995 print_warning("src/main.rs not found; skipping services wiring");
996 return Ok(());
997 }
998
999 let mut contents = fs::read_to_string(&main_path)
1000 .with_context(|| format!("Failed to read {}", main_path.display()))?;
1001
1002 if contents.contains("mod services;") {
1003 return Ok(());
1004 }
1005
1006 if contents.contains("mod routes;") {
1007 contents = contents.replace("mod routes;\n", "mod routes;\nmod services;\n");
1008 } else {
1009 contents = format!("mod services;\n{}", contents);
1010 }
1011
1012 write_file(&main_path, &contents)
1013 .with_context(|| format!("Failed to write {}", main_path.display()))?;
1014 print_success("Added mod services to src/main.rs");
1015 Ok(())
1016}
1017
1018fn render_openapi_docs_file(paths: &[String]) -> String {
1019 let mut output = String::new();
1020 output.push_str("#[cfg(feature = \"openapi\")]\n");
1021 output.push_str("tideway::openapi_doc!(\n");
1022 output.push_str(" pub(crate) ApiDoc,\n");
1023 output.push_str(" paths(\n");
1024 for path in paths {
1025 output.push_str(" ");
1026 output.push_str(path);
1027 output.push_str(",\n");
1028 }
1029 output.push_str(" )\n");
1030 output.push_str(");\n");
1031 output
1032}
1033
1034fn generate_sea_orm_scaffold(
1035 project_dir: &Path,
1036 resource_name: &str,
1037 resource_plural: &str,
1038 id_type: ResourceIdType,
1039) -> Result<()> {
1040 let src_dir = project_dir.join("src");
1041 let entities_dir = src_dir.join("entities");
1042 ensure_dir(&entities_dir)
1043 .with_context(|| format!("Failed to create {}", entities_dir.display()))?;
1044
1045 let entities_mod = entities_dir.join("mod.rs");
1046 if !entities_mod.exists() {
1047 let contents = "//! Database entities.\n\n";
1048 write_file_with_force(&entities_mod, contents, false)?;
1049 print_success("Created src/entities/mod.rs");
1050 }
1051 wire_entities_mod(&entities_mod, resource_name)?;
1052
1053 let entity_path = entities_dir.join(format!("{}.rs", resource_name));
1054 let entity_contents = render_sea_orm_entity(resource_name, resource_plural, id_type);
1055 write_file_with_force(&entity_path, &entity_contents, false)?;
1056
1057 let migration_root = project_dir.join("migration");
1058 let migration_src = migration_root.join("src");
1059 if !migration_src.exists() {
1060 ensure_dir(&migration_src)
1061 .with_context(|| format!("Failed to create {}", migration_src.display()))?;
1062 }
1063 if !migration_root.join("Cargo.toml").exists() {
1064 print_warning("migration/Cargo.toml not found (run `sea-orm-cli migrate init` if needed)");
1065 }
1066
1067 let (migration_mod, migration_file) =
1068 next_migration_name(&migration_src, resource_plural)?;
1069 let migration_contents = render_sea_orm_migration(resource_plural, id_type);
1070 let migration_path = migration_src.join(&migration_file);
1071 write_file_with_force(&migration_path, &migration_contents, false)?;
1072
1073 let migration_lib = migration_src.join("lib.rs");
1074 if !migration_lib.exists() {
1075 let contents = render_migration_lib(&migration_mod);
1076 write_file_with_force(&migration_lib, &contents, false)?;
1077 print_success("Created migration/src/lib.rs");
1078 } else {
1079 update_migration_lib(&migration_lib, &migration_mod)?;
1080 }
1081
1082 print_success("Generated SeaORM entity + migration");
1083 Ok(())
1084}
1085
1086fn wire_entities_mod(mod_path: &Path, resource_name: &str) -> Result<()> {
1087 let mut contents = fs::read_to_string(mod_path)
1088 .with_context(|| format!("Failed to read {}", mod_path.display()))?;
1089 let mod_line = format!("pub mod {};", resource_name);
1090 if !contents.contains(&mod_line) {
1091 contents.push_str(&format!("\n{}\n", mod_line));
1092 write_file(mod_path, &contents)
1093 .with_context(|| format!("Failed to write {}", mod_path.display()))?;
1094 }
1095 Ok(())
1096}
1097
1098fn render_sea_orm_entity(
1099 resource_name: &str,
1100 resource_plural: &str,
1101 id_type: ResourceIdType,
1102) -> String {
1103 let resource_pascal = to_pascal_case(resource_name);
1104 let id_field = if matches!(id_type, ResourceIdType::Uuid) {
1105 " #[sea_orm(primary_key, auto_increment = false)]\n pub id: Uuid,\n"
1106 } else {
1107 " #[sea_orm(primary_key, auto_increment = true)]\n pub id: i32,\n"
1108 };
1109 let uuid_import = if matches!(id_type, ResourceIdType::Uuid) {
1110 "use uuid::Uuid;\n"
1111 } else {
1112 ""
1113 };
1114 format!(
1115 r#"//! SeaORM entity for {resource_pascal}.
1116
1117use sea_orm::entity::prelude::*;
1118{uuid_import}
1119
1120#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
1121#[sea_orm(table_name = "{resource_plural}")]
1122pub struct Model {{
1123{id_field}
1124 pub name: String,
1125}}
1126
1127#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
1128pub enum Relation {{}}
1129
1130impl ActiveModelBehavior for ActiveModel {{}}
1131"#,
1132 resource_pascal = resource_pascal,
1133 resource_plural = resource_plural,
1134 id_field = id_field,
1135 uuid_import = uuid_import
1136 )
1137}
1138
1139fn next_migration_name(migration_src: &Path, resource_plural: &str) -> Result<(String, String)> {
1140 let mut max_num = 0u64;
1141 let mut width = 3usize;
1142
1143 if migration_src.exists() {
1144 for entry in fs::read_dir(migration_src)
1145 .with_context(|| format!("Failed to read {}", migration_src.display()))?
1146 {
1147 let entry = entry?;
1148 let Some(name) = entry.file_name().to_str().map(|s| s.to_string()) else {
1149 continue;
1150 };
1151 if !name.starts_with('m') || !name.ends_with(".rs") {
1152 continue;
1153 }
1154 let stem = name.trim_end_matches(".rs");
1155 let number_part = stem
1156 .trim_start_matches('m')
1157 .split('_')
1158 .next()
1159 .unwrap_or("");
1160 if number_part.chars().all(|c| c.is_ascii_digit()) && !number_part.is_empty() {
1161 if let Ok(num) = number_part.parse::<u64>() {
1162 max_num = max_num.max(num);
1163 width = width.max(number_part.len());
1164 }
1165 }
1166 }
1167 }
1168
1169 let next = max_num + 1;
1170 let prefix = format!("m{:0width$}", next, width = width);
1171 let mod_name = format!("{prefix}_create_{resource_plural}");
1172 let file_name = format!("{mod_name}.rs");
1173 Ok((mod_name, file_name))
1174}
1175
1176fn render_sea_orm_migration(resource_plural: &str, id_type: ResourceIdType) -> String {
1177 let table_enum = to_pascal_case(resource_plural);
1178 let id_column = if matches!(id_type, ResourceIdType::Uuid) {
1179 format!(
1180 "ColumnDef::new({table_enum}::Id)\n .uuid()\n .not_null()\n .primary_key()"
1181 )
1182 } else {
1183 format!(
1184 "ColumnDef::new({table_enum}::Id)\n .integer()\n .not_null()\n .auto_increment()\n .primary_key()"
1185 )
1186 };
1187 format!(
1188 r#"use sea_orm_migration::prelude::*;
1189
1190#[derive(DeriveMigrationName)]
1191pub struct Migration;
1192
1193#[async_trait::async_trait]
1194impl MigrationTrait for Migration {{
1195 async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
1196 manager
1197 .create_table(
1198 Table::create()
1199 .table({table_enum}::Table)
1200 .if_not_exists()
1201 .col(
1202 {id_column},
1203 )
1204 .col(ColumnDef::new({table_enum}::Name).string().not_null())
1205 .to_owned(),
1206 )
1207 .await
1208 }}
1209
1210 async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
1211 manager
1212 .drop_table(Table::drop().table({table_enum}::Table).to_owned())
1213 .await
1214 }}
1215}}
1216
1217#[derive(Iden)]
1218enum {table_enum} {{
1219 Table,
1220 Id,
1221 Name,
1222}}
1223"#,
1224 table_enum = table_enum,
1225 id_column = id_column
1226 )
1227}
1228
1229fn render_migration_lib(mod_name: &str) -> String {
1230 format!(
1231 r#"//! Database migrations.
1232
1233pub use sea_orm_migration::prelude::*;
1234
1235mod {mod_name};
1236
1237pub struct Migrator;
1238
1239#[async_trait::async_trait]
1240impl MigratorTrait for Migrator {{
1241 fn migrations() -> Vec<Box<dyn MigrationTrait>> {{
1242 vec![Box::new({mod_name}::Migration)]
1243 }}
1244}}
1245"#,
1246 mod_name = mod_name
1247 )
1248}
1249
1250fn update_migration_lib(path: &Path, mod_name: &str) -> Result<()> {
1251 let mut contents = fs::read_to_string(path)
1252 .with_context(|| format!("Failed to read {}", path.display()))?;
1253
1254 let mod_line = format!("mod {};", mod_name);
1255 if !contents.contains(&mod_line) {
1256 let mut lines = contents.lines().map(|line| line.to_string()).collect::<Vec<_>>();
1257 let mut insert_at = None;
1258 for (idx, line) in lines.iter().enumerate() {
1259 if line.trim_start().starts_with("mod ") {
1260 insert_at = Some(idx + 1);
1261 }
1262 }
1263 let insert_at = insert_at.unwrap_or_else(|| {
1264 let prelude_line = lines
1265 .iter()
1266 .position(|line| line.contains("sea_orm_migration::prelude"))
1267 .map(|idx| idx + 1)
1268 .unwrap_or(0);
1269 prelude_line
1270 });
1271 lines.insert(insert_at, mod_line);
1272 contents = lines.join("\n");
1273 if !contents.ends_with('\n') {
1274 contents.push('\n');
1275 }
1276 }
1277
1278 if !contents.contains(&format!("{}::Migration", mod_name)) {
1279 let mut lines = contents.lines().map(|line| line.to_string()).collect::<Vec<_>>();
1280 let mut vec_start = None;
1281 let mut vec_end = None;
1282 for (idx, line) in lines.iter().enumerate() {
1283 if vec_start.is_none() && line.contains("vec![") {
1284 vec_start = Some(idx);
1285 continue;
1286 }
1287 if vec_start.is_some() && line.trim_start().starts_with(']') {
1288 vec_end = Some(idx);
1289 break;
1290 }
1291 }
1292 if let (Some(start), Some(end)) = (vec_start, vec_end) {
1293 let base_indent = lines[start]
1294 .chars()
1295 .take_while(|c| c.is_whitespace())
1296 .collect::<String>();
1297 let entry_indent = format!("{base_indent} ");
1298 lines.insert(end, format!("{entry_indent}Box::new({}::Migration),", mod_name));
1299 contents = lines.join("\n");
1300 if !contents.ends_with('\n') {
1301 contents.push('\n');
1302 }
1303 } else {
1304 print_warning("Could not find migrations vector in migration/src/lib.rs");
1305 }
1306 }
1307
1308 write_file(path, &contents)
1309 .with_context(|| format!("Failed to write {}", path.display()))?;
1310 Ok(())
1311}
1312fn wire_routes_mod(routes_dir: &Path, resource_name: &str) -> Result<()> {
1313 let mod_path = routes_dir.join("mod.rs");
1314 if !mod_path.exists() {
1315 print_warning("routes/mod.rs not found; skipping auto wiring");
1316 return Ok(());
1317 }
1318
1319 let mut contents = fs::read_to_string(&mod_path)
1320 .with_context(|| format!("Failed to read {}", mod_path.display()))?;
1321 let mod_line = format!("pub mod {};", resource_name);
1322 if !contents.contains(&mod_line) {
1323 contents.push_str(&format!("\n{}\n", mod_line));
1324 write_file(&mod_path, &contents)
1325 .with_context(|| format!("Failed to write {}", mod_path.display()))?;
1326 }
1327 Ok(())
1328}
1329
1330fn generate_repository(
1331 project_dir: &Path,
1332 resource_name: &str,
1333 id_type: ResourceIdType,
1334 paginate: bool,
1335 search: bool,
1336) -> Result<()> {
1337 let src_dir = project_dir.join("src");
1338 let repos_dir = src_dir.join("repositories");
1339 ensure_dir(&repos_dir)
1340 .with_context(|| format!("Failed to create {}", repos_dir.display()))?;
1341
1342 let repos_mod = repos_dir.join("mod.rs");
1343 if !repos_mod.exists() {
1344 let contents = "//! Repository layer.\n\n";
1345 write_file_with_force(&repos_mod, contents, false)?;
1346 print_success("Created src/repositories/mod.rs");
1347 }
1348 wire_repositories_mod(&repos_mod, resource_name)?;
1349
1350 let repo_path = repos_dir.join(format!("{}.rs", resource_name));
1351 let repo_contents = render_repository(resource_name, id_type, paginate, search);
1352 write_file_with_force(&repo_path, &repo_contents, false)?;
1353 print_success("Generated repository");
1354 Ok(())
1355}
1356
1357fn wire_repositories_mod(mod_path: &Path, resource_name: &str) -> Result<()> {
1358 let mut contents = fs::read_to_string(mod_path)
1359 .with_context(|| format!("Failed to read {}", mod_path.display()))?;
1360 let mod_line = format!("pub mod {};", resource_name);
1361 if !contents.contains(&mod_line) {
1362 contents.push_str(&format!("\n{}\n", mod_line));
1363 write_file(mod_path, &contents)
1364 .with_context(|| format!("Failed to write {}", mod_path.display()))?;
1365 }
1366 Ok(())
1367}
1368
1369fn render_repository(
1370 resource_name: &str,
1371 id_type: ResourceIdType,
1372 paginate: bool,
1373 search: bool,
1374) -> String {
1375 let resource_pascal = to_pascal_case(resource_name);
1376 let (id_type_str, uuid_import) = if matches!(id_type, ResourceIdType::Uuid) {
1377 ("uuid::Uuid", "use uuid::Uuid;\n")
1378 } else {
1379 ("i32", "")
1380 };
1381 let create_id_field = if matches!(id_type, ResourceIdType::Uuid) {
1382 " id: Set(Uuid::new_v4()),\n"
1383 } else {
1384 ""
1385 };
1386 let list_signature = if paginate {
1387 if search {
1388 "pub async fn list(&self, limit: Option<u64>, offset: Option<u64>, search: Option<String>) -> Result<Vec<{resource_name}::Model>> {{"
1389 .to_string()
1390 } else {
1391 "pub async fn list(&self, limit: Option<u64>, offset: Option<u64>) -> Result<Vec<{resource_name}::Model>> {{"
1392 .to_string()
1393 }
1394 } else {
1395 "pub async fn list(&self) -> Result<Vec<{resource_name}::Model>> {{".to_string()
1396 };
1397 let list_params = if paginate {
1398 let search_query = if search {
1399 " if let Some(search) = search.as_deref() { query = query.filter({resource_name}::Column::Name.contains(search)); }\n"
1400 } else {
1401 ""
1402 };
1403 format!(
1404 " 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?)",
1405 search_query = search_query,
1406 resource_name = resource_name
1407 )
1408 } else {
1409 " Ok({resource_name}::Entity::find().all(&self.db).await?)".to_string()
1410 };
1411 let sea_orm_imports = if search {
1412 "use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};"
1413 } else {
1414 "use sea_orm::{ActiveModelTrait, EntityTrait, Set};"
1415 };
1416 format!(
1417 r#"{sea_orm_imports}
1418use tideway::Result;
1419{uuid_import}
1420
1421use crate::entities::{resource_name};
1422
1423pub struct {resource_pascal}Repository {{
1424 db: sea_orm::DatabaseConnection,
1425}}
1426
1427impl {resource_pascal}Repository {{
1428 pub fn new(db: sea_orm::DatabaseConnection) -> Self {{
1429 Self {{ db }}
1430 }}
1431
1432 {list_signature}
1433{list_params}
1434 }}
1435
1436 pub async fn get(&self, id: {id_type}) -> Result<Option<{resource_name}::Model>> {{
1437 Ok({resource_name}::Entity::find_by_id(id).one(&self.db).await?)
1438 }}
1439
1440 pub async fn create(&self, name: String) -> Result<{resource_name}::Model> {{
1441 let active = {resource_name}::ActiveModel {{
1442{create_id_field}
1443 name: Set(name),
1444 ..Default::default()
1445 }};
1446 Ok(active.insert(&self.db).await?)
1447 }}
1448
1449 pub async fn update(&self, id: {id_type}, name: Option<String>) -> Result<{resource_name}::Model> {{
1450 let model = {resource_name}::Entity::find_by_id(id).one(&self.db).await?;
1451 let model =
1452 model.ok_or_else(|| tideway::TidewayError::not_found("{resource_pascal} not found"))?;
1453 let mut active: {resource_name}::ActiveModel = model.into();
1454 if let Some(name) = name {{
1455 active.name = Set(name);
1456 }}
1457 Ok(active.update(&self.db).await?)
1458 }}
1459
1460 pub async fn delete(&self, id: {id_type}) -> Result<()> {{
1461 {resource_name}::Entity::delete_by_id(id)
1462 .exec(&self.db)
1463 .await?;
1464 Ok(())
1465 }}
1466}}
1467"#,
1468 resource_name = resource_name,
1469 resource_pascal = resource_pascal,
1470 id_type = id_type_str,
1471 uuid_import = uuid_import,
1472 create_id_field = create_id_field,
1473 list_signature = list_signature,
1474 list_params = list_params,
1475 sea_orm_imports = sea_orm_imports
1476 )
1477}
1478
1479fn generate_service(
1480 project_dir: &Path,
1481 resource_name: &str,
1482 id_type: ResourceIdType,
1483 paginate: bool,
1484 search: bool,
1485) -> Result<()> {
1486 let src_dir = project_dir.join("src");
1487 let services_dir = src_dir.join("services");
1488 ensure_dir(&services_dir)
1489 .with_context(|| format!("Failed to create {}", services_dir.display()))?;
1490
1491 let services_mod = services_dir.join("mod.rs");
1492 if !services_mod.exists() {
1493 let contents = "//! Service layer.\n\n";
1494 write_file_with_force(&services_mod, contents, false)?;
1495 print_success("Created src/services/mod.rs");
1496 }
1497 wire_services_mod(&services_mod, resource_name)?;
1498
1499 let service_path = services_dir.join(format!("{}.rs", resource_name));
1500 let service_contents = render_service(resource_name, id_type, paginate, search);
1501 write_file_with_force(&service_path, &service_contents, false)?;
1502 print_success("Generated service");
1503 Ok(())
1504}
1505
1506fn wire_services_mod(mod_path: &Path, resource_name: &str) -> Result<()> {
1507 let mut contents = fs::read_to_string(mod_path)
1508 .with_context(|| format!("Failed to read {}", mod_path.display()))?;
1509 let mod_line = format!("pub mod {};", resource_name);
1510 if !contents.contains(&mod_line) {
1511 contents.push_str(&format!("\n{}\n", mod_line));
1512 write_file(mod_path, &contents)
1513 .with_context(|| format!("Failed to write {}", mod_path.display()))?;
1514 }
1515 Ok(())
1516}
1517
1518fn render_service(
1519 resource_name: &str,
1520 id_type: ResourceIdType,
1521 paginate: bool,
1522 search: bool,
1523) -> String {
1524 let resource_pascal = to_pascal_case(resource_name);
1525 let id_type_str = if matches!(id_type, ResourceIdType::Uuid) {
1526 "uuid::Uuid"
1527 } else {
1528 "i32"
1529 };
1530 let uuid_import = if matches!(id_type, ResourceIdType::Uuid) {
1531 "use uuid::Uuid;\n"
1532 } else {
1533 ""
1534 };
1535 let list_signature = if paginate {
1536 if search {
1537 "pub async fn list(&self, limit: Option<u64>, offset: Option<u64>, search: Option<String>) -> Result<Vec<crate::entities::{resource_name}::Model>> {{"
1538 .to_string()
1539 } else {
1540 "pub async fn list(&self, limit: Option<u64>, offset: Option<u64>) -> Result<Vec<crate::entities::{resource_name}::Model>> {{"
1541 .to_string()
1542 }
1543 } else {
1544 "pub async fn list(&self) -> Result<Vec<crate::entities::{resource_name}::Model>> {{"
1545 .to_string()
1546 };
1547 let list_body = if paginate {
1548 if search {
1549 " self.repo.list(limit, offset, search).await"
1550 } else {
1551 " self.repo.list(limit, offset).await"
1552 }
1553 } else {
1554 " self.repo.list().await"
1555 };
1556 format!(
1557 r#"use tideway::Result;
1558{uuid_import}
1559
1560use crate::repositories::{resource_name}::{resource_pascal}Repository;
1561
1562pub struct {resource_pascal}Service {{
1563 repo: {resource_pascal}Repository,
1564}}
1565
1566impl {resource_pascal}Service {{
1567 pub fn new(repo: {resource_pascal}Repository) -> Self {{
1568 Self {{ repo }}
1569 }}
1570
1571 {list_signature}
1572{list_body}
1573 }}
1574
1575 pub async fn get(&self, id: {id_type}) -> Result<Option<crate::entities::{resource_name}::Model>> {{
1576 self.repo.get(id).await
1577 }}
1578
1579 pub async fn create(&self, name: String) -> Result<crate::entities::{resource_name}::Model> {{
1580 self.repo.create(name).await
1581 }}
1582
1583 pub async fn update(
1584 &self,
1585 id: {id_type},
1586 name: Option<String>,
1587 ) -> Result<crate::entities::{resource_name}::Model> {{
1588 self.repo.update(id, name).await
1589 }}
1590
1591 pub async fn delete(&self, id: {id_type}) -> Result<()> {{
1592 self.repo.delete(id).await
1593 }}
1594}}
1595"#,
1596 resource_name = resource_name,
1597 resource_pascal = resource_pascal,
1598 id_type = id_type_str,
1599 uuid_import = uuid_import,
1600 list_signature = list_signature,
1601 list_body = list_body
1602 )
1603}
1604
1605fn generate_repository_tests(
1606 project_dir: &Path,
1607 project_name: &str,
1608 resource_name: &str,
1609 id_type: ResourceIdType,
1610) -> Result<()> {
1611 let tests_dir = project_dir.join("tests");
1612 ensure_dir(&tests_dir)
1613 .with_context(|| format!("Failed to create {}", tests_dir.display()))?;
1614
1615 let file_path = tests_dir.join(format!("repository_{}.rs", resource_name));
1616 let contents = render_repository_tests(project_name, resource_name, id_type);
1617 write_file_with_force(&file_path, &contents, false)?;
1618 print_success("Generated repository tests");
1619 Ok(())
1620}
1621
1622fn render_repository_tests(
1623 project_name: &str,
1624 resource_name: &str,
1625 _id_type: ResourceIdType,
1626) -> String {
1627 let resource_pascal = to_pascal_case(resource_name);
1628 format!(
1629 r#"use sea_orm::Database;
1630use tideway::Result;
1631
1632use {project_name}::repositories::{resource_name}::{resource_pascal}Repository;
1633
1634#[tokio::test]
1635#[ignore = "Requires DATABASE_URL and existing migrations"]
1636async fn repository_crud_smoke() -> Result<()> {{
1637 let database_url = std::env::var("DATABASE_URL")
1638 .expect("DATABASE_URL is required for repository tests");
1639 let db = Database::connect(&database_url).await?;
1640 let repo = {resource_pascal}Repository::new(db);
1641
1642 let created = repo.create("Example".to_string()).await?;
1643 let _ = repo.list().await?;
1644 repo.delete(created.id).await?;
1645 Ok(())
1646}}
1647"#,
1648 project_name = project_name,
1649 resource_name = resource_name,
1650 resource_pascal = resource_pascal
1651 )
1652}
1653
1654fn wire_main_rs(src_dir: &Path, resource_name: &str, resource_pascal: &str) -> Result<()> {
1655 let main_path = src_dir.join("main.rs");
1656 if !main_path.exists() {
1657 print_warning("src/main.rs not found; skipping auto wiring");
1658 return Ok(());
1659 }
1660
1661 let mut contents = fs::read_to_string(&main_path)
1662 .with_context(|| format!("Failed to read {}", main_path.display()))?;
1663
1664 let register_line = format!(
1665 ".register_module(routes::{}::{}Module)",
1666 resource_name, resource_pascal
1667 );
1668 if contents.contains(®ister_line) {
1669 return Ok(());
1670 }
1671
1672 if let Some(pos) = contents.find(".register_module(") {
1673 let line_end = contents[pos..]
1674 .find('\n')
1675 .map(|idx| pos + idx)
1676 .unwrap_or(contents.len());
1677 contents.insert_str(line_end + 1, &format!(" {}\n", register_line));
1678 } else {
1679 print_warning("Could not find register_module call in main.rs");
1680 }
1681
1682 write_file(&main_path, &contents)
1683 .with_context(|| format!("Failed to write {}", main_path.display()))?;
1684 Ok(())
1685}
1686
1687fn write_file_with_force(path: &Path, contents: &str, force: bool) -> Result<()> {
1688 if path.exists() && !force {
1689 print_warning(&format!(
1690 "Skipping {} (use --force to overwrite)",
1691 path.display()
1692 ));
1693 return Ok(());
1694 }
1695 if let Some(parent) = path.parent() {
1696 ensure_dir(parent)
1697 .with_context(|| format!("Failed to create {}", parent.display()))?;
1698 }
1699 write_file(path, contents)
1700 .with_context(|| format!("Failed to write {}", path.display()))?;
1701 Ok(())
1702}
1703
1704fn normalize_name(name: &str) -> String {
1705 name.trim().to_lowercase().replace('-', "_")
1706}
1707
1708fn pluralize(name: &str) -> String {
1709 if name.ends_with('s') {
1710 format!("{}es", name)
1711 } else {
1712 format!("{}s", name)
1713 }
1714}
1715
1716fn to_pascal_case(s: &str) -> String {
1717 s.split('_')
1718 .filter(|part| !part.is_empty())
1719 .map(|word| {
1720 let mut chars = word.chars();
1721 match chars.next() {
1722 None => String::new(),
1723 Some(first) => first.to_uppercase().chain(chars).collect(),
1724 }
1725 })
1726 .collect()
1727}
1728
1729fn has_feature(cargo_path: &Path, feature: &str) -> bool {
1730 let Ok(contents) = fs::read_to_string(cargo_path) else {
1731 return false;
1732 };
1733 let Ok(doc) = contents.parse::<toml_edit::DocumentMut>() else {
1734 return false;
1735 };
1736
1737 let Some(tideway) = doc
1738 .get("dependencies")
1739 .and_then(|deps| deps.get("tideway"))
1740 else {
1741 return false;
1742 };
1743
1744 let Some(features) = tideway.get("features").and_then(|item| item.as_array()) else {
1745 return false;
1746 };
1747
1748 features.iter().any(|v| v.as_str() == Some(feature))
1749}
1750
1751fn has_dependency(cargo_path: &Path, dependency: &str) -> bool {
1752 let Ok(contents) = fs::read_to_string(cargo_path) else {
1753 return false;
1754 };
1755 let Ok(doc) = contents.parse::<toml_edit::DocumentMut>() else {
1756 return false;
1757 };
1758
1759 doc.get("dependencies")
1760 .and_then(|deps| deps.get(dependency))
1761 .is_some()
1762}
1763
1764fn add_uuid_dependency(cargo_path: &Path) -> Result<()> {
1765 let contents = fs::read_to_string(cargo_path)
1766 .with_context(|| format!("Failed to read {}", cargo_path.display()))?;
1767 let mut doc = contents.parse::<toml_edit::DocumentMut>()?;
1768 let deps = doc["dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1769 let deps_table = deps
1770 .as_table_mut()
1771 .context("dependencies should be a table")?;
1772
1773 let mut table = toml_edit::InlineTable::new();
1774 table.get_or_insert("version", "1");
1775 table.get_or_insert("features", array_value(&["v4"]));
1776 deps_table.insert(
1777 "uuid",
1778 toml_edit::Item::Value(toml_edit::Value::InlineTable(table)),
1779 );
1780
1781 write_file(cargo_path, &doc.to_string())
1782 .with_context(|| format!("Failed to write {}", cargo_path.display()))?;
1783 Ok(())
1784}
1785
1786fn project_name_from_cargo(cargo_path: &Path, project_dir: &Path) -> String {
1787 let Ok(contents) = fs::read_to_string(cargo_path) else {
1788 return fallback_project_name(project_dir);
1789 };
1790 let Ok(doc) = contents.parse::<toml_edit::DocumentMut>() else {
1791 return fallback_project_name(project_dir);
1792 };
1793
1794 doc.get("package")
1795 .and_then(|pkg| pkg.get("name"))
1796 .and_then(|value| value.as_str())
1797 .map(|name| name.replace('-', "_"))
1798 .unwrap_or_else(|| fallback_project_name(project_dir))
1799}
1800
1801fn fallback_project_name(project_dir: &Path) -> String {
1802 project_dir
1803 .file_name()
1804 .and_then(|n| n.to_str())
1805 .unwrap_or("my_app")
1806 .replace('-', "_")
1807}