bot-forge 1.0.2

Rust CLI for installing agent skills and developer tools from configurable forms.
Documentation
//! Bounded platform, filesystem, configuration, and network diagnostics.

use std::env;
use std::fs::{self, OpenOptions};
use std::net::ToSocketAddrs;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

use fs2::available_space;

use crate::config::load_config_with_overlays;
use crate::diagnostics::proxy::configuration_summary;
use crate::model::DiagnosticCheck;
use crate::paths::{app_home, journal_dir, managed_bin_dir};
use crate::state::cache::status;
use crate::state::journal::load_pending;
use crate::util::resolve_command;

const NETWORK_TIMEOUT: Duration = Duration::from_secs(3);
const DISK_WARNING_BYTES: u64 = 512 * 1024 * 1024;

/// Run the complete ordered diagnostic suite for the current platform.
///
/// Individual environmental failures are returned as [`DiagnosticCheck`] findings rather than
/// short-circuiting the suite.
pub(crate) fn doctor_checks(explicit_config: Option<&Path>) -> Vec<DiagnosticCheck> {
    let mut checks = vec![
        architecture_check(),
        command_check("command.git", "git"),
        command_check("command.cargo", "cargo"),
        command_check("command.rustup", "rustup"),
    ];
    if cfg!(windows) {
        checks.push(command_check("command.package-manager", "winget"));
    } else if cfg!(target_os = "linux") {
        checks.push(command_check("command.package-manager", "apt-get"));
    } else if cfg!(target_os = "macos") {
        checks.push(command_check_with_args(
            "command.package-manager",
            "brew",
            &["--version"],
        ));
        checks.push(command_check_with_args(
            "command.xcode-tools",
            "xcrun",
            &["--find", "clang"],
        ));
        checks.push(macos_translation_check());
    }
    checks.extend([
        proxy_check(),
        shell_check(),
        data_directory_check(),
        managed_bin_directory_check(),
        disk_space_check(),
        lock_directory_check(),
        cache_check(),
        pending_journal_check(),
        config_source_check(explicit_config),
        dns_check(),
        tls_check(),
    ]);
    checks
}

fn proxy_check() -> DiagnosticCheck {
    check(
        "network.proxy-config",
        true,
        configuration_summary(),
        None,
        0,
    )
}

fn lock_directory_check() -> DiagnosticCheck {
    directory_summary_check("filesystem.locks", &app_home().join("locks"))
}

fn cache_check() -> DiagnosticCheck {
    let started = Instant::now();
    match status() {
        Ok(status) => check(
            "cache.health",
            true,
            format!(
                "{} files / {} MiB",
                status.files,
                status.bytes / 1024 / 1024
            ),
            None,
            started.elapsed().as_millis(),
        ),
        Err(error) => check(
            "cache.health",
            false,
            "cache inspection failed".to_string(),
            Some(error.to_string()),
            started.elapsed().as_millis(),
        ),
    }
}

fn pending_journal_check() -> DiagnosticCheck {
    let started = Instant::now();
    let pending = load_pending();
    match pending {
        Ok(checkpoints) => check(
            "state.pending-journals",
            checkpoints.is_empty(),
            format!(
                "{} pending in {}",
                checkpoints.len(),
                journal_dir().display()
            ),
            (!checkpoints.is_empty())
                .then(|| "run bot-forge resume to inspect or resume it".to_string()),
            started.elapsed().as_millis(),
        ),
        Err(error) => check(
            "state.pending-journals",
            false,
            "journal inspection failed".to_string(),
            Some(error.to_string()),
            started.elapsed().as_millis(),
        ),
    }
}

fn config_source_check(explicit: Option<&Path>) -> DiagnosticCheck {
    match load_config_with_overlays(explicit, &[]) {
        Ok(loaded) => check(
            "config.source",
            true,
            loaded
                .path
                .map(|path| path.display().to_string())
                .unwrap_or_else(|| "builtin rust-dev catalog".to_string()),
            None,
            0,
        ),
        Err(error) => check(
            "config.source",
            false,
            explicit
                .map(|path| path.display().to_string())
                .unwrap_or_else(|| "configuration discovery failed".to_string()),
            Some(error.to_string()),
            0,
        ),
    }
}

fn directory_summary_check(id: &str, directory: &std::path::Path) -> DiagnosticCheck {
    let result = fs::create_dir_all(directory);
    check(
        id,
        result.is_ok(),
        directory.display().to_string(),
        result
            .err()
            .map(|error| format!("failed to access directory: {error}")),
        0,
    )
}

fn architecture_check() -> DiagnosticCheck {
    check(
        "system.architecture",
        true,
        format!("{} / {}", env::consts::OS, env::consts::ARCH),
        None,
        0,
    )
}

fn command_check(id: &str, command: &str) -> DiagnosticCheck {
    command_check_with_args(id, command, &["--version"])
}

fn command_check_with_args(id: &str, command: &str, args: &[&str]) -> DiagnosticCheck {
    let started = Instant::now();
    let resolved = resolve_command(command);
    let available = Command::new(&resolved)
        .args(args)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .is_ok_and(|status| status.success());
    check(
        id,
        available,
        format!(
            "{command} {}",
            if available {
                "available"
            } else {
                "unavailable"
            }
        ),
        (!available).then(|| format!("install {command} or add it to PATH")),
        started.elapsed().as_millis(),
    )
}

