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