Skip to main content

ferro_cli/commands/
make_scaffold.rs

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/// Tracks which smart defaults were applied and why.
11#[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)>, // (name, type, reason)
19}
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        // Project type
34        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        // Test pattern
43        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        // Factory pattern
53        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        // Applied flags summary
63        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        // Field inferences
78        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    // Track what smart defaults were applied
102    let mut smart_defaults = SmartDefaults::default();
103
104    // Apply smart defaults unless disabled
105    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    // Validate resource name
112    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    // Parse fields (with type inference tracking)
120    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    // Display smart defaults summary (unless quiet or no smart defaults)
132    if !quiet && !no_smart_defaults && smart_defaults.has_any() {
133        smart_defaults.display(api_only, with_tests, with_factory);
134
135        // Interactive confirmation unless --yes is passed
136        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    // Detect foreign keys from field names
153    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
161    generate_migration(
162        &name,
163        &snake_name,
164        &plural_snake,
165        &parsed_fields,
166        &foreign_keys,
167    );
168
169    // Generate model (includes entity)
170    generate_model(&name, &snake_name, &parsed_fields, &foreign_keys);
171
172    // Generate controller
173    generate_controller(
174        &name,
175        &snake_name,
176        &plural_snake,
177        &parsed_fields,
178        &foreign_keys,
179        api_only,
180    );
181
182    // Generate Inertia pages (skip for API-only scaffold)
183    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    // Generate tests if requested
194    if with_tests {
195        generate_tests(
196            &name,
197            &snake_name,
198            &plural_snake,
199            &parsed_fields,
200            with_factory,
201        );
202    }
203
204    // Generate factory if requested
205    if with_factory {
206        generate_scaffold_factory(&name, &snake_name, &parsed_fields, &foreign_keys);
207    }
208
209    // Auto-register routes or print instructions
210    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                // Just field name, infer type from naming convention
370                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                // Explicit name:type format
388                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
410/// Infer field type from naming conventions.
411///
412/// Returns (FieldType, reason) tuple.
413///
414/// Patterns:
415/// - `*_id` -> bigint (foreign key)
416/// - `*_at` -> datetime (timestamp)
417/// - `email` -> string
418/// - `password` -> string
419/// - `is_*` or `has_*` -> bool
420/// - default -> string
421fn infer_field_type(name: &str) -> (FieldType, &'static str) {
422    // Foreign key pattern
423    if name.ends_with("_id") {
424        return (FieldType::BigInteger, "foreign key pattern");
425    }
426
427    // Timestamp pattern
428    if name.ends_with("_at") {
429        return (FieldType::DateTime, "timestamp pattern");
430    }
431
432    // Boolean patterns
433    if name.starts_with("is_") || name.starts_with("has_") {
434        return (FieldType::Boolean, "boolean pattern");
435    }
436
437    // Common field names
438    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    // Simple pluralization rules
497    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    // Check for both possible migration directory locations
519    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    // Generate timestamp
530    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    // Build column definitions
536    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    // Build foreign key constraints for validated FKs only
547    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    // Build FK table enum imports if we have validated FKs
576    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 mod.rs
662    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    // Check for both possible mod.rs locations
673    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    // Add module declaration
685    let mod_declaration = format!("pub mod {migration_name};");
686    if content.contains(&mod_declaration) {
687        return;
688    }
689
690    // Find where to insert module declaration (after existing pub mod lines)
691    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    // Also add to Migrator
703    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            // Find the vec![ line and add before closing ]
710        }
711    }
712
713    // Simple approach: find "]" line in migrations() and insert before it
714    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        // Find last ] in migrations function and insert before it
719        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    // Build field definitions for the entity
762    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    // Build Relation enum variants for validated FKs
772    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    // Build Related<T> impls for validated FKs
803    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 mod.rs
865    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    // Build update field assignments (builder setter calls)
907    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    // Build form struct fields with validation attributes
916    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    // Convert ForeignKeyInfo to ForeignKeyField for templates
938    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        // Use FK-aware API template if there are foreign keys
950        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        // Use FK-aware template if there are foreign keys
972        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 mod.rs
995    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
1040    generate_index_page(&pages_dir, name, snake_name, plural_snake, fields);
1041
1042    // Generate Show page
1043    generate_show_page(&pages_dir, name, snake_name, plural_snake, fields);
1044
1045    // Generate Create page
1046    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
1056    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    // Build table headers
1078    let headers: String = fields
1079        .iter()
1080        .map(|f| format!("              <th>{}</th>\n", to_pascal_case(&f.name)))
1081        .collect();
1082
1083    // Build table cells
1084    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    // Build field displays
1179    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    // Build form inputs with FK select dropdowns
1286    let form_inputs: String = fields
1287        .iter()
1288        .map(|f| {
1289            // Check if this field is a foreign key
1290            if let Some(fk) = foreign_keys.iter().find(|fk| fk.field_name == f.name) {
1291                if fk.validated {
1292                    // Validated FK: render select dropdown
1293                    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                    // Unvalidated FK: render number input with TODO
1321                    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                // Regular field
1341                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    // Build initial data
1399    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    // Build TypeScript interfaces for related data
1414    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    // Build props interface with related data
1433    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    // Build props destructuring
1442    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    // Build form inputs with FK select dropdowns
1512    let form_inputs: String = fields
1513        .iter()
1514        .map(|f| {
1515            // Check if this field is a foreign key
1516            if let Some(fk) = foreign_keys.iter().find(|fk| fk.field_name == f.name) {
1517                if fk.validated {
1518                    // Validated FK: render select dropdown
1519                    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                    // Unvalidated FK: render number input with TODO
1547                    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                // Regular field
1567                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    // Build initial data from prop
1625    let initial_data: String = fields
1626        .iter()
1627        .map(|f| format!("    {}: {}.{},\n", f.name, snake_name, f.name))
1628        .collect();
1629
1630    // Build TypeScript interfaces for related data
1631    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    // Build props interface with related data
1650    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    // Build props destructuring
1659    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    // Check if resource already registered
1764    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    // Show what will be added and confirm
1771    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    // Find routes! macro and insert before its closing brace
1796    // Note: resource! macro accesses controllers::name module directly
1797    // No additional use statement needed since routes already imports controllers
1798    let _ = use_statement; // Mark as intentionally unused
1799
1800    // Find the closing brace of routes! macro
1801    // Strategy: Find "routes! {" and then find its matching "}"
1802    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    // Choose template based on whether factory is also being generated
1852    let test_content = if with_factory {
1853        // Convert Field to ScaffoldField for template
1854        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.rs
1875    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    // Convert Field to ScaffoldField for template
1923    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    // Convert ForeignKeyInfo to ScaffoldForeignKey for template
1932    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(), // users -> user
1938            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.rs
1953    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
1983/// Apply smart defaults for scaffold generation based on project structure.
1984///
1985/// Returns (api_only, with_tests, with_factory) tuple with detected defaults.
1986/// User-explicit flags are preserved (e.g., if --api is passed, always use API mode).
1987/// Apply smart defaults for scaffold generation based on project structure.
1988///
1989/// Returns (api_only, with_tests, with_factory) tuple with detected defaults.
1990/// User-explicit flags are preserved (e.g., if --api is passed, always use API mode).
1991fn apply_smart_defaults(
1992    explicit_api: bool,
1993    explicit_tests: bool,
1994    explicit_factory: bool,
1995    tracking: &mut SmartDefaults,
1996) -> (bool, bool, bool) {
1997    // Analyze project once for all detections
1998    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
2008/// Apply smart default for API-only mode based on analyzed conventions.
2009fn apply_api_smart_default_from_conventions(
2010    explicit_api: bool,
2011    conventions: &ProjectConventions,
2012    tracking: &mut SmartDefaults,
2013) -> bool {
2014    // If user explicitly requested API mode, honor that
2015    if explicit_api {
2016        return true;
2017    }
2018
2019    // If no Inertia pages found, suggest API-only mode
2020    if !conventions.has_inertia_pages {
2021        tracking.api_detected = true;
2022        return true;
2023    }
2024
2025    false
2026}
2027
2028/// Apply smart default for --with-tests based on existing test patterns.
2029fn apply_test_smart_default(
2030    explicit_tests: bool,
2031    conventions: &ProjectConventions,
2032    tracking: &mut SmartDefaults,
2033) -> bool {
2034    // If user explicitly requested tests, honor that
2035    if explicit_tests {
2036        return true;
2037    }
2038
2039    // If existing test pattern detected, suggest --with-tests
2040    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
2049/// Apply smart default for --with-factory based on existing factory patterns.
2050fn apply_factory_smart_default(
2051    explicit_factory: bool,
2052    conventions: &ProjectConventions,
2053    tracking: &mut SmartDefaults,
2054) -> bool {
2055    // If user explicitly requested factory, honor that
2056    if explicit_factory {
2057        return true;
2058    }
2059
2060    // If existing factory pattern detected, suggest --with-factory
2061    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}