kegani-cli 0.1.3

CLI tool for Kegani framework
Documentation
//! `keg gen` command — Code generation toolchain
//!
//! Subcommands:
//!   api <name>      — Generate full CRUD stack (model + repository + logic + service + controller + routes)
//!   model <name>    — Generate entity struct
//!   service <name>  — Generate service layer
//!   repository <name> — Generate repository (sqlx)
//!   controller <name> — Generate controller + routes
//!   middleware <name> — Generate named middleware
//!   migration <name>  — Generate SQL migration

use anyhow::{Context, Result};
use clap::ValueEnum;
use console::{style, Emoji};
use std::fs;
use std::path::Path;

/// Placeholders used in templates
const PROJECT_NAME_PLACEHOLDER: &str = "{{PROJECT_NAME}}";
const PROJECT_NAME_SNAKE_PLACEHOLDER: &str = "{{PROJECT_NAME_SNAKE}}";
const RESOURCE_NAME_PLACEHOLDER: &str = "{{RESOURCE_NAME}}";
const RESOURCE_NAME_PASCAL_PLACEHOLDER: &str = "{{RESOURCE_NAME_PASCAL}}";

/// What to generate
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum GenKind {
    /// Full CRUD stack (model + repository + logic + service + controller + routes)
    Api,
    /// Entity/model only
    Model,
    /// Service layer
    Service,
    /// Repository (sqlx)
    Repository,
    /// Controller + routes
    Controller,
    /// Middleware
    Middleware,
    /// SQL migration
    Migration,
}

/// Generate code
pub fn gen(kind: GenKind, name: &str) -> Result<()> {
    println!();
    println!(
        "{} {} {}",
        Emoji("🔧", ""),
        style("Generating").bold(),
        style(format!("{:?}", kind).to_lowercase()).cyan()
    );
    println!();

    match kind {
        GenKind::Api => gen_api(name)?,
        GenKind::Model => gen_model(name)?,
        GenKind::Service => gen_service(name)?,
        GenKind::Repository => gen_repository(name)?,
        GenKind::Controller => gen_controller(name)?,
        GenKind::Middleware => gen_middleware(name)?,
        GenKind::Migration => gen_migration(name)?,
    }

    println!();
    println!(
        "{} {} generated successfully!",
        Emoji("", ""),
        style(name).cyan().bold()
    );
    println!();

    Ok(())
}

// ── Full CRUD stack ──────────────────────────────────────────────────────────

fn gen_api(name: &str) -> Result<()> {
    let pascal = to_pascal_case(name);
    let snake = to_snake_case(name);

    let replacements = &[
        (PROJECT_NAME_PLACEHOLDER, "TODO_project_name"),
        (PROJECT_NAME_SNAKE_PLACEHOLDER, "TODO_project_snake"),
        (RESOURCE_NAME_PLACEHOLDER, &snake),
        (RESOURCE_NAME_PASCAL_PLACEHOLDER, &pascal),
    ];

    // internal/model/entity/<name>.rs
    let entity_dir = Path::new("internal/model/entity");
    fs::create_dir_all(entity_dir).context("Failed to create internal/model/entity/")?;
    write_file(
        &entity_dir.join(format!("{}.rs", snake)),
        &render_template(include_str!("../templates/internal_model_entity_RESOURCE_rs.txt"), replacements),
    )?;
    println!("  {} internal/model/entity/{}.rs", style("").green(), snake);

    // internal/repository/<name>_repo.rs
    let repo_dir = Path::new("internal/repository");
    fs::create_dir_all(repo_dir).context("Failed to create internal/repository/")?;
    write_file(
        &repo_dir.join(format!("{}_repo.rs", snake)),
        &render_template(include_str!("../templates/internal_repository_RESOURCE_rs.txt"), replacements),
    )?;
    println!("  {} internal/repository/{}_repo.rs", style("").green(), snake);

    // internal/logic/<name>_logic.rs
    let logic_dir = Path::new("internal/logic");
    fs::create_dir_all(logic_dir).context("Failed to create internal/logic/")?;
    write_file(
        &logic_dir.join(format!("{}_logic.rs", snake)),
        &render_template(include_str!("../templates/internal_logic_RESOURCE_rs.txt"), replacements),
    )?;
    println!("  {} internal/logic/{}_logic.rs", style("").green(), snake);

    // internal/service/<name>_service.rs
    let service_dir = Path::new("internal/service");
    fs::create_dir_all(service_dir).context("Failed to create internal/service/")?;
    write_file(
        &service_dir.join(format!("{}_service.rs", snake)),
        &render_template(include_str!("../templates/internal_service_RESOURCE_rs.txt"), replacements),
    )?;
    println!("  {} internal/service/{}_service.rs", style("").green(), snake);

    // src/controller/api/<name>.rs
    let controller_dir = Path::new("src/controller/api");
    fs::create_dir_all(controller_dir).context("Failed to create src/controller/api/")?;
    write_file(
        &controller_dir.join(format!("{}.rs", snake)),
        &render_template(include_str!("../templates/src_controller_api_RESOURCE_rs.txt"), replacements),
    )?;
    println!("  {} src/controller/api/{}.rs", style("").green(), snake);

    // src/routes/api/v1/<name>.rs
    let routes_dir = Path::new("src/routes/api/v1");
    fs::create_dir_all(routes_dir).context("Failed to create src/routes/api/v1/")?;
    write_file(
        &routes_dir.join(format!("{}.rs", snake)),
        &render_template(include_str!("../templates/src_routes_api_v1_RESOURCE_rs.txt"), replacements),
    )?;
    println!("  {} src/routes/api/v1/{}.rs", style("").green(), snake);

    // Update module files
    update_mod_file(Path::new("internal/model/entity/mod.rs"), &format!("pub mod {};", snake));
    update_mod_file(Path::new("internal/repository/mod.rs"), &format!("pub mod {};", snake));
    update_mod_file(Path::new("internal/logic/mod.rs"), &format!("pub mod {};", snake));
    update_mod_file(Path::new("internal/service/mod.rs"), &format!("pub mod {};", snake));
    update_mod_file(Path::new("src/controller/api/mod.rs"), &format!("pub mod {};", snake));

    // Update routes api v1 mod
    update_routes_v1_mod(&snake);

    Ok(())
}

