whisper-macos-cli 0.1.0

Transcribe audio files locally on Apple Silicon via whisper.cpp with Metal GPU acceleration, exposing a strict stdin/stdout JSON contract for AI agents and Unix pipelines.
Documentation
use std::time::Duration;

use serde_json::json;

use crate::error::Error;
use crate::model::{registry, storage};
use crate::output;

const NETWORK_PROBE_HOST: &str = "https://huggingface.co";
const NETWORK_PROBE_TIMEOUT: Duration = Duration::from_secs(3);

pub fn run(correlation_id: &str) -> Result<(), Error> {
    let mut checks = Vec::new();

    check_macos(&mut checks);
    check_arch(&mut checks);
    check_command("cmake", &["--version"], "brew install cmake", &mut checks);
    check_command(
        "clang",
        &["--version"],
        "xcode-select --install",
        &mut checks,
    );
    check_xcode(&mut checks);
    check_model(&mut checks);
    check_network(&mut checks);
    check_disk_space(&mut checks);

    let all_ok = checks.iter().all(|c| c["status"] == "ok");

    let result = json!({
        "schema_version": env!("CARGO_PKG_VERSION"),
        "correlation_id": correlation_id,
        "checks": checks,
        "all_ok": all_ok,
    });
    output::write_json_value(&result).map_err(Error::Io)?;

    if all_ok {
        Ok(())
    } else {
        Err(Error::Config("system check failed".into()))
    }
}

fn check_macos(checks: &mut Vec<serde_json::Value>) {
    if cfg!(target_os = "macos") {
        checks.push(json!({"name": "macOS", "status": "ok", "detail": "macOS detected"}));
    } else {
        checks.push(json!({"name": "macOS", "status": "fail", "detail": "not macOS — Metal GPU unavailable"}));
    }
}

fn check_arch(checks: &mut Vec<serde_json::Value>) {
    if cfg!(target_arch = "aarch64") {
        checks.push(json!({"name": "Apple Silicon", "status": "ok", "detail": "aarch64"}));
    } else {
        let arch = std::env::consts::ARCH;
        checks.push(json!({
            "name": "Apple Silicon",
            "status": "fail",
            "detail": format!("{arch} — Metal GPU requires Apple Silicon"),
        }));
    }
}

fn check_command(cmd: &str, args: &[&str], hint: &str, checks: &mut Vec<serde_json::Value>) {
    match std::process::Command::new(cmd).args(args).output() {
        Ok(out) if out.status.success() => {
            let stdout = String::from_utf8_lossy(&out.stdout);
            let version = stdout.lines().next().unwrap_or("").trim().to_string();
            let stderr = String::from_utf8_lossy(&out.stderr);
            let version = if version.is_empty() {
                stderr.lines().next().unwrap_or("").trim().to_string()
            } else {
                version
            };
            checks.push(json!({"name": cmd, "status": "ok", "detail": version}));
        }
        _ => {
            checks.push(json!({
                "name": cmd,
                "status": "fail",
                "detail": format!("not found — install via: {hint}"),
            }));
        }
    }
}

fn check_xcode(checks: &mut Vec<serde_json::Value>) {
    match std::process::Command::new("xcode-select")
        .arg("-p")
        .output()
    {
        Ok(out) if out.status.success() => {
            let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
            checks.push(json!({"name": "Xcode CLI Tools", "status": "ok", "detail": path}));
        }
        _ => {
            checks.push(json!({
                "name": "Xcode CLI Tools",
                "status": "fail",
                "detail": "not installed — run: xcode-select --install",
            }));
        }
    }
}

fn check_model(checks: &mut Vec<serde_json::Value>) {
    let model = registry::default_model();
    let name = format!("Model {}", model.name);
    match storage::is_model_downloaded(model) {
        Ok(true) => {
            checks.push(json!({"name": name, "status": "ok", "detail": "downloaded"}));
        }
        Ok(false) => {
            checks.push(json!({
                "name": name,
                "status": "fail",
                "detail": "not found — run: whisper-macos-cli models download",
            }));
        }
        Err(e) => {
            checks.push(json!({"name": name, "status": "fail", "detail": e.to_string()}));
        }
    }
}

fn check_network(checks: &mut Vec<serde_json::Value>) {
    let client = match reqwest::blocking::Client::builder()
        .timeout(NETWORK_PROBE_TIMEOUT)
        .user_agent(concat!(
            "whisper-macos-cli/",
            env!("CARGO_PKG_VERSION"),
            " (doctor)"
        ))
        .build()
    {
        Ok(c) => c,
        Err(e) => {
            checks.push(json!({
                "name": "Network",
                "status": "fail",
                "detail": format!("cannot build HTTP client: {e}"),
            }));
            return;
        }
    };

    match client.head(NETWORK_PROBE_HOST).send() {
        Ok(resp) if resp.status().is_success() || resp.status().is_redirection() => {
            checks.push(json!({"name": "Network", "status": "ok", "detail": NETWORK_PROBE_HOST}));
        }
        Ok(resp) => {
            checks.push(json!({
                "name": "Network",
                "status": "fail",
                "detail": format!("HTTP {} from {}", resp.status(), NETWORK_PROBE_HOST),
            }));
        }
        Err(e) => {
            checks.push(json!({
                "name": "Network",
                "status": "fail",
                "detail": format!("air-gapped or unreachable: {e}"),
            }));
        }
    }
}

fn check_disk_space(checks: &mut Vec<serde_json::Value>) {
    if let Ok(dirs) = crate::model::storage::models_dir() {
        if let Some(parent) = dirs.parent() {
            let available = available_disk_bytes(parent);
            let required = 4 * 1024 * 1024 * 1024u64;
            match available {
                Some(avail) if avail >= required => {
                    checks.push(json!({
                        "name": "Disk Space",
                        "status": "ok",
                        "detail": format!("{} MB available", avail / (1024 * 1024)),
                    }));
                }
                Some(avail) => {
                    checks.push(json!({
                        "name": "Disk Space",
                        "status": "fail",
                        "detail": format!("only {} MB available, need at least 4GB", avail / (1024 * 1024)),
                    }));
                }
                None => {
                    checks.push(json!({
                        "name": "Disk Space",
                        "status": "ok",
                        "detail": "unable to determine (non-POSIX platform)",
                    }));
                }
            }
        }
    }
}

#[cfg(unix)]
fn available_disk_bytes(path: &std::path::Path) -> Option<u64> {
    use std::ffi::CString;
    use std::os::unix::ffi::OsStrExt;

    let cpath = CString::new(path.as_os_str().as_bytes()).ok()?;
    let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
    let result = unsafe { libc::statvfs(cpath.as_ptr(), &mut stat) };
    if result != 0 {
        return None;
    }
    let available = stat.f_bavail as u64 * stat.f_frsize;
    Some(available)
}

#[cfg(not(unix))]
fn available_disk_bytes(_path: &std::path::Path) -> Option<u64> {
    None
}