rok-cli 0.3.2

Developer CLI for rok-based Axum applications
//! `rok make:from-json` and `rok make:from-schema` — generate model + migration from JSON.

use std::{fs, path::Path};

use console::style;
use heck::ToUpperCamelCase;
use serde_json::Value;

use crate::commands::crud::{self, FieldDef};

// ── JSON type inference ───────────────────────────────────────────────────────

fn infer_field_type(_key: &str, value: &Value) -> String {
    match value {
        Value::String(_) => "string".to_string(),
        Value::Bool(_) => "bool".to_string(),
        Value::Null => "json".to_string(),
        Value::Array(_) => "json".to_string(),
        Value::Number(n) => {
            if n.is_f64() && n.as_f64().map(|f| f.fract() != 0.0).unwrap_or(false) {
                "float".to_string()
            } else {
                "int".to_string()
            }
        }
        Value::Object(_) => {
            // Nested object with an "id" key → FK reference
            "json".to_string()
        }
    }
}

fn json_to_fields(obj: &serde_json::Map<String, Value>) -> Vec<FieldDef> {
    obj.iter()
        .filter(|(k, _)| k.as_str() != "id")
        .map(|(k, v)| FieldDef {
            name: k.clone(),
            field_type: infer_field_type(k, v),
            optional: false,
        })
        .collect()
}

// ── Entry points ──────────────────────────────────────────────────────────────

/// `rok make:from-json <Name> <json_string_or_file>`
pub fn from_json(
    name: &str,
    json_input: &str,
    from_file: bool,
    dry_run: bool,
    force: bool,
) -> anyhow::Result<()> {
    let raw = if from_file {
        if !Path::new(json_input).exists() {
            anyhow::bail!("File not found: {json_input}");
        }
        fs::read_to_string(json_input)?
    } else {
        json_input.to_owned()
    };

    let parsed: Value =
        serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("Invalid JSON: {e}"))?;

    let obj = match &parsed {
        Value::Object(m) => m,
        _ => anyhow::bail!("JSON must be an object {{ ... }}"),
    };

    let fields = json_to_fields(obj);
    if fields.is_empty() {
        anyhow::bail!("No fields inferred from JSON (object was empty or only had 'id')");
    }

    println!(
        "{} Inferred {} fields from JSON:",
        style("rok").green().bold(),
        fields.len()
    );
    for f in &fields {
        println!(
            "  {:<20} → {}",
            style(&f.name).cyan(),
            style(&f.field_type).yellow()
        );
    }

    if dry_run {
        println!("\n{} Dry-run mode — no files written.", style("").bold());
        return Ok(());
    }

    println!();
    crud::crud(
        name,
        Some(
            &fields
                .iter()
                .map(|f| format!("{}:{}", f.name, f.field_type))
                .collect::<Vec<_>>()
                .join(","),
        ),
        None,  // default bigserial PK
        false, // no auth guard
        false, // no soft-delete
        false, // no paginate
        true,  // timestamps
        force,
    )
}

/// `rok make:from-schema <Name> --file schema.json`
///
/// Handles JSON Schema draft-07 with `type`, `properties`, `required` fields.
pub fn from_schema(
    name: &str,
    schema_file: &str,
    dry_run: bool,
    force: bool,
) -> anyhow::Result<()> {
    if !Path::new(schema_file).exists() {
        anyhow::bail!("Schema file not found: {schema_file}");
    }

    let raw = fs::read_to_string(schema_file)?;
    let schema: Value =
        serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("Invalid JSON Schema: {e}"))?;

    let props = schema
        .get("properties")
        .and_then(|v| v.as_object())
        .ok_or_else(|| anyhow::anyhow!("Schema must have a 'properties' object"))?;

    let fields: Vec<FieldDef> = props
        .iter()
        .filter(|(k, _)| k.as_str() != "id")
        .map(|(k, v)| {
            let ft = schema_type_to_field_type(v);
            FieldDef {
                name: k.clone(),
                field_type: ft,
                optional: false,
            }
        })
        .collect();

    if fields.is_empty() {
        anyhow::bail!("No fields found in schema properties");
    }

    let pascal = name.to_upper_camel_case();

    println!(
        "{} Inferred {} fields from JSON Schema:",
        style("rok").green().bold(),
        fields.len()
    );
    for f in &fields {
        println!(
            "  {:<20} → {}",
            style(&f.name).cyan(),
            style(&f.field_type).yellow()
        );
    }

    if dry_run {
        println!("\n{} Dry-run mode — no files written.", style("").bold());
        return Ok(());
    }

    let _ = pascal;
    println!();
    crud::crud(
        name,
        Some(
            &fields
                .iter()
                .map(|f| format!("{}:{}", f.name, f.field_type))
                .collect::<Vec<_>>()
                .join(","),
        ),
        None,
        false,
        false,
        false,
        true,
        force,
    )
}

fn schema_type_to_field_type(prop: &Value) -> String {
    let type_str = prop
        .get("type")
        .and_then(|v| v.as_str())
        .unwrap_or("string");
    let format = prop.get("format").and_then(|v| v.as_str()).unwrap_or("");

    match (type_str, format) {
        ("string", "date-time") => "datetime".to_string(),
        ("string", "date") => "date".to_string(),
        ("string", "uuid") => "uuid".to_string(),
        ("string", _) => "string".to_string(),
        ("integer", _) => "int".to_string(),
        ("number", _) => "float".to_string(),
        ("boolean", _) => "bool".to_string(),
        ("array", _) => "json".to_string(),
        ("object", _) => "json".to_string(),
        _ => "string".to_string(),
    }
}