slave 0.9.1

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles and Toasty models and migrations, add server-rendered web apps, and route them by domain or path
//! `slave doctor`: checks a project's setup and says how to fix what is
//! missing: the database, sign-in, each app's client ID and the redirect
//! URIs to register at the provider.

use std::net::{TcpStream, ToSocketAddrs};
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::Duration;

use anyhow::{Result, bail};
use slave_gateway::Config;

use crate::apps::{self, Env};
use crate::commands::build;
use crate::commands::make_app::env_prefix;
use crate::{project, runtime};

/// Collects the outcome of each check.
#[derive(Default)]
struct Report {
    problems: usize,
}

impl Report {
    fn ok(&self, what: &str, detail: &str) {
        println!("  ✓ {what:<22} {detail}");
    }

    fn fail(&mut self, what: &str, detail: &str) {
        self.problems += 1;
        println!("  ✗ {what:<22} {detail}");
    }

    fn note(&self, what: &str, detail: &str) {
        println!("  ! {what:<22} {detail}");
    }
}

pub fn run() -> Result<()> {
    let root = project::find_root()?;
    let mut report = Report::default();
    println!("\n{}\n", project::package_name(&root)?);

    if runtime::is_present(&root) {
        report.ok("runtime", "runtime/ serves the backend and the apps");
    } else {
        report.fail(
            "runtime",
            "runtime/ is missing (projects before slave 0.9); copy it from a new project",
        );
    }
    if root.join(".env").is_file() {
        report.ok(".env", "found");
    } else {
        report.fail(".env", "missing; copy .env.example to .env");
    }
    let env = Env::load(&root)?;

    println!("\nDatabase");
    check_database(&mut report, &env);
    let migrations = std::fs::read_dir(root.join("database/migrations"))
        .map(|dir| dir.flatten().count())
        .unwrap_or(0);
    report.note(
        "migrations",
        &format!("{migrations} in database/migrations; apply them with `slave migrate`"),
    );

    println!("\nSign-in (OpenID Connect)");
    let audiences: Vec<String> = env
        .get("OIDC_AUDIENCES")
        .unwrap_or_default()
        .split(',')
        .map(|a| a.trim().to_owned())
        .filter(|a| !a.is_empty())
        .collect();
    check_issuer(&mut report, &env);
    if audiences.is_empty() {
        report.fail(
            "OIDC_AUDIENCES",
            "empty; list every app's client ID (comma-separated)",
        );
    } else {
        report.ok("OIDC_AUDIENCES", &audiences.join(", "));
    }

    let config = Config::load(root.join(apps::CONFIG)).ok();
    if let Some(config) = &config {
        println!("\nApps");
        check_apps(&mut report, &env, config, &audiences);
    }

    println!("\nBuild");
    check_build(&mut report, &root);

    println!();
    if report.problems == 0 {
        println!("All set: run `slave serve`.");
        Ok(())
    } else {
        println!(
            "To try the project before setting up a provider, `slave serve --dev` signs in\n\
             with a built-in development provider (anyone can sign in as any email)."
        );
        bail!("{} problem(s) found", report.problems)
    }
}

fn check_database(report: &mut Report, env: &Env) {
    let Some(url) = env.get("DATABASE_URL").filter(|url| !url.trim().is_empty()) else {
        report.fail(
            "DATABASE_URL",
            "empty; set a PostgreSQL URL, e.g. postgresql://localhost:5432/app",
        );
        return;
    };
    let Some(address) = postgres_address(&url) else {
        report.fail(
            "DATABASE_URL",
            "not a postgresql:// URL (postgresql://user:pass@host:5432/db)",
        );
        return;
    };
    let shown = redact(&url);
    if reachable(&address) {
        report.ok("DATABASE_URL", &format!("{shown} (reachable)"));
    } else {
        report.fail(
            "DATABASE_URL",
            &format!("{shown}: cannot reach {address}; is PostgreSQL running?"),
        );
    }
}

