slave 0.12.0

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles, teams and SurrealDB 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::time::Duration;

use anyhow::{Result, bail};

use crate::apps::Env;
use crate::commands::build;
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 (email codes and passkeys)");
    match env.get("SMTP_URL").filter(|url| !url.trim().is_empty()) {
        Some(_)
            if env
                .get("MAIL_FROM")
                .is_none_or(|from| from.trim().is_empty()) =>
        {
            report.fail("MAIL_FROM", "empty; set the sender of sign-in codes")
        }
        Some(_) => report.ok("SMTP_URL", "set; sign-in codes are emailed"),
        None => report.note(
            "SMTP_URL",
            "empty; debug builds log sign-in codes instead of emailing them",
        ),
    }
    let rp_id = env
        .get("PASSKEY_RP_ID")
        .filter(|id| !id.trim().is_empty())
        .unwrap_or_else(|| "localhost".to_owned());
    report.ok("PASSKEY_RP_ID", &format!("{rp_id} (and its subdomains)"));

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

    println!();
    if report.problems == 0 {
        println!("All set: run `slave serve`.");
        Ok(())
    } else {
        bail!("{} problem(s) found", report.problems)
    }
}

fn check_database(report: &mut Report, env: &Env) {
    let url = env
        .get("SURREAL_URL")
        .filter(|url| !url.trim().is_empty())
        .unwrap_or_else(|| "ws://127.0.0.1:8000".to_owned());
    let Some(address) = surreal_address(&url) else {
        report.fail(
            "SURREAL_URL",
            &format!("`{url}` is not a ws:// or wss:// URL (e.g. ws://127.0.0.1:8000)"),
        );
        return;
    };
    let namespace = env.get("SURREAL_NS").unwrap_or_else(|| "main".to_owned());
    let database = env.get("SURREAL_DB").unwrap_or_else(|| "main".to_owned());
    let shown = format!("{url} {namespace}/{database}");
    if reachable(&address) {
        report.ok("SURREAL_URL", &format!("{shown} (reachable)"));
    } else {
        report.fail(
            "SURREAL_URL",
            &format!(
                "{shown}: cannot reach {address}; start SurrealDB with \
                 `surreal start --user root --pass root`"
            ),
        );
    }
    match (env.get("SURREAL_USER"), env.get("SURREAL_PASS")) {
        (Some(user), Some(_)) if !user.trim().is_empty() => {
            let level = env
                .get("SURREAL_AUTH_LEVEL")
                .unwrap_or_else(|| "root".to_owned());
            report.ok("SURREAL_USER", &format!("{user} ({level} user)"));
        }
        (Some(user), None) if !user.trim().is_empty() => {
            report.fail("SURREAL_PASS", "empty; SURREAL_USER needs a password");
        }
        _ => report.note(
            "SURREAL_USER",
            "empty; connecting without signing in (only for `surreal start --unauthenticated`)",
        ),
    }
}

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 `ws://`, `wss://`, `http://` or `https://` URL.
fn surreal_address(url: &str) -> Option<String> {
    let (scheme, rest) = url.split_once("://")?;
    let default_port = match scheme {
        "ws" | "http" => 80,
        "wss" | "https" => 443,
        _ => return None,
    };
    let host_port = rest.split(['/', '?']).next()?;
    if host_port.is_empty() {
        return None;
    }
    Some(if host_port.contains(':') && !host_port.ends_with(']') {
        host_port.to_owned()
    } else {
        format!("{host_port}:{default_port}")
    })
}

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_database_urls() {
        assert_eq!(
            surreal_address("ws://127.0.0.1:8000").as_deref(),
            Some("127.0.0.1:8000")
        );
        assert_eq!(
            surreal_address("wss://demo.aws-use1.surreal.cloud").as_deref(),
            Some("demo.aws-use1.surreal.cloud:443")
        );
        assert_eq!(surreal_address("postgresql://x"), None);
    }
}