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| HttpResponse::internal_server_error(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| HttpResponse::internal_server_error(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| HttpResponse::internal_server_error(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 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
794pub 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
964pub 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
1137pub 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 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 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 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 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 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 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 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}