rok-cli 0.6.1

Developer CLI for rok-based Axum applications
//! `rok deploy:check` — pre-deploy validation gate (M7.5).

use std::path::Path;

pub fn check() -> anyhow::Result<()> {
    let gates: &[(&str, fn() -> Gate)] = &[
        ("Cargo.lock committed",              gate_lockfile),
        ("No uncommitted changes",            gate_clean_git),
        ("Release build passes",              gate_release_build),
        ("DATABASE_URL set",                  gate_database_url),
        ("No debug! / dbg! macros in src/",   gate_no_debug_macros),
        ("No TODO / FIXME in src/",           gate_no_todo),
        ("CHANGELOG.md updated",              gate_changelog),
    ];

    let mut passed = 0usize;
    let mut blocked = 0usize;
    let mut warned = 0usize;

    println!();
    println!("rok deploy:check — pre-deploy gate");
    println!("{}", "".repeat(50));

    for (label, gate_fn) in gates {
        let g = gate_fn();
        match g.status {
            GateStatus::Pass => {
                passed += 1;
                println!("{label}");
            }
            GateStatus::Warn => {
                warned += 1;
                println!("  ! {label}{}", g.message.as_deref().unwrap_or(""));
            }
            GateStatus::Block => {
                blocked += 1;
                println!("{label}{}", g.message.as_deref().unwrap_or("BLOCKED"));
            }
        }
    }

    println!("{}", "".repeat(50));
    println!("  {passed} passed  {warned} warnings  {blocked} blocked");
    println!();

    if blocked > 0 {
        anyhow::bail!("{blocked} gate(s) failed — fix them before deploying");
    }
    Ok(())
}

// ── Gate ──────────────────────────────────────────────────────────────────────

enum GateStatus { Pass, Warn, Block }

struct Gate {
    status: GateStatus,
    message: Option<String>,
}

impl Gate {
    fn pass() -> Self { Self { status: GateStatus::Pass, message: None } }
    fn warn(msg: impl Into<String>) -> Self { Self { status: GateStatus::Warn, message: Some(msg.into()) } }
    fn block(msg: impl Into<String>) -> Self { Self { status: GateStatus::Block, message: Some(msg.into()) } }
}

// ── Individual gates ──────────────────────────────────────────────────────────

fn gate_lockfile() -> Gate {
    match std::process::Command::new("git")
        .args(["status", "--porcelain", "Cargo.lock"])
        .output()
    {
        Ok(o) => {
            let s = String::from_utf8_lossy(&o.stdout);
            if s.trim().is_empty() { Gate::pass() }
            else { Gate::warn("Cargo.lock has uncommitted changes — commit it for reproducible builds") }
        }
        Err(_) => Gate::warn("git not found — cannot check Cargo.lock"),
    }
}

fn gate_clean_git() -> Gate {
    match std::process::Command::new("git")
        .args(["status", "--porcelain"])
        .output()
    {
        Ok(o) => {
            let s = String::from_utf8_lossy(&o.stdout);
            if s.trim().is_empty() { Gate::pass() }
            else { Gate::warn(format!("{} uncommitted change(s)", s.lines().count())) }
        }
        Err(_) => Gate::warn("git not found"),
    }
}

fn gate_release_build() -> Gate {
    println!("  … running cargo check --release (this may take a moment)");
    match std::process::Command::new("cargo")
        .args(["check", "--release", "--quiet"])
        .output()
    {
        Ok(o) if o.status.success() => Gate::pass(),
        Ok(o) => {
            let stderr = String::from_utf8_lossy(&o.stderr);
            Gate::block(format!("cargo check --release failed: {}", stderr.lines().next().unwrap_or("")))
        }
        Err(e) => Gate::block(format!("cargo not found: {e}")),
    }
}

fn gate_database_url() -> Gate {
    if std::env::var("DATABASE_URL").is_ok() { Gate::pass() }
    else { Gate::warn("DATABASE_URL not set in environment") }
}

fn gate_no_debug_macros() -> Gate {
    if !Path::new("src").exists() { return Gate::pass(); }
    let output = std::process::Command::new("grep")
        .args(["-r", "--include=*.rs", "-l", r"debug!\|dbg!", "src/"])
        .output();
    match output {
        Ok(o) if o.stdout.is_empty() => Gate::pass(),
        Ok(o) => {
            let files = String::from_utf8_lossy(&o.stdout);
            Gate::warn(format!("debug!/dbg! found in: {}", files.lines().next().unwrap_or("")))
        }
        Err(_) => Gate::pass(), // grep not available — skip
    }
}

fn gate_no_todo() -> Gate {
    if !Path::new("src").exists() { return Gate::pass(); }
    let output = std::process::Command::new("grep")
        .args(["-r", "--include=*.rs", "-l", r"TODO\|FIXME", "src/"])
        .output();
    match output {
        Ok(o) if o.stdout.is_empty() => Gate::pass(),
        Ok(o) => {
            let count = String::from_utf8_lossy(&o.stdout).lines().count();
            Gate::warn(format!("{count} file(s) contain TODO/FIXME comments"))
        }
        Err(_) => Gate::pass(),
    }
}

fn gate_changelog() -> Gate {
    if Path::new("CHANGELOG.md").exists() {
        Gate::pass()
    } else {
        Gate::warn("CHANGELOG.md not found — consider documenting changes")
    }
}