rok-cli 0.3.2

Developer CLI for rok-based Axum applications
//! `rok dev` and `rok env:check` — dev-mode server and env validation.

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

use console::style;

// ── rok dev ───────────────────────────────────────────────────────────────────

pub fn dev() -> anyhow::Result<()> {
    println!(
        "{} Starting dev server (cargo watch)...",
        style("rok dev").green().bold()
    );
    println!("  Watching src/ — restart on change");
    println!("  Ctrl+C to stop\n");

    let status = Command::new("cargo")
        .args(["watch", "--watch", "src/", "-x", "run"])
        .status();

    match status {
        Ok(s) if s.success() => Ok(()),
        Ok(_) => anyhow::bail!("cargo watch exited with error"),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            anyhow::bail!("cargo-watch not found. Install it with:\n  cargo install cargo-watch")
        }
        Err(e) => Err(e.into()),
    }
}

// ── rok serve ─────────────────────────────────────────────────────────────────

pub fn serve(port: Option<u16>, release: bool) -> anyhow::Result<()> {
    let _ = dotenvy::dotenv();

    let mut env: Vec<(String, String)> = Vec::new();
    if let Some(p) = port {
        env.push(("LISTEN_ADDR".to_string(), format!("0.0.0.0:{p}")));
        println!(
            "{} Listening on port {p}",
            style("rok serve").green().bold()
        );
    }

    let cargo_cmd = if release { "run --release" } else { "run" };

    // Try cargo-watch first
    let watch_test = Command::new("cargo").args(["watch", "--version"]).output();
    let use_watch = watch_test.map(|o| o.status.success()).unwrap_or(false);

    if use_watch {
        println!(
            "{} Starting with cargo-watch (auto-reload on change)...",
            style("rok serve").green().bold()
        );
        println!("  Ctrl+C to stop\n");

        let mut cmd = Command::new("cargo");
        cmd.args(["watch", "--watch", "src/", "-x", cargo_cmd]);
        for (k, v) in &env {
            cmd.env(k, v);
        }
        let status = cmd.status()?;
        if !status.success() {
            anyhow::bail!("cargo watch exited with error");
        }
    } else {
        println!(
            "{} Starting server (install cargo-watch for auto-reload)...",
            style("rok serve").green().bold()
        );
        println!("  Tip: cargo install cargo-watch\n");

        let mut args = vec!["run"];
        if release {
            args.push("--release");
        }
        let mut cmd = Command::new("cargo");
        cmd.args(&args);
        for (k, v) in &env {
            cmd.env(k, v);
        }
        let status = cmd.status()?;
        if !status.success() {
            anyhow::bail!("cargo run exited with error");
        }
    }

    Ok(())
}

// ── rok env:check ─────────────────────────────────────────────────────────────

pub fn env_check() -> anyhow::Result<()> {
    dotenvy::dotenv().ok();

    println!(
        "{} Scanning config files for required env vars...\n",
        style("env:check").green().bold()
    );

    let config_dir = Path::new("src/config");
    if !config_dir.exists() {
        anyhow::bail!("src/config/ not found — is this a rok project?");
    }

    let mut keys: Vec<String> = Vec::new();
    for entry in fs::read_dir(config_dir)? {
        let entry = entry?;
        if entry.path().extension().and_then(|s| s.to_str()) != Some("rs") {
            continue;
        }
        if entry
            .file_name()
            .to_str()
            .map(|n| n == "mod.rs")
            .unwrap_or(false)
        {
            continue;
        }

        let source = fs::read_to_string(entry.path())?;
        collect_env_keys(&source, &mut keys);
    }

    if keys.is_empty() {
        println!("  No #[env(...)] attributes found in src/config/");
        return Ok(());
    }

    keys.sort();
    keys.dedup();

    let mut pass = 0usize;
    let mut fail = 0usize;

    for key in &keys {
        match std::env::var(key) {
            Ok(val) if !val.is_empty() => {
                let preview = if val.len() > 20 {
                    format!("{}", &val[..20])
                } else {
                    val.clone()
                };
                println!(
                    "  {} {:<30} = {}",
                    style("").green(),
                    key,
                    style(preview).dim()
                );
                pass += 1;
            }
            _ => {
                println!("  {} {}", style("").red(), style(key).red().bold());
                fail += 1;
            }
        }
    }

    println!();
    if fail == 0 {
        println!("{} All {} env vars set.", style("").green().bold(), pass);
    } else {
        println!(
            "{} {pass} set, {} missing — add them to your .env file.",
            style("!").yellow().bold(),
            style(fail).red().bold()
        );
    }

    Ok(())
}

fn collect_env_keys(source: &str, keys: &mut Vec<String>) {
    // Scan for #[env("KEY")] and #[env("KEY", default = ...)] patterns
    let chars = source.char_indices().peekable();
    let marker = "#[env(\"";

    for (i, _) in chars {
        if source[i..].starts_with(marker) {
            let start = i + marker.len();
            if let Some(end) = source[start..].find('"') {
                let key = &source[start..start + end];
                if !key.is_empty() {
                    keys.push(key.to_string());
                }
            }
        }
    }
}

