dorker 0.1.7

Docker for headless Android emulator environments
use crate::sdk::{Sdk, host_abi};
use crate::store::Store;
use crate::style::Style;
use anyhow::Result;
use std::process::Command;

#[derive(Clone, Copy, PartialEq)]
enum Level {
    Ok,
    Warn,
    Fail,
}

struct Check {
    name: &'static str,
    level: Level,
    detail: String,
    fix: Option<String>,
}

fn out(cmd: &mut Command) -> Option<String> {
    let o = cmd.output().ok()?;
    o.status
        .success()
        .then(|| String::from_utf8_lossy(&o.stdout).trim().to_string())
}

fn first_line(s: &str) -> String {
    s.lines().next().unwrap_or("").to_string()
}

fn collect() -> Vec<Check> {
    let mut v = Vec::new();
    let mut push = |name, level, detail: String, fix: Option<&str>| {
        v.push(Check {
            name,
            level,
            detail,
            fix: fix.map(String::from),
        })
    };

    // Platform
    let os = std::env::consts::OS;
    let arch = std::env::consts::ARCH;
    let ver = if os == "macos" {
        out(Command::new("sw_vers").arg("-productVersion"))
    } else {
        None
    };
    let supported = (os == "macos" && arch == "aarch64") || os == "linux";
    push(
        "platform",
        if supported { Level::Ok } else { Level::Warn },
        format!("{os} {} {arch}", ver.unwrap_or_default())
            .trim()
            .to_string(),
        (!supported)
            .then_some("dorker targets macOS Apple Silicon first; other hosts are untested"),
    );

    // SDK
    let sdk = Sdk::locate();
    match &sdk {
        Ok(s) => push("android sdk", Level::Ok, s.root.display().to_string(), None),
        Err(e) => push(
            "android sdk",
            Level::Fail,
            first_line(&e.to_string()),
            Some("brew install --cask android-commandlinetools  # then set ANDROID_HOME"),
        ),
    }

    if let Ok(s) = &sdk {
        // emulator
        if s.emulator().is_file() {
            let v = out(Command::new(s.emulator()).arg("-version")).unwrap_or_default();
            let line = v
                .lines()
                .find(|l| l.contains("emulator version"))
                .unwrap_or("")
                .to_string();
            push("emulator", Level::Ok, line, None);
        } else {
            push(
                "emulator",
                Level::Fail,
                format!("not found at {}", s.emulator().display()),
                Some("android sdk install emulator"),
            );
        }
        // adb
        if s.adb().is_file() {
            let v = out(Command::new(s.adb()).arg("version")).unwrap_or_default();
            push("adb", Level::Ok, first_line(&v), None);
        } else {
            push(
                "adb",
                Level::Fail,
                format!("not found at {}", s.adb().display()),
                Some("android sdk install platform-tools"),
            );
        }
        // system images
        let images = s.system_images();
        let host: Vec<_> = images.iter().filter(|i| i.abi == host_abi()).collect();
        if host.is_empty() {
            push(
                "system images",
                Level::Fail,
                format!("no {} images installed", host_abi()),
                Some(&format!(
                    "dorker pull android:35   (or: android sdk install \"system-images;android-35;google_apis;{}\")",
                    host_abi()
                )),
            );
        } else {
            let list: Vec<String> = host
                .iter()
                .map(|i| {
                    format!(
                        "android:{}-{}",
                        crate::sdk::version_label(i.api, i.minor),
                        i.flavour
                    )
                })
                .collect();
            push("system images", Level::Ok, list.join(", "), None);
        }
    }

    // Hardware acceleration
    if os == "macos" {
        let hv = out(Command::new("sysctl").args(["-n", "kern.hv_support"]));
        match hv.as_deref() {
            Some("1") => push(
                "acceleration",
                Level::Ok,
                "Hypervisor.framework available".into(),
                None,
            ),
            _ => push(
                "acceleration",
                Level::Fail,
                "Hypervisor.framework unavailable".into(),
                Some("Run on bare-metal macOS; nested virtualisation is not supported"),
            ),
        }
    } else if std::path::Path::new("/dev/kvm").exists() {
        push("acceleration", Level::Ok, "/dev/kvm present".into(), None);
    } else {
        push(
            "acceleration",
            Level::Fail,
            "/dev/kvm missing".into(),
            Some("Enable KVM and add your user to the kvm group"),
        );
    }

    // Disk space (df -k <dir>: available KiB is column 4)
    let home = crate::sdk::dirs_home().unwrap_or_else(|| "/".into());
    if let Some(df) = out(Command::new("df").arg("-k").arg(&home)) {
        let avail_kb: u64 = df
            .lines()
            .nth(1)
            .and_then(|l| l.split_whitespace().nth(3))
            .and_then(|s| s.parse().ok())
            .unwrap_or(0);
        let gb = avail_kb / 1024 / 1024;
        push(
            "disk space",
            if gb >= 10 { Level::Ok } else { Level::Warn },
            format!("{gb} GB free"),
            (gb < 10).then_some("each instance needs roughly 4-8 GB; free up space"),
        );
    }

    // Store
    match Store::open() {
        Ok(_) => push(
            "state dir",
            Level::Ok,
            crate::store::default_root()
                .map(|p| p.display().to_string())
                .unwrap_or_else(|_| "~/.dorker".into()),
            None,
        ),
        Err(e) => push(
            "state dir",
            Level::Fail,
            e.to_string(),
            Some("check permissions on ~/.dorker"),
        ),
    }
    v
}

