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