aranet-cli 0.2.0

Command-line interface for Aranet environmental sensors
Documentation
//! Doctor command implementation.
//!
//! Performs BLE diagnostics and permission checks to help troubleshoot
//! connectivity issues.

use anyhow::Result;
use aranet_core::scan::{self, ScanOptions};
use owo_colors::OwoColorize;

use crate::style;

/// Check result with status and message.
struct Check {
    #[allow(dead_code)]
    name: &'static str,
    passed: bool,
    warning: bool,
    message: String,
}

impl Check {
    fn pass(name: &'static str, message: impl Into<String>) -> Self {
        Self {
            name,
            passed: true,
            warning: false,
            message: message.into(),
        }
    }

    fn warn(name: &'static str, message: impl Into<String>) -> Self {
        Self {
            name,
            passed: true,
            warning: true,
            message: message.into(),
        }
    }

    fn fail(name: &'static str, message: impl Into<String>) -> Self {
        Self {
            name,
            passed: false,
            warning: false,
            message: message.into(),
        }
    }
}

pub async fn cmd_doctor(verbose: bool, no_color: bool) -> Result<()> {
    println!(
        "{}",
        style::format_title("Aranet Doctor - BLE Diagnostics", no_color)
    );
    println!();

    let mut checks: Vec<Check> = Vec::new();
    let mut check_num = 0;

    // Check 1: Bluetooth adapter availability
    check_num += 1;
    print_check_start(check_num, "Bluetooth Adapter", no_color);
    let adapter_check = check_adapter().await;
    print_check_result(&adapter_check, no_color);
    let adapter_ok = adapter_check.passed;
    checks.push(adapter_check);

    // Check 2: Bluetooth permissions (platform-specific)
    check_num += 1;
    print_check_start(check_num, "Bluetooth Permissions", no_color);
    let permission_check = check_permissions().await;
    print_check_result(&permission_check, no_color);
    checks.push(permission_check);

    // Check 3: Scan for devices (only if adapter is available)
    if adapter_ok {
        check_num += 1;
        print_check_start(check_num, "Device Scan", no_color);
        let scan_check = check_scan().await;
        print_check_result(&scan_check, no_color);
        checks.push(scan_check);
    }

    // Check 4: Config file validity
    check_num += 1;
    print_check_start(check_num, "Configuration", no_color);
    let config_check = check_config();
    print_check_result(&config_check, no_color);
    checks.push(config_check);

    println!();
    println!("{}", "".repeat(50));

    // Summary
    let passed = checks.iter().filter(|c| c.passed && !c.warning).count();
    let warnings = checks.iter().filter(|c| c.warning).count();
    let failed = checks.iter().filter(|c| !c.passed).count();

    let summary = if no_color {
        format!(
            "Summary: {} passed, {} warnings, {} failed",
            passed, warnings, failed
        )
    } else {
        format!(
            "Summary: {} passed, {} warnings, {} failed",
            format!("{}", passed).green(),
            format!("{}", warnings).yellow(),
            format!("{}", failed).red()
        )
    };
    println!("{}", summary);
    println!();

    // Print platform-specific help if there are failures
    if failed > 0 {
        print_troubleshooting_help(verbose, no_color);
    } else if warnings > 0 {
        println!("System is functional but some checks had warnings.");
        println!("Run with --verbose for more details.");
    } else {
        let msg = "All checks passed! Your system is ready to use Aranet devices.";
        println!("{}", style::format_success(msg, no_color));
    }

    Ok(())
}

fn print_check_start(num: usize, name: &str, no_color: bool) {
    // Use simple static output instead of a spinner that can't animate during sync blocking
    use std::io::{Write, stdout};
    if no_color {
        print!("[{}] {} ... ", num, name);
    } else {
        print!("{} {} ... ", format!("[{}]", num).dimmed(), name);
    }
    // Flush to ensure the message appears before the blocking operation
    let _ = stdout().flush();
}

fn print_check_result(check: &Check, no_color: bool) {
    let (icon, msg) = if check.passed && !check.warning {
        if no_color {
            ("[OK]".to_string(), check.message.clone())
        } else {
            (format!("{}", "[OK]".green()), check.message.clone())
        }
    } else if check.warning {
        if no_color {
            ("[!!]".to_string(), check.message.clone())
        } else {
            (
                format!("{}", "[!!]".yellow()),
                format!("{}", check.message.yellow()),
            )
        }
    } else if no_color {
        ("[FAIL]".to_string(), check.message.clone())
    } else {
        (
            format!("{}", "[FAIL]".red()),
            format!("{}", check.message.red()),
        )
    };
    println!("{} {}", icon, msg);
}

