agent-berth 1.2.0

Monitor and resume coding agents
Documentation
use std::io::{self, Write};

use anyhow::Result;

use crate::db;
use crate::paths::Context;
use crate::providers::ProviderKind;
use crate::service;
use crate::tmux;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Level {
    Ok,
    Warn,
    Error,
    Skip,
}

impl Level {
    fn label(self) -> &'static str {
        match self {
            Self::Ok => "ok",
            Self::Warn => "warn",
            Self::Error => "error",
            Self::Skip => "skip",
        }
    }
}

struct Check {
    level: Level,
    name: &'static str,
    detail: String,
}

pub fn run(ctx: &Context) -> Result<()> {
    let checks = collect(ctx);
    print_checks(&checks)?;
    if checks.iter().any(|check| check.level == Level::Error) {
        anyhow::bail!("doctor found issues");
    }
    Ok(())
}

fn collect(ctx: &Context) -> Vec<Check> {
    let mut checks = vec![
        binary(ctx),
        server(ctx),
        service_check(ctx),
        state(ctx),
        tmux_check(),
        fzf_check(),
    ];
    for provider in ProviderKind::ALL {
        checks.push(provider_check(ctx, provider));
    }
    checks
}

fn binary(ctx: &Context) -> Check {
    let path = &ctx.berth_bin;
    if path.is_file() {
        Check {
            level: Level::Ok,
            name: "binary",
            detail: path.display().to_string(),
        }
    } else {
        Check {
            level: Level::Error,
            name: "binary",
            detail: format!("{} not found", path.display()),
        }
    }
}

fn server(ctx: &Context) -> Check {
    let endpoint = ctx.endpoint_display();
    if service::running(ctx) {
        Check {
            level: Level::Ok,
            name: "server",
            detail: format!("running on {endpoint}"),
        }
    } else {
        Check {
            level: Level::Error,
            name: "server",
            detail: format!("not running on {endpoint} (start with: agent-berth server)"),
        }
    }
}

fn service_check(ctx: &Context) -> Check {
    if service::installed(ctx) {
        Check {
            level: Level::Ok,
            name: "service",
            detail: service_label().into(),
        }
    } else {
        Check {
            level: Level::Warn,
            name: "service",
            detail: format!("{} not installed (run: agent-berth setup)", service_label()),
        }
    }
}

fn service_label() -> &'static str {
    #[cfg(windows)]
    {
        "scheduled task AgentBerth"
    }
    #[cfg(unix)]
    {
        "systemd user unit agent-berth.service"
    }
}

fn state(ctx: &Context) -> Check {
    let path = ctx.db_path();
    if service::running(ctx) {
        return Check {
            level: Level::Ok,
            name: "state",
            detail: format!("{} (held by server)", path.display()),
        };
    }
    if !path.exists() {
        return Check {
            level: Level::Warn,
            name: "state",
            detail: format!("{} missing", path.display()),
        };
    }
    match db::load_from_path(ctx) {
        Ok(store) => {
            let sessions = store.listed().len();
            Check {
                level: Level::Ok,
                name: "state",
                detail: format!(
                    "{} ({sessions} session{})",
                    path.display(),
                    if sessions == 1 { "" } else { "s" }
                ),
            }
        }
        Err(err) => Check {
            level: Level::Error,
            name: "state",
            detail: format!("cannot read {}: {err:#}", path.display()),
        },
    }
}

fn tmux_check() -> Check {
    if tmux::available() {
        Check {
            level: Level::Ok,
            name: "tmux",
            detail: "found".into(),
        }
    } else {
        Check {
            level: Level::Warn,
            name: "tmux",
            detail: "not on PATH (needed to resume CLI sessions)".into(),
        }
    }
}

fn fzf_check() -> Check {
    if crate::paths::on_path("fzf") {
        Check {
            level: Level::Ok,
            name: "fzf",
            detail: "found".into(),
        }
    } else {
        Check {
            level: Level::Warn,
            name: "fzf",
            detail: "not on PATH (needed to attach to agents)".into(),
        }
    }
}

fn provider_check(ctx: &Context, provider: ProviderKind) -> Check {
    let name = provider.name();
    if !provider.is_installed(ctx) {
        return Check {
            level: Level::Skip,
            name,
            detail: "not installed".into(),
        };
    }
    if !provider.hooks_installed(ctx) {
        return Check {
            level: Level::Error,
            name,
            detail: format!(
                "hooks missing ({}) (run: agent-berth setup --no-service)",
                provider.hook_path(ctx).display()
            ),
        };
    }
    if provider.hooks_stale(ctx) {
        return Check {
            level: Level::Warn,
            name,
            detail: format!(
                "hooks point at a different binary ({})",
                provider.hook_path(ctx).display()
            ),
        };
    }
    Check {
        level: Level::Ok,
        name,
        detail: format!("hooks installed ({})", provider.hook_path(ctx).display()),
    }
}

fn print_checks(checks: &[Check]) -> Result<()> {
    let name_width = checks
        .iter()
        .map(|check| check.name.len())
        .max()
        .unwrap_or(0);
    let mut out = io::stdout().lock();
    for check in checks {
        writeln!(
            out,
            "{:<5}  {:name_width$}  {}",
            check.level.label(),
            check.name,
            check.detail,
        )?;
    }
    Ok(())
}

#[cfg(test)]
#[path = "doctor_tests.rs"]
mod tests;