Skip to main content

tideway_cli/commands/
resource.rs

1//! Resource command - generate CRUD modules for API development.
2
3use 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            format!("pub async fn list(&self, limit: Option<u64>, offset: Option<u64>, search: Option<String>) -> Result<Vec<{resource_name}::Model>> {{")
1389        } else {
1390            format!("pub async fn list(&self, limit: Option<u64>, offset: Option<u64>) -> Result<Vec<{resource_name}::Model>> {{")
1391        }
1392    } else {
1393        format!("pub async fn list(&self) -> Result<Vec<{resource_name}::Model>> {{")
1394    };
1395    let list_params = if paginate {
1396        let search_query = if search {
1397            format!("        if let Some(search) = search.as_deref() {{ query = query.filter({resource_name}::Column::Name.contains(search)); }}\n")
1398        } else {
1399            String::new()
1400        };
1401        format!(
1402            "        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?)",
1403            search_query = search_query,
1404        )
1405    } else {
1406        format!("        Ok({resource_name}::Entity::find().all(&self.db).await?)")
1407    };
1408    let sea_orm_imports = match (paginate, search) {
1409        (true, true) => "use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set};",
1410        (true, false) => "use sea_orm::{ActiveModelTrait, EntityTrait, QuerySelect, Set};",
1411        (false, true) => "use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};",
1412        (false, false) => "use sea_orm::{ActiveModelTrait, EntityTrait, Set};",
1413    };
1414    format!(
1415        r#"{sea_orm_imports}
1416use tideway::Result;
1417{uuid_import}
1418
1419use crate::entities::{resource_name};
1420
1421pub struct {resource_pascal}Repository {{
1422    db: sea_orm::DatabaseConnection,
1423}}
1424
1425impl {resource_pascal}Repository {{
1426    pub fn new(db: sea_orm::DatabaseConnection) -> Self {{
1427        Self {{ db }}
1428    }}
1429
1430    {list_signature}
1431{list_params}
1432    }}
1433
1434    pub async fn get(&self, id: {id_type}) -> Result<Option<{resource_name}::Model>> {{
1435        Ok({resource_name}::Entity::find_by_id(id).one(&self.db).await?)
1436    }}
1437
1438    pub async fn create(&self, name: String) -> Result<{resource_name}::Model> {{
1439        let active = {resource_name}::ActiveModel {{
1440{create_id_field}
1441            name: Set(name),
1442            ..Default::default()
1443        }};
1444        Ok(active.insert(&self.db).await?)
1445    }}
1446
1447    pub async fn update(&self, id: {id_type}, name: Option<String>) -> Result<{resource_name}::Model> {{
1448        let model = {resource_name}::Entity::find_by_id(id).one(&self.db).await?;
1449        let model =
1450            model.ok_or_else(|| tideway::TidewayError::not_found("{resource_pascal} not found"))?;
1451        let mut active: {resource_name}::ActiveModel = model.into();
1452        if let Some(name) = name {{
1453            active.name = Set(name);
1454        }}
1455        Ok(active.update(&self.db).await?)
1456    }}
1457
1458    pub async fn delete(&self, id: {id_type}) -> Result<()> {{
1459        {resource_name}::Entity::delete_by_id(id)
1460            .exec(&self.db)
1461            .await?;
1462        Ok(())
1463    }}
1464}}
1465"#,
1466        resource_name = resource_name,
1467        resource_pascal = resource_pascal,
1468        id_type = id_type_str,
1469        uuid_import = uuid_import,
1470        create_id_field = create_id_field,
1471        list_signature = list_signature,
1472        list_params = list_params,
1473        sea_orm_imports = sea_orm_imports
1474    )
1475}
1476
1477fn generate_service(
1478    project_dir: &Path,
1479    resource_name: &str,
1480    id_type: ResourceIdType,
1481    paginate: bool,
1482    search: bool,
1483) -> Result<()> {
1484    let src_dir = project_dir.join("src");
1485    let services_dir = src_dir.join("services");
1486    ensure_dir(&services_dir)
1487        .with_context(|| format!("Failed to create {}", services_dir.display()))?;
1488
1489    let services_mod = services_dir.join("mod.rs");
1490    if !services_mod.exists() {
1491        let contents = "//! Service layer.\n\n";
1492        write_file_with_force(&services_mod, contents, false)?;
1493        print_success("Created src/services/mod.rs");
1494    }
1495    wire_services_mod(&services_mod, resource_name)?;
1496
1497    let service_path = services_dir.join(format!("{}.rs", resource_name));
1498    let service_contents = render_service(resource_name, id_type, paginate, search);
1499    write_file_with_force(&service_path, &service_contents, false)?;
1500    print_success("Generated service");
1501    Ok(())
1502}
1503
1504fn wire_services_mod(mod_path: &Path, resource_name: &str) -> Result<()> {
1505    let mut contents = fs::read_to_string(mod_path)
1506        .with_context(|| format!("Failed to read {}", mod_path.display()))?;
1507    let mod_line = format!("pub mod {};", resource_name);
1508    if !contents.contains(&mod_line) {
1509        contents.push_str(&format!("\n{}\n", mod_line));
1510        write_file(mod_path, &contents)
1511            .with_context(|| format!("Failed to write {}", mod_path.display()))?;
1512    }
1513    Ok(())
1514}
1515
1516fn render_service(
1517    resource_name: &str,
1518    id_type: ResourceIdType,
1519    paginate: bool,
1520    search: bool,
1521) -> String {
1522    let resource_pascal = to_pascal_case(resource_name);
1523    let id_type_str = if matches!(id_type, ResourceIdType::Uuid) {
1524        "uuid::Uuid"
1525    } else {
1526        "i32"
1527    };
1528    let uuid_import = if matches!(id_type, ResourceIdType::Uuid) {
1529        "use uuid::Uuid;\n"
1530    } else {
1531        ""
1532    };
1533    let list_signature = if paginate {
1534        if search {
1535            format!("pub async fn list(&self, limit: Option<u64>, offset: Option<u64>, search: Option<String>) -> Result<Vec<crate::entities::{resource_name}::Model>> {{")
1536        } else {
1537            format!("pub async fn list(&self, limit: Option<u64>, offset: Option<u64>) -> Result<Vec<crate::entities::{resource_name}::Model>> {{")
1538        }
1539    } else {
1540        format!("pub async fn list(&self) -> Result<Vec<crate::entities::{resource_name}::Model>> {{")
1541    };
1542    let list_body = if paginate {
1543        if search {
1544            "        self.repo.list(limit, offset, search).await"
1545        } else {
1546            "        self.repo.list(limit, offset).await"
1547        }
1548    } else {
1549        "        self.repo.list().await"
1550    };
1551    format!(
1552        r#"use tideway::Result;
1553{uuid_import}
1554
1555use crate::repositories::{resource_name}::{resource_pascal}Repository;
1556
1557pub struct {resource_pascal}Service {{
1558    repo: {resource_pascal}Repository,
1559}}
1560
1561impl {resource_pascal}Service {{
1562    pub fn new(repo: {resource_pascal}Repository) -> Self {{
1563        Self {{ repo }}
1564    }}
1565
1566    {list_signature}
1567{list_body}
1568    }}
1569
1570    pub async fn get(&self, id: {id_type}) -> Result<Option<crate::entities::{resource_name}::Model>> {{
1571        self.repo.get(id).await
1572    }}
1573
1574    pub async fn create(&self, name: String) -> Result<crate::entities::{resource_name}::Model> {{
1575        self.repo.create(name).await
1576    }}
1577
1578    pub async fn update(
1579        &self,
1580        id: {id_type},
1581        name: Option<String>,
1582    ) -> Result<crate::entities::{resource_name}::Model> {{
1583        self.repo.update(id, name).await
1584    }}
1585
1586    pub async fn delete(&self, id: {id_type}) -> Result<()> {{
1587        self.repo.delete(id).await
1588    }}
1589}}
1590"#,
1591        resource_name = resource_name,
1592        resource_pascal = resource_pascal,
1593        id_type = id_type_str,
1594        uuid_import = uuid_import,
1595        list_signature = list_signature,
1596        list_body = list_body
1597    )
1598}
1599
1600fn generate_repository_tests(
1601    project_dir: &Path,
1602    project_name: &str,
1603    resource_name: &str,
1604    id_type: ResourceIdType,
1605) -> Result<()> {
1606    let tests_dir = project_dir.join("tests");
1607    ensure_dir(&tests_dir)
1608        .with_context(|| format!("Failed to create {}", tests_dir.display()))?;
1609
1610    let file_path = tests_dir.join(format!("repository_{}.rs", resource_name));
1611    let contents = render_repository_tests(project_name, resource_name, id_type);
1612    write_file_with_force(&file_path, &contents, false)?;
1613    print_success("Generated repository tests");
1614    Ok(())
1615}
1616
1617fn render_repository_tests(
1618    project_name: &str,
1619    resource_name: &str,
1620    _id_type: ResourceIdType,
1621) -> String {
1622    let resource_pascal = to_pascal_case(resource_name);
1623    format!(
1624        r#"use sea_orm::Database;
1625use tideway::Result;
1626
1627use {project_name}::repositories::{resource_name}::{resource_pascal}Repository;
1628
1629#[tokio::test]
1630#[ignore = "Requires DATABASE_URL and existing migrations"]
1631async fn repository_crud_smoke() -> Result<()> {{
1632    let database_url = std::env::var("DATABASE_URL")
1633        .expect("DATABASE_URL is required for repository tests");
1634    let db = Database::connect(&database_url).await?;
1635    let repo = {resource_pascal}Repository::new(db);
1636
1637    let created = repo.create("Example".to_string()).await?;
1638    let _ = repo.list().await?;
1639    repo.delete(created.id).await?;
1640    Ok(())
1641}}
1642"#,
1643        project_name = project_name,
1644        resource_name = resource_name,
1645        resource_pascal = resource_pascal
1646    )
1647}
1648
1649fn wire_main_rs(src_dir: &Path, resource_name: &str, resource_pascal: &str) -> Result<()> {
1650    let main_path = src_dir.join("main.rs");
1651    if !main_path.exists() {
1652        print_warning("src/main.rs not found; skipping auto wiring");
1653        return Ok(());
1654    }
1655
1656    let mut contents = fs::read_to_string(&main_path)
1657        .with_context(|| format!("Failed to read {}", main_path.display()))?;
1658
1659    let register_line = format!(
1660        ".register_module(routes::{}::{}Module)",
1661        resource_name, resource_pascal
1662    );
1663    if contents.contains(&register_line) {
1664        return Ok(());
1665    }
1666
1667    if let Some(pos) = contents.find(".register_module(") {
1668        let line_end = contents[pos..]
1669            .find('\n')
1670            .map(|idx| pos + idx)
1671            .unwrap_or(contents.len());
1672        contents.insert_str(line_end + 1, &format!("        {}\n", register_line));
1673    } else {
1674        print_warning("Could not find register_module call in main.rs");
1675    }
1676
1677    write_file(&main_path, &contents)
1678        .with_context(|| format!("Failed to write {}", main_path.display()))?;
1679    Ok(())
1680}
1681
1682fn write_file_with_force(path: &Path, contents: &str, force: bool) -> Result<()> {
1683    if path.exists() && !force {
1684        print_warning(&format!(
1685            "Skipping {} (use --force to overwrite)",
1686            path.display()
1687        ));
1688        return Ok(());
1689    }
1690    if let Some(parent) = path.parent() {
1691        ensure_dir(parent)
1692            .with_context(|| format!("Failed to create {}", parent.display()))?;
1693    }
1694    write_file(path, contents)
1695        .with_context(|| format!("Failed to write {}", path.display()))?;
1696    Ok(())
1697}
1698
1699fn normalize_name(name: &str) -> String {
1700    name.trim().to_lowercase().replace('-', "_")
1701}
1702
1703fn pluralize(name: &str) -> String {
1704    if name.ends_with('s') {
1705        format!("{}es", name)
1706    } else {
1707        format!("{}s", name)
1708    }
1709}
1710
1711fn to_pascal_case(s: &str) -> String {
1712    s.split('_')
1713        .filter(|part| !part.is_empty())
1714        .map(|word| {
1715            let mut chars = word.chars();
1716            match chars.next() {
1717                None => String::new(),
1718                Some(first) => first.to_uppercase().chain(chars).collect(),
1719            }
1720        })
1721        .collect()
1722}
1723
1724fn has_feature(cargo_path: &Path, feature: &str) -> bool {
1725    let Ok(contents) = fs::read_to_string(cargo_path) else {
1726        return false;
1727    };
1728    let Ok(doc) = contents.parse::<toml_edit::DocumentMut>() else {
1729        return false;
1730    };
1731
1732    let Some(tideway) = doc
1733        .get("dependencies")
1734        .and_then(|deps| deps.get("tideway"))
1735    else {
1736        return false;
1737    };
1738
1739    let Some(features) = tideway.get("features").and_then(|item| item.as_array()) else {
1740        return false;
1741    };
1742
1743    features.iter().any(|v| v.as_str() == Some(feature))
1744}
1745
1746fn has_dependency(cargo_path: &Path, dependency: &str) -> bool {
1747    let Ok(contents) = fs::read_to_string(cargo_path) else {
1748        return false;
1749    };
1750    let Ok(doc) = contents.parse::<toml_edit::DocumentMut>() else {
1751        return false;
1752    };
1753
1754    doc.get("dependencies")
1755        .and_then(|deps| deps.get(dependency))
1756        .is_some()
1757}
1758
1759fn add_uuid_dependency(cargo_path: &Path) -> Result<()> {
1760    let contents = fs::read_to_string(cargo_path)
1761        .with_context(|| format!("Failed to read {}", cargo_path.display()))?;
1762    let mut doc = contents.parse::<toml_edit::DocumentMut>()?;
1763    let deps = doc["dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1764    let deps_table = deps
1765        .as_table_mut()
1766        .context("dependencies should be a table")?;
1767
1768    let mut table = toml_edit::InlineTable::new();
1769    table.get_or_insert("version", "1");
1770    table.get_or_insert("features", array_value(&["v4"]));
1771    deps_table.insert(
1772        "uuid",
1773        toml_edit::Item::Value(toml_edit::Value::InlineTable(table)),
1774    );
1775
1776    write_file(cargo_path, &doc.to_string())
1777        .with_context(|| format!("Failed to write {}", cargo_path.display()))?;
1778    Ok(())
1779}
1780
1781fn project_name_from_cargo(cargo_path: &Path, project_dir: &Path) -> String {
1782    let Ok(contents) = fs::read_to_string(cargo_path) else {
1783        return fallback_project_name(project_dir);
1784    };
1785    let Ok(doc) = contents.parse::<toml_edit::DocumentMut>() else {
1786        return fallback_project_name(project_dir);
1787    };
1788
1789    doc.get("package")
1790        .and_then(|pkg| pkg.get("name"))
1791        .and_then(|value| value.as_str())
1792        .map(|name| name.replace('-', "_"))
1793        .unwrap_or_else(|| fallback_project_name(project_dir))
1794}
1795
1796fn fallback_project_name(project_dir: &Path) -> String {
1797    project_dir
1798        .file_name()
1799        .and_then(|n| n.to_str())
1800        .unwrap_or("my_app")
1801        .replace('-', "_")
1802}