kegani-cli 0.1.0

CLI tool for Kegani framework
Documentation
//! `keg init` command — Create a complete Kegani project
//!
//! Generates a full layered architecture project with:
//!   - manifest/config/     (configuration system)
//!   - internal/           (model → dto → repository → logic → service)
//!   - src/                (controller → routes → middleware)
//!   - resource/           (static assets, OpenAPI spec)
//!   - migrations/         (SQL migrations)
//!   - Dockerfile + docker-compose.yml

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

/// Placeholder values for templates
const AUTHOR_PLACEHOLDER: &str = "{{AUTHOR}}";
const PROJECT_NAME_PLACEHOLDER: &str = "{{PROJECT_NAME}}";
const PROJECT_NAME_SNAKE_PLACEHOLDER: &str = "{{PROJECT_NAME_SNAKE}}";
const PROJECT_NAME_PASCAL_PLACEHOLDER: &str = "{{PROJECT_NAME_PASCAL}}";
const RESOURCE_NAME_PLACEHOLDER: &str = "{{RESOURCE_NAME}}";
const RESOURCE_NAME_PASCAL_PLACEHOLDER: &str = "{{RESOURCE_NAME_PASCAL}}";

/// Create a complete Kegani project
pub fn init(name: &str, template: &str) -> Result<()> {
    let project_path = Path::new(name);

    // Validate
    if project_path.exists() && project_path.is_dir() {
        let entries = fs::read_dir(project_path)?;
        if entries.count() > 0 {
            anyhow::bail!(
                "Directory '{}' is not empty. Please specify an empty directory or a new name.",
                name
            );
        }
    }

    if !name.matches(['/', '\\']).take(1).next().is_none() {
        // name contains a path separator, check if parent exists
        if let Some(parent) = project_path.parent() {
            if !parent.exists() {
                anyhow::bail!("Parent directory does not exist: {}", parent.display());
            }
        }
    }

    println!();
    println!("{} {}", Emoji("", ""), style("Initializing Kegani project").bold());
    println!("{}", style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").dim());
    println!("  {} {}", style("Project:").dim(), style(name).cyan().bold());
    println!("  {} {}", style("Template:").dim(), style(template).cyan().bold());
    println!("{}", style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").dim());
    println!();

    // Create project directory
    if !project_path.exists() {
        fs::create_dir_all(project_path).context("Failed to create project directory")?;
    }
    println!("{} {}", Emoji("📁", ""), style("Created project directory").green());

    // Generate project structure
    generate_project_structure(project_path, name)?;

    println!();
    println!(
        "{} {} project '{}' initialized!",
        Emoji("🎉", ""),
        style("").green(),
        style(name).cyan().bold()
    );
    println!();
    println!("{}", style("Next steps:").bold());
    println!(
        "  {} {} {}",
        style("1.").dim(),
        style("cd"),
        style(name).cyan()
    );
    println!("  {} {}", style("2.").dim(), style("cp .env.example .env"));
    println!(
        "  {} {} {}",
        style("3.").dim(),
        style("cargo run"),
        style("(starts on http://127.0.0.1:8080)").dim()
    );
    println!("  {} {} {}", style("4.").dim(), style("keg gen api"), style("to generate your first resource").dim());
    println!();

    Ok(())
}

fn generate_project_structure(project_path: &Path, project_name: &str) -> Result<()> {
    let snake_name = to_snake_case(project_name);
    let pascal_name = to_pascal_case(project_name);

    let replacements = &[
        (PROJECT_NAME_PLACEHOLDER, project_name),
        (PROJECT_NAME_SNAKE_PLACEHOLDER, &snake_name),
        (PROJECT_NAME_PASCAL_PLACEHOLDER, &pascal_name),
        (AUTHOR_PLACEHOLDER, "Your Name <you@example.com>"),
    ];

    // Detect if current directory is inside the kegani workspace by checking
    // the parent chain for kegani/Cargo.toml (the workspace root marker).
    let workspace_root = find_kegani_workspace(&std::env::current_dir()?);
    if workspace_root.is_some() {
        println!("  {} {}", style("").dim(), style("Detected kegani workspace — using local path dependency").dim());
    }

    println!("{} Creating project structure", style("⚙️").cyan());

    // ── src/ ──────────────────────────────────────────────────────────────────

    let src_dir = project_path.join("src");
    fs::create_dir_all(&src_dir).context("Failed to create src/")?;

    write_file(&src_dir.join("main.rs"), &render_template(include_str!("../templates/src_main_rs.txt"), replacements))?;
    println!("  {} {}", style("").green(), style("src/main.rs").dim());

    write_file(&src_dir.join("lib.rs"), &render_template(include_str!("../templates/src_lib_rs.txt"), replacements))?;
    println!("  {} {}", style("").green(), style("src/lib.rs").dim());

    write_file(&src_dir.join("error.rs"), &render_template(include_str!("../templates/src_error_rs.txt"), replacements))?;
    println!("  {} {}", style("").green(), style("src/error.rs").dim());

    // src/routes/
    let routes_dir = src_dir.join("routes");
    fs::create_dir_all(&routes_dir).context("Failed to create src/routes/")?;
    write_file(&routes_dir.join("mod.rs"), &render_template(include_str!("../templates/src_routes_mod_rs.txt"), replacements))?;
    println!("  {} {}", style("").green(), style("src/routes/mod.rs").dim());

    let routes_api_dir = routes_dir.join("api");
    fs::create_dir_all(&routes_api_dir).context("Failed to create src/routes/api/")?;
    write_file(&routes_api_dir.join("mod.rs"), &render_template(include_str!("../templates/src_routes_api_mod_rs.txt"), replacements))?;
    println!("  {} {}", style("").green(), style("src/routes/api/mod.rs").dim());

    // v1 directory created empty; populated by `keg gen api <name>`
    let routes_api_v1_dir = routes_api_dir.join("v1");
    fs::create_dir_all(&routes_api_v1_dir).context("Failed to create src/routes/api/v1/")?;
    println!("  {} {}", style("").green(), style("src/routes/api/v1/ (empty, populated by keg gen api)").dim());

    // Health routes
    write_file(&routes_dir.join("health.rs"), &render_template(include_str!("../templates/src_routes_health_rs.txt"), replacements))?;
    println!("  {} {}", style("").green(), style("src/routes/health.rs").dim());

    // src/controller/
    let controller_dir = src_dir.join("controller");
    fs::create_dir_all(&controller_dir).context("Failed to create src/controller/")?;
    write_file(&controller_dir.join("mod.rs"), &render_template(include_str!("../templates/src_controller_mod_rs.txt"), &[
        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
    ]))?;
    println!("  {} {}", style("").green(), style("src/controller/mod.rs").dim());

    let controller_api_dir = controller_dir.join("api");
    fs::create_dir_all(&controller_api_dir).context("Failed to create src/controller/api/")?;
    write_file(&controller_api_dir.join("mod.rs"), &render_template(include_str!("../templates/src_controller_api_mod_rs.txt"), &[
        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
        (RESOURCE_NAME_PASCAL_PLACEHOLDER, "{{RESOURCE_NAME_PASCAL}}"),
    ]))?;
    println!("  {} {}", style("").green(), style("src/controller/api/mod.rs").dim());

    // src/middleware/
    let middleware_dir = src_dir.join("middleware");
    fs::create_dir_all(&middleware_dir).context("Failed to create src/middleware/")?;
    write_file(&middleware_dir.join("mod.rs"), &render_template(include_str!("../templates/src_middleware_mod_rs.txt"), replacements))?;
    println!("  {} {}", style("").green(), style("src/middleware/mod.rs").dim());
    write_file(&middleware_dir.join("auth.rs"), &render_template(include_str!("../templates/src_middleware_auth_rs.txt"), replacements))?;
    println!("  {} {}", style("").green(), style("src/middleware/auth.rs").dim());

    // ── internal/ ─────────────────────────────────────────────────────────────

    let internal_dir = project_path.join("internal");
    fs::create_dir_all(&internal_dir).context("Failed to create internal/")?;
    write_file(&internal_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_mod_rs.txt"), replacements))?;
    println!("  {} {}", style("").green(), style("internal/mod.rs").dim());

    // internal/model/
    let model_dir = internal_dir.join("model");
    fs::create_dir_all(&model_dir).context("Failed to create internal/model/")?;
    write_file(&model_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_model_mod_rs.txt"), replacements))?;
    println!("  {} {}", style("").green(), style("internal/model/mod.rs").dim());

    let entity_dir = model_dir.join("entity");
    fs::create_dir_all(&entity_dir).context("Failed to create internal/model/entity/")?;
    write_file(&entity_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_model_entity_mod_rs.txt"), &[
        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
    ]))?;
    println!("  {} {}", style("").green(), style("internal/model/entity/mod.rs").dim());

    // internal/dto/
    let dto_dir = internal_dir.join("dto");
    fs::create_dir_all(&dto_dir).context("Failed to create internal/dto/")?;
    write_file(&dto_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_dto_mod_rs.txt"), replacements))?;
    println!("  {} {}", style("").green(), style("internal/dto/mod.rs").dim());

    let dto_req_dir = dto_dir.join("requests");
    fs::create_dir_all(&dto_req_dir).context("Failed to create internal/dto/requests/")?;
    write_file(&dto_req_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_dto_requests_mod_rs.txt"), &[
        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
    ]))?;

    let dto_resp_dir = dto_dir.join("responses");
    fs::create_dir_all(&dto_resp_dir).context("Failed to create internal/dto/responses/")?;
    write_file(&dto_resp_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_dto_responses_mod_rs.txt"), &[
        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
    ]))?;

    // internal/repository/
    let repo_dir = internal_dir.join("repository");
    fs::create_dir_all(&repo_dir).context("Failed to create internal/repository/")?;
    write_file(&repo_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_repository_mod_rs.txt"), &[
        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
    ]))?;
    println!("  {} {}", style("").green(), style("internal/repository/mod.rs").dim());

    // internal/logic/
    let logic_dir = internal_dir.join("logic");
    fs::create_dir_all(&logic_dir).context("Failed to create internal/logic/")?;
    write_file(&logic_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_logic_mod_rs.txt"), &[
        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
    ]))?;
    println!("  {} {}", style("").green(), style("internal/logic/mod.rs").dim());

    // internal/service/
    let service_dir = internal_dir.join("service");
    fs::create_dir_all(&service_dir).context("Failed to create internal/service/")?;
    write_file(&service_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_service_mod_rs.txt"), &[
        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
    ]))?;
    println!("  {} {}", style("").green(), style("internal/service/mod.rs").dim());

    // ── manifest/config/ ──────────────────────────────────────────────────────

    let manifest_dir = project_path.join("manifest").join("config");
    fs::create_dir_all(&manifest_dir).context("Failed to create manifest/config/")?;
    write_file(&manifest_dir.join("config.yaml"), &render_template(include_str!("../templates/manifest_config_yaml.txt"), replacements))?;
    println!("  {} {}", style("").green(), style("manifest/config/config.yaml").dim());
    write_file(&manifest_dir.join("config.prod.yaml"), &render_template(include_str!("../templates/manifest_config_prod_yaml.txt"), replacements))?;
    write_file(&manifest_dir.join("config.test.yaml"), &render_template(include_str!("../templates/manifest_config_test_yaml.txt"), replacements))?;

    // ── resource/ ────────────────────────────────────────────────────────────

    let resource_dir = project_path.join("resource").join("openapi");
    fs::create_dir_all(&resource_dir).context("Failed to create resource/openapi/")?;
    write_file(&resource_dir.join("schema.yaml"), &render_template(include_str!("../templates/resource_openapi_schema_yaml.txt"), replacements))?;
    println!("  {} {}", style("").green(), style("resource/openapi/schema.yaml").dim());

    // ── protocol/ ──────────────────────────────────────────────────────────────

    let protocol_dir = project_path.join("protocol").join("protobuf");
    fs::create_dir_all(&protocol_dir).context("Failed to create protocol/protobuf/")?;
    write_file(&protocol_dir.join("mod.rs"), &render_template(include_str!("../templates/protocol_protobuf_mod_rs.txt"), replacements))?;

    // ── migrations/ ──────────────────────────────────────────────────────────

    let migrations_dir = project_path.join("migrations");
    fs::create_dir_all(&migrations_dir).context("Failed to create migrations/")?;
    write_file(&migrations_dir.join("001_init.sql"), &render_template(include_str!("../templates/migrations_001_init_sql.txt"), &[
        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
    ]))?;
    println!("  {} {}", style("").green(), style("migrations/001_init.sql").dim());

    // ── tests/ ─────────────────────────────────────────────────────────────────

    let tests_dir = project_path.join("tests");
    fs::create_dir_all(&tests_dir).context("Failed to create tests/")?;
    write_file(&tests_dir.join("api_test.rs"), &render_template(include_str!("../templates/tests_api_test_rs.txt"), replacements))?;
    write_file(&tests_dir.join("mod.rs"), include_str!("../templates/tests_mod_rs.txt"))?;
    println!("  {} {}", style("").green(), style("tests/api_test.rs").dim());

    // ── Root files ─────────────────────────────────────────────────────────────

    // Write Cargo.toml with workspace detection.
    // Two variants: local path (inside kegani workspace) vs crates.io (outside)
    let cargo_toml_path = project_path.join("Cargo.toml");
    let cargo_content = render_template(
        include_str!("../templates/Cargo.toml.txt"),
        &[
            (PROJECT_NAME_PLACEHOLDER, project_name),
            (PROJECT_NAME_SNAKE_PLACEHOLDER, &snake_name),
            (AUTHOR_PLACEHOLDER, "Your Name <you@example.com>"),
        ],
    );
    // Patch the dependency line based on workspace detection.
    // When inside the kegani workspace, use a relative path from the generated
    // project up to the workspace root (my-api/../.. → kegani/).
    let cargo_content = if let Some(workspace_root) = find_kegani_workspace(&std::env::current_dir()?) {
        // Compute relative path from project to workspace root.
        // The generated project sits at: <workspace_root>/<project_name>/
        // So ".." from the project reaches the workspace root.
        let dep_line = "kegani = { path = \"../..\" }".to_string();
        cargo_content.replace("{{KEGANI_DEP}}", &dep_line)
    } else {
        cargo_content.replace("{{KEGANI_DEP}}", "kegani = \"0.1\"")
    };
    write_file(&cargo_toml_path, &cargo_content)?;
    println!("  {} {}", style("").green(), style("Cargo.toml").dim());

    write_file(&project_path.join("config.yaml"), include_str!("../templates/config_yaml.txt"))?;
    write_file(&project_path.join(".env.example"), &render_template(include_str!("../templates/env_example_txt.txt"), &[
        (PROJECT_NAME_PLACEHOLDER, project_name),
    ]))?;
    write_file(&project_path.join(".env"), "# Copy from .env.example and fill in your values\n")?;
    println!("  {} {}", style("").green(), style(".env.example").dim());

    write_file(&project_path.join(".gitignore"), include_str!("../templates/gitignore_txt.txt"))?;
    println!("  {} {}", style("").green(), style(".gitignore").dim());

    write_file(&project_path.join("README.md"), &render_template(include_str!("../templates/readme_md.txt"), &[
        (PROJECT_NAME_PLACEHOLDER, project_name),
    ]))?;
    println!("  {} {}", style("").green(), style("README.md").dim());

    write_file(&project_path.join("Dockerfile"), &render_template(include_str!("../templates/dockerfile_txt.txt"), &[
        (PROJECT_NAME_PLACEHOLDER, project_name),
    ]))?;
    println!("  {} {}", style("").green(), style("Dockerfile").dim());

    write_file(&project_path.join("docker-compose.yml"), &render_template(include_str!("../templates/docker_compose_yml.txt"), &[
        (PROJECT_NAME_PLACEHOLDER, project_name),
    ]))?;
    println!("  {} {}", style("").green(), style("docker-compose.yml").dim());

    Ok(())
}

// ── Template 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))
}

/// Convert "my-api" → "my_api"
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(' ', "_")
}

/// Convert "my-api" → "MyApi"
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
}

/// Walk up the directory tree from `start` looking for `kegani/Cargo.toml`.
/// Returns the path to the kegani workspace root if found.
fn find_kegani_workspace(start: &std::path::Path) -> Option<std::path::PathBuf> {
    let mut current = Some(start.to_path_buf());
    while let Some(p) = current {
        if p.join("kegani/Cargo.toml").exists() {
            return Some(p);
        }
        current = p.parent().map(|q| q.to_path_buf());
    }
    None
}