fn macos_translation_check() -> DiagnosticCheck {
    let translated = Command::new("/usr/sbin/sysctl")
        .args(["-in", "sysctl.proc_translated"])
        .output()
        .ok()
        .filter(|output| output.status.success())
        .is_some_and(|output| String::from_utf8_lossy(&output.stdout).trim() == "1");
    check(
        "system.macos-translation",
        !translated,
        if translated {
            "running under Rosetta 2"
        } else {
            "native process architecture"
        }
        .to_string(),
        translated.then(|| {
            "rerun the installer to select the native Apple Silicon bot-forge and avoid the Intel toolchain".to_string()
        }),
        0,
    )
}

fn shell_check() -> DiagnosticCheck {
    let shell = env::var("SHELL")
        .or_else(|_| env::var("COMSPEC"))
        .unwrap_or_else(|_| "unknown".to_string());
    check("system.shell", shell != "unknown", shell, None, 0)
}

fn data_directory_check() -> DiagnosticCheck {
    writable_directory_check("filesystem.data-dir-permission", &app_home())
}

fn managed_bin_directory_check() -> DiagnosticCheck {
    writable_directory_check("filesystem.managed-bin", &managed_bin_dir())
}

fn writable_directory_check(id: &str, directory: &Path) -> DiagnosticCheck {
    let started = Instant::now();
    let result = fs::create_dir_all(directory).and_then(|()| {
        let probe = directory.join(format!(".write-probe-{}", std::process::id()));
        OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&probe)
            .and_then(|_| fs::remove_file(probe))
    });
    check(
        id,
        result.is_ok(),
        if result.is_ok() {
            format!("{} writable", directory.display())
        } else {
            format!("{} not writable", directory.display())
        },
        result
            .err()
            .map(|error| format!("check directory permissions: {error}")),
        started.elapsed().as_millis(),
    )
}

fn disk_space_check() -> DiagnosticCheck {
    let started = Instant::now();
    let directory = app_home();
    match available_space(&directory) {
        Ok(bytes) => check(
            "filesystem.disk-space",
            bytes >= DISK_WARNING_BYTES,
            format!("{} MiB available", bytes / 1024 / 1024),
            (bytes < DISK_WARNING_BYTES).then(|| "free at least 512 MiB and retry".to_string()),
            started.elapsed().as_millis(),
        ),
        Err(error) => check(
            "filesystem.disk-space",
            false,
            "available space unknown".to_string(),
            Some(format!("failed to read available space: {error}")),
            started.elapsed().as_millis(),
        ),
    }
}

fn dns_check() -> DiagnosticCheck {
    let started = Instant::now();
    let resolved = ("github.com", 443)
        .to_socket_addrs()
        .is_ok_and(|mut addresses| addresses.next().is_some());
    check(
        "network.dns",
        resolved,
        if resolved {
            "github.com resolved"
        } else {
            "github.com resolution failed"
        }
        .to_string(),
        (!resolved).then(|| "check DNS, proxy, or network connectivity".to_string()),
        started.elapsed().as_millis(),
    )
}

fn tls_check() -> DiagnosticCheck {
    let started = Instant::now();
    let connected = reqwest::blocking::Client::builder()
        .timeout(NETWORK_TIMEOUT)
        .build()
        .ok()
        .and_then(|client| client.head("https://github.com/").send().ok())
        .is_some_and(|response| response.status().is_success());
    check(
        "network.tls-endpoint",
        connected,
        if connected {
            "github.com:443 reachable"
        } else {
            "github.com:443 unreachable"
        }
        .to_string(),
        (!connected).then(|| "check outbound TLS, firewall, or HTTPS_PROXY".to_string()),
        started.elapsed().as_millis(),
    )
}

fn check(
    id: &str,
    healthy: bool,
    summary: String,
    suggestion: Option<String>,
    duration_ms: u128,
) -> DiagnosticCheck {
    DiagnosticCheck {
        id: id.to_string(),
        severity: if healthy { "info" } else { "warning" }.to_string(),
        summary,
        suggestion,
        duration_ms,
    }
}

#[cfg(test)]
mod tests {
    use crate::diagnostics::doctor_checks;

    #[test]
    fn diagnostics_have_stable_unique_ids_and_timings() {
        let checks = doctor_checks(None);
        let ids = checks
            .iter()
            .map(|check| check.id.as_str())
            .collect::<std::collections::BTreeSet<_>>();
        assert_eq!(ids.len(), checks.len());
        assert!(ids.contains("filesystem.data-dir-permission"));
        assert!(ids.contains("filesystem.managed-bin"));
        assert!(ids.contains("filesystem.disk-space"));
        assert!(ids.contains("network.dns"));
        assert!(ids.contains("network.tls-endpoint"));
        assert!(ids.contains("network.proxy-config"));
        assert!(ids.contains("cache.health"));
        assert!(ids.contains("state.pending-journals"));
        assert!(ids.contains("config.source"));
        assert_eq!(
            ids.contains("command.package-manager"),
            cfg!(windows) || cfg!(target_os = "linux") || cfg!(target_os = "macos")
        );
    }
}