rok-cli 0.3.3

Developer CLI for rok-based Axum applications
mod controller;
mod factory;
mod migration;
mod model;
mod policy;
mod resource;
mod routes;
mod test;
mod validator;

use std::{fs, io::Write as _, path::Path};

use console::style;
use heck::{ToSnakeCase, ToUpperCamelCase};

// ── Field types ───────────────────────────────────────────────────────────────

#[derive(Debug, Clone)]
pub struct FieldDef {
    pub name: String,
    pub field_type: String,
    pub optional: bool,
}

impl FieldDef {
    pub fn parse_list(input: &str) -> Vec<Self> {
        input
            .split(',')
            .filter_map(|s| {
                let s = s.trim();
                let mut parts = s.splitn(2, ':');
                let name = parts.next()?.trim().to_snake_case();
                let raw_ft = parts.next()?.trim().to_lowercase();
                if name.is_empty() || raw_ft.is_empty() {
                    return None;
                }
                let optional = raw_ft.ends_with('?');
                let field_type = raw_ft.trim_end_matches('?').to_string();
                Some(FieldDef {
                    name,
                    field_type,
                    optional,
                })
            })
            .collect()
    }
}

pub(super) struct TypeMap {
    pub rust_type: &'static str,
    pub sql_type: &'static str,
    pub validate_attr: &'static str,
    pub fake_val: &'static str,
    pub resource_type: &'static str,
}

pub(super) fn type_map(ft: &str) -> TypeMap {
    match ft {
        "string" => TypeMap {
            rust_type: "String",
            sql_type: "VARCHAR(255)",
            validate_attr: "#[validate(required, max = 255)]",
            fake_val: "\"sample text\".to_string()",
            resource_type: "String",
        },
        "text" => TypeMap {
            rust_type: "String",
            sql_type: "TEXT",
            validate_attr: "#[validate(required)]",
            fake_val: "\"long form content here.\".to_string()",
            resource_type: "String",
        },
        "bool" => TypeMap {
            rust_type: "bool",
            sql_type: "BOOLEAN",
            validate_attr: "",
            fake_val: "false",
            resource_type: "bool",
        },
        "int" | "bigint" => TypeMap {
            rust_type: "i64",
            sql_type: "BIGINT",
            validate_attr: "#[validate(min = 0)]",
            fake_val: "0_i64",
            resource_type: "i64",
        },
        "float" => TypeMap {
            rust_type: "f64",
            sql_type: "DOUBLE PRECISION",
            validate_attr: "",
            fake_val: "0.0_f64",
            resource_type: "f64",
        },
        "decimal" => TypeMap {
            rust_type: "f64",
            sql_type: "DECIMAL(10, 2)",
            validate_attr: "",
            fake_val: "0.0_f64",
            resource_type: "f64",
        },
        "uuid" => TypeMap {
            rust_type: "String",
            sql_type: "UUID",
            validate_attr: "",
            fake_val: "\"00000000-0000-0000-0000-000000000000\".to_string()",
            resource_type: "String",
        },
        "date" => TypeMap {
            rust_type: "chrono::NaiveDate",
            sql_type: "DATE",
            validate_attr: "",
            fake_val: "chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()",
            resource_type: "String",
        },
        "datetime" => TypeMap {
            rust_type: "chrono::DateTime<chrono::Utc>",
            sql_type: "TIMESTAMPTZ",
            validate_attr: "",
            fake_val: "chrono::Utc::now()",
            resource_type: "String",
        },
        "json" => TypeMap {
            rust_type: "serde_json::Value",
            sql_type: "JSONB",
            validate_attr: "",
            fake_val: "serde_json::Value::Null",
            resource_type: "serde_json::Value",
        },
        _ => TypeMap {
            rust_type: "String",
            sql_type: "TEXT",
            validate_attr: "",
            fake_val: "String::new()",
            resource_type: "String",
        },
    }
}

// ── PK helpers ────────────────────────────────────────────────────────────────

