1use super::entity::to_pascal_case;
2use super::entity::to_snake_case;
3
4pub struct ScaffoldField {
10 pub name: String,
11 pub field_type: String,
12}
13
14pub struct ScaffoldForeignKey {
16 pub field_name: String,
18 pub target_model: String,
20 pub target_snake: String,
22 pub validated: bool,
24}
25
26pub 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 let fk_field_names: Vec<&str> = foreign_keys
36 .iter()
37 .map(|fk| fk.field_name.as_str())
38 .collect();
39
40 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 let fake_assignments: String = fields
54 .iter()
55 .map(|f| {
56 if fk_field_names.contains(&f.name.as_str()) {
57 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 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 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 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
225fn 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
240fn 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
256pub 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
347pub fn scaffold_test_with_factory_template(
352 snake_name: &str,
353 plural_snake: &str,
354 pascal_name: &str,
355 fields: &[ScaffoldField],
356) -> String {
357 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#[derive(Debug, Clone)]
482pub struct ForeignKeyField {
483 pub field_name: String,
485 pub target_model: String,
487 pub target_table: String,
489 pub validated: bool,
491}
492
493pub 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 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'), fk.target_model,
512 fk.target_model
513 )
514 })
515 .collect();
516
517 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 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 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 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 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 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 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 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 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 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
784pub 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
945pub 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
1097pub 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 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 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 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 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 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 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 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}