slave 0.8.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 route:list` and `slave route:match <host>`, like `php artisan route:list`.

use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;

use anyhow::{Result, bail};

use crate::apps::{self, Apps, Env};
use crate::project;

/// Loads the project's routing table, failing if it has none.
fn load() -> Result<Apps> {
    let root = project::find_root()?;
    let env = Env::load(&root)?;
    let mcp = mcp_upstream(&env);
    match Apps::load(&root, &env, &mcp)? {
        Some(apps) => Ok(apps),
        None => bail!("{} not found in this project", apps::CONFIG),
    }
}

/// `APP_HOST:APP_PORT` of the MCP backend as seen from this machine.
pub fn mcp_upstream(env: &Env) -> String {
    let host = env.get("APP_HOST").unwrap_or_else(|| "127.0.0.1".into());
    let port = env
        .get("APP_PORT")
        .and_then(|port| port.trim().parse().ok())
        .unwrap_or(3000);
    apps::connect_address(&host, port)
}

pub fn list() -> Result<()> {
    let apps = load()?;
    // Routed entries in matching order, then apps reached directly.
    let direct = apps
        .routes
        .iter()
        .filter(|route| route.host.is_none() && route.path.is_none());
    let rows: Vec<[String; 4]> = apps
        .routes
        .in_matching_order()
        .map(|(_, route)| route)
        .chain(direct)
        .map(|route| {
            let app = if route.is_mcp {
                "mcp (backend)".to_owned()
            } else {
                route.app.clone()
            };
            let status = if is_up(&route.upstream) { "up" } else { "down" };
            [
                route.label(),
                app,
                route.upstream.clone(),
                status.to_owned(),
            ]
        })
        .collect();

    let header = ["ROUTE", "APP", "UPSTREAM", "STATUS"];
    let widths: Vec<usize> = (0..4)
        .map(|i| {
            rows.iter()
                .map(|row| row[i].len())
                .chain([header[i].len()])
                .max()
                .unwrap_or(0)
        })
        .collect();
    let print = |row: &[&str]| {
        let line: Vec<String> = row
            .iter()
            .zip(&widths)
            .map(|(cell, width)| format!("{cell:<width$}"))
            .collect();
        println!("  {}", line.join("  ").trim_end());
    };
    println!();
    print(&header);
    for row in &rows {
        print(&row.each_ref().map(String::as_str));
    }
    let gateway = if crate::gateway::is_installed(&project::find_root()?) {
        format!("gateway: http://{}", apps.listen)
    } else {
        "gateway not installed: routed entries are inactive (`slave install:gateway`)".to_owned()
    };
    println!(
        "\n  {} entr{}, most specific first · {gateway}",
        rows.len(),
        if rows.len() == 1 { "y" } else { "ies" }
    );
    Ok(())
}

/// `target` is `host`, `host/path` or `/path` (any host).
pub fn matches(target: &str) -> Result<()> {
    let apps = load()?;
    let target = target
        .strip_prefix("http://")
        .or_else(|| target.strip_prefix("https://"))
        .unwrap_or(target);
    let (host, path) = match target.find('/') {
        Some(at) => (&target[..at], &target[at..]),
        None => (target, "/"),
    };
    let Some(found) = apps.routes.find(host, path) else {
        bail!("no route matches `{target}`; the gateway answers 404");
    };
    let route = found.route;
    println!(
        "  {target} → {} (route #{}) → {} ({})",
        route.label(),
        found.index + 1,
        if route.is_mcp { "mcp" } else { &route.app },
        route.upstream
    );
    if let Some(prefix) = &route.path {
        println!("  path: forwarded unchanged; the app's base path is {prefix}");
    }
    for (name, value) in &found.params {
        println!(
            "  header: {}: {value}",
            slave_gateway::routes::param_header(name)
        );
    }
    if route.is_mcp {
        println!(
            "  header: host: {} (rewritten for the MCP backend)",
            route.upstream
        );
    }
    Ok(())
}

/// Whether something accepts connections at `upstream`.
fn is_up(upstream: &str) -> bool {
    upstream
        .to_socket_addrs()
        .ok()
        .and_then(|mut addrs| addrs.next())
        .is_some_and(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(300)).is_ok())
}