1use std::fs;
2use std::path::Path;
3
4use crate::analyzer::{
5 FactoryPattern, ForeignKeyInfo, ProjectAnalyzer, ProjectConventions, TestPattern,
6};
7use crate::templates;
8use dialoguer::Confirm;
9
10#[derive(Debug, Default)]
12struct SmartDefaults {
13 api_detected: bool,
14 test_detected: bool,
15 test_count: usize,
16 factory_detected: bool,
17 factory_count: usize,
18 field_inferences: Vec<(String, String, String)>, }
20
21impl SmartDefaults {
22 fn has_any(&self) -> bool {
23 self.api_detected
24 || self.test_detected
25 || self.factory_detected
26 || !self.field_inferences.is_empty()
27 }
28
29 fn display(&self, api_only: bool, with_tests: bool, with_factory: bool) {
30 println!("\nš Smart Defaults Detected:");
31 println!(" āāāāāāāāāāāāāāāāāāāāāāāāā");
32
33 if self.api_detected {
35 println!(" Project type: API-only (no Inertia pages found)");
36 } else if api_only {
37 println!(" Project type: API-only (explicit --api flag)");
38 } else {
39 println!(" Project type: Full-stack (Inertia pages present)");
40 }
41
42 if self.test_detected {
44 println!(
45 " Test pattern: Per-controller ({} existing test files)",
46 self.test_count
47 );
48 } else if with_tests {
49 println!(" Test pattern: Enabled (explicit --with-tests flag)");
50 }
51
52 if self.factory_detected {
54 println!(
55 " Factory pattern: Per-model ({} existing factories)",
56 self.factory_count
57 );
58 } else if with_factory {
59 println!(" Factory pattern: Enabled (explicit --with-factory flag)");
60 }
61
62 let mut flags = Vec::new();
64 if api_only {
65 flags.push("--api");
66 }
67 if with_tests {
68 flags.push("--with-tests");
69 }
70 if with_factory {
71 flags.push("--with-factory");
72 }
73 if !flags.is_empty() {
74 println!("\n Applied flags: {}", flags.join(" "));
75 }
76
77 if !self.field_inferences.is_empty() {
79 println!("\n Field type inference:");
80 for (name, field_type, reason) in &self.field_inferences {
81 println!(" {name} ā {field_type} ({reason})");
82 }
83 }
84
85 println!();
86 }
87}
88
89#[allow(clippy::too_many_arguments)]
90pub fn run(
91 name: String,
92 fields: Vec<String>,
93 with_tests: bool,
94 with_factory: bool,
95 auto_routes: bool,
96 yes: bool,
97 api_only: bool,
98 no_smart_defaults: bool,
99 quiet: bool,
100) {
101 let mut smart_defaults = SmartDefaults::default();
103
104 let (api_only, with_tests, with_factory) = if no_smart_defaults {
106 (api_only, with_tests, with_factory)
107 } else {
108 apply_smart_defaults(api_only, with_tests, with_factory, &mut smart_defaults)
109 };
110
111 if !is_valid_identifier(&name) {
113 eprintln!(
114 "Error: '{name}' is not a valid identifier. Use PascalCase (e.g., Post, UserProfile)"
115 );
116 std::process::exit(1);
117 }
118
119 let parsed_fields = match parse_fields(&fields, &mut smart_defaults, no_smart_defaults) {
121 Ok(f) => f,
122 Err(e) => {
123 eprintln!("Error parsing fields: {e}");
124 std::process::exit(1);
125 }
126 };
127
128 let snake_name = to_snake_case(&name);
129 let plural_snake = pluralize(&snake_name);
130
131 if !quiet && !no_smart_defaults && smart_defaults.has_any() {
133 smart_defaults.display(api_only, with_tests, with_factory);
134
135 if !yes {
137 let confirmed = Confirm::new()
138 .with_prompt("Proceed with generation?")
139 .default(true)
140 .interact()
141 .unwrap_or(false);
142
143 if !confirmed {
144 println!("Aborted.");
145 return;
146 }
147 }
148 }
149
150 println!("š Scaffolding {name}...\n");
151
152 let analyzer = ProjectAnalyzer::current_dir();
154 let field_tuples: Vec<(&str, &str)> = parsed_fields
155 .iter()
156 .map(|f| (f.name.as_str(), f.field_type.to_display_name()))
157 .collect();
158 let foreign_keys = analyzer.detect_foreign_keys(&field_tuples);
159
160 generate_migration(
162 &name,
163 &snake_name,
164 &plural_snake,
165 &parsed_fields,
166 &foreign_keys,
167 );
168
169 generate_model(&name, &snake_name, &parsed_fields, &foreign_keys);
171
172 generate_controller(
174 &name,
175 &snake_name,
176 &plural_snake,
177 &parsed_fields,
178 &foreign_keys,
179 api_only,
180 );
181
182 if !api_only {
184 generate_inertia_pages(
185 &name,
186 &snake_name,
187 &plural_snake,
188 &parsed_fields,
189 &foreign_keys,
190 );
191 }
192
193 if with_tests {
195 generate_tests(
196 &name,
197 &snake_name,
198 &plural_snake,
199 &parsed_fields,
200 with_factory,
201 );
202 }
203
204 if with_factory {
206 generate_scaffold_factory(&name, &snake_name, &parsed_fields, &foreign_keys);
207 }
208
209 if auto_routes {
211 register_routes(&snake_name, &plural_snake, yes);
212 } else {
213 print_route_instructions(&name, &snake_name, &plural_snake);
214 }
215
216 if api_only {
217 println!("\nā
API scaffold for {name} created successfully!");
218 } else {
219 println!("\nā
Scaffold for {name} created successfully!");
220 }
221}
222
223#[derive(Debug, Clone)]
224struct Field {
225 name: String,
226 field_type: FieldType,
227}
228
229#[derive(Debug, Clone)]
230enum FieldType {
231 String,
232 Text,
233 Integer,
234 BigInteger,
235 Float,
236 Boolean,
237 DateTime,
238 Date,
239 Uuid,
240}
241
242impl FieldType {
243 fn from_str(s: &str) -> Result<Self, String> {
244 match s.to_lowercase().as_str() {
245 "string" | "str" => Ok(FieldType::String),
246 "text" => Ok(FieldType::Text),
247 "int" | "integer" | "i32" => Ok(FieldType::Integer),
248 "bigint" | "biginteger" | "i64" => Ok(FieldType::BigInteger),
249 "float" | "f64" | "double" => Ok(FieldType::Float),
250 "bool" | "boolean" => Ok(FieldType::Boolean),
251 "datetime" | "timestamp" => Ok(FieldType::DateTime),
252 "date" => Ok(FieldType::Date),
253 "uuid" => Ok(FieldType::Uuid),
254 _ => Err(format!("Unknown field type: '{s}'. Valid types: string, text, integer, bigint, float, bool, datetime, date, uuid")),
255 }
256 }
257
258 fn to_display_name(&self) -> &'static str {
259 match self {
260 FieldType::String => "string",
261 FieldType::Text => "text",
262 FieldType::Integer => "integer",
263 FieldType::BigInteger => "bigint",
264 FieldType::Float => "float",
265 FieldType::Boolean => "bool",
266 FieldType::DateTime => "datetime",
267 FieldType::Date => "date",
268 FieldType::Uuid => "uuid",
269 }
270 }
271
272 fn to_rust_type(&self) -> &'static str {
273 match self {
274 FieldType::String => "String",
275 FieldType::Text => "String",
276 FieldType::Integer => "i32",
277 FieldType::BigInteger => "i64",
278 FieldType::Float => "f64",
279 FieldType::Boolean => "bool",
280 FieldType::DateTime => "chrono::DateTime<chrono::Utc>",
281 FieldType::Date => "chrono::NaiveDate",
282 FieldType::Uuid => "uuid::Uuid",
283 }
284 }
285
286 fn to_sea_orm_method(&self) -> &'static str {
287 match self {
288 FieldType::String => "string()",
289 FieldType::Text => "text()",
290 FieldType::Integer => "integer()",
291 FieldType::BigInteger => "big_integer()",
292 FieldType::Float => "double()",
293 FieldType::Boolean => "boolean()",
294 FieldType::DateTime => "timestamp_with_time_zone()",
295 FieldType::Date => "date()",
296 FieldType::Uuid => "uuid()",
297 }
298 }
299
300 fn to_typescript_type(&self) -> &'static str {
301 match self {
302 FieldType::String => "string",
303 FieldType::Text => "string",
304 FieldType::Integer => "number",
305 FieldType::BigInteger => "number",
306 FieldType::Float => "number",
307 FieldType::Boolean => "boolean",
308 FieldType::DateTime => "string",
309 FieldType::Date => "string",
310 FieldType::Uuid => "string",
311 }
312 }
313
314 fn to_form_input_type(&self) -> &'static str {
315 match self {
316 FieldType::String => "text",
317 FieldType::Text => "textarea",
318 FieldType::Integer => "number",
319 FieldType::BigInteger => "number",
320 FieldType::Float => "number",
321 FieldType::Boolean => "checkbox",
322 FieldType::DateTime => "datetime-local",
323 FieldType::Date => "date",
324 FieldType::Uuid => "text",
325 }
326 }
327
328 fn to_validation_attr(&self) -> &'static str {
329 match self {
330 FieldType::String => "#[rule(required, string)]",
331 FieldType::Text => "#[rule(required, string)]",
332 FieldType::Integer => "#[rule(required, integer)]",
333 FieldType::BigInteger => "#[rule(required, integer)]",
334 FieldType::Float => "#[rule(required, numeric)]",
335 FieldType::Boolean => "#[rule(required, boolean)]",
336 FieldType::DateTime => "#[rule(required, date)]",
337 FieldType::Date => "#[rule(required, date)]",
338 FieldType::Uuid => "#[rule(required, string)]",
339 }
340 }
341
342 fn to_scaffold_type(&self) -> &'static str {
343 match self {
344 FieldType::String => "string",
345 FieldType::Text => "text",
346 FieldType::Integer => "integer",
347 FieldType::BigInteger => "bigint",
348 FieldType::Float => "float",
349 FieldType::Boolean => "bool",
350 FieldType::DateTime => "datetime",
351 FieldType::Date => "date",
352 FieldType::Uuid => "uuid",
353 }
354 }
355}
356
357fn parse_fields(
358 fields: &[String],
359 tracking: &mut SmartDefaults,
360 no_smart_defaults: bool,
361) -> Result<Vec<Field>, String> {
362 let mut parsed = Vec::new();
363
364 for field_str in fields {
365 let parts: Vec<&str> = field_str.split(':').collect();
366
367 let (name, field_type) = match parts.len() {
368 1 => {
369 let name = parts[0].to_string();
371 if !is_valid_field_name(&name) {
372 return Err(format!(
373 "Invalid field name: '{name}'. Use snake_case (e.g., user_id)"
374 ));
375 }
376 let (field_type, reason) = infer_field_type(&name);
377 if !no_smart_defaults {
378 tracking.field_inferences.push((
379 name.clone(),
380 field_type.to_display_name().to_string(),
381 reason.to_string(),
382 ));
383 }
384 (name, field_type)
385 }
386 2 => {
387 let name = parts[0].to_string();
389 if !is_valid_field_name(&name) {
390 return Err(format!(
391 "Invalid field name: '{name}'. Use snake_case (e.g., user_id)"
392 ));
393 }
394 let field_type = FieldType::from_str(parts[1])?;
395 (name, field_type)
396 }
397 _ => {
398 return Err(format!(
399 "Invalid field format: '{field_str}'. Expected format: name or name:type (e.g., title or title:string)"
400 ));
401 }
402 };
403
404 parsed.push(Field { name, field_type });
405 }
406
407 Ok(parsed)
408}
409
410fn infer_field_type(name: &str) -> (FieldType, &'static str) {
422 if name.ends_with("_id") {
424 return (FieldType::BigInteger, "foreign key pattern");
425 }
426
427 if name.ends_with("_at") {
429 return (FieldType::DateTime, "timestamp pattern");
430 }
431
432 if name.starts_with("is_") || name.starts_with("has_") {
434 return (FieldType::Boolean, "boolean pattern");
435 }
436
437 match name {
439 "email" => (FieldType::String, "common field"),
440 "password" => (FieldType::String, "hashed field"),
441 _ => (FieldType::String, "default"),
442 }
443}
444
445fn is_valid_identifier(name: &str) -> bool {
446 if name.is_empty() {
447 return false;
448 }
449 let first = name.chars().next().unwrap();
450 if !first.is_ascii_uppercase() {
451 return false;
452 }
453 name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
454}
455
456fn is_valid_field_name(name: &str) -> bool {
457 if name.is_empty() {
458 return false;
459 }
460 let first = name.chars().next().unwrap();
461 if !first.is_ascii_lowercase() && first != '_' {
462 return false;
463 }
464 name.chars()
465 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
466}
467
468fn to_snake_case(name: &str) -> String {
469 let mut result = String::new();
470 for (i, c) in name.chars().enumerate() {
471 if c.is_uppercase() {
472 if i > 0 {
473 result.push('_');
474 }
475 result.push(c.to_ascii_lowercase());
476 } else {
477 result.push(c);
478 }
479 }
480 result
481}
482
483fn to_pascal_case(name: &str) -> String {
484 name.split('_')
485 .map(|word| {
486 let mut chars = word.chars();
487 match chars.next() {
488 None => String::new(),
489 Some(first) => first.to_uppercase().chain(chars).collect(),
490 }
491 })
492 .collect()
493}
494
495fn pluralize(name: &str) -> String {
496 if name.ends_with('s') || name.ends_with('x') || name.ends_with("ch") || name.ends_with("sh") {
498 format!("{name}es")
499 } else if name.ends_with('y')
500 && !name.ends_with("ay")
501 && !name.ends_with("ey")
502 && !name.ends_with("oy")
503 && !name.ends_with("uy")
504 {
505 format!("{}ies", &name[..name.len() - 1])
506 } else {
507 format!("{name}s")
508 }
509}
510
511fn generate_migration(
512 _name: &str,
513 _snake_name: &str,
514 plural_snake: &str,
515 fields: &[Field],
516 foreign_keys: &[ForeignKeyInfo],
517) {
518 let migrations_dir = if Path::new("src/migrations").exists() {
520 Path::new("src/migrations")
521 } else if Path::new("src/database/migrations").exists() {
522 Path::new("src/database/migrations")
523 } else {
524 eprintln!("Error: migrations directory not found. Are you in a Ferro project?");
525 eprintln!("Expected: src/migrations or src/database/migrations");
526 std::process::exit(1);
527 };
528
529 let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
531 let migration_name = format!("m{timestamp}_create_{plural_snake}_table");
532 let file_name = format!("{migration_name}.rs");
533 let file_path = migrations_dir.join(&file_name);
534
535 let mut columns = String::new();
537 for field in fields {
538 columns.push_str(&format!(
539 " .col(ColumnDef::new({name}::{column}).{method}.not_null())\n",
540 name = to_pascal_case(plural_snake),
541 column = to_pascal_case(&field.name),
542 method = field.field_type.to_sea_orm_method()
543 ));
544 }
545
546 let mut fk_constraints = String::new();
548 let mut fk_comments = String::new();
549 for fk in foreign_keys {
550 if fk.validated {
551 fk_constraints.push_str(&format!(
552 r#" .foreign_key(
553 ForeignKey::create()
554 .name("fk_{table}_{field}")
555 .from({table_enum}::Table, {table_enum}::{column})
556 .to({target_table_enum}::Table, {target_table_enum}::Id)
557 .on_delete(ForeignKeyAction::Cascade)
558 .on_update(ForeignKeyAction::Cascade),
559 )
560"#,
561 table = plural_snake,
562 field = fk.field_name,
563 table_enum = to_pascal_case(plural_snake),
564 column = to_pascal_case(&fk.field_name),
565 target_table_enum = to_pascal_case(&fk.target_table),
566 ));
567 } else {
568 fk_comments.push_str(&format!(
569 "// Note: {} model not found - FK constraint for {} skipped\n",
570 fk.target_model, fk.field_name
571 ));
572 }
573 }
574
575 let fk_table_enums: String = foreign_keys
577 .iter()
578 .filter(|fk| fk.validated)
579 .map(|fk| {
580 format!(
581 r#"
582/// Reference to {target_table} table for FK constraint
583#[derive(Iden)]
584pub enum {target_table_enum} {{
585 Table,
586 Id,
587}}
588"#,
589 target_table = fk.target_table,
590 target_table_enum = to_pascal_case(&fk.target_table),
591 )
592 })
593 .collect();
594
595 let migration_content = format!(
596 r#"use sea_orm_migration::prelude::*;
597
598{fk_comments}#[derive(DeriveMigrationName)]
599pub struct Migration;
600
601#[async_trait::async_trait]
602impl MigrationTrait for Migration {{
603 async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
604 manager
605 .create_table(
606 Table::create()
607 .table({table_enum}::Table)
608 .if_not_exists()
609 .col(
610 ColumnDef::new({table_enum}::Id)
611 .big_integer()
612 .not_null()
613 .auto_increment()
614 .primary_key(),
615 )
616{columns}{fk_constraints} .col(
617 ColumnDef::new({table_enum}::CreatedAt)
618 .timestamp_with_time_zone()
619 .not_null()
620 .default(Expr::current_timestamp()),
621 )
622 .col(
623 ColumnDef::new({table_enum}::UpdatedAt)
624 .timestamp_with_time_zone()
625 .not_null()
626 .default(Expr::current_timestamp()),
627 )
628 .to_owned(),
629 )
630 .await
631 }}
632
633 async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
634 manager
635 .drop_table(Table::drop().table({table_enum}::Table).to_owned())
636 .await
637 }}
638}}
639
640#[derive(Iden)]
641pub enum {table_enum} {{
642 Table,
643 Id,
644{iden_columns} CreatedAt,
645 UpdatedAt,
646}}
647{fk_table_enums}"#,
648 table_enum = to_pascal_case(plural_snake),
649 columns = columns,
650 fk_constraints = fk_constraints,
651 fk_comments = fk_comments,
652 iden_columns = fields
653 .iter()
654 .map(|f| format!(" {},\n", to_pascal_case(&f.name)))
655 .collect::<String>(),
656 fk_table_enums = fk_table_enums,
657 );
658
659 fs::write(&file_path, migration_content).expect("Failed to write migration file");
660
661 update_migrations_mod(&migration_name);
663
664 println!(
665 " š¦ Created migration: {}/{}",
666 migrations_dir.display(),
667 file_name
668 );
669}
670
671fn update_migrations_mod(migration_name: &str) {
672 let mod_path = if Path::new("src/migrations/mod.rs").exists() {
674 Path::new("src/migrations/mod.rs")
675 } else if Path::new("src/database/migrations/mod.rs").exists() {
676 Path::new("src/database/migrations/mod.rs")
677 } else {
678 eprintln!("Warning: migrations/mod.rs not found");
679 return;
680 };
681
682 let content = fs::read_to_string(mod_path).expect("Failed to read mod.rs");
683
684 let mod_declaration = format!("pub mod {migration_name};");
686 if content.contains(&mod_declaration) {
687 return;
688 }
689
690 let mut lines: Vec<&str> = content.lines().collect();
692 let mut insert_index = 0;
693
694 for (i, line) in lines.iter().enumerate() {
695 if line.starts_with("pub mod m") {
696 insert_index = i + 1;
697 }
698 }
699
700 lines.insert(insert_index, &mod_declaration);
701
702 let migrator_addition = format!(" Box::new({migration_name}::Migration),");
704
705 let mut updated_lines = Vec::new();
706 for line in lines {
707 updated_lines.push(line.to_string());
708 if line.contains("fn migrations()") {
709 }
711 }
712
713 let content = updated_lines.join("\n");
715 let content = if content.contains("vec![]") {
716 content.replace("vec![]", &format!("vec![\n{migrator_addition}\n ]"))
717 } else if content.contains("vec![") {
718 let mut result = String::new();
720 let mut in_migrations = false;
721 let mut bracket_depth = 0;
722
723 for line in content.lines() {
724 if line.contains("fn migrations()") {
725 in_migrations = true;
726 }
727
728 if in_migrations {
729 if line.contains("vec![") {
730 bracket_depth += 1;
731 }
732 if line.trim() == "]" && bracket_depth == 1 {
733 result.push_str(&migrator_addition);
734 result.push('\n');
735 bracket_depth = 0;
736 in_migrations = false;
737 }
738 }
739
740 result.push_str(line);
741 result.push('\n');
742 }
743
744 result
745 } else {
746 content
747 };
748
749 fs::write(mod_path, content).expect("Failed to write mod.rs");
750}
751
752fn generate_model(name: &str, snake_name: &str, fields: &[Field], foreign_keys: &[ForeignKeyInfo]) {
753 let models_dir = Path::new("src/models");
754
755 if !models_dir.exists() {
756 fs::create_dir_all(models_dir).expect("Failed to create models directory");
757 }
758
759 let file_path = models_dir.join(format!("{snake_name}.rs"));
760
761 let mut field_defs = String::new();
763 for field in fields {
764 field_defs.push_str(&format!(
765 " pub {}: {},\n",
766 field.name,
767 field.field_type.to_rust_type()
768 ));
769 }
770
771 let validated_fks: Vec<&ForeignKeyInfo> =
773 foreign_keys.iter().filter(|fk| fk.validated).collect();
774
775 let relation_enum = if validated_fks.is_empty() {
776 "#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\npub enum Relation {}".to_string()
777 } else {
778 let variants: String = validated_fks
779 .iter()
780 .map(|fk| {
781 let target_snake = to_snake_case(&fk.target_model);
782 format!(
783 r#" #[sea_orm(
784 belongs_to = "super::{target_snake}::Entity",
785 from = "Column::{fk_column}",
786 to = "super::{target_snake}::Column::Id"
787 )]
788 {target_pascal},
789"#,
790 target_snake = target_snake,
791 fk_column = to_pascal_case(&fk.field_name),
792 target_pascal = fk.target_model,
793 )
794 })
795 .collect();
796
797 format!(
798 "#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\npub enum Relation {{\n{variants}}}\n"
799 )
800 };
801
802 let related_impls: String = validated_fks
804 .iter()
805 .map(|fk| {
806 let target_snake = to_snake_case(&fk.target_model);
807 format!(
808 r#"
809impl Related<super::{target_snake}::Entity> for Entity {{
810 fn to() -> RelationDef {{
811 Relation::{target_pascal}.def()
812 }}
813}}
814"#,
815 target_snake = target_snake,
816 target_pascal = fk.target_model,
817 )
818 })
819 .collect();
820
821 let model_content = format!(
822 r#"//! {name} model
823
824use ferro::database::{{Model as DatabaseModel, ModelMut, QueryBuilder}};
825use sea_orm::entity::prelude::*;
826use sea_orm::Set;
827use serde::Serialize;
828
829#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]
830#[sea_orm(table_name = "{table_name}")]
831pub struct Model {{
832 #[sea_orm(primary_key)]
833 pub id: i64,
834{field_defs} pub created_at: DateTimeUtc,
835 pub updated_at: DateTimeUtc,
836}}
837
838{relation_enum}
839
840impl ActiveModelBehavior for ActiveModel {{}}
841
842impl DatabaseModel for Entity {{}}
843impl ModelMut for Entity {{}}
844{related_impls}
845/// Type alias for convenient access
846pub type {name} = Model;
847
848impl Model {{
849 /// Start a query builder
850 pub fn query() -> QueryBuilder<Entity> {{
851 QueryBuilder::new()
852 }}
853}}
854"#,
855 name = name,
856 table_name = pluralize(snake_name),
857 field_defs = field_defs,
858 relation_enum = relation_enum,
859 related_impls = related_impls,
860 );
861
862 fs::write(&file_path, model_content).expect("Failed to write model file");
863
864 update_models_mod(snake_name);
866
867 println!(" š¦ Created model: src/models/{snake_name}.rs");
868}
869
870fn update_models_mod(snake_name: &str) {
871 let mod_path = Path::new("src/models/mod.rs");
872
873 if !mod_path.exists() {
874 let content = format!("pub mod {snake_name};\npub use {snake_name}::*;\n");
875 fs::write(mod_path, content).expect("Failed to write mod.rs");
876 return;
877 }
878
879 let content = fs::read_to_string(mod_path).expect("Failed to read mod.rs");
880 let mod_declaration = format!("pub mod {snake_name};");
881
882 if content.contains(&mod_declaration) {
883 return;
884 }
885
886 let updated = format!("{content}{mod_declaration}\npub use {snake_name}::*;\n");
887 fs::write(mod_path, updated).expect("Failed to write mod.rs");
888}
889
890fn generate_controller(
891 name: &str,
892 snake_name: &str,
893 plural_snake: &str,
894 fields: &[Field],
895 foreign_keys: &[ForeignKeyInfo],
896 api_only: bool,
897) {
898 let controllers_dir = Path::new("src/controllers");
899
900 if !controllers_dir.exists() {
901 fs::create_dir_all(controllers_dir).expect("Failed to create controllers directory");
902 }
903
904 let file_path = controllers_dir.join(format!("{snake_name}_controller.rs"));
905
906 let mut update_fields = String::new();
908 for field in fields {
909 update_fields.push_str(&format!(
910 " .set_{}(form.{}.clone())\n",
911 field.name, field.name
912 ));
913 }
914
915 let mut form_fields = String::new();
917 for field in fields {
918 let rule_attr = field.field_type.to_validation_attr();
919 form_fields.push_str(&format!(
920 " {}\n pub {}: {},\n",
921 rule_attr,
922 field.name,
923 field.field_type.to_rust_type()
924 ));
925 }
926
927 let insert_fields: String = fields
928 .iter()
929 .map(|f| {
930 format!(
931 " {}: ActiveValue::Set(form.{}.clone()),\n",
932 f.name, f.name
933 )
934 })
935 .collect();
936
937 let fk_fields: Vec<templates::ForeignKeyField> = foreign_keys
939 .iter()
940 .map(|fk| templates::ForeignKeyField {
941 field_name: fk.field_name.clone(),
942 target_model: fk.target_model.clone(),
943 target_table: fk.target_table.clone(),
944 validated: fk.validated,
945 })
946 .collect();
947
948 let controller_content = if api_only {
949 if !fk_fields.is_empty() {
951 templates::api_controller_with_fk_template(
952 name,
953 snake_name,
954 plural_snake,
955 &form_fields,
956 &update_fields,
957 &insert_fields,
958 &fk_fields,
959 )
960 } else {
961 templates::api_controller_template(
962 name,
963 snake_name,
964 plural_snake,
965 &form_fields,
966 &update_fields,
967 &insert_fields,
968 )
969 }
970 } else if !fk_fields.is_empty() {
971 templates::scaffold_controller_with_fk_template(
973 name,
974 snake_name,
975 plural_snake,
976 &form_fields,
977 &update_fields,
978 &insert_fields,
979 &fk_fields,
980 )
981 } else {
982 templates::scaffold_controller_template(
983 name,
984 snake_name,
985 plural_snake,
986 &form_fields,
987 &update_fields,
988 &insert_fields,
989 )
990 };
991
992 fs::write(&file_path, controller_content).expect("Failed to write controller file");
993
994 update_controllers_mod(snake_name);
996
997 let controller_type = if api_only {
998 "API controller"
999 } else {
1000 "controller"
1001 };
1002 println!(" š¦ Created {controller_type}: src/controllers/{snake_name}_controller.rs");
1003}
1004
1005fn update_controllers_mod(snake_name: &str) {
1006 let mod_path = Path::new("src/controllers/mod.rs");
1007 let module_name = format!("{snake_name}_controller");
1008
1009 if !mod_path.exists() {
1010 let content = format!("pub mod {module_name};\n");
1011 fs::write(mod_path, content).expect("Failed to write mod.rs");
1012 return;
1013 }
1014
1015 let content = fs::read_to_string(mod_path).expect("Failed to read mod.rs");
1016 let mod_declaration = format!("pub mod {module_name};");
1017
1018 if content.contains(&mod_declaration) {
1019 return;
1020 }
1021
1022 let updated = format!("{content}{mod_declaration}\n");
1023 fs::write(mod_path, updated).expect("Failed to write mod.rs");
1024}
1025
1026fn generate_inertia_pages(
1027 name: &str,
1028 snake_name: &str,
1029 plural_snake: &str,
1030 fields: &[Field],
1031 foreign_keys: &[ForeignKeyInfo],
1032) {
1033 let pages_dir = Path::new("frontend/src/pages").join(plural_snake);
1034
1035 if !pages_dir.exists() {
1036 fs::create_dir_all(&pages_dir).expect("Failed to create pages directory");
1037 }
1038
1039 generate_index_page(&pages_dir, name, snake_name, plural_snake, fields);
1041
1042 generate_show_page(&pages_dir, name, snake_name, plural_snake, fields);
1044
1045 generate_create_page(
1047 &pages_dir,
1048 name,
1049 snake_name,
1050 plural_snake,
1051 fields,
1052 foreign_keys,
1053 );
1054
1055 generate_edit_page(
1057 &pages_dir,
1058 name,
1059 snake_name,
1060 plural_snake,
1061 fields,
1062 foreign_keys,
1063 );
1064
1065 println!(" š¦ Created Inertia pages: frontend/src/pages/{plural_snake}/");
1066}
1067
1068fn generate_index_page(
1069 pages_dir: &Path,
1070 name: &str,
1071 snake_name: &str,
1072 plural_snake: &str,
1073 fields: &[Field],
1074) {
1075 let file_path = pages_dir.join("Index.tsx");
1076
1077 let headers: String = fields
1079 .iter()
1080 .map(|f| format!(" <th>{}</th>\n", to_pascal_case(&f.name)))
1081 .collect();
1082
1083 let cells: String = fields
1085 .iter()
1086 .map(|f| {
1087 format!(
1088 " <td>{{{snake}.{}}}</td>\n",
1089 f.name,
1090 snake = snake_name
1091 )
1092 })
1093 .collect();
1094
1095 let content = format!(
1096 r#"import {{ Link }} from '@inertiajs/react';
1097
1098interface {name} {{
1099 id: number;
1100{ts_fields} created_at: string;
1101 updated_at: string;
1102}}
1103
1104interface Props {{
1105 {plural}: {name}[];
1106}}
1107
1108export default function Index({{ {plural} }}: Props) {{
1109 return (
1110 <div className="container mx-auto px-4 py-8">
1111 <div className="flex justify-between items-center mb-6">
1112 <h1 className="text-2xl font-bold">{name_display}</h1>
1113 <Link
1114 href="/{plural}/create"
1115 className="bg-primary text-primary-foreground px-4 py-2 rounded hover:opacity-90"
1116 >
1117 Create {name}
1118 </Link>
1119 </div>
1120
1121 <table className="min-w-full bg-card border border-border">
1122 <thead>
1123 <tr className="bg-muted">
1124 <th className="px-4 py-2 text-left">ID</th>
1125{headers} <th className="px-4 py-2 text-left">Actions</th>
1126 </tr>
1127 </thead>
1128 <tbody>
1129 {{{plural}.map(({snake}) => (
1130 <tr key={{{snake}.id}} className="border-t">
1131 <td className="px-4 py-2">{{{snake}.id}}</td>
1132{cells} <td className="px-4 py-2">
1133 <Link
1134 href={{`/{plural}/${{{snake}.id}}`}}
1135 className="text-primary hover:underline mr-2"
1136 >
1137 View
1138 </Link>
1139 <Link
1140 href={{`/{plural}/${{{snake}.id}}/edit`}}
1141 className="text-primary hover:underline"
1142 >
1143 Edit
1144 </Link>
1145 </td>
1146 </tr>
1147 ))}}
1148 </tbody>
1149 </table>
1150 </div>
1151 );
1152}}
1153"#,
1154 name = name,
1155 name_display = pluralize(name),
1156 snake = snake_name,
1157 plural = plural_snake,
1158 headers = headers,
1159 cells = cells,
1160 ts_fields = fields
1161 .iter()
1162 .map(|f| format!(" {}: {};\n", f.name, f.field_type.to_typescript_type()))
1163 .collect::<String>()
1164 );
1165
1166 fs::write(file_path, content).expect("Failed to write Index.tsx");
1167}
1168
1169fn generate_show_page(
1170 pages_dir: &Path,
1171 name: &str,
1172 snake_name: &str,
1173 plural_snake: &str,
1174 fields: &[Field],
1175) {
1176 let file_path = pages_dir.join("Show.tsx");
1177
1178 let field_displays: String = fields
1180 .iter()
1181 .map(|f| {
1182 format!(
1183 r#" <div className="mb-4">
1184 <label className="block text-foreground font-bold">{label}</label>
1185 <p>{{{snake}.{field}}}</p>
1186 </div>
1187"#,
1188 label = to_pascal_case(&f.name),
1189 snake = snake_name,
1190 field = f.name
1191 )
1192 })
1193 .collect();
1194
1195 let content = format!(
1196 r#"import {{ Link, router }} from '@inertiajs/react';
1197
1198interface {name} {{
1199 id: number;
1200{ts_fields} created_at: string;
1201 updated_at: string;
1202}}
1203
1204interface Props {{
1205 {snake}: {name};
1206}}
1207
1208export default function Show({{ {snake} }}: Props) {{
1209 const handleDelete = () => {{
1210 if (confirm('Are you sure you want to delete this {snake}?')) {{
1211 router.delete(`/{plural}/${{{snake}.id}}`);
1212 }}
1213 }};
1214
1215 return (
1216 <div className="container mx-auto px-4 py-8">
1217 <div className="max-w-2xl mx-auto">
1218 <div className="flex justify-between items-center mb-6">
1219 <h1 className="text-2xl font-bold">{name} Details</h1>
1220 <div>
1221 <Link
1222 href="/{plural}"
1223 className="text-muted-foreground hover:underline mr-4"
1224 >
1225 Back to list
1226 </Link>
1227 <Link
1228 href={{`/{plural}/${{{snake}.id}}/edit`}}
1229 className="bg-primary text-primary-foreground px-4 py-2 rounded hover:opacity-90 mr-2"
1230 >
1231 Edit
1232 </Link>
1233 <button
1234 onClick={{handleDelete}}
1235 className="bg-destructive text-destructive-foreground px-4 py-2 rounded hover:opacity-90"
1236 >
1237 Delete
1238 </button>
1239 </div>
1240 </div>
1241
1242 <div className="bg-card shadow rounded-lg p-6">
1243 <div className="mb-4">
1244 <label className="block text-foreground font-bold">ID</label>
1245 <p>{{{snake}.id}}</p>
1246 </div>
1247{field_displays}
1248 <div className="mb-4">
1249 <label className="block text-foreground font-bold">Created At</label>
1250 <p>{{new Date({snake}.created_at).toLocaleString()}}</p>
1251 </div>
1252 <div className="mb-4">
1253 <label className="block text-foreground font-bold">Updated At</label>
1254 <p>{{new Date({snake}.updated_at).toLocaleString()}}</p>
1255 </div>
1256 </div>
1257 </div>
1258 </div>
1259 );
1260}}
1261"#,
1262 name = name,
1263 snake = snake_name,
1264 plural = plural_snake,
1265 field_displays = field_displays,
1266 ts_fields = fields
1267 .iter()
1268 .map(|f| format!(" {}: {};\n", f.name, f.field_type.to_typescript_type()))
1269 .collect::<String>()
1270 );
1271
1272 fs::write(file_path, content).expect("Failed to write Show.tsx");
1273}
1274
1275fn generate_create_page(
1276 pages_dir: &Path,
1277 name: &str,
1278 _snake_name: &str,
1279 plural_snake: &str,
1280 fields: &[Field],
1281 foreign_keys: &[ForeignKeyInfo],
1282) {
1283 let file_path = pages_dir.join("Create.tsx");
1284
1285 let form_inputs: String = fields
1287 .iter()
1288 .map(|f| {
1289 if let Some(fk) = foreign_keys.iter().find(|fk| fk.field_name == f.name) {
1291 if fk.validated {
1292 let target_plural = pluralize(&to_snake_case(&fk.target_model));
1294 let target_snake = to_snake_case(&fk.target_model);
1295 format!(
1296 r#" <div className="mb-4">
1297 <label className="block text-foreground mb-2">{label}</label>
1298 <select
1299 value={{data.{field}}}
1300 onChange={{e => setData('{field}', parseInt(e.target.value) || 0)}}
1301 className="w-full border rounded px-3 py-2"
1302 >
1303 <option value="">Select {target_label}...</option>
1304 {{{target_plural}.map(({target_snake}) => (
1305 <option key={{{target_snake}.id}} value={{{target_snake}.id}}>
1306 {{{target_snake}.name ?? {target_snake}.title ?? {target_snake}.email ?? {target_snake}.id}}
1307 </option>
1308 ))}}
1309 </select>
1310 {{errors.{field} && <p className="text-destructive text-sm mt-1">{{errors.{field}}}</p>}}
1311 </div>
1312"#,
1313 label = to_pascal_case(&f.name),
1314 field = f.name,
1315 target_label = fk.target_model,
1316 target_plural = target_plural,
1317 target_snake = target_snake
1318 )
1319 } else {
1320 format!(
1322 r#" {{/* TODO: Replace with select once {target_model} model exists */}}
1323 <div className="mb-4">
1324 <label className="block text-foreground mb-2">{label}</label>
1325 <input
1326 type="number"
1327 value={{data.{field}}}
1328 onChange={{e => setData('{field}', parseInt(e.target.value) || 0)}}
1329 className="w-full border rounded px-3 py-2"
1330 />
1331 {{errors.{field} && <p className="text-destructive text-sm mt-1">{{errors.{field}}}</p>}}
1332 </div>
1333"#,
1334 label = to_pascal_case(&f.name),
1335 field = f.name,
1336 target_model = fk.target_model
1337 )
1338 }
1339 } else {
1340 let input_type = f.field_type.to_form_input_type();
1342 if input_type == "textarea" {
1343 format!(
1344 r#" <div className="mb-4">
1345 <label className="block text-foreground mb-2">{label}</label>
1346 <textarea
1347 value={{data.{field}}}
1348 onChange={{e => setData('{field}', e.target.value)}}
1349 className="w-full border rounded px-3 py-2"
1350 rows={{4}}
1351 />
1352 {{errors.{field} && <p className="text-destructive text-sm mt-1">{{errors.{field}}}</p>}}
1353 </div>
1354"#,
1355 label = to_pascal_case(&f.name),
1356 field = f.name
1357 )
1358 } else if input_type == "checkbox" {
1359 format!(
1360 r#" <div className="mb-4">
1361 <label className="flex items-center">
1362 <input
1363 type="checkbox"
1364 checked={{data.{field}}}
1365 onChange={{e => setData('{field}', e.target.checked)}}
1366 className="mr-2"
1367 />
1368 <span className="text-foreground">{label}</span>
1369 </label>
1370 {{errors.{field} && <p className="text-destructive text-sm mt-1">{{errors.{field}}}</p>}}
1371 </div>
1372"#,
1373 label = to_pascal_case(&f.name),
1374 field = f.name
1375 )
1376 } else {
1377 format!(
1378 r#" <div className="mb-4">
1379 <label className="block text-foreground mb-2">{label}</label>
1380 <input
1381 type="{input_type}"
1382 value={{data.{field}}}
1383 onChange={{e => setData('{field}', e.target.value)}}
1384 className="w-full border rounded px-3 py-2"
1385 />
1386 {{errors.{field} && <p className="text-destructive text-sm mt-1">{{errors.{field}}}</p>}}
1387 </div>
1388"#,
1389 label = to_pascal_case(&f.name),
1390 field = f.name,
1391 input_type = input_type
1392 )
1393 }
1394 }
1395 })
1396 .collect();
1397
1398 let initial_data: String = fields
1400 .iter()
1401 .map(|f| {
1402 let default_value = match f.field_type {
1403 FieldType::String | FieldType::Text => "''",
1404 FieldType::Integer | FieldType::BigInteger | FieldType::Float => "0",
1405 FieldType::Boolean => "false",
1406 FieldType::DateTime | FieldType::Date => "''",
1407 FieldType::Uuid => "''",
1408 };
1409 format!(" {}: {},\n", f.name, default_value)
1410 })
1411 .collect();
1412
1413 let validated_fks: Vec<_> = foreign_keys.iter().filter(|fk| fk.validated).collect();
1415 let fk_interfaces: String = validated_fks
1416 .iter()
1417 .map(|fk| {
1418 format!(
1419 r#"
1420interface {target_model} {{
1421 id: number;
1422 name?: string;
1423 title?: string;
1424 email?: string;
1425}}
1426"#,
1427 target_model = fk.target_model
1428 )
1429 })
1430 .collect();
1431
1432 let fk_props: String = validated_fks
1434 .iter()
1435 .map(|fk| {
1436 let target_plural = pluralize(&to_snake_case(&fk.target_model));
1437 format!(" {}: {}[];\n", target_plural, fk.target_model)
1438 })
1439 .collect();
1440
1441 let fk_destructure: String = if validated_fks.is_empty() {
1443 String::new()
1444 } else {
1445 validated_fks
1446 .iter()
1447 .map(|fk| pluralize(&to_snake_case(&fk.target_model)))
1448 .collect::<Vec<_>>()
1449 .join(", ")
1450 + ", "
1451 };
1452
1453 let content = format!(
1454 r#"import {{ Link, useForm }} from '@inertiajs/react';
1455{fk_interfaces}
1456interface Props {{
1457{fk_props} errors?: Record<string, string[]>;
1458}}
1459
1460export default function Create({{ {fk_destructure}errors: serverErrors }}: Props) {{
1461 const {{ data, setData, post, processing, errors }} = useForm({{
1462{initial_data} }});
1463
1464 const handleSubmit = (e: React.FormEvent) => {{
1465 e.preventDefault();
1466 post('/{plural_snake}');
1467 }};
1468
1469 return (
1470 <div className="container mx-auto px-4 py-8">
1471 <div className="max-w-2xl mx-auto">
1472 <div className="flex justify-between items-center mb-6">
1473 <h1 className="text-2xl font-bold">Create {name}</h1>
1474 <Link href="/{plural_snake}" className="text-muted-foreground hover:underline">
1475 Back to list
1476 </Link>
1477 </div>
1478
1479 <form onSubmit={{handleSubmit}} className="bg-card shadow rounded-lg p-6">
1480{form_inputs}
1481 <div className="flex justify-end">
1482 <button
1483 type="submit"
1484 disabled={{processing}}
1485 className="bg-primary text-primary-foreground px-4 py-2 rounded hover:opacity-90 disabled:opacity-50"
1486 >
1487 {{processing ? 'Creating...' : 'Create {name}'}}
1488 </button>
1489 </div>
1490 </form>
1491 </div>
1492 </div>
1493 );
1494}}
1495"#
1496 );
1497
1498 fs::write(file_path, content).expect("Failed to write Create.tsx");
1499}
1500
1501fn generate_edit_page(
1502 pages_dir: &Path,
1503 name: &str,
1504 snake_name: &str,
1505 plural_snake: &str,
1506 fields: &[Field],
1507 foreign_keys: &[ForeignKeyInfo],
1508) {
1509 let file_path = pages_dir.join("Edit.tsx");
1510
1511 let form_inputs: String = fields
1513 .iter()
1514 .map(|f| {
1515 if let Some(fk) = foreign_keys.iter().find(|fk| fk.field_name == f.name) {
1517 if fk.validated {
1518 let target_plural = pluralize(&to_snake_case(&fk.target_model));
1520 let target_snake = to_snake_case(&fk.target_model);
1521 format!(
1522 r#" <div className="mb-4">
1523 <label className="block text-foreground mb-2">{label}</label>
1524 <select
1525 value={{data.{field}}}
1526 onChange={{e => setData('{field}', parseInt(e.target.value) || 0)}}
1527 className="w-full border rounded px-3 py-2"
1528 >
1529 <option value="">Select {target_label}...</option>
1530 {{{target_plural}.map(({target_snake}) => (
1531 <option key={{{target_snake}.id}} value={{{target_snake}.id}}>
1532 {{{target_snake}.name ?? {target_snake}.title ?? {target_snake}.email ?? {target_snake}.id}}
1533 </option>
1534 ))}}
1535 </select>
1536 {{errors.{field} && <p className="text-destructive text-sm mt-1">{{errors.{field}}}</p>}}
1537 </div>
1538"#,
1539 label = to_pascal_case(&f.name),
1540 field = f.name,
1541 target_label = fk.target_model,
1542 target_plural = target_plural,
1543 target_snake = target_snake
1544 )
1545 } else {
1546 format!(
1548 r#" {{/* TODO: Replace with select once {target_model} model exists */}}
1549 <div className="mb-4">
1550 <label className="block text-foreground mb-2">{label}</label>
1551 <input
1552 type="number"
1553 value={{data.{field}}}
1554 onChange={{e => setData('{field}', parseInt(e.target.value) || 0)}}
1555 className="w-full border rounded px-3 py-2"
1556 />
1557 {{errors.{field} && <p className="text-destructive text-sm mt-1">{{errors.{field}}}</p>}}
1558 </div>
1559"#,
1560 label = to_pascal_case(&f.name),
1561 field = f.name,
1562 target_model = fk.target_model
1563 )
1564 }
1565 } else {
1566 let input_type = f.field_type.to_form_input_type();
1568 if input_type == "textarea" {
1569 format!(
1570 r#" <div className="mb-4">
1571 <label className="block text-foreground mb-2">{label}</label>
1572 <textarea
1573 value={{data.{field}}}
1574 onChange={{e => setData('{field}', e.target.value)}}
1575 className="w-full border rounded px-3 py-2"
1576 rows={{4}}
1577 />
1578 {{errors.{field} && <p className="text-destructive text-sm mt-1">{{errors.{field}}}</p>}}
1579 </div>
1580"#,
1581 label = to_pascal_case(&f.name),
1582 field = f.name
1583 )
1584 } else if input_type == "checkbox" {
1585 format!(
1586 r#" <div className="mb-4">
1587 <label className="flex items-center">
1588 <input
1589 type="checkbox"
1590 checked={{data.{field}}}
1591 onChange={{e => setData('{field}', e.target.checked)}}
1592 className="mr-2"
1593 />
1594 <span className="text-foreground">{label}</span>
1595 </label>
1596 {{errors.{field} && <p className="text-destructive text-sm mt-1">{{errors.{field}}}</p>}}
1597 </div>
1598"#,
1599 label = to_pascal_case(&f.name),
1600 field = f.name
1601 )
1602 } else {
1603 format!(
1604 r#" <div className="mb-4">
1605 <label className="block text-foreground mb-2">{label}</label>
1606 <input
1607 type="{input_type}"
1608 value={{data.{field}}}
1609 onChange={{e => setData('{field}', e.target.value)}}
1610 className="w-full border rounded px-3 py-2"
1611 />
1612 {{errors.{field} && <p className="text-destructive text-sm mt-1">{{errors.{field}}}</p>}}
1613 </div>
1614"#,
1615 label = to_pascal_case(&f.name),
1616 field = f.name,
1617 input_type = input_type
1618 )
1619 }
1620 }
1621 })
1622 .collect();
1623
1624 let initial_data: String = fields
1626 .iter()
1627 .map(|f| format!(" {}: {}.{},\n", f.name, snake_name, f.name))
1628 .collect();
1629
1630 let validated_fks: Vec<_> = foreign_keys.iter().filter(|fk| fk.validated).collect();
1632 let fk_interfaces: String = validated_fks
1633 .iter()
1634 .map(|fk| {
1635 format!(
1636 r#"
1637interface {target_model} {{
1638 id: number;
1639 name?: string;
1640 title?: string;
1641 email?: string;
1642}}
1643"#,
1644 target_model = fk.target_model
1645 )
1646 })
1647 .collect();
1648
1649 let fk_props: String = validated_fks
1651 .iter()
1652 .map(|fk| {
1653 let target_plural = pluralize(&to_snake_case(&fk.target_model));
1654 format!(" {}: {}[];\n", target_plural, fk.target_model)
1655 })
1656 .collect();
1657
1658 let fk_destructure: String = if validated_fks.is_empty() {
1660 String::new()
1661 } else {
1662 validated_fks
1663 .iter()
1664 .map(|fk| pluralize(&to_snake_case(&fk.target_model)))
1665 .collect::<Vec<_>>()
1666 .join(", ")
1667 + ", "
1668 };
1669
1670 let content = format!(
1671 r#"import {{ Link, useForm }} from '@inertiajs/react';
1672{fk_interfaces}
1673interface {name} {{
1674 id: number;
1675{ts_fields} created_at: string;
1676 updated_at: string;
1677}}
1678
1679interface Props {{
1680 {snake}: {name};
1681{fk_props} errors?: Record<string, string[]>;
1682}}
1683
1684export default function Edit({{ {snake}, {fk_destructure}errors: serverErrors }}: Props) {{
1685 const {{ data, setData, put, processing, errors }} = useForm({{
1686{initial_data} }});
1687
1688 const handleSubmit = (e: React.FormEvent) => {{
1689 e.preventDefault();
1690 put(`/{plural}/${{{snake}.id}}`);
1691 }};
1692
1693 return (
1694 <div className="container mx-auto px-4 py-8">
1695 <div className="max-w-2xl mx-auto">
1696 <div className="flex justify-between items-center mb-6">
1697 <h1 className="text-2xl font-bold">Edit {name}</h1>
1698 <Link href="/{plural}" className="text-muted-foreground hover:underline">
1699 Back to list
1700 </Link>
1701 </div>
1702
1703 <form onSubmit={{handleSubmit}} className="bg-card shadow rounded-lg p-6">
1704{form_inputs}
1705 <div className="flex justify-end">
1706 <button
1707 type="submit"
1708 disabled={{processing}}
1709 className="bg-primary text-primary-foreground px-4 py-2 rounded hover:opacity-90 disabled:opacity-50"
1710 >
1711 {{processing ? 'Saving...' : 'Save Changes'}}
1712 </button>
1713 </div>
1714 </form>
1715 </div>
1716 </div>
1717 );
1718}}
1719"#,
1720 name = name,
1721 snake = snake_name,
1722 plural = plural_snake,
1723 form_inputs = form_inputs,
1724 initial_data = initial_data,
1725 ts_fields = fields
1726 .iter()
1727 .map(|f| format!(" {}: {};\n", f.name, f.field_type.to_typescript_type()))
1728 .collect::<String>(),
1729 fk_interfaces = fk_interfaces,
1730 fk_props = fk_props,
1731 fk_destructure = fk_destructure
1732 );
1733
1734 fs::write(file_path, content).expect("Failed to write Edit.tsx");
1735}
1736
1737fn print_route_instructions(name: &str, snake_name: &str, plural_snake: &str) {
1738 println!("\nš Add these routes to src/routes.rs:\n");
1739 println!(
1740 r#"use crate::controllers::{snake_name}_controller;
1741
1742// {name} routes
1743route("/{plural_snake}", {snake_name}_controller::index);
1744route("/{plural_snake}/create", {snake_name}_controller::create);
1745route_post("/{plural_snake}", {snake_name}_controller::store);
1746route("/{plural_snake}/{{id}}", {snake_name}_controller::show);
1747route("/{plural_snake}/{{id}}/edit", {snake_name}_controller::edit);
1748route_put("/{plural_snake}/{{id}}", {snake_name}_controller::update);
1749route_delete("/{plural_snake}/{{id}}", {snake_name}_controller::destroy);"#
1750 );
1751}
1752
1753fn register_routes(snake_name: &str, plural_snake: &str, skip_confirm: bool) {
1754 let routes_path = Path::new("src/routes.rs");
1755
1756 if !routes_path.exists() {
1757 eprintln!("Warning: src/routes.rs not found. Skipping route registration.");
1758 return;
1759 }
1760
1761 let content = fs::read_to_string(routes_path).expect("Failed to read routes.rs");
1762
1763 let resource_pattern = format!("resource!(\"/{plural_snake}\"");
1765 if content.contains(&resource_pattern) {
1766 println!(" āļø Route already registered for /{plural_snake}");
1767 return;
1768 }
1769
1770 let route_entry = format!(
1772 "\n // {} routes\n resource!(\"/{}\", controllers::{}),",
1773 to_pascal_case(snake_name),
1774 plural_snake,
1775 snake_name
1776 );
1777 let use_statement = format!("{}::{}_controller", "controllers", snake_name);
1778
1779 println!("\nš Route registration:");
1780 println!(" Will add: resource!(\"/{plural_snake}\", controllers::{snake_name})");
1781
1782 if !skip_confirm {
1783 let confirmed = Confirm::new()
1784 .with_prompt("Register route in src/routes.rs?")
1785 .default(true)
1786 .interact()
1787 .unwrap_or(false);
1788
1789 if !confirmed {
1790 println!(" āļø Skipped route registration");
1791 return;
1792 }
1793 }
1794
1795 let _ = use_statement; if let Some(routes_start) = content.find("routes!") {
1803 if let Some(brace_start) = content[routes_start..].find('{') {
1804 let routes_content_start = routes_start + brace_start + 1;
1805 let mut depth = 1;
1806 let mut insert_pos = None;
1807
1808 for (i, c) in content[routes_content_start..].char_indices() {
1809 match c {
1810 '{' => depth += 1,
1811 '}' => {
1812 depth -= 1;
1813 if depth == 0 {
1814 insert_pos = Some(routes_content_start + i);
1815 break;
1816 }
1817 }
1818 _ => {}
1819 }
1820 }
1821
1822 if let Some(pos) = insert_pos {
1823 let updated_content =
1824 format!("{}{}\n{}", &content[..pos], route_entry, &content[pos..]);
1825
1826 fs::write(routes_path, updated_content).expect("Failed to write routes.rs");
1827 println!(" ā
Registered route in src/routes.rs");
1828 return;
1829 }
1830 }
1831 }
1832
1833 eprintln!("Warning: Could not find routes! macro. Skipping route registration.");
1834}
1835
1836fn generate_tests(
1837 name: &str,
1838 snake_name: &str,
1839 plural_snake: &str,
1840 fields: &[Field],
1841 with_factory: bool,
1842) {
1843 let tests_dir = Path::new("src/tests");
1844
1845 if !tests_dir.exists() {
1846 fs::create_dir_all(tests_dir).expect("Failed to create tests directory");
1847 }
1848
1849 let file_path = tests_dir.join(format!("{snake_name}_controller_test.rs"));
1850
1851 let test_content = if with_factory {
1853 let scaffold_fields: Vec<templates::ScaffoldField> = fields
1855 .iter()
1856 .map(|f| templates::ScaffoldField {
1857 name: f.name.clone(),
1858 field_type: f.field_type.to_scaffold_type().to_string(),
1859 })
1860 .collect();
1861
1862 templates::scaffold_test_with_factory_template(
1863 snake_name,
1864 plural_snake,
1865 name,
1866 &scaffold_fields,
1867 )
1868 } else {
1869 templates::scaffold_test_template(snake_name, plural_snake)
1870 };
1871
1872 fs::write(&file_path, test_content).expect("Failed to write test file");
1873
1874 update_tests_mod(snake_name);
1876
1877 let test_type = if with_factory {
1878 "test (with factory usage)"
1879 } else {
1880 "test"
1881 };
1882 println!(" š¦ Created {test_type}: src/tests/{snake_name}_controller_test.rs");
1883}
1884
1885fn update_tests_mod(snake_name: &str) {
1886 let mod_path = Path::new("src/tests/mod.rs");
1887 let module_name = format!("{snake_name}_controller_test");
1888
1889 if !mod_path.exists() {
1890 let content = format!("pub mod {module_name};\n");
1891 fs::write(mod_path, content).expect("Failed to write mod.rs");
1892 return;
1893 }
1894
1895 let content = fs::read_to_string(mod_path).expect("Failed to read mod.rs");
1896 let mod_declaration = format!("pub mod {module_name};");
1897
1898 if content.contains(&mod_declaration) {
1899 return;
1900 }
1901
1902 let updated = format!("{content}{mod_declaration}\n");
1903 fs::write(mod_path, updated).expect("Failed to write mod.rs");
1904}
1905
1906fn generate_scaffold_factory(
1907 name: &str,
1908 snake_name: &str,
1909 fields: &[Field],
1910 foreign_keys: &[ForeignKeyInfo],
1911) {
1912 let factories_dir = Path::new("src/factories");
1913
1914 if !factories_dir.exists() {
1915 fs::create_dir_all(factories_dir).expect("Failed to create factories directory");
1916 }
1917
1918 let file_name = format!("{snake_name}_factory");
1919 let struct_name = format!("{name}Factory");
1920 let file_path = factories_dir.join(format!("{file_name}.rs"));
1921
1922 let scaffold_fields: Vec<templates::ScaffoldField> = fields
1924 .iter()
1925 .map(|f| templates::ScaffoldField {
1926 name: f.name.clone(),
1927 field_type: f.field_type.to_scaffold_type().to_string(),
1928 })
1929 .collect();
1930
1931 let scaffold_fks: Vec<templates::ScaffoldForeignKey> = foreign_keys
1933 .iter()
1934 .map(|fk| templates::ScaffoldForeignKey {
1935 field_name: fk.field_name.clone(),
1936 target_model: fk.target_model.clone(),
1937 target_snake: fk.target_table.trim_end_matches('s').to_string(), validated: fk.validated,
1939 })
1940 .collect();
1941
1942 let factory_content = templates::scaffold_factory_template(
1943 &file_name,
1944 &struct_name,
1945 name,
1946 &scaffold_fields,
1947 &scaffold_fks,
1948 );
1949
1950 fs::write(&file_path, factory_content).expect("Failed to write factory file");
1951
1952 update_factories_mod(&file_name);
1954
1955 println!(" š¦ Created factory: src/factories/{file_name}.rs");
1956}
1957
1958fn update_factories_mod(file_name: &str) {
1959 let mod_path = Path::new("src/factories/mod.rs");
1960
1961 if !mod_path.exists() {
1962 let content = format!(
1963 "{}pub mod {};\npub use {}::*;\n",
1964 templates::factories_mod(),
1965 file_name,
1966 file_name
1967 );
1968 fs::write(mod_path, content).expect("Failed to write mod.rs");
1969 return;
1970 }
1971
1972 let content = fs::read_to_string(mod_path).expect("Failed to read mod.rs");
1973 let mod_declaration = format!("pub mod {file_name};");
1974
1975 if content.contains(&mod_declaration) {
1976 return;
1977 }
1978
1979 let updated = format!("{content}{mod_declaration}\npub use {file_name}::*;\n");
1980 fs::write(mod_path, updated).expect("Failed to write mod.rs");
1981}
1982
1983fn apply_smart_defaults(
1992 explicit_api: bool,
1993 explicit_tests: bool,
1994 explicit_factory: bool,
1995 tracking: &mut SmartDefaults,
1996) -> (bool, bool, bool) {
1997 let analyzer = ProjectAnalyzer::current_dir();
1999 let conventions = analyzer.analyze();
2000
2001 let api_only = apply_api_smart_default_from_conventions(explicit_api, &conventions, tracking);
2002 let with_tests = apply_test_smart_default(explicit_tests, &conventions, tracking);
2003 let with_factory = apply_factory_smart_default(explicit_factory, &conventions, tracking);
2004
2005 (api_only, with_tests, with_factory)
2006}
2007
2008fn apply_api_smart_default_from_conventions(
2010 explicit_api: bool,
2011 conventions: &ProjectConventions,
2012 tracking: &mut SmartDefaults,
2013) -> bool {
2014 if explicit_api {
2016 return true;
2017 }
2018
2019 if !conventions.has_inertia_pages {
2021 tracking.api_detected = true;
2022 return true;
2023 }
2024
2025 false
2026}
2027
2028fn apply_test_smart_default(
2030 explicit_tests: bool,
2031 conventions: &ProjectConventions,
2032 tracking: &mut SmartDefaults,
2033) -> bool {
2034 if explicit_tests {
2036 return true;
2037 }
2038
2039 if conventions.test_pattern == TestPattern::PerController && conventions.test_file_count > 0 {
2041 tracking.test_detected = true;
2042 tracking.test_count = conventions.test_file_count;
2043 return true;
2044 }
2045
2046 false
2047}
2048
2049fn apply_factory_smart_default(
2051 explicit_factory: bool,
2052 conventions: &ProjectConventions,
2053 tracking: &mut SmartDefaults,
2054) -> bool {
2055 if explicit_factory {
2057 return true;
2058 }
2059
2060 if conventions.factory_pattern == FactoryPattern::PerModel && conventions.factory_file_count > 0
2062 {
2063 tracking.factory_detected = true;
2064 tracking.factory_count = conventions.factory_file_count;
2065 return true;
2066 }
2067
2068 false
2069}