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