lmrc-cli 0.3.16

CLI tool for scaffolding LMRC Stack infrastructure projects
Documentation
use colored::Colorize;
use std::process::Command;

use crate::error::Result;

#[derive(Debug)]
struct CheckResult {
    name: String,
    passed: bool,
    message: String,
    level: CheckLevel,
}

#[derive(Debug)]
enum CheckLevel {
    Required,
    Optional,
}

impl CheckResult {
    fn pass(name: &str, message: String) -> Self {
        Self {
            name: name.to_string(),
            passed: true,
            message,
            level: CheckLevel::Required,
        }
    }

    fn fail(name: &str, message: String) -> Self {
        Self {
            name: name.to_string(),
            passed: false,
            message,
            level: CheckLevel::Required,
        }
    }

    fn optional(name: &str, passed: bool, message: String) -> Self {
        Self {
            name: name.to_string(),
            passed,
            message,
            level: CheckLevel::Optional,
        }
    }
}

pub async fn doctor(fix: bool) -> Result<()> {
    println!("\n{}\n", "Checking development environment...".cyan().bold());

    let mut checks = vec![];

    // Check Rust
    checks.push(check_rust_version());

    // Check Cargo
    checks.push(check_cargo());

    // Check Docker
    checks.push(check_docker());

    // Check Docker Compose
    checks.push(check_docker_compose());

    // Check Git
    checks.push(check_git());

    // Check kubectl (optional)
    checks.push(check_kubectl());

    // Check environment variables
    checks.extend(check_env_vars());

    // Check ports
    checks.extend(check_ports());

    // Display results
    display_results(&checks);

    // Summary
    print_summary(&checks);

    if fix {
        println!("\n{}", "Auto-fix is not yet implemented.".yellow());
        println!("Please install missing tools manually.");
    }

    Ok(())
}

fn check_rust_version() -> CheckResult {
    match Command::new("rustc").arg("--version").output() {
        Ok(output) => {
            let version_str = String::from_utf8_lossy(&output.stdout);
            let version = version_str.trim();

            // Parse version
            if let Some(ver) = version.split_whitespace().nth(1) {
                if is_version_sufficient(ver, "1.75.0") {
                    CheckResult::pass("Rust", format!("{}", version))
                } else {
                    CheckResult::fail("Rust", format!("{} (required: >= 1.75.0)", version))
                }
            } else {
                CheckResult::pass("Rust", version.to_string())
            }
        }
        Err(_) => CheckResult::fail("Rust", "Not found. Install from https://rustup.rs".to_string()),
    }
}

fn check_cargo() -> CheckResult {
    match Command::new("cargo").arg("--version").output() {
        Ok(output) => {
            let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
            CheckResult::pass("Cargo", version)
        }
        Err(_) => CheckResult::fail("Cargo", "Not found".to_string()),
    }
}

fn check_docker() -> CheckResult {
    match Command::new("docker").arg("--version").output() {
        Ok(output) => {
            let version_str = String::from_utf8_lossy(&output.stdout);
            let version = version_str.trim();

            // Check if version >= 20.10
            if let Some(ver) = extract_docker_version(version) {
                if is_version_sufficient(&ver, "20.10.0") {
                    CheckResult::pass("Docker", version.to_string())
                } else {
                    CheckResult::fail("Docker", format!("{} (required: >= 20.10)", version))
                }
            } else {
                CheckResult::pass("Docker", version.to_string())
            }
        }
        Err(_) => CheckResult::fail("Docker", "Not found. Install from https://docs.docker.com/get-docker/".to_string()),
    }
}

fn check_docker_compose() -> CheckResult {
    // Try docker compose (v2)
    match Command::new("docker").args(&["compose", "version"]).output() {
        Ok(output) => {
            let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
            CheckResult::pass("Docker Compose", version)
        }
        Err(_) => {
            // Try docker-compose (v1)
            match Command::new("docker-compose").arg("--version").output() {
                Ok(output) => {
                    let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
                    CheckResult::pass("Docker Compose", version)
                }
                Err(_) => CheckResult::fail(
                    "Docker Compose",
                    "Not found. Install from https://docs.docker.com/compose/install/".to_string(),
                ),
            }
        }
    }
}

fn check_git() -> CheckResult {
    match Command::new("git").arg("--version").output() {
        Ok(output) => {
            let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
            CheckResult::pass("Git", version)
        }
        Err(_) => CheckResult::fail("Git", "Not found. Install from https://git-scm.com/".to_string()),
    }
}