async fn check_adapter() -> Check {
    match scan::get_adapter().await {
        Ok(_adapter) => Check::pass("Bluetooth Adapter", "Found and accessible"),
        Err(e) => {
            let msg = format!("Not available ({})", e);
            Check::fail("Bluetooth Adapter", msg)
        }
    }
}

async fn check_scan() -> Check {
    let options = ScanOptions::default()
        .duration_secs(3)
        .filter_aranet_only(true);

    match scan::scan_with_options(options).await {
        Ok(devices) => {
            if devices.is_empty() {
                Check::warn("BLE Scanning", "No Aranet devices found nearby")
            } else {
                let names: Vec<String> = devices.iter().filter_map(|d| d.name.clone()).collect();
                Check::pass(
                    "BLE Scanning",
                    format!("Found {} device(s): {}", devices.len(), names.join(", ")),
                )
            }
        }
        Err(e) => Check::fail("BLE Scanning", format!("Failed ({})", e)),
    }
}

async fn check_permissions() -> Check {
    // Platform-specific permission checks
    #[cfg(target_os = "macos")]
    {
        // On macOS, we can check if we got an adapter - if we did, permissions are likely OK
        // There's no direct API to check Bluetooth permissions, but a successful adapter
        // access implies permissions are granted
        match scan::get_adapter().await {
            Ok(_) => Check::pass("Bluetooth Permissions", "Bluetooth access granted"),
            Err(_) => Check::warn(
                "Bluetooth Permissions",
                "May need to grant Bluetooth permission in System Settings",
            ),
        }
    }

    #[cfg(target_os = "linux")]
    {
        // On Linux, check if user is in bluetooth group
        if let Ok(output) = std::process::Command::new("groups").output() {
            let groups = String::from_utf8_lossy(&output.stdout);
            if groups.contains("bluetooth") {
                Check::pass("Bluetooth Permissions", "User is in bluetooth group")
            } else {
                Check::warn(
                    "Bluetooth Permissions",
                    "User not in bluetooth group (may need: sudo usermod -aG bluetooth $USER)",
                )
            }
        } else {
            Check::warn("Bluetooth Permissions", "Could not check group membership")
        }
    }

    #[cfg(target_os = "windows")]
    {
        // On Windows, Bluetooth permissions are typically granted by default
        Check::pass(
            "Bluetooth Permissions",
            "Windows grants Bluetooth access by default",
        )
    }

    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
    {
        Check::warn(
            "Bluetooth Permissions",
            "Unknown platform - cannot check permissions",
        )
    }
}

fn check_config() -> Check {
    use crate::config::Config;

    let config_path = Config::path();
    if !config_path.exists() {
        return Check::pass("Configuration", "No config file (using defaults)");
    }

    match Config::load() {
        Ok(config) => {
            let alias_count = config.aliases.len();
            let default_device = config.device.is_some();
            Check::pass(
                "Configuration",
                format!(
                    "Valid ({} alias{}, default device: {})",
                    alias_count,
                    if alias_count == 1 { "" } else { "es" },
                    if default_device { "set" } else { "not set" }
                ),
            )
        }
        Err(err) => Check::warn("Configuration", format!("Invalid config file ({err})")),
    }
}

fn print_troubleshooting_help(verbose: bool, no_color: bool) {
    let title = if no_color {
        "Troubleshooting Tips:".to_string()
    } else {
        format!("{}", "Troubleshooting Tips:".yellow())
    };
    println!("{}", title);
    println!();

    #[cfg(target_os = "macos")]
    {
        println!("macOS:");
        println!("  • Ensure Bluetooth is enabled in System Settings");
        println!("  • Grant Bluetooth permission to Terminal/your app");
        println!("  • Try: System Settings → Privacy & Security → Bluetooth");
        if verbose {
            println!("  • Check if other BLE apps work (e.g., LightBlue)");
            println!("  • Try resetting Bluetooth: sudo pkill bluetoothd");
        }
    }

    #[cfg(target_os = "linux")]
    {
        println!("Linux:");
        println!("  • Ensure BlueZ is installed: sudo apt install bluez");
        println!("  • Check Bluetooth service: systemctl status bluetooth");
        println!("  • Add user to bluetooth group: sudo usermod -aG bluetooth $USER");
        if verbose {
            println!("  • Check adapter: hciconfig -a");
            println!("  • Restart Bluetooth: sudo systemctl restart bluetooth");
        }
    }

    #[cfg(target_os = "windows")]
    {
        println!("Windows:");
        println!("  • Ensure Bluetooth is enabled in Settings");
        println!("  • Check Device Manager for Bluetooth adapter");
        println!("  • Update Bluetooth drivers if needed");
        if verbose {
            println!("  • Try: Settings → Bluetooth & devices → Bluetooth → On");
        }
    }

    println!();
}