openlatch-client 0.0.1

The open-source security layer for AI agents — client forwarder
Documentation
/// `openlatch doctor` command handler.
///
/// Runs diagnostic checks and reports results.
/// All path references use `config::openlatch_dir()` per PLAT-02.
use crate::cli::commands::lifecycle;
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::config;
use crate::error::OlError;
use crate::hooks;

/// Run the `openlatch doctor` command.
///
/// Checks: agent detection, config file, auth token, daemon reachability, hook presence.
/// Prints results with checkmarks (pass) or crosses (fail), and a summary.
///
/// # Errors
///
/// Returns an error only if config cannot be loaded (other check failures are reported, not returned).
pub fn run_doctor(output: &OutputConfig) -> Result<(), OlError> {
    if output.format == OutputFormat::Human && !output.quiet {
        eprintln!("OpenLatch Doctor");
        eprintln!();
    }

    let cfg = config::Config::load(None, None, false)?;
    let ol_dir = config::openlatch_dir();

    let mut issues: Vec<String> = Vec::new();
    let mut checks: Vec<DoctorCheck> = Vec::new();

    // Check 1: Agent detection
    match hooks::detect_agent() {
        Ok(agent) => {
            let label = match &agent {
                hooks::DetectedAgent::ClaudeCode { claude_dir, .. } => {
                    format!("Claude Code ({})", claude_dir.display())
                }
            };
            checks.push(DoctorCheck::pass(format!("Agent detected: {label}")));
        }
        Err(e) => {
            let msg = format!("Agent not found: {} ({})", e.message, e.code);
            checks.push(DoctorCheck::fail(msg.clone()));
            issues.push("No AI agent detected. Install Claude Code to use OpenLatch.".to_string());
        }
    }

    // Check 2: Config file exists and is parseable (PLAT-02: OS-aware path)
    let config_path = ol_dir.join("config.toml");
    if config_path.exists() {
        match config::Config::load(None, None, false) {
            Ok(_) => {
                checks.push(DoctorCheck::pass(format!(
                    "Config file: {}",
                    config_path.display()
                )));
            }
            Err(e) => {
                checks.push(DoctorCheck::fail(format!(
                    "Config file invalid: {} ({})",
                    e.message, e.code
                )));
                issues.push(format!(
                    "Config file '{}' has errors. Delete and re-run 'openlatch init'.",
                    config_path.display()
                ));
            }
        }
    } else {
        checks.push(DoctorCheck::fail(format!(
            "Config file missing: {}",
            config_path.display()
        )));
        issues.push("Config file missing. Run 'openlatch init' to create it.".to_string());
    }

    // Check 3: Auth token exists and non-empty (PLAT-02: OS-aware path)
    let token_path = ol_dir.join("daemon.token");
    if token_path.exists() {
        match std::fs::read_to_string(&token_path) {
            Ok(content) if !content.trim().is_empty() => {
                checks.push(DoctorCheck::pass(format!(
                    "Auth token: {} (valid)",
                    token_path.display()
                )));
            }
            Ok(_) => {
                checks.push(DoctorCheck::fail(format!(
                    "Auth token empty: {}",
                    token_path.display()
                )));
                issues.push("Auth token is empty. Run 'openlatch init' to regenerate.".to_string());
            }
            Err(e) => {
                checks.push(DoctorCheck::fail(format!(
                    "Auth token unreadable: {}{e}",
                    token_path.display()
                )));
                issues.push(format!(
                    "Cannot read token file '{}'. Check file permissions.",
                    token_path.display()
                ));
            }
        }
    } else {
        checks.push(DoctorCheck::fail(format!(
            "Auth token missing: {}",
            token_path.display()
        )));
        issues.push("Auth token missing. Run 'openlatch init' to generate one.".to_string());
    }

    // Check 4: Daemon reachable via /health
    let pid = lifecycle::read_pid_file();
    let daemon_alive = pid.map(lifecycle::is_process_alive).unwrap_or(false);
    if daemon_alive {
        let url = format!("http://127.0.0.1:{}/health", cfg.port);
        let reachable = reqwest::blocking::get(&url)
            .map(|r| r.status().is_success())
            .unwrap_or(false);
        if reachable {
            checks.push(DoctorCheck::pass(format!(
                "Daemon: running on port {} (PID {})",
                cfg.port,
                pid.unwrap()
            )));
        } else {
            checks.push(DoctorCheck::fail(format!(
                "Daemon: process alive (PID {}) but /health unreachable on port {}",
                pid.unwrap(),
                cfg.port
            )));
            issues.push(format!(
                "Daemon process exists but /health on port {} doesn't respond. Try 'openlatch restart'.",
                cfg.port
            ));
        }
    } else {
        checks.push(DoctorCheck::fail(format!(
            "Daemon: not running (port {})",
            cfg.port
        )));
        issues.push("Daemon is not running. Run 'openlatch start' to start it.".to_string());
    }

    // Check 5: Hooks installed in settings.json
    if let Ok(agent) = hooks::detect_agent() {
        let settings_path = match &agent {
            hooks::DetectedAgent::ClaudeCode { settings_path, .. } => settings_path.clone(),
        };

        if settings_path.exists() {
            match std::fs::read_to_string(&settings_path) {
                Ok(content) => {
                    // Check for _openlatch marker entries
                    let has_pre_tool_use =
                        content.contains("PreToolUse") && content.contains("_openlatch");
                    let has_user_prompt =
                        content.contains("UserPromptSubmit") && content.contains("_openlatch");
                    let has_stop = content.contains("Stop") && content.contains("_openlatch");

                    let mut missing: Vec<&str> = Vec::new();
                    if !has_pre_tool_use {
                        missing.push("PreToolUse");
                    }
                    if !has_user_prompt {
                        missing.push("UserPromptSubmit");
                    }
                    if !has_stop {
                        missing.push("Stop");
                    }

                    if missing.is_empty() {
                        checks.push(DoctorCheck::pass(format!(
                            "Hooks: all entries present in {}",
                            settings_path.display()
                        )));
                    } else {
                        for hook in &missing {
                            checks.push(DoctorCheck::fail(format!(
                                "Hooks: {hook} missing from {}",
                                settings_path.display()
                            )));
                        }
                        issues.push(format!(
                            "Missing hooks: {}. Run 'openlatch init' to fix hook installation.",
                            missing.join(", ")
                        ));
                    }
                }
                Err(e) => {
                    checks.push(DoctorCheck::fail(format!(
                        "Hooks: cannot read {}{e}",
                        settings_path.display()
                    )));
                    issues.push(format!(
                        "Cannot read settings file '{}'. Check permissions.",
                        settings_path.display()
                    ));
                }
            }
        } else {
            checks.push(DoctorCheck::fail(format!(
                "Hooks: settings.json not found at {}",
                settings_path.display()
            )));
            issues.push(
                "settings.json not found. Run 'openlatch init' to install hooks.".to_string(),
            );
        }
    }

    // Output results
    if output.format == OutputFormat::Json {
        let checks_json: Vec<serde_json::Value> = checks
            .iter()
            .map(|c| {
                serde_json::json!({
                    "pass": c.pass,
                    "message": c.message,
                })
            })
            .collect();
        output.print_json(&serde_json::json!({
            "checks": checks_json,
            "issues": issues,
            "issue_count": issues.len(),
        }));
    } else if !output.quiet {
        for check in &checks {
            if check.pass {
                let mark = crate::cli::color::checkmark(output.color);
                eprintln!("{mark} {}", check.message);
            } else {
                let cross = crate::cli::color::cross(output.color);
                eprintln!("{cross} {}", check.message);
            }
        }

        eprintln!();
        if issues.is_empty() {
            eprintln!("All checks passed.");
        } else {
            let n = issues.len();
            eprintln!(
                "{n} issue{} found. Run 'openlatch init' to fix hook installation.",
                if n == 1 { "" } else { "s" }
            );
        }
    }

    Ok(())
}

/// A single doctor check result.
struct DoctorCheck {
    pass: bool,
    message: String,
}

impl DoctorCheck {
    fn pass(message: impl Into<String>) -> Self {
        Self {
            pass: true,
            message: message.into(),
        }
    }

    fn fail(message: impl Into<String>) -> Self {
        Self {
            pass: false,
            message: message.into(),
        }
    }
}