bctx 0.1.27

bctx CLI — intercept CLI commands and compress output for LLM coding agents
use super::home_dir;
use anyhow::Result;
use std::path::Path;

pub fn handle() -> Result<()> {
    println!("bctx doctor — system health check");
    println!();

    let home = home_dir();
    let bctx_dir = format!("{home}/.bctx");
    let beacons_dir = format!("{home}/.bctx/beacons");
    let vault_dir = format!("{home}/.bctx/vault");
    let exec_db = format!("{home}/.bctx/executions.db");
    let claude_mcp = format!("{home}/.claude/mcp.json");
    let cursor_mcp = format!("{home}/.cursor/mcp.json");
    let cloud_token = format!("{home}/.bctx/cloud_token.json");

    section("Environment");
    let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
    check(
        &format!("{home_var} set"),
        std::env::var("HOME")
            .or_else(|_| std::env::var("USERPROFILE"))
            .is_ok(),
    );
    check_bin("bctx in PATH", "bctx");
    check_bin("git in PATH", "git");
    check_bin("cargo in PATH", "cargo");

    section("Local storage");
    check("~/.bctx/ exists", Path::new(&bctx_dir).exists());
    check("~/.bctx/executions.db exists", Path::new(&exec_db).exists());
    info(
        "~/.bctx/vault/",
        if Path::new(&vault_dir).exists() {
            "present"
        } else {
            "not yet created (lazy)"
        },
    );
    info(
        "~/.bctx/beacons/",
        if Path::new(&beacons_dir).exists() {
            "present"
        } else {
            "not yet created (lazy)"
        },
    );

    section("Vault");
    let crystallized = format!("{vault_dir}/crystallized.json");
    let resonant = format!("{vault_dir}/resonant.json");
    let crystal_facts = count_json_array(&crystallized);
    let resonant_facts = count_json_array(&resonant);
    check_count("crystallized facts", crystal_facts);
    check_count("resonant facts", resonant_facts);

    section("Beacons");
    let beacon_count = count_dir_files(&beacons_dir);
    check_count("beacon events recorded", beacon_count);

    let hook_marker = "# bctx";
    #[cfg(not(windows))]
    let shell_configs: Vec<String> = vec![
        format!("{home}/.zshrc"),
        format!("{home}/.bashrc"),
        format!("{home}/.bash_profile"),
        format!("{home}/.config/fish/config.fish"),
    ];
    #[cfg(windows)]
    let shell_configs: Vec<String> = {
        let docs = format!("{home}/Documents");
        vec![
            format!("{docs}/PowerShell/Microsoft.PowerShell_profile.ps1"),
            format!("{docs}/WindowsPowerShell/Microsoft.PowerShell_profile.ps1"),
        ]
    };
    let hook_rc: Option<String> = shell_configs.iter().find_map(|rc| {
        std::fs::read_to_string(rc)
            .ok()
            .filter(|c| c.contains(hook_marker))
            .map(|_| rc.replace(&home, "~"))
    });

    section("Shell hook");
    if let Some(ref rc_short) = hook_rc {
        println!("  ✓  shell hook present ({rc_short})");
    } else {
        println!("  ✗  shell hook not found");
        println!("      Run `bctx init` to install the shell hook.");
    }

    section("Agent integration");
    check("Claude Code mcp.json", Path::new(&claude_mcp).exists());
    check("Cursor mcp.json", Path::new(&cursor_mcp).exists());

    section("Cloud");
    let token_present = Path::new(&cloud_token).exists();
    check("cloud_token.json present", token_present);
    if token_present {
        if let Some(tier) = read_cloud_tier(&cloud_token) {
            println!("      tier: {tier}");
        }
    }

    section("Recipes");
    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
    let proj_recipe_dir = cwd.join(".bctx").join("recipes");
    let user_recipe_dir = std::path::PathBuf::from(&home)
        .join(".config")
        .join("bctx")
        .join("recipes");
    let proj_count = count_dir_files(&proj_recipe_dir.to_string_lossy());
    let user_count = count_dir_files(&user_recipe_dir.to_string_lossy());
    info("project recipes (.bctx/recipes/)", &proj_count.to_string());
    info(
        "user recipes (~/.config/bctx/recipes/)",
        &user_count.to_string(),
    );

    // Validate loaded recipes and report errors
    let recipes = weave::recipe::loader::load_from_dir(&proj_recipe_dir).unwrap_or_default();
    for r in &recipes {
        let errs = weave::recipe::validator::validate(r);
        if errs.is_empty() {
            println!("  ✓  recipe \"{}\" valid", r.name);
        } else {
            for e in &errs {
                println!("  ✗  recipe \"{}\": {e}", r.name);
            }
        }
    }
    if proj_count + user_count == 0 {
        println!(
            "  ·  no recipes loaded — create .bctx/recipes/my-tool.toml to customise compression"
        );
    }

    section("Runtime");
    let tiktoken_ok = forge::budget::estimator::TokenEstimator::count("hello world") > 0;
    check("tiktoken BPE loaded", tiktoken_ok);

    // Quick vault query smoke-test
    let vault_ok = std::panic::catch_unwind(|| {
        let v = atlas::vault_store::vault().lock().unwrap();
        let _ = v.query(&vault::retrieval::query::VaultQuery::new("__doctor_probe__").top_k(0));
    })
    .is_ok();
    check("Vault query smoke test", vault_ok);

    println!();
    if hook_rc.is_none() {
        println!("  Tip: run `bctx init` to install the shell hook and start saving tokens.");
    }
    if !Path::new(&claude_mcp).exists() && !Path::new(&cursor_mcp).exists() {
        println!("  Tip: run `bctx init --agent claude` to set up agent hooks.");
    }
    if !token_present {
        println!("  Tip: run `bctx login` to connect to the cloud for Vault sync.");
    }

    Ok(())
}

fn section(label: &str) {
    println!("\n  {label}");
    println!("  {}", "".repeat(label.len()));
}

fn check(label: &str, ok: bool) {
    let mark = if ok { "" } else { "" };
    println!("  {mark}  {label}");
}

fn check_bin(label: &str, bin: &str) {
    let ok = which_bin(bin);
    let mark = if ok { "" } else { "" };
    println!("  {mark}  {label}");
}

fn check_count(label: &str, n: usize) {
    println!("{label}: {n}");
}

fn info(label: &str, detail: &str) {
    println!("  ·  {label}: {detail}");
}

fn which_bin(bin: &str) -> bool {
    // Windows: `where`  |  Unix: `which`
    let finder = if cfg!(windows) { "where" } else { "which" };
    std::process::Command::new(finder)
        .arg(bin)
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

fn count_json_array(path: &str) -> usize {
    let Ok(data) = std::fs::read_to_string(path) else {
        return 0;
    };
    let Ok(arr) = serde_json::from_str::<serde_json::Value>(&data) else {
        return 0;
    };
    arr.as_array().map(|a| a.len()).unwrap_or(0)
}

fn count_dir_files(path: &str) -> usize {
    std::fs::read_dir(path)
        .map(|d| d.filter_map(|e| e.ok()).count())
        .unwrap_or(0)
}

fn read_cloud_tier(path: &str) -> Option<String> {
    let data = std::fs::read_to_string(path).ok()?;
    let v: serde_json::Value = serde_json::from_str(&data).ok()?;
    v["tier"].as_str().map(String::from)
}