/// The human-readable report. Padding is applied before colouring, so styled and plain output
/// have identical columns (tests strip the escapes and compare).
fn render(checks: &[Check], failed: bool, st: &Style) -> String {
    let mut out = String::new();
    for c in checks {
        let mark = match c.level {
            Level::Ok => st.green("ok  "),
            Level::Warn => st.yellow("warn"),
            Level::Fail => st.red_bold("FAIL"),
        };
        let name = st.bold(&format!("{:<14}", c.name));
        let detail = match c.level {
            Level::Ok => c.detail.clone(),
            Level::Warn => st.yellow(&c.detail),
            Level::Fail => st.red_bold(&c.detail),
        };
        out.push_str(&format!("[{mark}] {name} {detail}\n"));
        if let (Some(fix), true) = (&c.fix, c.level != Level::Ok) {
            out.push_str(&format!("       {} {}\n", st.dim("fix:"), st.cyan(fix)));
        }
    }
    let verdict = if failed {
        st.red_bold("Some checks failed.")
    } else {
        st.green_bold("All required checks passed.")
    };
    out.push_str(&format!("\n{verdict}\n"));
    out
}

pub fn run(json: bool) -> Result<i32> {
    let checks = collect();
    let failed = checks.iter().any(|c| c.level == Level::Fail);
    if json {
        let rows: Vec<_> = checks
            .iter()
            .map(|c| {
                serde_json::json!({
                    "check": c.name,
                    "status": match c.level { Level::Ok => "ok", Level::Warn => "warn", Level::Fail => "fail" },
                    "detail": c.detail, "fix": c.fix,
                })
            })
            .collect();
        println!(
            "{}",
            serde_json::to_string_pretty(&serde_json::json!({ "ok": !failed, "checks": rows }))?
        );
    } else {
        print!("{}", render(&checks, failed, &Style::stdout()));
    }
    Ok(if failed { crate::errors::DEPENDENCY } else { 0 })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::style::strip_ansi;

    fn sample() -> Vec<Check> {
        vec![
            Check {
                name: "platform",
                level: Level::Ok,
                detail: "macos 26 aarch64".into(),
                fix: None,
            },
            Check {
                name: "disk space",
                level: Level::Warn,
                detail: "3 GB free".into(),
                fix: Some("free up space".into()),
            },
            Check {
                name: "emulator",
                level: Level::Fail,
                detail: "not found".into(),
                fix: Some("android sdk install emulator".into()),
            },
        ]
    }

    #[test]
    fn plain_output_is_unchanged_and_has_no_escapes() {
        let plain = render(&sample(), true, &Style { on: false });
        assert!(!plain.contains('\x1b'));
        assert_eq!(
            plain,
            "[ok  ] platform       macos 26 aarch64\n\
             [warn] disk space     3 GB free\n       fix: free up space\n\
             [FAIL] emulator       not found\n       fix: android sdk install emulator\n\
             \nSome checks failed.\n"
        );
    }

    #[test]
    fn colour_adds_escapes_without_changing_the_text_or_columns() {
        let plain = render(&sample(), true, &Style { on: false });
        let styled = render(&sample(), true, &Style { on: true });
        assert!(styled.contains("\x1b[32mok  \x1b[0m"), "ok is green");
        assert!(styled.contains("\x1b[33mwarn\x1b[0m"), "warn is yellow");
        assert!(styled.contains("\x1b[1;31mFAIL\x1b[0m"), "FAIL is bold red");
        assert_eq!(
            strip_ansi(&styled),
            plain,
            "same text and alignment once the colour is removed"
        );
        let ok = render(&sample()[..1], false, &Style { on: true });
        assert!(ok.contains("\x1b[1;32mAll required checks passed.\x1b[0m"));
    }
}