remem-ai 0.3.0

Persistent memory for Claude Code — single binary, zero subprocesses
Documentation
use anyhow::Result;
use std::path::PathBuf;

use crate::db;

struct Check {
    name: &'static str,
    status: Status,
    detail: String,
}

enum Status {
    Ok,
    Warn,
    Fail,
}

impl Check {
    fn icon(&self) -> &'static str {
        match self.status {
            Status::Ok => "ok",
            Status::Warn => "WARN",
            Status::Fail => "FAIL",
        }
    }
}

pub fn run_doctor() -> Result<()> {
    let version = env!("CARGO_PKG_VERSION");
    println!("remem v{} — system check", version);
    println!();

    let checks = vec![
        check_binary(),
        check_database(),
        check_hooks(),
        check_mcp(),
        check_pending_queue(),
        check_disk_space(),
    ];

    let mut warns = 0;
    let mut fails = 0;

    for check in &checks {
        let marker = check.icon();
        println!("  [{}] {}: {}", marker, check.name, check.detail);
        match check.status {
            Status::Warn => warns += 1,
            Status::Fail => fails += 1,
            Status::Ok => {}
        }
    }

    println!();
    if fails > 0 {
        println!(
            "{} check(s) failed, {} warning(s). Run `remem install` to fix hook/MCP issues.",
            fails, warns
        );
    } else if warns > 0 {
        println!("All checks passed with {} warning(s).", warns);
    } else {
        println!("All checks passed.");
    }

    Ok(())
}

fn check_binary() -> Check {
    let exe = std::env::current_exe()
        .map(|p| p.display().to_string())
        .unwrap_or_else(|_| "unknown".to_string());
    Check {
        name: "Binary",
        status: Status::Ok,
        detail: exe,
    }
}

fn check_database() -> Check {
    let db_path = db::db_path();
    if !db_path.exists() {
        return Check {
            name: "Database",
            status: Status::Fail,
            detail: format!("{} (not found)", db_path.display()),
        };
    }

    let size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
    match db::open_db() {
        Ok(conn) => {
            let memory_count: i64 = conn
                .query_row(
                    "SELECT COUNT(*) FROM memories WHERE status = 'active'",
                    [],
                    |r| r.get(0),
                )
                .unwrap_or(0);
            Check {
                name: "Database",
                status: Status::Ok,
                detail: format!(
                    "{} ({:.1} MB, {} memories)",
                    db_path.display(),
                    size as f64 / 1_048_576.0,
                    memory_count
                ),
            }
        }
        Err(e) => Check {
            name: "Database",
            status: Status::Fail,
            detail: format!("{} (open error: {})", db_path.display(), e),
        },
    }
}

fn check_hooks() -> Check {
    let settings_path = dirs::home_dir()
        .map(|h| h.join(".claude").join("settings.json"))
        .unwrap_or_else(|| PathBuf::from("~/.claude/settings.json"));

    if !settings_path.exists() {
        return Check {
            name: "Hooks",
            status: Status::Fail,
            detail: format!("{} not found", settings_path.display()),
        };
    }

    let content = match std::fs::read_to_string(&settings_path) {
        Ok(c) => c,
        Err(e) => {
            return Check {
                name: "Hooks",
                status: Status::Fail,
                detail: format!("cannot read {}: {}", settings_path.display(), e),
            }
        }
    };

    let hooks = ["PostToolUse", "Stop", "SessionStart", "UserPromptSubmit"];
    let mut found = 0;
    for hook in &hooks {
        if content.contains(hook) && content.contains("remem") {
            found += 1;
        }
    }

    if found == hooks.len() {
        Check {
            name: "Hooks",
            status: Status::Ok,
            detail: format!("{}/{} registered in settings.json", found, hooks.len()),
        }
    } else if found > 0 {
        Check {
            name: "Hooks",
            status: Status::Warn,
            detail: format!(
                "{}/{} registered (run `remem install` to fix)",
                found,
                hooks.len()
            ),
        }
    } else {
        Check {
            name: "Hooks",
            status: Status::Fail,
            detail: "no remem hooks found (run `remem install`)".to_string(),
        }
    }
}

fn check_mcp() -> Check {
    // Check both possible MCP config locations
    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
    let mcp_paths = [
        home.join(".claude.json"),
        home.join(".claude").join("claude_desktop_config.json"),
    ];

    for path in &mcp_paths {
        if path.exists() {
            if let Ok(content) = std::fs::read_to_string(path) {
                if content.contains("remem") && content.contains("mcp") {
                    return Check {
                        name: "MCP server",
                        status: Status::Ok,
                        detail: format!("registered in {}", path.display()),
                    };
                }
            }
        }
    }

    Check {
        name: "MCP server",
        status: Status::Fail,
        detail: "not registered (run `remem install`)".to_string(),
    }
}

fn check_pending_queue() -> Check {
    let conn = match db::open_db() {
        Ok(c) => c,
        Err(_) => {
            return Check {
                name: "Pending queue",
                status: Status::Warn,
                detail: "cannot open database".to_string(),
            }
        }
    };

    let pending: i64 = conn
        .query_row("SELECT COUNT(*) FROM pending_observations", [], |r| {
            r.get(0)
        })
        .unwrap_or(0);

    let stuck_jobs: i64 = conn
        .query_row(
            "SELECT COUNT(*) FROM jobs WHERE state = 'running' \
             AND lease_expires_epoch < strftime('%s', 'now')",
            [],
            |r| r.get(0),
        )
        .unwrap_or(0);

    if stuck_jobs > 0 {
        Check {
            name: "Pending queue",
            status: Status::Warn,
            detail: format!(
                "{} pending, {} stuck jobs (will auto-recover)",
                pending, stuck_jobs
            ),
        }
    } else if pending > 100 {
        Check {
            name: "Pending queue",
            status: Status::Warn,
            detail: format!("{} pending (backlog building up)", pending),
        }
    } else {
        Check {
            name: "Pending queue",
            status: Status::Ok,
            detail: format!("{} pending", pending),
        }
    }
}

fn check_disk_space() -> Check {
    let db_path = db::db_path();
    let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
    let log_path = db_path.parent().map(|p| p.join("remem.log"));
    let log_size = log_path
        .and_then(|p| std::fs::metadata(&p).ok())
        .map(|m| m.len())
        .unwrap_or(0);

    let total_mb = (db_size + log_size) as f64 / 1_048_576.0;

    if total_mb > 500.0 {
        Check {
            name: "Disk usage",
            status: Status::Warn,
            detail: format!(
                "{:.1} MB total (DB: {:.1} MB, logs: {:.1} MB) — consider `remem cleanup`",
                total_mb,
                db_size as f64 / 1_048_576.0,
                log_size as f64 / 1_048_576.0
            ),
        }
    } else {
        Check {
            name: "Disk usage",
            status: Status::Ok,
            detail: format!(
                "{:.1} MB total (DB: {:.1} MB, logs: {:.1} MB)",
                total_mb,
                db_size as f64 / 1_048_576.0,
                log_size as f64 / 1_048_576.0
            ),
        }
    }
}