rok-cli 0.3.3

Developer CLI for rok-based Axum applications
//! `rok secrets:generate` — read `.env.example`, generate crypto-random values
//! for secret-like keys (SECRET, KEY, PASSWORD, TOKEN, etc.), and write `.env`.

use rand::RngCore;

fn generate_hex_key(bytes: usize) -> String {
    let mut buf = vec![0u8; bytes];
    rand::thread_rng().fill_bytes(&mut buf);
    buf.iter().map(|b| format!("{b:02x}")).collect()
}

/// Should this env var get a generated secret?
fn should_generate(key: &str, value: &str) -> bool {
    let upper = key.to_uppercase();

    // Never generate for connection / binding config
    let skip = [
        "DATABASE",
        "DB_",
        "HOST",
        "PORT",
        "ADDR",
        "URL",
        "LISTEN",
        "APP_NAME",
        "APP_DEBUG",
        "APP_ENV",
        "LOG_",
    ];
    if skip
        .iter()
        .any(|p| upper.contains(p) || upper.starts_with(p))
    {
        return false;
    }

    // Placeholder values trigger generation
    let v = value.trim();
    if v.is_empty()
        || v == "\"\""
        || v == "''"
        || v.eq_ignore_ascii_case("change-me")
        || v.eq_ignore_ascii_case("change-me-in-production")
        || v.eq_ignore_ascii_case("changethis")
        || v.starts_with("your-")
        || v.starts_with('<')
        || v.starts_with('[')
    {
        return true;
    }

    // Key-name-based heuristic
    upper.contains("SECRET")
        || upper.contains("_KEY")
        || upper.ends_with("KEY")
        || upper.contains("PASSWORD")
        || upper.contains("TOKEN")
        || upper.contains("SALT")
        || upper == "APP_KEY"
}

pub fn run() -> anyhow::Result<()> {
    use std::path::Path;

    let env_path = Path::new(".env");
    if env_path.exists() {
        anyhow::bail!(
            ".env already exists. Delete it first or use `rok key:rotate --write` \
             to update individual keys."
        );
    }

    let example_path = Path::new(".env.example");
    if !example_path.exists() {
        anyhow::bail!(".env.example not found in current directory");
    }

    let content = std::fs::read_to_string(example_path)?;
    let mut output = String::new();
    let mut generated: Vec<String> = Vec::new();

    for line in content.lines() {
        let trimmed = line.trim();

        // Preserve blank lines and comments
        if trimmed.is_empty() || trimmed.starts_with('#') {
            output.push_str(line);
            output.push('\n');
            continue;
        }

        if let Some((key, value)) = trimmed.split_once('=') {
            let key = key.trim();
            let val = value.trim();

            if should_generate(key, val) {
                let secret = generate_hex_key(32);
                output.push_str(&format!("{key}={secret}\n"));
                generated.push(key.to_string());
            } else {
                output.push_str(line);
                output.push('\n');
            }
        } else {
            output.push_str(line);
            output.push('\n');
        }
    }

    std::fs::write(env_path, output)?;

    println!(
        "{} Generated .env with secrets:",
        console::style("secrets:generate").green().bold()
    );
    for key in &generated {
        println!("  {key}=<generated>");
    }
    if generated.is_empty() {
        println!("  (no keys needed secret generation; .env is a copy of .env.example)");
    }

    Ok(())
}