Skip to main content

ferro_cli/templates/
scaffold.rs

1use super::entity::to_pascal_case;
2use super::entity::to_snake_case;
3
4// ============================================================================
5// Scaffold Factory Template
6// ============================================================================
7
8/// Scaffold field information for factory generation
9pub struct ScaffoldField {
10    pub name: String,
11    pub field_type: String,
12}
13
14/// Foreign key information for scaffold generation
15pub struct ScaffoldForeignKey {
16    /// The field name (e.g., "user_id")
17    pub field_name: String,
18    /// The target model name in PascalCase (e.g., "User")
19    pub target_model: String,
20    /// The target model name in snake_case (e.g., "user")
21    pub target_snake: String,
22    /// Whether the target model exists in the project
23    pub validated: bool,
24}
25
26/// Template for generating factory with pre-populated fields from scaffold definition
27pub fn scaffold_factory_template(
28    _file_name: &str,
29    struct_name: &str,
30    model_name: &str,
31    fields: &[ScaffoldField],
32    foreign_keys: &[ScaffoldForeignKey],
33) -> String {
34    // Separate FK fields from regular fields for special handling
35    let fk_field_names: Vec<&str> = foreign_keys
36        .iter()
37        .map(|fk| fk.field_name.as_str())
38        .collect();
39
40    // Build field definitions
41    let field_defs: String = fields
42        .iter()
43        .map(|f| {
44            format!(
45                "    pub {}: {},\n",
46                f.name,
47                rust_type_for_factory(&f.field_type)
48            )
49        })
50        .collect();
51
52    // Build Fake::* assignments - handle FK fields specially
53    let fake_assignments: String = fields
54        .iter()
55        .map(|f| {
56            if fk_field_names.contains(&f.name.as_str()) {
57                // Find the FK info
58                let fk = foreign_keys.iter().find(|fk| fk.field_name == f.name);
59                if let Some(fk) = fk {
60                    if fk.validated {
61                        format!(
62                            "            {}: 0, // Set via with_{target}() or create will make one\n",
63                            f.name,
64                            target = fk.target_snake
65                        )
66                    } else {
67                        format!(
68                            "            {}: Fake::integer(1, 1000000) as i64, // TODO: Create {target} first\n",
69                            f.name,
70                            target = fk.target_model
71                        )
72                    }
73                } else {
74                    format!("            {}: {},\n", f.name, fake_value_for_type(&f.field_type))
75                }
76            } else {
77                format!("            {}: {},\n", f.name, fake_value_for_type(&f.field_type))
78            }
79        })
80        .collect();
81
82    // Build factory imports for validated FKs
83    let fk_imports: String = foreign_keys
84        .iter()
85        .filter(|fk| fk.validated)
86        .map(|fk| {
87            format!(
88                "use crate::factories::{target_snake}_factory::{target_pascal}Factory;\n",
89                target_snake = fk.target_snake,
90                target_pascal = fk.target_model
91            )
92        })
93        .collect();
94
95    // Build with_* methods for validated FKs
96    let with_methods: String = foreign_keys
97        .iter()
98        .filter(|fk| fk.validated)
99        .map(|fk| {
100            format!(
101                r#"
102    /// Set the {target_snake} for this factory
103    pub fn with_{target_snake}(mut self, {target_snake}_id: i64) -> Self {{
104        self.{field_name} = {target_snake}_id;
105        self
106    }}
107"#,
108                target_snake = fk.target_snake,
109                field_name = fk.field_name
110            )
111        })
112        .collect();
113
114    // Build create method that creates related records first (for validated FKs)
115    let validated_fks: Vec<&ScaffoldForeignKey> =
116        foreign_keys.iter().filter(|fk| fk.validated).collect();
117    let create_method = if validated_fks.is_empty() {
118        String::new()
119    } else {
120        let create_relations: String = validated_fks
121            .iter()
122            .map(|fk| {
123                format!(
124                    "        let {target_snake} = {target_pascal}Factory::factory().create(db).await;\n",
125                    target_snake = fk.target_snake,
126                    target_pascal = fk.target_model
127                )
128            })
129            .collect();
130
131        let set_fk_fields: String = validated_fks
132            .iter()
133            .map(|fk| {
134                format!(
135                    "        result.{field_name} = {target_snake}.id;\n",
136                    field_name = fk.field_name,
137                    target_snake = fk.target_snake
138                )
139            })
140            .collect();
141
142        format!(
143            r#"
144    /// Create related records and set FK fields
145    pub async fn create_with_relations(&self, db: &DatabaseConnection) -> Self {{
146{create_relations}        let mut result = self.clone();
147{set_fk_fields}        result
148    }}
149"#
150        )
151    };
152
153    format!(
154        r#"//! {struct_name} factory
155//!
156//! Generated with `ferro make:scaffold --with-factory`
157
158use ferro::testing::{{Factory, FactoryTraits, Fake}};
159{fk_imports}// use ferro::testing::DatabaseFactory;
160// use crate::models::{model_lower}::{{self, Model as {model_name}}};
161// use sea_orm::DatabaseConnection;
162
163/// Factory for creating {model_name} instances in tests
164#[derive(Clone)]
165pub struct {struct_name} {{
166    pub id: i64,
167{field_defs}    pub created_at: String,
168    pub updated_at: String,
169}}
170
171impl {struct_name} {{{with_methods}{create_method}}}
172
173impl Factory for {struct_name} {{
174    fn definition() -> Self {{
175        Self {{
176            id: 0, // Will be set by database
177{fake_assignments}            created_at: Fake::datetime(),
178            updated_at: Fake::datetime(),
179        }}
180    }}
181
182    fn traits() -> FactoryTraits<Self> {{
183        FactoryTraits::new()
184    }}
185}}
186
187// Uncomment to enable database persistence with create():
188//
189// #[ferro::async_trait]
190// impl DatabaseFactory for {struct_name} {{
191//     type Entity = {model_lower}::Entity;
192//     type ActiveModel = {model_lower}::ActiveModel;
193// }}
194
195// Usage in tests:
196//
197// // Make without persisting:
198// let model = {struct_name}::factory().make();
199//
200// // Apply named trait:
201// let custom = {struct_name}::factory().trait_("custom").make();
202//
203// // With inline state:
204// let model = {struct_name}::factory()
205//     .state(|m| m.id = 42)
206//     .make();
207//
208// // Create with database persistence:
209// let model = {struct_name}::factory().create().await?;
210//
211// // Create multiple:
212// let models = {struct_name}::factory().count(5).create_many().await?;
213"#,
214        struct_name = struct_name,
215        model_name = model_name,
216        model_lower = model_name.to_lowercase(),
217        field_defs = field_defs,
218        fake_assignments = fake_assignments,
219        fk_imports = fk_imports,
220        with_methods = with_methods,
221        create_method = create_method,
222    )
223}
224
225/// Convert scaffold field type to Rust type for factory
226fn rust_type_for_factory(field_type: &str) -> &'static str {
227    match field_type.to_lowercase().as_str() {
228        "string" | "str" | "text" => "String",
229        "int" | "integer" | "i32" => "i32",
230        "bigint" | "biginteger" | "i64" => "i64",
231        "float" | "f64" | "double" => "f64",
232        "bool" | "boolean" => "bool",
233        "datetime" | "timestamp" => "String",
234        "date" => "String",
235        "uuid" => "String",
236        _ => "String",
237    }
238}
239
240/// Generate Fake::* value based on field type
241fn fake_value_for_type(field_type: &str) -> &'static str {
242    match field_type.to_lowercase().as_str() {
243        "string" | "str" => "Fake::word()",
244        "text" => "Fake::sentence()",
245        "int" | "integer" | "i32" => "Fake::integer(1, 1000)",
246        "bigint" | "biginteger" | "i64" => "Fake::integer(1, 1000000) as i64",
247        "float" | "f64" | "double" => "Fake::float(0.0, 1000.0)",
248        "bool" | "boolean" => "Fake::boolean()",
249        "datetime" | "timestamp" => "Fake::datetime()",
250        "date" => "Fake::date()",
251        "uuid" => "Fake::uuid()",
252        _ => "Fake::word()",
253    }
254}
255
256// ============================================================================
257// Scaffold Test Template
258// ============================================================================
259
260/// Template for generating controller tests with make:scaffold --with-tests
261pub fn scaffold_test_template(snake_name: &str, plural_snake: &str) -> String {
262    format!(
263        r#"//! {plural_pascal} controller tests
264//!
265//! Generated with `ferro make:scaffold --with-tests`
266
267use ferro::testing::{{TestClient, TestResponse}};
268
269/// Test that the {plural} index endpoint returns success
270#[tokio::test]
271async fn test_{plural}_index() {{
272    let client = TestClient::new();
273
274    let response = client.get("/{plural}").send().await;
275
276    // TODO: Configure TestClient with your app's router
277    // response.assert_ok();
278    assert!(response.status().is_success());
279}}
280
281/// Test that showing a single {snake} returns success
282#[tokio::test]
283async fn test_{plural}_show() {{
284    let client = TestClient::new();
285
286    let response = client.get("/{plural}/1").send().await;
287
288    // TODO: Create a test record first, then verify response
289    // response.assert_ok().assert_json_has("{snake}");
290    assert!(response.status().is_success());
291}}
292
293/// Test that creating a {snake} works
294#[tokio::test]
295async fn test_{plural}_store() {{
296    let client = TestClient::new();
297
298    let response = client
299        .post("/{plural}")
300        .json(&serde_json::json!({{
301            // TODO: Add your model fields here
302        }}))
303        .send()
304        .await;
305
306    // TODO: Verify redirect or JSON response
307    // response.assert_status(302);
308    assert!(response.status().is_success());
309}}
310
311/// Test that updating a {snake} works
312#[tokio::test]
313async fn test_{plural}_update() {{
314    let client = TestClient::new();
315
316    let response = client
317        .put("/{plural}/1")
318        .json(&serde_json::json!({{
319            // TODO: Add your model fields here
320        }}))
321        .send()
322        .await;
323
324    // TODO: Verify redirect or JSON response
325    // response.assert_status(302);
326    assert!(response.status().is_success());
327}}
328
329/// Test that deleting a {snake} works
330#[tokio::test]
331async fn test_{plural}_destroy() {{
332    let client = TestClient::new();
333
334    let response = client.delete("/{plural}/1").send().await;
335
336    // TODO: Verify redirect or JSON response
337    // response.assert_status(302);
338    assert!(response.status().is_success());
339}}
340"#,
341        snake = snake_name,
342        plural = plural_snake,
343        plural_pascal = to_pascal_case(plural_snake),
344    )
345}
346
347/// Template for generating controller tests that use factories
348///
349/// Generated when both --with-tests and --with-factory flags are used.
350/// Tests create model instances using the factory for realistic test data.
351pub fn scaffold_test_with_factory_template(
352    snake_name: &str,
353    plural_snake: &str,
354    pascal_name: &str,
355    fields: &[ScaffoldField],
356) -> String {
357    // Build JSON fields for store/update tests from factory data
358    let json_fields: String = fields
359        .iter()
360        .map(|f| format!("            \"{}\": factory.{}.clone(),\n", f.name, f.name))
361        .collect();
362
363    format!(
364        r#"//! {plural_pascal} controller tests
365//!
366//! Generated with `ferro make:scaffold --with-tests --with-factory`
367
368use ferro::testing::{{Factory, TestClient, TestDatabase, TestResponse}};
369use crate::factories::{snake}_factory::{pascal}Factory;
370
371/// Test that the {plural} index endpoint returns a list
372#[tokio::test]
373async fn test_{plural}_index() {{
374    let db = TestDatabase::new().await;
375    let client = TestClient::with_db(db.clone());
376
377    // Create 3 {plural} using factory
378    for _ in 0..3 {{
379        let model = {pascal}Factory::factory().create(&db).await.unwrap();
380    }}
381
382    let response = client.get("/{plural}").send().await;
383
384    response.assert_ok();
385    // response.assert_json_path("data").assert_count(3);
386}}
387
388/// Test that showing a single {snake} returns the correct record
389#[tokio::test]
390async fn test_{plural}_show() {{
391    let db = TestDatabase::new().await;
392    let client = TestClient::with_db(db.clone());
393
394    // Create a {snake} using factory
395    let {snake} = {pascal}Factory::factory().create(&db).await.unwrap();
396
397    let response = client.get(&format!("/{plural}/{{}}", {snake}.id)).send().await;
398
399    response.assert_ok();
400    // response.assert_json_path("data.id").assert_eq({snake}.id);
401}}
402
403/// Test that creating a {snake} persists to database
404#[tokio::test]
405async fn test_{plural}_store() {{
406    let db = TestDatabase::new().await;
407    let client = TestClient::with_db(db.clone());
408
409    // Use factory to generate valid input data
410    let factory = {pascal}Factory::definition();
411
412    let response = client
413        .post("/{plural}")
414        .json(&serde_json::json!({{
415{json_fields}        }}))
416        .send()
417        .await;
418
419    response.assert_created();
420    // Verify record was created in database
421    // let count = {pascal}::query().count(&db).await.unwrap();
422    // assert_eq!(count, 1);
423}}
424
425/// Test that updating a {snake} modifies the record
426#[tokio::test]
427async fn test_{plural}_update() {{
428    let db = TestDatabase::new().await;
429    let client = TestClient::with_db(db.clone());
430
431    // Create initial {snake}
432    let {snake} = {pascal}Factory::factory().create(&db).await.unwrap();
433
434    // Use factory for updated data
435    let factory = {pascal}Factory::definition();
436
437    let response = client
438        .put(&format!("/{plural}/{{}}", {snake}.id))
439        .json(&serde_json::json!({{
440{json_fields}        }}))
441        .send()
442        .await;
443
444    response.assert_ok();
445    // Verify record was updated
446    // let updated = {pascal}::find({snake}.id, &db).await.unwrap();
447    // assert_ne!(updated.field, {snake}.field);
448}}
449
450/// Test that deleting a {snake} removes the record
451#[tokio::test]
452async fn test_{plural}_destroy() {{
453    let db = TestDatabase::new().await;
454    let client = TestClient::with_db(db.clone());
455
456    // Create a {snake} using factory
457    let {snake} = {pascal}Factory::factory().create(&db).await.unwrap();
458
459    let response = client.delete(&format!("/{plural}/{{}}", {snake}.id)).send().await;
460
461    response.assert_ok();
462    // Verify record was deleted
463    // let exists = {pascal}::find({snake}.id, &db).await.is_ok();
464    // assert!(!exists);
465}}
466"#,
467        snake = snake_name,
468        plural = plural_snake,
469        pascal = pascal_name,
470        plural_pascal = to_pascal_case(plural_snake),
471        json_fields = json_fields,
472    )
473}
474
475// ============================================================================
476// FK-Aware Scaffold Templates
477// ============================================================================
478
479/// Foreign key information for template generation.
480/// Mirrors the ForeignKeyInfo from analyzer.rs for use in templates.
481#[derive(Debug, Clone)]
482pub struct ForeignKeyField {
483    /// The field name (e.g., "user_id")
484    pub field_name: String,
485    /// The target model name in PascalCase (e.g., "User")
486    pub target_model: String,
487    /// The target table name in snake_case plural (e.g., "users")
488    pub target_table: String,
489    /// Whether the target model exists in the project
490    pub validated: bool,
491}
492
493/// Template for generating full-stack controller with FK eager loading
494pub fn scaffold_controller_with_fk_template(
495    name: &str,
496    snake_name: &str,
497    plural_snake: &str,
498    form_fields: &str,
499    update_fields: &str,
500    insert_fields: &str,
501    foreign_keys: &[ForeignKeyField],
502) -> String {
503    // Build FK imports
504    let fk_imports: String = foreign_keys
505        .iter()
506        .filter(|fk| fk.validated)
507        .map(|fk| {
508            format!(
509                "use crate::models::{}::{{Entity as {}Entity, Model as {}}};\n",
510                fk.target_table.trim_end_matches('s'), // singularize for module name
511                fk.target_model,
512                fk.target_model
513            )
514        })
515        .collect();
516
517    // Build props for related data in Index
518    let fk_index_props: String = foreign_keys
519        .iter()
520        .filter(|fk| fk.validated)
521        .map(|fk| format!("    pub {}: Vec<{}>,\n", fk.target_table, fk.target_model))
522        .collect();
523
524    // Build fetching code for index
525    let fk_index_fetches: String = foreign_keys
526        .iter()
527        .filter(|fk| fk.validated)
528        .map(|fk| {
529            format!(
530                "    let {} = {}Entity::find().all(db).await\n        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?;\n",
531                fk.target_table,
532                fk.target_model
533            )
534        })
535        .collect();
536
537    // Build props assignment for index
538    let fk_index_props_assign: String = foreign_keys
539        .iter()
540        .filter(|fk| fk.validated)
541        .map(|fk| format!(", {}", fk.target_table))
542        .collect();
543
544    // Build props for Create page
545    let fk_create_props: String = foreign_keys
546        .iter()
547        .filter(|fk| fk.validated)
548        .map(|fk| format!("    pub {}: Vec<{}>,\n", fk.target_table, fk.target_model))
549        .collect();
550
551    // Build fetching code for create
552    let fk_create_fetches: String = foreign_keys
553        .iter()
554        .filter(|fk| fk.validated)
555        .map(|fk| {
556            format!(
557                "    let {} = {}Entity::find().all(db).await\n        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?;\n",
558                fk.target_table,
559                fk.target_model
560            )
561        })
562        .collect();
563
564    // Build props assignment for create
565    let fk_create_props_assign: String = foreign_keys
566        .iter()
567        .filter(|fk| fk.validated)
568        .map(|fk| format!(", {}", fk.target_table))
569        .collect();
570
571    // Build props for Edit page
572    let fk_edit_props: String = foreign_keys
573        .iter()
574        .filter(|fk| fk.validated)
575        .map(|fk| format!("    pub {}: Vec<{}>,\n", fk.target_table, fk.target_model))
576        .collect();
577
578    // Build fetching code for edit
579    let fk_edit_fetches: String = foreign_keys
580        .iter()
581        .filter(|fk| fk.validated)
582        .map(|fk| {
583            format!(
584                "    let {} = {}Entity::find().all(db).await\n        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?;\n",
585                fk.target_table,
586                fk.target_model
587            )
588        })
589        .collect();
590
591    // Build props assignment for edit
592    let fk_edit_props_assign: String = foreign_keys
593        .iter()
594        .filter(|fk| fk.validated)
595        .map(|fk| format!(", {}", fk.target_table))
596        .collect();
597
598    // Generate validated FK comment if there are unvalidated FKs
599    let unvalidated_fks: Vec<_> = foreign_keys.iter().filter(|fk| !fk.validated).collect();
600    let unvalidated_comment = if !unvalidated_fks.is_empty() {
601        let fk_list: String = unvalidated_fks
602            .iter()
603            .map(|fk| {
604                format!(
605                    "// - {} (model {} not found)",
606                    fk.field_name, fk.target_model
607                )
608            })
609            .collect::<Vec<_>>()
610            .join("\n");
611        format!(
612            "\n// TODO: The following FK fields have no corresponding model:\n{fk_list}\n// Create these models to enable relationship loading.\n"
613        )
614    } else {
615        String::new()
616    };
617
618    format!(
619        r#"//! {name} controller
620//!
621//! Generated with `ferro make:scaffold`
622{unvalidated_comment}
623use ferro::{{
624    http::{{Request, Response, HttpResponse}},
625    inertia::{{Inertia, SavedInertiaContext}},
626    validation::Validatable,
627    ValidateRules,
628}};
629use sea_orm::{{EntityTrait, ActiveModelTrait, ActiveValue}};
630use serde::{{Deserialize, Serialize}};
631
632use crate::models::{snake_name}::{{self, Entity, Model as {name}}};
633{fk_imports}
634#[derive(Debug, Deserialize, Serialize, ValidateRules)]
635pub struct {name}Form {{
636{form_fields}}}
637
638#[derive(Debug, Serialize)]
639pub struct {plural_pascal}IndexProps {{
640    pub {plural}: Vec<{name}>,
641{fk_index_props}}}
642
643#[derive(Debug, Serialize)]
644pub struct {name}ShowProps {{
645    pub {snake}: {name},
646}}
647
648#[derive(Debug, Serialize)]
649pub struct {name}CreateProps {{
650    pub errors: Option<std::collections::HashMap<String, Vec<String>>>,
651{fk_create_props}}}
652
653#[derive(Debug, Serialize)]
654pub struct {name}EditProps {{
655    pub {snake}: {name},
656    pub errors: Option<std::collections::HashMap<String, Vec<String>>>,
657{fk_edit_props}}}
658
659/// List all {plural}
660pub async fn index(req: Request) -> Response {{
661    let db = req.db();
662    let {plural} = {snake_name}::Entity::find()
663        .all(db)
664        .await
665        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?;
666
667{fk_index_fetches}
668    Inertia::render(&req, "{plural_pascal}/Index", {plural_pascal}IndexProps {{ {plural}{fk_index_props_assign} }})
669}}
670
671/// Show a single {snake}
672pub async fn show(req: Request, id: i64) -> Response {{
673    let db = req.db();
674    let {snake} = {snake_name}::Entity::find_by_id(id)
675        .one(db)
676        .await
677        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?
678        .ok_or_else(|| HttpResponse::not_found("{name} not found"))?;
679
680    Inertia::render(&req, "{plural_pascal}/Show", {name}ShowProps {{ {snake} }})
681}}
682
683/// Show create form
684pub async fn create(req: Request) -> Response {{
685    let db = req.db();
686{fk_create_fetches}
687    Inertia::render(&req, "{plural_pascal}/Create", {name}CreateProps {{ errors: None{fk_create_props_assign} }})
688}}
689
690/// Store a new {snake}
691pub async fn store(req: Request) -> Response {{
692    let ctx = SavedInertiaContext::from(&req);
693    let db = req.db();
694    let form: {name}Form = req.input().await.map_err(|e| {{
695        HttpResponse::bad_request(format!("Invalid form data: {{}}", e))
696    }})?;
697
698    // Validate using derive macro
699    if let Err(errors) = form.validate() {{
700{fk_create_fetches}        return Inertia::render_ctx(&ctx, "{plural_pascal}/Create", {name}CreateProps {{
701            errors: Some(errors.into_messages()){fk_create_props_assign}
702        }});
703    }}
704
705    let model = {snake_name}::ActiveModel {{
706        id: ActiveValue::NotSet,
707{insert_fields}        created_at: ActiveValue::Set(chrono::Utc::now()),
708        updated_at: ActiveValue::Set(chrono::Utc::now()),
709    }};
710
711    let result = model.insert(db).await
712        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?;
713
714    HttpResponse::redirect(&format!("/{plural}/{{}}", result.id))
715}}
716
717/// Show edit form
718pub async fn edit(req: Request, id: i64) -> Response {{
719    let db = req.db();
720    let {snake} = {snake_name}::Entity::find_by_id(id)
721        .one(db)
722        .await
723        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?
724        .ok_or_else(|| HttpResponse::not_found("{name} not found"))?;
725
726{fk_edit_fetches}
727    Inertia::render(&req, "{plural_pascal}/Edit", {name}EditProps {{ {snake}, errors: None{fk_edit_props_assign} }})
728}}
729
730/// Update an existing {snake}
731pub async fn update(req: Request, id: i64) -> Response {{
732    let ctx = SavedInertiaContext::from(&req);
733    let db = req.db();
734    let form: {name}Form = req.input().await.map_err(|e| {{
735        HttpResponse::bad_request(format!("Invalid form data: {{}}", e))
736    }})?;
737
738    let {snake} = {snake_name}::Entity::find_by_id(id)
739        .one(db)
740        .await
741        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?
742        .ok_or_else(|| HttpResponse::not_found("{name} not found"))?;
743
744    // Validate using derive macro
745    if let Err(errors) = form.validate() {{
746{fk_edit_fetches}        return Inertia::render_ctx(&ctx, "{plural_pascal}/Edit", {name}EditProps {{
747            {snake},
748            errors: Some(errors.into_messages()){fk_edit_props_assign}
749        }});
750    }}
751
752    {snake}
753        .update()
754{update_fields}        .save()
755        .await
756        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?;
757
758    HttpResponse::redirect(&format!("/{plural}/{{}}", id))
759}}
760
761/// Delete a {snake}
762pub async fn destroy(req: Request, id: i64) -> Response {{
763    let db = req.db();
764    {snake_name}::Entity::delete_by_id(id)
765        .exec(db)
766        .await
767        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?;
768
769    HttpResponse::redirect("/{plural}")
770}}
771"#,
772        name = name,
773        snake = snake_name,
774        snake_name = snake_name,
775        plural = plural_snake,
776        plural_pascal = to_pascal_case(plural_snake),
777        form_fields = form_fields,
778        update_fields = update_fields,
779        insert_fields = insert_fields,
780        fk_imports = fk_imports,
781        fk_index_props = fk_index_props,
782        fk_index_fetches = fk_index_fetches,
783        fk_index_props_assign = fk_index_props_assign,
784        fk_create_props = fk_create_props,
785        fk_create_fetches = fk_create_fetches,
786        fk_create_props_assign = fk_create_props_assign,
787        fk_edit_props = fk_edit_props,
788        fk_edit_fetches = fk_edit_fetches,
789        fk_edit_props_assign = fk_edit_props_assign,
790        unvalidated_comment = unvalidated_comment,
791    )
792}
793
794/// Template for generating full-stack controller without FK relationships
795pub fn scaffold_controller_template(
796    name: &str,
797    snake_name: &str,
798    plural_snake: &str,
799    form_fields: &str,
800    update_fields: &str,
801    insert_fields: &str,
802) -> String {
803    format!(
804        r#"//! {name} controller
805//!
806//! Generated with `ferro make:scaffold`
807
808use ferro::{{
809    http::{{Request, Response, HttpResponse}},
810    inertia::{{Inertia, SavedInertiaContext}},
811    validation::Validatable,
812    ValidateRules,
813}};
814use sea_orm::{{EntityTrait, ActiveModelTrait, ActiveValue}};
815use serde::{{Deserialize, Serialize}};
816
817use crate::models::{snake_name}::{{self, Entity, Model as {name}}};
818
819#[derive(Debug, Deserialize, Serialize, ValidateRules)]
820pub struct {name}Form {{
821{form_fields}}}
822
823#[derive(Debug, Serialize)]
824pub struct {plural_pascal}IndexProps {{
825    pub {plural}: Vec<{name}>,
826}}
827
828#[derive(Debug, Serialize)]
829pub struct {name}ShowProps {{
830    pub {snake}: {name},
831}}
832
833#[derive(Debug, Serialize)]
834pub struct {name}CreateProps {{
835    pub errors: Option<std::collections::HashMap<String, Vec<String>>>,
836}}
837
838#[derive(Debug, Serialize)]
839pub struct {name}EditProps {{
840    pub {snake}: {name},
841    pub errors: Option<std::collections::HashMap<String, Vec<String>>>,
842}}
843
844/// List all {plural}
845pub async fn index(req: Request) -> Response {{
846    let db = req.db();
847    let {plural} = {snake_name}::Entity::find()
848        .all(db)
849        .await
850        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?;
851
852    Inertia::render(&req, "{plural_pascal}/Index", {plural_pascal}IndexProps {{ {plural} }})
853}}
854
855/// Show a single {snake}
856pub async fn show(req: Request, id: i64) -> Response {{
857    let db = req.db();
858    let {snake} = {snake_name}::Entity::find_by_id(id)
859        .one(db)
860        .await
861        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?
862        .ok_or_else(|| HttpResponse::not_found("{name} not found"))?;
863
864    Inertia::render(&req, "{plural_pascal}/Show", {name}ShowProps {{ {snake} }})
865}}
866
867/// Show create form
868pub async fn create(req: Request) -> Response {{
869    Inertia::render(&req, "{plural_pascal}/Create", {name}CreateProps {{ errors: None }})
870}}
871
872/// Store a new {snake}
873pub async fn store(req: Request) -> Response {{
874    let ctx = SavedInertiaContext::from(&req);
875    let form: {name}Form = req.input().await.map_err(|e| {{
876        HttpResponse::bad_request(format!("Invalid form data: {{}}", e))
877    }})?;
878
879    // Validate using derive macro
880    if let Err(errors) = form.validate() {{
881        return Inertia::render_ctx(&ctx, "{plural_pascal}/Create", {name}CreateProps {{
882            errors: Some(errors.into_messages()),
883        }});
884    }}
885
886    let db = req.db();
887    let model = {snake_name}::ActiveModel {{
888        id: ActiveValue::NotSet,
889{insert_fields}        created_at: ActiveValue::Set(chrono::Utc::now()),
890        updated_at: ActiveValue::Set(chrono::Utc::now()),
891    }};
892
893    let result = model.insert(db).await
894        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?;
895
896    HttpResponse::redirect(&format!("/{plural}/{{}}", result.id))
897}}
898
899/// Show edit form
900pub async fn edit(req: Request, id: i64) -> Response {{
901    let db = req.db();
902    let {snake} = {snake_name}::Entity::find_by_id(id)
903        .one(db)
904        .await
905        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?
906        .ok_or_else(|| HttpResponse::not_found("{name} not found"))?;
907
908    Inertia::render(&req, "{plural_pascal}/Edit", {name}EditProps {{ {snake}, errors: None }})
909}}
910
911/// Update an existing {snake}
912pub async fn update(req: Request, id: i64) -> Response {{
913    let ctx = SavedInertiaContext::from(&req);
914    let form: {name}Form = req.input().await.map_err(|e| {{
915        HttpResponse::bad_request(format!("Invalid form data: {{}}", e))
916    }})?;
917
918    let db = req.db();
919    let {snake} = {snake_name}::Entity::find_by_id(id)
920        .one(db)
921        .await
922        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?
923        .ok_or_else(|| HttpResponse::not_found("{name} not found"))?;
924
925    // Validate using derive macro
926    if let Err(errors) = form.validate() {{
927        return Inertia::render_ctx(&ctx, "{plural_pascal}/Edit", {name}EditProps {{
928            {snake},
929            errors: Some(errors.into_messages()),
930        }});
931    }}
932
933    {snake}
934        .update()
935{update_fields}        .save()
936        .await
937        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?;
938
939    HttpResponse::redirect(&format!("/{plural}/{{}}", id))
940}}
941
942/// Delete a {snake}
943pub async fn destroy(req: Request, id: i64) -> Response {{
944    let db = req.db();
945    {snake_name}::Entity::delete_by_id(id)
946        .exec(db)
947        .await
948        .map_err(|e| HttpResponse::internal_server_error(e.to_string()))?;
949
950    HttpResponse::redirect("/{plural}")
951}}
952"#,
953        name = name,
954        snake = snake_name,
955        snake_name = snake_name,
956        plural = plural_snake,
957        plural_pascal = to_pascal_case(plural_snake),
958        form_fields = form_fields,
959        update_fields = update_fields,
960        insert_fields = insert_fields,
961    )
962}
963
964// ============================================================================
965// API Controller Template
966// ============================================================================
967
968/// Template for generating API-only controller with make:scaffold --api
969pub fn api_controller_template(
970    name: &str,
971    snake_name: &str,
972    plural_snake: &str,
973    form_fields: &str,
974    update_fields: &str,
975    insert_fields: &str,
976) -> String {
977    format!(
978        r#"//! {name} API controller
979//!
980//! Generated with `ferro make:scaffold --api`
981
982use ferro::{{handler, json_response, Request, Response}};
983use crate::models::{snake_name}::{{self, Column, Entity, Model as {name}}};
984use sea_orm::{{ColumnTrait, EntityTrait, QueryFilter}};
985
986/// Form data for creating/updating {name}
987#[derive(serde::Deserialize)]
988pub struct {name}Form {{
989{form_fields}
990}}
991
992/// List all {plural_snake}
993///
994/// GET /{plural_snake}
995#[handler]
996pub async fn index(req: Request) -> Response {{
997    let db = req.db();
998    let {plural_snake} = Entity::find().all(db).await.map_err(|e| {{
999        tracing::error!("Failed to fetch {plural_snake}: {{:?}}", e);
1000        ferro::error_response!(500, "Failed to fetch {plural_snake}")
1001    }})?;
1002
1003    let total = {plural_snake}.len();
1004
1005    json_response!({{
1006        "data": {plural_snake},
1007        "meta": {{
1008            "total": total
1009        }}
1010    }})
1011}}
1012
1013/// Get a single {snake_name}
1014///
1015/// GET /{plural_snake}/{{id}}
1016#[handler]
1017pub async fn show(req: Request) -> Response {{
1018    let db = req.db();
1019    let id: i64 = req.param("id").unwrap_or_default();
1020
1021    let {snake_name} = Entity::find_by_id(id as i32)
1022        .one(db)
1023        .await
1024        .map_err(|e| {{
1025            tracing::error!("Failed to fetch {snake_name}: {{:?}}", e);
1026            ferro::error_response!(500, "Failed to fetch {snake_name}")
1027        }})?
1028        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
1029
1030    json_response!({{
1031        "data": {snake_name}
1032    }})
1033}}
1034
1035/// Create a new {snake_name}
1036///
1037/// POST /{plural_snake}
1038#[handler]
1039pub async fn store(req: Request) -> Response {{
1040    let db = req.db();
1041    let form: {name}Form = req.input().await?;
1042
1043    let {snake_name} = {snake_name}::ActiveModel {{
1044{insert_fields}
1045        ..Default::default()
1046    }};
1047
1048    let result = Entity::insert({snake_name})
1049        .exec(db)
1050        .await
1051        .map_err(|e| {{
1052            tracing::error!("Failed to create {snake_name}: {{:?}}", e);
1053            ferro::error_response!(500, "Failed to create {snake_name}")
1054        }})?;
1055
1056    let created = Entity::find_by_id(result.last_insert_id)
1057        .one(db)
1058        .await
1059        .map_err(|e| {{
1060            tracing::error!("Failed to fetch created {snake_name}: {{:?}}", e);
1061            ferro::error_response!(500, "Failed to fetch created {snake_name}")
1062        }})?
1063        .ok_or_else(|| ferro::error_response!(500, "Failed to retrieve created {snake_name}"))?;
1064
1065    json_response!({{
1066        "data": created,
1067        "message": "{name} created successfully"
1068    }})
1069}}
1070
1071/// Update an existing {snake_name}
1072///
1073/// PUT /{plural_snake}/{{id}}
1074#[handler]
1075pub async fn update(req: Request) -> Response {{
1076    let db = req.db();
1077    let id: i64 = req.param("id").unwrap_or_default();
1078    let form: {name}Form = req.input().await?;
1079
1080    let existing = Entity::find_by_id(id as i32)
1081        .one(db)
1082        .await
1083        .map_err(|e| {{
1084            tracing::error!("Failed to fetch {snake_name}: {{:?}}", e);
1085            ferro::error_response!(500, "Failed to fetch {snake_name}")
1086        }})?
1087        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
1088
1089    let updated = existing
1090        .update()
1091{update_fields}        .save()
1092        .await
1093        .map_err(|e| {{
1094            tracing::error!("Failed to update {snake_name}: {{:?}}", e);
1095            ferro::error_response!(500, "Failed to update {snake_name}")
1096        }})?;
1097
1098    json_response!({{
1099        "data": updated,
1100        "message": "{name} updated successfully"
1101    }})
1102}}
1103
1104/// Delete a {snake_name}
1105///
1106/// DELETE /{plural_snake}/{{id}}
1107#[handler]
1108pub async fn destroy(req: Request) -> Response {{
1109    let db = req.db();
1110    let id: i64 = req.param("id").unwrap_or_default();
1111
1112    let existing = Entity::find_by_id(id as i32)
1113        .one(db)
1114        .await
1115        .map_err(|e| {{
1116            tracing::error!("Failed to fetch {snake_name}: {{:?}}", e);
1117            ferro::error_response!(500, "Failed to fetch {snake_name}")
1118        }})?
1119        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
1120
1121    Entity::delete_by_id(existing.id)
1122        .exec(db)
1123        .await
1124        .map_err(|e| {{
1125            tracing::error!("Failed to delete {snake_name}: {{:?}}", e);
1126            ferro::error_response!(500, "Failed to delete {snake_name}")
1127        }})?;
1128
1129    json_response!({{
1130        "message": "{name} deleted successfully"
1131    }})
1132}}
1133"#,
1134    )
1135}
1136
1137/// Template for generating API-only controller with FK nested data support
1138pub fn api_controller_with_fk_template(
1139    name: &str,
1140    snake_name: &str,
1141    plural_snake: &str,
1142    form_fields: &str,
1143    update_fields: &str,
1144    insert_fields: &str,
1145    foreign_keys: &[ForeignKeyField],
1146) -> String {
1147    // Build FK imports for validated foreign keys
1148    let fk_imports: String = foreign_keys
1149        .iter()
1150        .filter(|fk| fk.validated)
1151        .map(|fk| {
1152            let target_snake = to_snake_case(&fk.target_model);
1153            format!(
1154                "use crate::models::{}::{{Entity as {}Entity, Model as {}}};\n",
1155                target_snake, fk.target_model, fk.target_model
1156            )
1157        })
1158        .collect();
1159
1160    // Build FK fetch code for index
1161    let fk_index_fetches: String = foreign_keys
1162        .iter()
1163        .filter(|fk| fk.validated)
1164        .map(|fk| {
1165            format!(
1166                r#"
1167    // Fetch {} for nested data
1168    let {}_map: std::collections::HashMap<i64, {}> = {}Entity::find()
1169        .all(db)
1170        .await
1171        .map_err(|e| {{
1172            tracing::error!("Failed to fetch {}: {{:?}}", e);
1173            ferro::error_response!(500, "Failed to fetch {}")
1174        }})?
1175        .into_iter()
1176        .map(|r| (r.id, r))
1177        .collect();
1178"#,
1179                fk.target_model,
1180                fk.target_table,
1181                fk.target_model,
1182                fk.target_model,
1183                fk.target_table,
1184                fk.target_table
1185            )
1186        })
1187        .collect();
1188
1189    // Build response data enrichment for index
1190    let fk_index_enrich: String = if foreign_keys.iter().any(|fk| fk.validated) {
1191        let enrichments: String = foreign_keys
1192            .iter()
1193            .filter(|fk| fk.validated)
1194            .map(|fk| {
1195                let target_snake = to_snake_case(&fk.target_model);
1196                format!(
1197                    r#"                "{target_snake}": {target_table}_map.get(&item.{fk_field}).cloned(),"#,
1198                    target_snake = target_snake,
1199                    target_table = fk.target_table,
1200                    fk_field = fk.field_name
1201                )
1202            })
1203            .collect::<Vec<_>>()
1204            .join("\n");
1205
1206        format!(
1207            r#"
1208    // Enrich data with related entities
1209    let enriched: Vec<serde_json::Value> = {plural_snake}
1210        .into_iter()
1211        .map(|item| {{
1212            serde_json::json!({{
1213                "id": item.id,
1214{enrichments}
1215                // Include all model fields
1216                ..serde_json::to_value(&item).unwrap_or_default().as_object().cloned().unwrap_or_default()
1217            }})
1218        }})
1219        .collect();
1220"#
1221        )
1222    } else {
1223        String::new()
1224    };
1225
1226    // Build FK fetch code for show
1227    let fk_show_fetches: String = foreign_keys
1228        .iter()
1229        .filter(|fk| fk.validated)
1230        .map(|fk| {
1231            let target_snake = to_snake_case(&fk.target_model);
1232            format!(
1233                r#"
1234    // Fetch related {target_model}
1235    let related_{target_snake} = {target_model}Entity::find_by_id({snake_name}.{fk_field})
1236        .one(db)
1237        .await
1238        .map_err(|e| {{
1239            tracing::error!("Failed to fetch related {target_model}: {{:?}}", e);
1240            ferro::error_response!(500, "Failed to fetch related {target_model}")
1241        }})?;
1242"#,
1243                target_model = fk.target_model,
1244                snake_name = snake_name,
1245                fk_field = fk.field_name,
1246                target_snake = target_snake,
1247            )
1248        })
1249        .collect();
1250
1251    // Build show response with nested data
1252    let fk_show_response: String = if foreign_keys.iter().any(|fk| fk.validated) {
1253        let nested_fields: String = foreign_keys
1254            .iter()
1255            .filter(|fk| fk.validated)
1256            .map(|fk| {
1257                let target_snake = to_snake_case(&fk.target_model);
1258                format!(r#"            "{target_snake}": related_{target_snake},"#)
1259            })
1260            .collect::<Vec<_>>()
1261            .join("\n");
1262
1263        format!(
1264            r#"json_response!({{
1265        "data": {{
1266            ..serde_json::to_value(&{snake_name}).unwrap_or_default().as_object().cloned().unwrap_or_default(),
1267{nested_fields}
1268        }}
1269    }})"#
1270        )
1271    } else {
1272        format!(
1273            r#"json_response!({{
1274        "data": {snake_name}
1275    }})"#
1276        )
1277    };
1278
1279    // Generate validated FK comment if there are unvalidated FKs
1280    let unvalidated_fks: Vec<_> = foreign_keys.iter().filter(|fk| !fk.validated).collect();
1281    let unvalidated_comment = if !unvalidated_fks.is_empty() {
1282        let fk_list: String = unvalidated_fks
1283            .iter()
1284            .map(|fk| {
1285                format!(
1286                    "// - {} (model {} not found)",
1287                    fk.field_name, fk.target_model
1288                )
1289            })
1290            .collect::<Vec<_>>()
1291            .join("\n");
1292        format!(
1293            "\n// TODO: The following FK fields have no corresponding model:\n{fk_list}\n// Create these models to enable nested data in responses.\n"
1294        )
1295    } else {
1296        String::new()
1297    };
1298
1299    // Determine if we use enriched data or raw data in index
1300    let has_validated_fks = foreign_keys.iter().any(|fk| fk.validated);
1301    let index_data_var = if has_validated_fks {
1302        "enriched"
1303    } else {
1304        plural_snake
1305    };
1306
1307    format!(
1308        r#"//! {name} API controller
1309//!
1310//! Generated with `ferro make:scaffold --api`
1311{unvalidated_comment}
1312use ferro::{{handler, json_response, Request, Response}};
1313use crate::models::{snake_name}::{{self, Column, Entity, Model as {name}}};
1314use sea_orm::{{ColumnTrait, EntityTrait, QueryFilter}};
1315{fk_imports}
1316/// Form data for creating/updating {name}
1317#[derive(serde::Deserialize)]
1318pub struct {name}Form {{
1319{form_fields}
1320}}
1321
1322/// List all {plural_snake} with nested related data
1323///
1324/// GET /{plural_snake}
1325#[handler]
1326pub async fn index(req: Request) -> Response {{
1327    let db = req.db();
1328    let {plural_snake} = Entity::find().all(db).await.map_err(|e| {{
1329        tracing::error!("Failed to fetch {plural_snake}: {{:?}}", e);
1330        ferro::error_response!(500, "Failed to fetch {plural_snake}")
1331    }})?;
1332{fk_index_fetches}{fk_index_enrich}
1333    let total = {index_data_var}.len();
1334
1335    json_response!({{
1336        "data": {index_data_var},
1337        "meta": {{
1338            "total": total
1339        }}
1340    }})
1341}}
1342
1343/// Get a single {snake_name} with nested related data
1344///
1345/// GET /{plural_snake}/{{id}}
1346#[handler]
1347pub async fn show(req: Request) -> Response {{
1348    let db = req.db();
1349    let id: i64 = req.param("id").unwrap_or_default();
1350
1351    let {snake_name} = Entity::find_by_id(id as i32)
1352        .one(db)
1353        .await
1354        .map_err(|e| {{
1355            tracing::error!("Failed to fetch {snake_name}: {{:?}}", e);
1356            ferro::error_response!(500, "Failed to fetch {snake_name}")
1357        }})?
1358        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
1359{fk_show_fetches}
1360    {fk_show_response}
1361}}
1362
1363/// Create a new {snake_name}
1364///
1365/// POST /{plural_snake}
1366#[handler]
1367pub async fn store(req: Request) -> Response {{
1368    let db = req.db();
1369    let form: {name}Form = req.input().await?;
1370
1371    let {snake_name} = {snake_name}::ActiveModel {{
1372{insert_fields}
1373        ..Default::default()
1374    }};
1375
1376    let result = Entity::insert({snake_name})
1377        .exec(db)
1378        .await
1379        .map_err(|e| {{
1380            tracing::error!("Failed to create {snake_name}: {{:?}}", e);
1381            ferro::error_response!(500, "Failed to create {snake_name}")
1382        }})?;
1383
1384    let created = Entity::find_by_id(result.last_insert_id)
1385        .one(db)
1386        .await
1387        .map_err(|e| {{
1388            tracing::error!("Failed to fetch created {snake_name}: {{:?}}", e);
1389            ferro::error_response!(500, "Failed to fetch created {snake_name}")
1390        }})?
1391        .ok_or_else(|| ferro::error_response!(500, "Failed to retrieve created {snake_name}"))?;
1392
1393    json_response!({{
1394        "data": created,
1395        "message": "{name} created successfully"
1396    }})
1397}}
1398
1399/// Update an existing {snake_name}
1400///
1401/// PUT /{plural_snake}/{{id}}
1402#[handler]
1403pub async fn update(req: Request) -> Response {{
1404    let db = req.db();
1405    let id: i64 = req.param("id").unwrap_or_default();
1406    let form: {name}Form = req.input().await?;
1407
1408    let existing = Entity::find_by_id(id as i32)
1409        .one(db)
1410        .await
1411        .map_err(|e| {{
1412            tracing::error!("Failed to fetch {snake_name}: {{:?}}", e);
1413            ferro::error_response!(500, "Failed to fetch {snake_name}")
1414        }})?
1415        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
1416
1417    let updated = existing
1418        .update()
1419{update_fields}        .save()
1420        .await
1421        .map_err(|e| {{
1422            tracing::error!("Failed to update {snake_name}: {{:?}}", e);
1423            ferro::error_response!(500, "Failed to update {snake_name}")
1424        }})?;
1425
1426    json_response!({{
1427        "data": updated,
1428        "message": "{name} updated successfully"
1429    }})
1430}}
1431
1432/// Delete a {snake_name}
1433///
1434/// DELETE /{plural_snake}/{{id}}
1435#[handler]
1436pub async fn destroy(req: Request) -> Response {{
1437    let db = req.db();
1438    let id: i64 = req.param("id").unwrap_or_default();
1439
1440    let existing = Entity::find_by_id(id as i32)
1441        .one(db)
1442        .await
1443        .map_err(|e| {{
1444            tracing::error!("Failed to fetch {snake_name}: {{:?}}", e);
1445            ferro::error_response!(500, "Failed to fetch {snake_name}")
1446        }})?
1447        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
1448
1449    Entity::delete_by_id(existing.id)
1450        .exec(db)
1451        .await
1452        .map_err(|e| {{
1453            tracing::error!("Failed to delete {snake_name}: {{:?}}", e);
1454            ferro::error_response!(500, "Failed to delete {snake_name}")
1455        }})?;
1456
1457    json_response!({{
1458        "message": "{name} deleted successfully"
1459    }})
1460}}
1461"#,
1462    )
1463}