fn check_kubectl() -> CheckResult {
    match Command::new("kubectl").arg("version").arg("--client").output() {
        Ok(output) => {
            let version = String::from_utf8_lossy(&output.stdout);
            let version_line = version.lines().next().unwrap_or("unknown");
            CheckResult::optional("kubectl", true, version_line.to_string())
        }
        Err(_) => CheckResult::optional(
            "kubectl",
            false,
            "Not found (optional, needed for K8s deployments)".to_string(),
        ),
    }
}

fn check_env_vars() -> Vec<CheckResult> {
    let mut results = vec![];

    // Check common environment variables
    let env_vars = vec![
        ("HETZNER_API_TOKEN", "Required for provisioning servers on Hetzner", CheckLevel::Optional),
        ("CLOUDFLARE_API_TOKEN", "Required for DNS management", CheckLevel::Optional),
        ("GITLAB_TOKEN", "Required for CI/CD setup", CheckLevel::Optional),
    ];

    for (var, description, level) in env_vars {
        match std::env::var(var) {
            Ok(value) => {
                let masked = mask_token(&value);
                results.push(CheckResult::optional(var, true, format!("Set ({})", masked)));
            }
            Err(_) => {
                results.push(CheckResult::optional(var, false, format!("Not set ({})", description)));
            }
        }
    }

    results
}

fn check_ports() -> Vec<CheckResult> {
    let mut results = vec![];

    let ports = vec![
        (5432, "PostgreSQL"),
        (5672, "RabbitMQ"),
        (6379, "Redis"),
        (8200, "Vault"),
        (8080, "Default API port"),
    ];

    for (port, service) in ports {
        match is_port_available(port) {
            true => {
                results.push(CheckResult::optional(
                    &format!("Port {}", port),
                    true,
                    format!("Available ({})", service),
                ));
            }
            false => {
                results.push(CheckResult::optional(
                    &format!("Port {}", port),
                    false,
                    format!("In use ({})", service),
                ));
            }
        }
    }

    results
}

fn display_results(checks: &[CheckResult]) {
    for check in checks {
        let symbol = if check.passed { "".green() } else { "".red() };
        let name = format!("{:25}", check.name);

        match check.level {
            CheckLevel::Required => {
                if check.passed {
                    println!("{} {} {}", symbol, name, check.message.green());
                } else {
                    println!("{} {} {}", symbol, name, check.message.red());
                }
            }
            CheckLevel::Optional => {
                if check.passed {
                    println!("{} {} {}", symbol, name, check.message);
                } else {
                    println!("{} {} {}", "".yellow(), name, check.message.yellow());
                }
            }
        }
    }
}

fn print_summary(checks: &[CheckResult]) {
    let total = checks.iter().filter(|c| matches!(c.level, CheckLevel::Required)).count();
    let passed = checks
        .iter()
        .filter(|c| matches!(c.level, CheckLevel::Required) && c.passed)
        .count();
    let optional_passed = checks
        .iter()
        .filter(|c| matches!(c.level, CheckLevel::Optional) && c.passed)
        .count();

    println!("\n{}", "Summary:".cyan().bold());
    println!("  Required checks: {}/{} passed", passed.to_string().green(), total);
    println!("  Optional checks: {} passed", optional_passed.to_string().cyan());

    if passed == total {
        println!("\n{}", "✓ Ready for local development!".green().bold());

        if optional_passed < (checks.len() - total) {
            println!("{}", "⚠ Some optional features unavailable (missing credentials)".yellow());
        }
    } else {
        println!("\n{}", "✗ Not ready - please install missing tools".red().bold());
        println!("\nRun {} to install missing tools", "lmrc doctor --fix".cyan());
    }
}

// Helper functions

fn is_version_sufficient(current: &str, required: &str) -> bool {
    let current_parts: Vec<u32> = current
        .split('.')
        .filter_map(|s| s.parse().ok())
        .collect();
    let required_parts: Vec<u32> = required
        .split('.')
        .filter_map(|s| s.parse().ok())
        .collect();

    for i in 0..3 {
        let c = current_parts.get(i).unwrap_or(&0);
        let r = required_parts.get(i).unwrap_or(&0);
        if c > r {
            return true;
        }
        if c < r {
            return false;
        }
    }
    true
}

fn extract_docker_version(version_str: &str) -> Option<String> {
    // Docker version 20.10.21, build baeda1f
    version_str
        .split_whitespace()
        .nth(2)
        .and_then(|v| v.trim_end_matches(',').parse::<String>().ok())
}

fn mask_token(token: &str) -> String {
    if token.len() > 8 {
        format!("{}...{}", &token[..4], &token[token.len()-4..])
    } else {
        "****".to_string()
    }
}

fn is_port_available(port: u16) -> bool {
    use std::net::TcpListener;
    TcpListener::bind(("127.0.0.1", port)).is_ok()
}