ferro-cli 0.2.4

CLI for scaffolding Ferro web applications
Documentation
// Entity/model generation templates (used by `db:sync`)

/// Convert PascalCase to snake_case
pub(crate) fn to_snake_case(s: &str) -> String {
    let mut result = String::new();
    for (i, c) in s.chars().enumerate() {
        if c.is_uppercase() && i > 0 {
            result.push('_');
        }
        result.push(c.to_ascii_lowercase());
    }
    result
}

/// Column information from database schema
pub struct ColumnInfo {
    pub name: String,
    pub col_type: String,
    pub is_nullable: bool,
    pub is_primary_key: bool,
}

/// Table information from database schema
pub struct TableInfo {
    pub name: String,
    pub columns: Vec<ColumnInfo>,
}

/// Rust reserved keywords that need escaping with r# prefix
const RUST_RESERVED_KEYWORDS: &[&str] = &[
    "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn", "for",
    "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return",
    "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use", "where",
    "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final", "macro",
    "override", "priv", "typeof", "unsized", "virtual", "yield", "try",
];

/// Check if a name is a Rust reserved keyword
fn is_reserved_keyword(name: &str) -> bool {
    RUST_RESERVED_KEYWORDS.contains(&name)
}

/// Escape a column name if it's a reserved keyword
fn escape_column_name(name: &str) -> String {
    if is_reserved_keyword(name) {
        format!("r#{name}")
    } else {
        name.to_string()
    }
}

/// Generate auto-generated entity file (regenerated on every sync)
pub fn entity_template(table_name: &str, columns: &[ColumnInfo]) -> String {
    let _struct_name = to_pascal_case(&singularize(table_name));

    // Generate column fields
    let column_fields: Vec<String> = columns
        .iter()
        .map(|col| {
            let rust_type = sql_type_to_rust_type(col);
            let mut attrs = Vec::new();

            if col.is_primary_key {
                attrs.push("    #[sea_orm(primary_key)]".to_string());
            }

            // Handle reserved keywords
            let field_name = escape_column_name(&col.name);
            if is_reserved_keyword(&col.name) {
                attrs.push(format!("    #[sea_orm(column_name = \"{}\")]", col.name));
            }

            let field = format!("    pub {field_name}: {rust_type},");
            if attrs.is_empty() {
                field
            } else {
                format!("{}\n{}", attrs.join("\n"), field)
            }
        })
        .collect();

    // Find primary key columns (reserved for future use)
    let _pk_columns: Vec<&ColumnInfo> = columns.iter().filter(|c| c.is_primary_key).collect();

    format!(
        r#"// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by `ferro db:sync` - Changes will be overwritten
// Add custom code to src/models/{table_name}.rs instead

use ferro::FerroModel;
use sea_orm::entity::prelude::*;
use serde::Serialize;

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, FerroModel)]
#[sea_orm(table_name = "{table_name}")]
pub struct Model {{
{columns}
}}

// Note: Relation enum is required here for DeriveEntityModel macro.
// Define your actual relations in src/models/{table_name}.rs using the Related trait.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {{}}
"#,
        table_name = table_name,
        columns = column_fields.join("\n"),
    )
}