// ── Model ─────────────────────────────────────────────────────────────────────

fn gen_model(name: &str) -> Result<()> {
    let pascal = to_pascal_case(name);
    let snake = to_snake_case(name);

    let entity_dir = Path::new("internal/model/entity");
    fs::create_dir_all(entity_dir).context("Failed to create internal/model/entity/")?;
    write_file(
        &entity_dir.join(format!("{}.rs", snake)),
        &render_template(include_str!("../templates/internal_model_entity_RESOURCE_rs.txt"), &[
            (RESOURCE_NAME_PLACEHOLDER, &snake),
            (RESOURCE_NAME_PASCAL_PLACEHOLDER, &pascal),
        ]),
    )?;
    println!("  {} internal/model/entity/{}.rs", style("").green(), snake);

    update_mod_file(Path::new("internal/model/entity/mod.rs"), &format!("pub mod {};", snake));

    Ok(())
}

// ── Service ───────────────────────────────────────────────────────────────────

fn gen_service(name: &str) -> Result<()> {
    let pascal = to_pascal_case(name);
    let snake = to_snake_case(name);

    let service_dir = Path::new("internal/service");
    fs::create_dir_all(service_dir).context("Failed to create internal/service/")?;
    write_file(
        &service_dir.join(format!("{}_service.rs", snake)),
        &render_template(include_str!("../templates/internal_service_RESOURCE_rs.txt"), &[
            (PROJECT_NAME_PLACEHOLDER, "TODO_project_name"),
            (PROJECT_NAME_SNAKE_PLACEHOLDER, "TODO_project_snake"),
            (RESOURCE_NAME_PLACEHOLDER, &snake),
            (RESOURCE_NAME_PASCAL_PLACEHOLDER, &pascal),
        ]),
    )?;
    println!("  {} internal/service/{}_service.rs", style("").green(), snake);

    update_mod_file(Path::new("internal/service/mod.rs"), &format!("pub mod {};", snake));

    Ok(())
}

// ── Repository ───────────────────────────────────────────────────────────────

fn gen_repository(name: &str) -> Result<()> {
    let pascal = to_pascal_case(name);
    let snake = to_snake_case(name);

    let repo_dir = Path::new("internal/repository");
    fs::create_dir_all(repo_dir).context("Failed to create internal/repository/")?;
    write_file(
        &repo_dir.join(format!("{}_repo.rs", snake)),
        &render_template(include_str!("../templates/internal_repository_RESOURCE_rs.txt"), &[
            (PROJECT_NAME_PLACEHOLDER, "TODO_project_name"),
            (PROJECT_NAME_SNAKE_PLACEHOLDER, "TODO_project_snake"),
            (RESOURCE_NAME_PLACEHOLDER, &snake),
            (RESOURCE_NAME_PASCAL_PLACEHOLDER, &pascal),
        ]),
    )?;
    println!("  {} internal/repository/{}_repo.rs", style("").green(), snake);

    update_mod_file(Path::new("internal/repository/mod.rs"), &format!("pub mod {};", snake));

    Ok(())
}

// ── Controller ───────────────────────────────────────────────────────────────

