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