fn check_issuer(report: &mut Report, env: &Env) {
    let Some(issuer) = env
        .get("OIDC_ISSUER_URL")
        .filter(|issuer| !issuer.trim().is_empty())
    else {
        report.fail(
            "OIDC_ISSUER_URL",
            "empty; set your provider's issuer (Google: https://accounts.google.com)",
        );
        return;
    };
    let discovery = format!(
        "{}/.well-known/openid-configuration",
        issuer.trim_end_matches('/')
    );
    let fetched = Command::new("curl")
        .args(["-sf", "--max-time", "5", "-o", "/dev/null", &discovery])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();
    match fetched {
        Ok(status) if status.success() => {
            report.ok("OIDC_ISSUER_URL", &format!("{issuer} (discovered)"))
        }
        Ok(_) => report.fail(
            "OIDC_ISSUER_URL",
            &format!("{issuer}: {discovery} did not answer; check the issuer URL"),
        ),
        Err(_) => report.note(
            "OIDC_ISSUER_URL",
            &format!("{issuer} (not checked: curl is not installed)"),
        ),
    }
}

fn check_apps(report: &mut Report, env: &Env, config: &Config, audiences: &[String]) {
    let mut seen: Vec<&str> = Vec::new();
    for domain in config.domains.iter().filter(|d| !d.is_mcp()) {
        if seen.contains(&domain.app.as_str()) {
            continue;
        }
        seen.push(&domain.app);
        let key = format!("{}_OIDC_CLIENT_ID", env_prefix(&domain.app));
        match env.get(&key).filter(|id| !id.trim().is_empty()) {
            None => report.fail(
                &domain.app,
                &format!("{key} is empty; set the app's client ID"),
            ),
            Some(id) if !audiences.contains(&id) => report.fail(
                &domain.app,
                &format!("{key}={id} is not in OIDC_AUDIENCES; add it there"),
            ),
            Some(id) => report.ok(&domain.app, &format!("{key}={id}")),
        }
        for entry in config.domains.iter().filter(|d| d.app == domain.app) {
            let base = if entry.is_routed() {
                apps::gateway_url(
                    &config.gateway.listen,
                    entry.host.as_deref(),
                    entry.path.as_deref(),
                )
            } else {
                format!("http://127.0.0.1:{}", entry.port.unwrap_or_default())
            };
            println!("    redirect URI to register: {base}/callback");
        }
    }
    if seen.is_empty() {
        report.note("apps", "none yet; add one with `slave make:app <name>`");
    }
}

fn check_build(report: &mut Report, root: &Path) {
    let Ok(target) = build::target_dir(root) else {
        report.note("build", "could not ask Cargo for the target directory");
        return;
    };
    let runtime =
        target
            .join("debug")
            .join(format!("{}{}", runtime::DIR, std::env::consts::EXE_SUFFIX));
    if runtime.is_file() {
        report.ok("runtime binary", "built (debug)");
    } else {
        report.fail("runtime binary", "not built yet; run `slave build`");
    }
}

/// `host:port` of a `postgresql://` or `postgres://` URL.
fn postgres_address(url: &str) -> Option<String> {
    let rest = url
        .strip_prefix("postgresql://")
        .or_else(|| url.strip_prefix("postgres://"))?;
    let authority = rest.split(['/', '?']).next()?;
    let host_port = authority
        .rsplit_once('@')
        .map_or(authority, |(_, host)| host);
    if host_port.is_empty() {
        return None;
    }
    Some(if host_port.contains(':') && !host_port.ends_with(']') {
        host_port.to_owned()
    } else {
        format!("{host_port}:5432")
    })
}

/// The URL with its password replaced by `***`.
fn redact(url: &str) -> String {
    let Some((scheme, rest)) = url.split_once("://") else {
        return url.to_owned();
    };
    match rest.rsplit_once('@') {
        Some((credentials, host)) => {
            let user = credentials.split(':').next().unwrap_or_default();
            format!("{scheme}://{user}:***@{host}")
        }
        None => url.to_owned(),
    }
}

fn reachable(address: &str) -> bool {
    address
        .to_socket_addrs()
        .ok()
        .and_then(|mut addrs| addrs.next())
        .is_some_and(|addr| TcpStream::connect_timeout(&addr, Duration::from_secs(3)).is_ok())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_and_redacts_database_urls() {
        assert_eq!(
            postgres_address("postgresql://u:p@db.example.com:6543/app?sslmode=require").as_deref(),
            Some("db.example.com:6543")
        );
        assert_eq!(
            postgres_address("postgres://localhost/app").as_deref(),
            Some("localhost:5432")
        );
        assert_eq!(postgres_address("mysql://x"), None);
        assert_eq!(
            redact("postgresql://postgres:secret@db:5432/app"),
            "postgresql://postgres:***@db:5432/app"
        );
    }
}