kasi-cli 0.5.6

CLI project scaffolder for the Kasi async web framework.
use std::env;
use std::fs;
use std::path::Path;
use std::process::{self, Command};
use std::time::{SystemTime, UNIX_EPOCH};

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() < 2 {
        print_usage();
        process::exit(1);
    }

    match args[1].as_str() {
        "new" => {
            if args.len() < 3 {
                eprintln!("Usage: kasi new <project-name>");
                process::exit(1);
            }
            let project_name = &args[2];
            let root = Path::new(project_name);

            if root.exists() {
                eprintln!("Error: Directory '{}' already exists.", project_name);
                process::exit(1);
            }
            if let Err(err) = scaffold_project(root, project_name) {
                eprintln!("Failed to create project: {}", err);
                process::exit(1);
            }

            println!("Created new Kasi microservice in ./{}", project_name);
            println!("\nNext steps:");
            println!("  cd {}", project_name);
            println!("  cargo run");
            println!("\nEndpoints ready:");
            println!("  GET  http://127.0.0");
            println!("  GET  http://127.0.0health");
            println!("  GET  http://127.0.0docs");
        }
        "migrate" => {
            if args.len() < 3 {
                eprintln!("Usage: kasi migrate <create <name> | up | revert | info>");
                process::exit(1);
            }
            handle_migrate(&args[2..]);
        }
        _ => {
            print_usage();
            process::exit(1);
        }
    }
}

fn handle_migrate(sub_args: &[String]) {
    match sub_args[0].as_str() {
        "create" => {
            if sub_args.len() < 2 {
                eprintln!("Usage: kasi migrate create <migration_name>");
                process::exit(1);
            }
            let name = sub_args[1].replace(' ', "_").to_lowercase();
            let dir = Path::new("migrations");
            fs::create_dir_all(dir).expect("Failed to create migrations directory");

            let ts = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_secs();
            let filename = format!("{}_{}.sql", ts, name);
            let filepath = dir.join(&filename);

            let template = format!(
                "-- Migration: {}\n-- Created at: {}\n\n-- Write your SQL schema changes below:\n",
                name, ts
            );
            fs::write(&filepath, template).expect("Failed to write migration file");
            println!("Created migration: {}", filepath.display());
        }
        cmd @ ("up" | "revert" | "info") => {
            let status = Command::new("sqlx").args(["migrate", cmd]).status();

            match status {
                Ok(s) if s.success() => {}
                Ok(s) => process::exit(s.code().unwrap_or(1)),
                Err(_) => {
                    eprintln!("Error: `sqlx-cli` is not installed.");
                    eprintln!(
                        "Install it with: cargo install sqlx-cli --no-default-features --features postgres"
                    );
                    process::exit(1);
                }
            }
        }
        _ => {
            eprintln!("Unknown migrate subcommand: {}", sub_args[0]);
            process::exit(1);
        }
    }
}

fn print_usage() {
    eprintln!("Kasi Framework CLI");
    eprintln!("\nUsage:");
    eprintln!("  kasi new <project-name>          Scaffold a new Kasi microservice");
    eprintln!("  kasi migrate create <name>       Create a new timestamped SQL migration");
    eprintln!("  kasi migrate up                  Apply pending migrations via sqlx-cli");
    eprintln!("  kasi migrate revert              Revert the last applied migration");
    eprintln!("  kasi migrate info                List applied and pending migrations");
}

fn scaffold_project(root: &Path, name: &str) -> std::io::Result<()> {
    fs::create_dir_all(root.join("src"))?;
    fs::create_dir_all(root.join("templates"))?;
    fs::create_dir_all(root.join("public"))?;

    let cargo_toml = format!(
        r#"[package]
name = "{name}"
version = "0.3.0"
edition = "2024"

[dependencies]
kasi-jb = {{ version = "0.2", default-features = false, features = ["templates"] }}
serde = {{ version = "1.0", features = ["derive"] }}
serde_json = "1.0"
"#
    );

    let main_rs = r#"use kasi_jb::prelude::*;

#[derive(Serialize, IntoResponse, Schema)]
struct HealthStatus {
    status: String,
    service: String,
}

#[endpoint(GET, "/")]
async fn index(engine: TemplateEngine) -> Result<Html, Error> {
    engine.render("index.html", template_context!(title => "Kasi Microservice"))
}

#[endpoint(GET, "/health")]
#[response(status = 200, description = "Service health check", json = HealthStatus)]
async fn health() -> HealthStatus {
    HealthStatus {
        status: "ok".into(),
        service: env!("CARGO_PKG_NAME").into(),
    }
}

#[main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let templates = TemplateEngine::from_dir("templates");

    let app = Application::builder(Config {
        host: "127.0.0.1".into(),
        port: 8080,
    })
    .middleware(TracingMiddleware::new())
    .extension(templates)
    .serve_dir("/static", "public")
    .route(index)
    .route(health)
    .build()
    .await?;

    app.serve().await?;
    Ok(())
}
"#;

    let index_html = r#"<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>{{ title }}</title>
    <link rel="stylesheet" href="/static/style.css" />
</head>
<body>
    <main>
        <h1>{{ title }} is running!</h1>
        <p>Explore your API documentation at <a href="/docs">/docs</a>.</p>
    </main>
</body>
</html>
"#;

    let style_css = r#"body {
    font-family: system-ui, -apple-system, sans-serif;
    max-width: 640px;
    margin: 4rem auto;
    padding: 0 1rem;
    color: #1a1a1a;
}
a { color: #0066cc; }
"#;

    let dockerfile = format!(
        r#"# Stage 1: Build optimized release binary
FROM rust:1.86-slim-bookworm AS builder
WORKDIR /app

# Install OpenSSL/pkg-config build dependencies
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*

# Cache dependency compilation
COPY Cargo.toml Cargo.lock* ./
RUN mkdir src && echo "fn main() {{}}" > src/main.rs
RUN cargo build --release && rm -rf src

# Build the actual microservice binary
COPY src ./src
COPY templates ./templates
COPY public ./public
RUN touch src/main.rs && cargo build --release

# Stage 2: Minimal production runtime image
FROM debian:bookworm-slim
WORKDIR /app

RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/{name} /usr/local/bin/{name}
COPY --from=builder /app/templates ./templates
COPY --from=builder /app/public ./public

EXPOSE 8080
ENV RUST_LOG=info
CMD ["{name}"]
"#
    );

    let dockerignore = r#"target/
.git/
.github/
*.log
.env
"#;

    fs::write(root.join("Dockerfile"), dockerfile)?;
    fs::write(root.join(".dockerignore"), dockerignore)?;
    fs::write(root.join("Cargo.toml"), cargo_toml)?;
    fs::write(root.join("src/main.rs"), main_rs)?;
    fs::write(root.join("templates/index.html"), index_html)?;
    fs::write(root.join("public/style.css"), style_css)?;

    Ok(())
}