fn gen_controller(name: &str) -> Result<()> {
    let pascal = to_pascal_case(name);
    let snake = to_snake_case(name);

    let controller_dir = Path::new("src/controller/api");
    fs::create_dir_all(controller_dir).context("Failed to create src/controller/api/")?;
    write_file(
        &controller_dir.join(format!("{}.rs", snake)),
        &render_template(include_str!("../templates/src_controller_api_RESOURCE_rs.txt"), &[
            (PROJECT_NAME_PLACEHOLDER, "TODO_project_name"),
            (PROJECT_NAME_SNAKE_PLACEHOLDER, "TODO_project_snake"),
            (RESOURCE_NAME_PLACEHOLDER, &snake),
            (RESOURCE_NAME_PASCAL_PLACEHOLDER, &pascal),
        ]),
    )?;
    println!("  {} src/controller/api/{}.rs", style("").green(), snake);

    update_mod_file(Path::new("src/controller/api/mod.rs"), &format!("pub mod {};", snake));

    // Routes
    let routes_dir = Path::new("src/routes/api/v1");
    fs::create_dir_all(routes_dir).context("Failed to create src/routes/api/v1/")?;
    write_file(
        &routes_dir.join(format!("{}.rs", snake)),
        &render_template(include_str!("../templates/src_routes_api_v1_RESOURCE_rs.txt"), &[
            (RESOURCE_NAME_PLACEHOLDER, &snake),
        ]),
    )?;
    println!("  {} src/routes/api/v1/{}.rs", style("").green(), snake);

    update_routes_v1_mod(&snake);

    Ok(())
}

// ── Middleware ───────────────────────────────────────────────────────────────

fn gen_middleware(name: &str) -> Result<()> {
    let pascal = to_pascal_case(name);
    let snake = to_snake_case(name);

    let middleware_dir = Path::new("src/middleware");
    fs::create_dir_all(middleware_dir).context("Failed to create src/middleware/")?;
    write_file(
        &middleware_dir.join(format!("{}.rs", snake)),
        &render_template(include_str!("../templates/src_middleware_auth_rs.txt"), &[
            (RESOURCE_NAME_PLACEHOLDER, &snake),
            (RESOURCE_NAME_PASCAL_PLACEHOLDER, &pascal),
        ]).replace("JwtAuth", &pascal).replace("JwtAuthMiddleware", &format!("{}Middleware", pascal)).as_str(),
    )?;
    println!("  {} src/middleware/{}.rs", style("").green(), snake);

    update_mod_file(Path::new("src/middleware/mod.rs"), &format!("pub mod {};", snake));

    Ok(())
}

// ── Migration ─────────────────────────────────────────────────────────────────

fn gen_migration(name: &str) -> Result<()> {
    let snake = to_snake_case(name);
    let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();

    let migrations_dir = Path::new("migrations");
    fs::create_dir_all(migrations_dir).context("Failed to create migrations/")?;

    let mut content = include_str!("../templates/migrations_001_init_sql.txt").to_string();
    content = content.replace("{{RESOURCE_NAME}}", &snake);

    let file_name = format!("{}_{}.sql", timestamp, snake);
    write_file(&migrations_dir.join(&file_name), &content)?;
    println!("  {} migrations/{}", style("").green(), file_name);

    Ok(())
}

// ── Helpers ────────────────────────────────────────────────────────────────────

fn render_template(content: &str, replacements: &[(&str, &str)]) -> String {
    let mut result = content.to_string();
    for (placeholder, value) in replacements {
        result = result.replace(placeholder, value);
    }
    result
}

fn write_file(path: &Path, content: &str) -> Result<()> {
    fs::write(path, content).context(format!("Failed to write file: {:?}", path))
}

fn update_mod_file(path: &Path, new_line: &str) {
    let content = if path.exists() {
        fs::read_to_string(path).unwrap_or_default()
    } else {
        String::new()
    };

    if !content.contains(new_line.trim()) {
        let new_content = if content.trim().is_empty() {
            new_line.to_string()
        } else {
            format!("{}\n{}", content.trim(), new_line)
        };
        let _ = fs::write(path, new_content);
    }
}

fn update_routes_v1_mod(name: &str) {
    let path = Path::new("src/routes/api/v1/mod.rs");
    let content = if path.exists() {
        fs::read_to_string(path).unwrap_or_default()
    } else {
        r#"//! API v1 routes
pub mod __placeholder__;
"#.to_string()
    };

    if !content.contains(&format!("pub mod {};", name)) {
        let new_content = content.replace("pub mod __placeholder__;", &format!("pub mod {};\npub mod __placeholder__;", name));
        let _ = fs::write(path, new_content);
    }
}

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.replace('-', "_").replace(' ', "_")
}

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.extend(c.to_uppercase());
            capitalize_next = false;
        } else {
            result.push(c);
        }
    }
    result
}