// ── .rok/config.toml ─────────────────────────────────────────────────────────

#[allow(dead_code)]
#[derive(Debug, Default)]
pub struct RokConfig {
    pub default_id: Option<String>,
    pub default_auth: bool,
    pub soft_delete: bool,
    pub with_tests: bool,
    pub with_factory: bool,
}

#[allow(dead_code)]
impl RokConfig {
    pub fn load() -> Self {
        let path = Path::new(".rok/config.toml");
        if !path.exists() {
            return Self::default();
        }

        let Ok(content) = fs::read_to_string(path) else {
            return Self::default();
        };

        let mut cfg = RokConfig::default();
        for line in content.lines() {
            let line = line.trim();
            if let Some(val) = line.strip_prefix("default_id") {
                cfg.default_id = extract_toml_string(val);
            } else if line.starts_with("default_auth") {
                cfg.default_auth = extract_toml_bool(line);
            } else if line.starts_with("soft_delete") {
                cfg.soft_delete = extract_toml_bool(line);
            } else if line.starts_with("with_tests") {
                cfg.with_tests = extract_toml_bool(line);
            } else if line.starts_with("with_factory") {
                cfg.with_factory = extract_toml_bool(line);
            }
        }
        cfg
    }
}

#[allow(dead_code)]
fn extract_toml_string(rest: &str) -> Option<String> {
    let rest = rest.trim().trim_start_matches('=').trim();
    if rest.starts_with('"') && rest.ends_with('"') {
        Some(rest[1..rest.len() - 1].to_string())
    } else {
        None
    }
}

#[allow(dead_code)]
fn extract_toml_bool(line: &str) -> bool {
    line.contains("true")
}

/// `rok config:show` — print all environment variables from .env, masking secrets.
pub fn config_show(as_json: bool) -> anyhow::Result<()> {
    use console::style;

    dotenvy::dotenv().ok();

    let env_path = std::path::Path::new(".env");
    let pairs: Vec<(String, String)> = if env_path.exists() {
        let content = fs::read_to_string(env_path)?;
        content
            .lines()
            .filter(|l| !l.trim_start().starts_with('#') && l.contains('='))
            .map(|l| {
                let mut parts = l.splitn(2, '=');
                let key = parts.next().unwrap_or("").trim().to_string();
                let raw = parts.next().unwrap_or("").to_string();
                let val = if is_secret(&key) {
                    mask_value(&raw)
                } else {
                    raw
                };
                (key, val)
            })
            .filter(|(k, _)| !k.is_empty())
            .collect()
    } else {
        Vec::new()
    };

    if pairs.is_empty() {
        if as_json {
            println!("{{}}");
        } else {
            println!("{}", style("No .env file found.").yellow());
            println!("Create a .env file in the project root to configure your application.");
        }
        return Ok(());
    }

    if as_json {
        let map: serde_json::Map<String, serde_json::Value> = pairs
            .into_iter()
            .map(|(k, v)| (k, serde_json::Value::String(v)))
            .collect();
        println!("{}", serde_json::to_string_pretty(&map)?);
    } else {
        println!("{}", style("Environment Configuration").green().bold());
        println!();
        for (key, val) in &pairs {
            println!("  {:<35} {}", style(key).cyan(), val);
        }
        println!();
        println!(
            "  {} {} variable(s)  •  secrets masked",
            style("").green(),
            pairs.len()
        );
    }

    Ok(())
}

const SECRET_SUBSTRINGS: &[&str] = &[
    "PASSWORD",
    "SECRET",
    "KEY",
    "TOKEN",
    "PASS",
    "AUTH",
    "HASH",
    "CREDENTIAL",
    "CERT",
    "PRIVATE",
];

fn is_secret(key: &str) -> bool {
    let upper = key.to_uppercase();
    SECRET_SUBSTRINGS.iter().any(|s| upper.contains(s))
}

fn mask_value(val: &str) -> String {
    if val.is_empty() {
        return String::new();
    }
    if val.len() <= 4 {
        return "****".to_string();
    }
    format!("{}{}", &val[..2], &val[val.len() - 2..])
}

pub fn init_rok_config() -> anyhow::Result<()> {
    let dir = Path::new(".rok");
    let path = dir.join("config.toml");

    if path.exists() {
        println!(
            "{} .rok/config.toml already exists.",
            style("skip").yellow()
        );
        return Ok(());
    }

    fs::create_dir_all(dir)?;
    fs::write(
        &path,
        r#"# rok project configuration
# These values are used as defaults by code generators.

# default_id = "ulid"   # ulid | cuid2 | uuid_v7 | snowflake | nanoid
default_auth    = false
soft_delete     = false
with_tests      = true
with_factory    = true
"#,
    )?;
    println!("{} Created .rok/config.toml", style("").green().bold());
    Ok(())
}