/// Generate user model file with Eloquent-like API (created only once, never overwritten)
///
/// The FerroModel derive macro (applied in entities/{table}.rs) generates:
/// - query() - Start a query builder
/// - create() - Return a builder for inserts
/// - set_*() - Field setters on Model
/// - update() - Save changes to database
/// - delete() - Delete record
/// - {Model}Builder struct with setters and insert()
/// - ActiveModelBehavior, Model, and ModelMut trait implementations
///
/// This template only generates re-exports, type alias, and custom code sections.
pub fn user_model_template(table_name: &str, struct_name: &str, columns: &[ColumnInfo]) -> String {
    let pk_field = columns
        .iter()
        .find(|c| c.is_primary_key)
        .map(|c| c.name.as_str())
        .unwrap_or("id");

    // Auto-implement Authenticatable for users table
    let authenticatable_impl = if table_name == "users" {
        format!(
            r#"
// ============================================================================
// AUTHENTICATION
// Auto-implemented Authenticatable trait for users table
// ============================================================================

impl ferro::auth::Authenticatable for Model {{
    fn auth_identifier(&self) -> i64 {{
        self.{pk_field} as i64
    }}

    fn as_any(&self) -> &dyn std::any::Any {{
        self
    }}
}}
"#
        )
    } else {
        String::new()
    };

    format!(
        r#"//! {struct_name} model
//!
//! This file contains custom implementations for the {struct_name} model.
//! The base entity is auto-generated in src/models/entities/{table_name}.rs
//!
//! The FerroModel derive macro provides the Eloquent-like API:
//! - {struct_name}::query() - Start a query builder
//! - {struct_name}::create().set_field("value").insert() - Create records
//! - model.set_field("value").update() - Update records
//! - model.delete() - Delete records
//!
//! This file is NEVER overwritten by `ferro db:sync` - your custom code is safe here.

// Re-export the auto-generated entity (includes FerroModel-generated code)
pub use super::entities::{table_name}::*;

/// Type alias for convenient access
pub type {struct_name} = Model;

// ============================================================================
// CUSTOM METHODS
// Add your custom query and mutation methods below
// ============================================================================

// Example custom finder:
// impl Model {{
//     pub async fn find_by_email(email: &str) -> Result<Option<Self>, ferro::FrameworkError> {{
//         Self::query().filter(Column::Email.eq(email)).first().await
//     }}
// }}

// ============================================================================
// RELATIONS
// Define relationships to other entities here
// ============================================================================

// Example: One-to-Many relation
// impl Entity {{
//     pub fn has_many_posts() -> RelationDef {{
//         Entity::has_many(super::posts::Entity).into()
//     }}
// }}

// Example: Belongs-To relation
// impl Entity {{
//     pub fn belongs_to_user() -> RelationDef {{
//         Entity::belongs_to(super::users::Entity)
//             .from(Column::UserId)
//             .to(super::users::Column::Id)
//             .into()
//     }}
// }}
{authenticatable_impl}"#,
    )
}

/// Generate entities/mod.rs (regenerated on every sync)
pub fn entities_mod_template(tables: &[TableInfo]) -> String {
    let mut content =
        String::from("// AUTO-GENERATED FILE - DO NOT EDIT\n// Generated by `ferro db:sync`\n\n");

    for table in tables {
        content.push_str(&format!("pub mod {};\n", table.name));
    }

    content
}

// Helper functions for entity generation

fn sql_type_to_rust_type(col: &ColumnInfo) -> String {
    let col_type_upper = col.col_type.to_uppercase();
    let base_type = if col_type_upper.contains("INT") {
        if col_type_upper.contains("BIGINT") || col_type_upper.contains("INT8") {
            "i64"
        } else if col_type_upper.contains("SMALLINT") || col_type_upper.contains("INT2") {
            "i16"
        } else {
            "i32"
        }
    } else if col_type_upper.contains("TEXT")
        || col_type_upper.contains("VARCHAR")
        || col_type_upper.contains("CHAR")
        || col_type_upper.contains("CHARACTER")
    {
        "String"
    } else if col_type_upper.contains("BOOL") {
        "bool"
    } else if col_type_upper.contains("REAL") || col_type_upper.contains("FLOAT4") {
        "f32"
    } else if col_type_upper.contains("DOUBLE") || col_type_upper.contains("FLOAT8") {
        "f64"
    } else if col_type_upper.contains("TIMESTAMP") || col_type_upper.contains("DATETIME") {
        "DateTimeUtc"
    } else if col_type_upper.contains("DATE") {
        "Date"
    } else if col_type_upper.contains("TIME") {
        "Time"
    } else if col_type_upper.contains("UUID") {
        "Uuid"
    } else if col_type_upper.contains("JSON") {
        "Json"
    } else if col_type_upper.contains("BYTEA") || col_type_upper.contains("BLOB") {
        "Vec<u8>"
    } else if col_type_upper.contains("DECIMAL") || col_type_upper.contains("NUMERIC") {
        "Decimal"
    } else {
        "String" // fallback
    };

    if col.is_nullable {
        format!("Option<{base_type}>")
    } else {
        base_type.to_string()
    }
}

pub(crate) fn to_pascal_case(s: &str) -> String {
    let mut result = String::new();
    let mut capitalize_next = true;

    for c in s.chars() {
        if c == '_' || c == '-' || c == ' ' {
            capitalize_next = true;
        } else if capitalize_next {
            result.push(c.to_uppercase().next().unwrap());
            capitalize_next = false;
        } else {
            result.push(c);
        }
    }
    result
}

fn singularize(word: &str) -> String {
    if let Some(stem) = word.strip_suffix("ies") {
        format!("{stem}y")
    } else if let Some(stem) = word.strip_suffix("es") {
        if word.ends_with("ses") || word.ends_with("xes") {
            word.to_string()
        } else {
            stem.to_string()
        }
    } else if let Some(stem) = word.strip_suffix('s') {
        if word.ends_with("ss") || word.ends_with("us") {
            word.to_string()
        } else {
            stem.to_string()
        }
    } else {
        word.to_string()
    }
}