pub(super) fn pk_rust_type(id: Option<&str>) -> &'static str {
    match id {
        Some("ulid") => "Ulid",
        Some("cuid2") => "Cuid2",
        Some("uuid_v7") => "UuidV7",
        Some("snowflake") => "Snowflake",
        Some("nanoid") => "NanoId",
        _ => "i64",
    }
}

pub(super) fn pk_use_import(id: Option<&str>) -> &'static str {
    match id {
        Some("ulid") => "use rok_ids::Ulid;\n",
        Some("cuid2") => "use rok_ids::Cuid2;\n",
        Some("uuid_v7") => "use rok_ids::UuidV7;\n",
        Some("snowflake") => "use rok_ids::Snowflake;\n",
        Some("nanoid") => "use rok_ids::NanoId;\n",
        _ => "",
    }
}

pub(super) fn pk_ddl(id: Option<&str>) -> &'static str {
    match id {
        Some("ulid") => "    \"id\" TEXT PRIMARY KEY NOT NULL",
        Some("cuid2") => "    \"id\" TEXT PRIMARY KEY NOT NULL",
        Some("uuid_v7") => "    \"id\" VARCHAR(36) PRIMARY KEY NOT NULL",
        Some("snowflake") => "    \"id\" BIGINT PRIMARY KEY NOT NULL",
        Some("nanoid") => "    \"id\" TEXT PRIMARY KEY NOT NULL",
        _ => "    \"id\" BIGSERIAL PRIMARY KEY",
    }
}

pub(super) fn pk_to_string(id: Option<&str>) -> &'static str {
    match id {
        None => "m.id",
        _ => "m.id.to_string()",
    }
}

// ── Migration folder helpers ──────────────────────────────────────────────────

fn migration_dir() -> &'static str {
    if Path::new("database/migrations").exists() {
        "database/migrations"
    } else {
        "migrations"
    }
}

fn next_migration_index(dir: &str) -> u32 {
    fs::read_dir(dir)
        .map(|rd| {
            rd.filter_map(|e| e.ok())
                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("sql"))
                .count() as u32
                + 1
        })
        .unwrap_or(1)
}

// ── File-system helpers ───────────────────────────────────────────────────────

fn write_new(path: &str, content: &str, force: bool) -> anyhow::Result<bool> {
    let p = Path::new(path);
    if p.exists() && !force {
        println!(
            "  {} {path} (already exists, use --force to overwrite)",
            style("skip").yellow()
        );
        return Ok(false);
    }
    if let Some(parent) = p.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(p, content)?;
    println!("  {} {path}", style("create").green().bold());
    Ok(true)
}

fn append_line(path: &str, line: &str) -> anyhow::Result<()> {
    if !Path::new(path).exists() {
        fs::create_dir_all(Path::new(path).parent().unwrap_or(Path::new(".")))?;
        fs::write(path, format!("{line}\n"))?;
        println!("  {} {path}", style("create").green().bold());
        return Ok(());
    }
    let existing = fs::read_to_string(path)?;
    if existing.contains(line.trim()) {
        return Ok(());
    }
    let mut f = fs::OpenOptions::new().append(true).open(path)?;
    writeln!(f, "{line}")?;
    println!("  {} {path}{line}", style("update").cyan().bold());
    Ok(())
}

// ── Main entry point ──────────────────────────────────────────────────────────

