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| ferro::error_response!(500, 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| ferro::error_response!(500, 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| ferro::error_response!(500, 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    database::{{Model as DatabaseModel, ModelMut}},
625    http::{{Request, Response}},
626    inertia::{{Inertia, SavedInertiaContext}},
627    validation::Validatable,
628    ActiveValue, ValidateRules,
629}};
630use sea_orm::Set;
631use serde::{{Deserialize, Serialize}};
632
633use crate::models::{snake_name}::{{self, Entity, Model as {name}}};
634{fk_imports}
635#[derive(Debug, Deserialize, Serialize, ValidateRules)]
636pub struct {name}Form {{
637{form_fields}}}
638
639#[derive(Debug, Serialize)]
640pub struct {plural_pascal}IndexProps {{
641    pub {plural}: Vec<{name}>,
642{fk_index_props}}}
643
644#[derive(Debug, Serialize)]
645pub struct {name}ShowProps {{
646    pub {snake}: {name},
647}}
648
649#[derive(Debug, Serialize)]
650pub struct {name}CreateProps {{
651    pub errors: Option<std::collections::HashMap<String, Vec<String>>>,
652{fk_create_props}}}
653
654#[derive(Debug, Serialize)]
655pub struct {name}EditProps {{
656    pub {snake}: {name},
657    pub errors: Option<std::collections::HashMap<String, Vec<String>>>,
658{fk_edit_props}}}
659
660/// List all {plural}
661pub async fn index(req: Request) -> Response {{
662    let {plural} = {snake_name}::Entity::all()
663        .await
664        .map_err(|e| ferro::error_response!(500, e.to_string()))?;
665
666{fk_index_fetches}
667    Inertia::render(&req, "{plural_pascal}/Index", {plural_pascal}IndexProps {{ {plural}{fk_index_props_assign} }})
668}}
669
670/// Show a single {snake}
671pub async fn show(req: Request, id: i64) -> Response {{
672    let {snake} = {snake_name}::Entity::find_by_pk(id)
673        .await
674        .map_err(|e| ferro::error_response!(500, e.to_string()))?
675        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
676
677    Inertia::render(&req, "{plural_pascal}/Show", {name}ShowProps {{ {snake} }})
678}}
679
680/// Show create form
681pub async fn create(req: Request) -> Response {{
682{fk_create_fetches}
683    Inertia::render(&req, "{plural_pascal}/Create", {name}CreateProps {{ errors: None{fk_create_props_assign} }})
684}}
685
686/// Store a new {snake}
687pub async fn store(req: Request) -> Response {{
688    let ctx = SavedInertiaContext::from(&req);
689    let form: {name}Form = req.input().await.map_err(|e| {{
690        ferro::error_response!(400, format!("Invalid form data: {{}}", e))
691    }})?;
692
693    // Validate using derive macro
694    if let Err(errors) = form.validate() {{
695{fk_create_fetches}        return Inertia::render_ctx(&ctx, "{plural_pascal}/Create", {name}CreateProps {{
696            errors: Some(errors.into_messages()){fk_create_props_assign}
697        }});
698    }}
699
700    let model = {snake_name}::ActiveModel {{
701        id: ActiveValue::NotSet,
702{insert_fields}        created_at: ActiveValue::Set(chrono::Utc::now()),
703        updated_at: ActiveValue::Set(chrono::Utc::now()),
704    }};
705
706    let result = {snake_name}::Entity::insert_one(model)
707        .await
708        .map_err(|e| ferro::error_response!(500, e.to_string()))?;
709
710    Inertia::redirect_ctx(&ctx, format!("/{plural}/{{}}", result.id))
711}}
712
713/// Show edit form
714pub async fn edit(req: Request, id: i64) -> Response {{
715    let {snake} = {snake_name}::Entity::find_by_pk(id)
716        .await
717        .map_err(|e| ferro::error_response!(500, e.to_string()))?
718        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
719
720{fk_edit_fetches}
721    Inertia::render(&req, "{plural_pascal}/Edit", {name}EditProps {{ {snake}, errors: None{fk_edit_props_assign} }})
722}}
723
724/// Update an existing {snake}
725pub async fn update(req: Request, id: i64) -> Response {{
726    let ctx = SavedInertiaContext::from(&req);
727    let form: {name}Form = req.input().await.map_err(|e| {{
728        ferro::error_response!(400, format!("Invalid form data: {{}}", e))
729    }})?;
730
731    let {snake} = {snake_name}::Entity::find_by_pk(id)
732        .await
733        .map_err(|e| ferro::error_response!(500, e.to_string()))?
734        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
735
736    // Validate using derive macro
737    if let Err(errors) = form.validate() {{
738{fk_edit_fetches}        return Inertia::render_ctx(&ctx, "{plural_pascal}/Edit", {name}EditProps {{
739            {snake},
740            errors: Some(errors.into_messages()){fk_edit_props_assign}
741        }});
742    }}
743
744    let mut active: {snake_name}::ActiveModel = {snake}.into();
745{update_fields}
746    {snake_name}::Entity::update_one(active)
747        .await
748        .map_err(|e| ferro::error_response!(500, e.to_string()))?;
749
750    Inertia::redirect_ctx(&ctx, format!("/{plural}/{{}}", id))
751}}
752
753/// Delete a {snake}
754pub async fn destroy(req: Request, id: i64) -> Response {{
755    {snake_name}::Entity::delete_by_pk(id)
756        .await
757        .map_err(|e| ferro::error_response!(500, e.to_string()))?;
758
759    Inertia::redirect(&req, "/{plural}")
760}}
761"#,
762        name = name,
763        snake = snake_name,
764        snake_name = snake_name,
765        plural = plural_snake,
766        plural_pascal = to_pascal_case(plural_snake),
767        form_fields = form_fields,
768        update_fields = update_fields,
769        insert_fields = insert_fields,
770        fk_imports = fk_imports,
771        fk_index_props = fk_index_props,
772        fk_index_fetches = fk_index_fetches,
773        fk_index_props_assign = fk_index_props_assign,
774        fk_create_props = fk_create_props,
775        fk_create_fetches = fk_create_fetches,
776        fk_create_props_assign = fk_create_props_assign,
777        fk_edit_props = fk_edit_props,
778        fk_edit_fetches = fk_edit_fetches,
779        fk_edit_props_assign = fk_edit_props_assign,
780        unvalidated_comment = unvalidated_comment,
781    )
782}
783
784/// Template for generating full-stack controller without FK relationships
785pub fn scaffold_controller_template(
786    name: &str,
787    snake_name: &str,
788    plural_snake: &str,
789    form_fields: &str,
790    update_fields: &str,
791    insert_fields: &str,
792) -> String {
793    format!(
794        r#"//! {name} controller
795//!
796//! Generated with `ferro make:scaffold`
797
798use ferro::{{
799    database::{{Model as DatabaseModel, ModelMut}},
800    http::{{Request, Response}},
801    inertia::{{Inertia, SavedInertiaContext}},
802    validation::Validatable,
803    ActiveValue, ValidateRules,
804}};
805use sea_orm::Set;
806use serde::{{Deserialize, Serialize}};
807
808use crate::models::{snake_name}::{{self, Entity, Model as {name}}};
809
810#[derive(Debug, Deserialize, Serialize, ValidateRules)]
811pub struct {name}Form {{
812{form_fields}}}
813
814#[derive(Debug, Serialize)]
815pub struct {plural_pascal}IndexProps {{
816    pub {plural}: Vec<{name}>,
817}}
818
819#[derive(Debug, Serialize)]
820pub struct {name}ShowProps {{
821    pub {snake}: {name},
822}}
823
824#[derive(Debug, Serialize)]
825pub struct {name}CreateProps {{
826    pub errors: Option<std::collections::HashMap<String, Vec<String>>>,
827}}
828
829#[derive(Debug, Serialize)]
830pub struct {name}EditProps {{
831    pub {snake}: {name},
832    pub errors: Option<std::collections::HashMap<String, Vec<String>>>,
833}}
834
835/// List all {plural}
836pub async fn index(req: Request) -> Response {{
837    let {plural} = {snake_name}::Entity::all()
838        .await
839        .map_err(|e| ferro::error_response!(500, e.to_string()))?;
840
841    Inertia::render(&req, "{plural_pascal}/Index", {plural_pascal}IndexProps {{ {plural} }})
842}}
843
844/// Show a single {snake}
845pub async fn show(req: Request, id: i64) -> Response {{
846    let {snake} = {snake_name}::Entity::find_by_pk(id)
847        .await
848        .map_err(|e| ferro::error_response!(500, e.to_string()))?
849        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
850
851    Inertia::render(&req, "{plural_pascal}/Show", {name}ShowProps {{ {snake} }})
852}}
853
854/// Show create form
855pub async fn create(req: Request) -> Response {{
856    Inertia::render(&req, "{plural_pascal}/Create", {name}CreateProps {{ errors: None }})
857}}
858
859/// Store a new {snake}
860pub async fn store(req: Request) -> Response {{
861    let ctx = SavedInertiaContext::from(&req);
862    let form: {name}Form = req.input().await.map_err(|e| {{
863        ferro::error_response!(400, format!("Invalid form data: {{}}", e))
864    }})?;
865
866    // Validate using derive macro
867    if let Err(errors) = form.validate() {{
868        return Inertia::render_ctx(&ctx, "{plural_pascal}/Create", {name}CreateProps {{
869            errors: Some(errors.into_messages()),
870        }});
871    }}
872
873    let model = {snake_name}::ActiveModel {{
874        id: ActiveValue::NotSet,
875{insert_fields}        created_at: ActiveValue::Set(chrono::Utc::now()),
876        updated_at: ActiveValue::Set(chrono::Utc::now()),
877    }};
878
879    let result = {snake_name}::Entity::insert_one(model)
880        .await
881        .map_err(|e| ferro::error_response!(500, e.to_string()))?;
882
883    Inertia::redirect_ctx(&ctx, format!("/{plural}/{{}}", result.id))
884}}
885
886/// Show edit form
887pub async fn edit(req: Request, id: i64) -> Response {{
888    let {snake} = {snake_name}::Entity::find_by_pk(id)
889        .await
890        .map_err(|e| ferro::error_response!(500, e.to_string()))?
891        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
892
893    Inertia::render(&req, "{plural_pascal}/Edit", {name}EditProps {{ {snake}, errors: None }})
894}}
895
896/// Update an existing {snake}
897pub async fn update(req: Request, id: i64) -> Response {{
898    let ctx = SavedInertiaContext::from(&req);
899    let form: {name}Form = req.input().await.map_err(|e| {{
900        ferro::error_response!(400, format!("Invalid form data: {{}}", e))
901    }})?;
902
903    let {snake} = {snake_name}::Entity::find_by_pk(id)
904        .await
905        .map_err(|e| ferro::error_response!(500, e.to_string()))?
906        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
907
908    // Validate using derive macro
909    if let Err(errors) = form.validate() {{
910        return Inertia::render_ctx(&ctx, "{plural_pascal}/Edit", {name}EditProps {{
911            {snake},
912            errors: Some(errors.into_messages()),
913        }});
914    }}
915
916    let mut active: {snake_name}::ActiveModel = {snake}.into();
917{update_fields}
918    {snake_name}::Entity::update_one(active)
919        .await
920        .map_err(|e| ferro::error_response!(500, e.to_string()))?;
921
922    Inertia::redirect_ctx(&ctx, format!("/{plural}/{{}}", id))
923}}
924
925/// Delete a {snake}
926pub async fn destroy(req: Request, id: i64) -> Response {{
927    {snake_name}::Entity::delete_by_pk(id)
928        .await
929        .map_err(|e| ferro::error_response!(500, e.to_string()))?;
930
931    Inertia::redirect(&req, "/{plural}")
932}}
933"#,
934        name = name,
935        snake = snake_name,
936        snake_name = snake_name,
937        plural = plural_snake,
938        plural_pascal = to_pascal_case(plural_snake),
939        form_fields = form_fields,
940        update_fields = update_fields,
941        insert_fields = insert_fields,
942    )
943}
944
945// ============================================================================
946// API Controller Template
947// ============================================================================
948
949/// Template for generating API-only controller with make:scaffold --api
950pub fn api_controller_template(
951    name: &str,
952    snake_name: &str,
953    plural_snake: &str,
954    form_fields: &str,
955    update_fields: &str,
956    insert_fields: &str,
957) -> String {
958    format!(
959        r#"//! {name} API controller
960//!
961//! Generated with `ferro make:scaffold --api`
962
963use ferro::{{
964    database::{{Model as DatabaseModel, ModelMut}},
965    handler, json_response, ActiveValue, Request, Response, ValidateRules,
966}};
967use sea_orm::Set;
968use crate::models::{snake_name}::{{self, Entity, Model as {name}}};
969
970/// Form data for creating/updating {name}
971#[derive(Debug, serde::Deserialize, serde::Serialize, ValidateRules)]
972pub struct {name}Form {{
973{form_fields}
974}}
975
976/// List all {plural_snake}
977///
978/// GET /{plural_snake}
979#[handler]
980pub async fn index(_req: Request) -> Response {{
981    let {plural_snake} = Entity::all().await.map_err(|e| {{
982        tracing::error!("Failed to fetch {plural_snake}: {{:?}}", e);
983        ferro::error_response!(500, "Failed to fetch {plural_snake}")
984    }})?;
985
986    let total = {plural_snake}.len();
987
988    json_response!({{
989        "data": {plural_snake},
990        "meta": {{
991            "total": total
992        }}
993    }})
994}}
995
996/// Get a single {snake_name}
997///
998/// GET /{plural_snake}/{{id}}
999#[handler]
1000pub async fn show(req: Request) -> Response {{
1001    let id: i64 = req.param_as::<i64>("id")
1002        .map_err(|_| ferro::error_response!(400, "Invalid id parameter"))?;
1003
1004    let {snake_name} = Entity::find_by_pk(id)
1005        .await
1006        .map_err(|e| {{
1007            tracing::error!("Failed to fetch {snake_name}: {{:?}}", e);
1008            ferro::error_response!(500, "Failed to fetch {snake_name}")
1009        }})?
1010        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
1011
1012    json_response!({{
1013        "data": {snake_name}
1014    }})
1015}}
1016
1017/// Create a new {snake_name}
1018///
1019/// POST /{plural_snake}
1020#[handler]
1021pub async fn store(req: Request) -> Response {{
1022    let form: {name}Form = req.input().await?;
1023
1024    let model = {snake_name}::ActiveModel {{
1025{insert_fields}
1026        ..Default::default()
1027    }};
1028
1029    let created = Entity::insert_one(model)
1030        .await
1031        .map_err(|e| {{
1032            tracing::error!("Failed to create {snake_name}: {{:?}}", e);
1033            ferro::error_response!(500, "Failed to create {snake_name}")
1034        }})?;
1035
1036    json_response!({{
1037        "data": created,
1038        "message": "{name} created successfully"
1039    }})
1040}}
1041
1042/// Update an existing {snake_name}
1043///
1044/// PUT /{plural_snake}/{{id}}
1045#[handler]
1046pub async fn update(req: Request) -> Response {{
1047    let id: i64 = req.param_as::<i64>("id")
1048        .map_err(|_| ferro::error_response!(400, "Invalid id parameter"))?;
1049    let form: {name}Form = req.input().await?;
1050
1051    let existing = Entity::find_by_pk(id)
1052        .await
1053        .map_err(|e| {{
1054            tracing::error!("Failed to fetch {snake_name}: {{:?}}", e);
1055            ferro::error_response!(500, "Failed to fetch {snake_name}")
1056        }})?
1057        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
1058
1059    let mut active: {snake_name}::ActiveModel = existing.into();
1060{update_fields}
1061    let updated = Entity::update_one(active)
1062        .await
1063        .map_err(|e| {{
1064            tracing::error!("Failed to update {snake_name}: {{:?}}", e);
1065            ferro::error_response!(500, "Failed to update {snake_name}")
1066        }})?;
1067
1068    json_response!({{
1069        "data": updated,
1070        "message": "{name} updated successfully"
1071    }})
1072}}
1073
1074/// Delete a {snake_name}
1075///
1076/// DELETE /{plural_snake}/{{id}}
1077#[handler]
1078pub async fn destroy(req: Request) -> Response {{
1079    let id: i64 = req.param_as::<i64>("id")
1080        .map_err(|_| ferro::error_response!(400, "Invalid id parameter"))?;
1081
1082    Entity::delete_by_pk(id)
1083        .await
1084        .map_err(|e| {{
1085            tracing::error!("Failed to delete {snake_name}: {{:?}}", e);
1086            ferro::error_response!(500, "Failed to delete {snake_name}")
1087        }})?;
1088
1089    json_response!({{
1090        "message": "{name} deleted successfully"
1091    }})
1092}}
1093"#,
1094    )
1095}
1096
1097/// Template for generating API-only controller with FK nested data support
1098pub fn api_controller_with_fk_template(
1099    name: &str,
1100    snake_name: &str,
1101    plural_snake: &str,
1102    form_fields: &str,
1103    update_fields: &str,
1104    insert_fields: &str,
1105    foreign_keys: &[ForeignKeyField],
1106) -> String {
1107    // Build FK imports for validated foreign keys
1108    let fk_imports: String = foreign_keys
1109        .iter()
1110        .filter(|fk| fk.validated)
1111        .map(|fk| {
1112            let target_snake = to_snake_case(&fk.target_model);
1113            format!(
1114                "use crate::models::{}::{{Entity as {}Entity, Model as {}}};\n",
1115                target_snake, fk.target_model, fk.target_model
1116            )
1117        })
1118        .collect();
1119
1120    // Build FK fetch code for index
1121    let fk_index_fetches: String = foreign_keys
1122        .iter()
1123        .filter(|fk| fk.validated)
1124        .map(|fk| {
1125            format!(
1126                r#"
1127    // Fetch {} for nested data
1128    let {}_map: std::collections::HashMap<i64, {}> = {}Entity::all()
1129        .await
1130        .map_err(|e| {{
1131            tracing::error!("Failed to fetch {}: {{:?}}", e);
1132            ferro::error_response!(500, "Failed to fetch {}")
1133        }})?
1134        .into_iter()
1135        .map(|r| (r.id, r))
1136        .collect();
1137"#,
1138                fk.target_model,
1139                fk.target_table,
1140                fk.target_model,
1141                fk.target_model,
1142                fk.target_table,
1143                fk.target_table
1144            )
1145        })
1146        .collect();
1147
1148    // Build response data enrichment for index
1149    let fk_index_enrich: String = if foreign_keys.iter().any(|fk| fk.validated) {
1150        let enrichments: String = foreign_keys
1151            .iter()
1152            .filter(|fk| fk.validated)
1153            .map(|fk| {
1154                let target_snake = to_snake_case(&fk.target_model);
1155                format!(
1156                    r#"                "{target_snake}": {target_table}_map.get(&item.{fk_field}).cloned(),"#,
1157                    target_snake = target_snake,
1158                    target_table = fk.target_table,
1159                    fk_field = fk.field_name
1160                )
1161            })
1162            .collect::<Vec<_>>()
1163            .join("\n");
1164
1165        format!(
1166            r#"
1167    // Enrich data with related entities
1168    let enriched: Vec<serde_json::Value> = {plural_snake}
1169        .into_iter()
1170        .map(|item| {{
1171            serde_json::json!({{
1172                "id": item.id,
1173{enrichments}
1174                // Include all model fields
1175                ..serde_json::to_value(&item).unwrap_or_default().as_object().cloned().unwrap_or_default()
1176            }})
1177        }})
1178        .collect();
1179"#
1180        )
1181    } else {
1182        String::new()
1183    };
1184
1185    // Build FK fetch code for show
1186    let fk_show_fetches: String = foreign_keys
1187        .iter()
1188        .filter(|fk| fk.validated)
1189        .map(|fk| {
1190            let target_snake = to_snake_case(&fk.target_model);
1191            format!(
1192                r#"
1193    // Fetch related {target_model}
1194    let related_{target_snake} = {target_model}Entity::find_by_pk({snake_name}.{fk_field})
1195        .await
1196        .map_err(|e| {{
1197            tracing::error!("Failed to fetch related {target_model}: {{:?}}", e);
1198            ferro::error_response!(500, "Failed to fetch related {target_model}")
1199        }})?;
1200"#,
1201                target_model = fk.target_model,
1202                snake_name = snake_name,
1203                fk_field = fk.field_name,
1204                target_snake = target_snake,
1205            )
1206        })
1207        .collect();
1208
1209    // Build show response with nested data
1210    let fk_show_response: String = if foreign_keys.iter().any(|fk| fk.validated) {
1211        let nested_fields: String = foreign_keys
1212            .iter()
1213            .filter(|fk| fk.validated)
1214            .map(|fk| {
1215                let target_snake = to_snake_case(&fk.target_model);
1216                format!(r#"            "{target_snake}": related_{target_snake},"#)
1217            })
1218            .collect::<Vec<_>>()
1219            .join("\n");
1220
1221        format!(
1222            r#"json_response!({{
1223        "data": {{
1224            ..serde_json::to_value(&{snake_name}).unwrap_or_default().as_object().cloned().unwrap_or_default(),
1225{nested_fields}
1226        }}
1227    }})"#
1228        )
1229    } else {
1230        format!(
1231            r#"json_response!({{
1232        "data": {snake_name}
1233    }})"#
1234        )
1235    };
1236
1237    // Generate validated FK comment if there are unvalidated FKs
1238    let unvalidated_fks: Vec<_> = foreign_keys.iter().filter(|fk| !fk.validated).collect();
1239    let unvalidated_comment = if !unvalidated_fks.is_empty() {
1240        let fk_list: String = unvalidated_fks
1241            .iter()
1242            .map(|fk| {
1243                format!(
1244                    "// - {} (model {} not found)",
1245                    fk.field_name, fk.target_model
1246                )
1247            })
1248            .collect::<Vec<_>>()
1249            .join("\n");
1250        format!(
1251            "\n// TODO: The following FK fields have no corresponding model:\n{fk_list}\n// Create these models to enable nested data in responses.\n"
1252        )
1253    } else {
1254        String::new()
1255    };
1256
1257    // Determine if we use enriched data or raw data in index
1258    let has_validated_fks = foreign_keys.iter().any(|fk| fk.validated);
1259    let index_data_var = if has_validated_fks {
1260        "enriched"
1261    } else {
1262        plural_snake
1263    };
1264
1265    format!(
1266        r#"//! {name} API controller
1267//!
1268//! Generated with `ferro make:scaffold --api`
1269{unvalidated_comment}
1270use ferro::{{database::{{Model as DatabaseModel, ModelMut}}, handler, json_response, Request, Response, ValidateRules}};
1271use crate::models::{snake_name}::{{self, Entity, Model as {name}}};
1272use sea_orm::Set;
1273{fk_imports}
1274/// Form data for creating/updating {name}
1275#[derive(Debug, serde::Deserialize, serde::Serialize, ValidateRules)]
1276pub struct {name}Form {{
1277{form_fields}
1278}}
1279
1280/// List all {plural_snake} with nested related data
1281///
1282/// GET /{plural_snake}
1283#[handler]
1284pub async fn index(_req: Request) -> Response {{
1285    let {plural_snake} = Entity::all().await.map_err(|e| {{
1286        tracing::error!("Failed to fetch {plural_snake}: {{:?}}", e);
1287        ferro::error_response!(500, "Failed to fetch {plural_snake}")
1288    }})?;
1289{fk_index_fetches}{fk_index_enrich}
1290    let total = {index_data_var}.len();
1291
1292    json_response!({{
1293        "data": {index_data_var},
1294        "meta": {{
1295            "total": total
1296        }}
1297    }})
1298}}
1299
1300/// Get a single {snake_name} with nested related data
1301///
1302/// GET /{plural_snake}/{{id}}
1303#[handler]
1304pub async fn show(req: Request) -> Response {{
1305    let id = req.param_as::<i64>("id").map_err(|_| ferro::error_response!(400, "Invalid id"))?;
1306
1307    let {snake_name} = Entity::find_by_pk(id)
1308        .await
1309        .map_err(|e| {{
1310            tracing::error!("Failed to fetch {snake_name}: {{:?}}", e);
1311            ferro::error_response!(500, "Failed to fetch {snake_name}")
1312        }})?
1313        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
1314{fk_show_fetches}
1315    {fk_show_response}
1316}}
1317
1318/// Create a new {snake_name}
1319///
1320/// POST /{plural_snake}
1321#[handler]
1322pub async fn store(req: Request) -> Response {{
1323    let form: {name}Form = req.input().await?;
1324
1325    let {snake_name} = {snake_name}::ActiveModel {{
1326{insert_fields}
1327        ..Default::default()
1328    }};
1329
1330    let created = Entity::insert_one({snake_name})
1331        .await
1332        .map_err(|e| {{
1333            tracing::error!("Failed to create {snake_name}: {{:?}}", e);
1334            ferro::error_response!(500, "Failed to create {snake_name}")
1335        }})?;
1336
1337    json_response!({{
1338        "data": created,
1339        "message": "{name} created successfully"
1340    }})
1341}}
1342
1343/// Update an existing {snake_name}
1344///
1345/// PUT /{plural_snake}/{{id}}
1346#[handler]
1347pub async fn update(req: Request) -> Response {{
1348    let id = req.param_as::<i64>("id").map_err(|_| ferro::error_response!(400, "Invalid id"))?;
1349    let form: {name}Form = req.input().await?;
1350
1351    let existing = Entity::find_by_pk(id)
1352        .await
1353        .map_err(|e| {{
1354            tracing::error!("Failed to fetch {snake_name}: {{:?}}", e);
1355            ferro::error_response!(500, "Failed to fetch {snake_name}")
1356        }})?
1357        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
1358
1359    let mut active: {snake_name}::ActiveModel = existing.into();
1360{update_fields}
1361    let updated = Entity::update_one(active)
1362        .await
1363        .map_err(|e| {{
1364            tracing::error!("Failed to update {snake_name}: {{:?}}", e);
1365            ferro::error_response!(500, "Failed to update {snake_name}")
1366        }})?;
1367
1368    json_response!({{
1369        "data": updated,
1370        "message": "{name} updated successfully"
1371    }})
1372}}
1373
1374/// Delete a {snake_name}
1375///
1376/// DELETE /{plural_snake}/{{id}}
1377#[handler]
1378pub async fn destroy(req: Request) -> Response {{
1379    let id = req.param_as::<i64>("id").map_err(|_| ferro::error_response!(400, "Invalid id"))?;
1380
1381    Entity::find_by_pk(id)
1382        .await
1383        .map_err(|e| {{
1384            tracing::error!("Failed to fetch {snake_name}: {{:?}}", e);
1385            ferro::error_response!(500, "Failed to fetch {snake_name}")
1386        }})?
1387        .ok_or_else(|| ferro::error_response!(404, "{name} not found"))?;
1388
1389    Entity::delete_by_pk(id)
1390        .await
1391        .map_err(|e| {{
1392            tracing::error!("Failed to delete {snake_name}: {{:?}}", e);
1393            ferro::error_response!(500, "Failed to delete {snake_name}")
1394        }})?;
1395
1396    json_response!({{
1397        "message": "{name} deleted successfully"
1398    }})
1399}}
1400"#,
1401    )
1402}