#[allow(clippy::too_many_arguments)]
pub fn crud(
    name: &str,
    fields_str: Option<&str>,
    id_type: Option<&str>,
    auth: bool,
    soft_delete: bool,
    paginate: bool,
    timestamps: bool,
    force: bool,
) -> anyhow::Result<()> {
    let fields_input: String = match fields_str {
        Some(s) => s.to_owned(),
        None => {
            use dialoguer::Input;
            println!("{}", style("rok make:crud — interactive wizard").bold());
            Input::<String>::new()
                .with_prompt("Fields (e.g. title:string,body:text,published:bool)")
                .interact_text()?
        }
    };

    let fields = FieldDef::parse_list(&fields_input);
    if fields.is_empty() {
        anyhow::bail!("No valid fields parsed. Use format: name:type,name:type");
    }

    let pascal = name.to_upper_camel_case();
    let snake = name.to_snake_case();

    println!(
        "\n{} Scaffolding {pascal} CRUD...\n",
        style("rok").green().bold()
    );

    // 1. Migration
    let mig_dir = migration_dir();
    let mig_idx = next_migration_index(mig_dir);
    let mig_path = format!("{mig_dir}/{mig_idx:03}_create_{snake}s_table.sql");
    write_new(
        &mig_path,
        &migration::gen(&snake, &fields, id_type, soft_delete, timestamps),
        force,
    )?;

    // 2. Model
    let model_path = format!("src/app/models/{snake}.rs");
    write_new(
        &model_path,
        &model::gen(&pascal, &snake, &fields, id_type, soft_delete, timestamps),
        force,
    )?;
    append_line("src/app/models/mod.rs", &format!("pub mod {snake};"))?;
    append_line(
        "src/app/models/mod.rs",
        &format!("pub use {snake}::{pascal};"),
    )?;

    // 3. Validators
    let cv_path = format!("src/app/validators/create_{snake}_request.rs");
    write_new(&cv_path, &validator::gen_create(&pascal, &fields), force)?;
    let uv_path = format!("src/app/validators/update_{snake}_request.rs");
    write_new(&uv_path, &validator::gen_update(&pascal, &fields), force)?;
    append_line(
        "src/app/validators/mod.rs",
        &format!("pub mod create_{snake}_request;"),
    )?;
    append_line(
        "src/app/validators/mod.rs",
        &format!("pub mod update_{snake}_request;"),
    )?;

    // 4. Resource
    let res_path = format!("src/app/resources/{snake}_resource.rs");
    write_new(
        &res_path,
        &resource::gen(&pascal, &snake, &fields, id_type, timestamps),
        force,
    )?;
    append_line(
        "src/app/resources/mod.rs",
        &format!("pub mod {snake}_resource;"),
    )?;
    append_line(
        "src/app/resources/mod.rs",
        &format!("pub use {snake}_resource::{pascal}Resource;"),
    )?;

    // 5. Policy
    let pol_path = format!("src/app/policies/{snake}_policy.rs");
    write_new(&pol_path, &policy::gen(&pascal, &snake), force)?;
    append_line(
        "src/app/policies/mod.rs",
        &format!("pub mod {snake}_policy;"),
    )?;

    // 6. Controller
    let ctrl_path = format!("src/app/controllers/{snake}_controller.rs");
    write_new(
        &ctrl_path,
        &controller::gen(&pascal, &snake, id_type, auth, paginate),
        force,
    )?;
    append_line(
        "src/app/controllers/mod.rs",
        &format!("pub mod {snake}_controller;"),
    )?;

    // 7. Factory
    let fac_path = format!("src/database/factories/{snake}_factory.rs");
    write_new(
        &fac_path,
        &factory::gen(&pascal, &snake, &fields, id_type, timestamps, soft_delete),
        force,
    )?;
    append_line(
        "src/database/factories/mod.rs",
        &format!("pub mod {snake}_factory;"),
    )?;

    // 8. Routes
    let route_path = format!("src/routes/{snake}s.rs");
    write_new(&route_path, &routes::gen(&pascal, &snake, id_type), force)?;
    append_line("src/routes/mod.rs", &format!("pub mod {snake}s;"))?;

    // 9. Test
    let test_path = format!("tests/{snake}_test.rs");
    write_new(&test_path, &test::gen(&pascal, &snake), force)?;

    // Summary
    println!(
        "\n{} Done! Add to api_router() in src/routes/api.rs:",
        style("").bold()
    );
    println!("    .merge({snake}s::{snake}_routes())\n");

    Ok(())
}