slave 0.4.0

Laravel-inspired CLI for Rust MCP servers: scaffold apps whose business operations are MCP tools over Streamable HTTP, with built-in auth, users, roles and Turso migrations
//! `slave make:app <name> --domain <host>`: a web app in `apps/<name>`.
//!
//! The app is a crate in the project's workspace — server-rendered Axum or a
//! Dioxus single-page app — that gets its data from the MCP backend. It is
//! registered in `config/apps.toml` so the gateway routes `<host>` to it, and
//! `slave serve` builds and starts it.

use std::fs;

use anyhow::{Context, Result, anyhow, bail};
use slave_gateway::Config;
use slave_gateway::routes::HostPattern;

use crate::cli::{MakeAppArgs, Stack};
use crate::{apps, generator, project, templates};

/// First port handed out to apps.
const FIRST_PORT: u16 = 4000;

pub fn run(args: &MakeAppArgs) -> Result<()> {
    let name = args.name.as_str();
    slave_gateway::validate_app_name(name).map_err(|err| anyhow!(err))?;
    HostPattern::parse(&args.domain).map_err(|err| anyhow!("--domain: {err}"))?;

    let root = project::find_root()?;
    let package = project::package_name(&root)?;
    if [package.as_str(), "gateway", "migrate"].contains(&name) {
        bail!("`{name}` is already a binary of this project; choose another app name");
    }

    let config_path = root.join(apps::CONFIG);
    let text = fs::read_to_string(&config_path).with_context(|| {
        format!(
            "{} not found; projects created before slave 0.4 need it (copy it from a new project)",
            apps::CONFIG
        )
    })?;
    let config = Config::parse(&text).with_context(|| format!("invalid {}", apps::CONFIG))?;
    if let Some(domain) = config.domains.iter().find(|d| d.host == args.domain) {
        bail!(
            "`{}` is already routed to `{}` in {}",
            args.domain,
            domain.app,
            apps::CONFIG
        );
    }
    if config.domains.iter().any(|d| d.app == name) {
        bail!(
            "app `{name}` already exists in {}; add another [[domain]] for it there",
            apps::CONFIG
        );
    }
    let app_dir = root.join("apps").join(name);
    if app_dir.exists() {
        bail!("apps/{name} already exists");
    }

    let port = match args.port {
        Some(0) => bail!("--port must be between 1 and 65535"),
        Some(port) => port,
        None => next_port(&config),
    };
    if let Some(domain) = config.domains.iter().find(|d| d.port == Some(port)) {
        bail!("port {port} is already used by app `{}`", domain.app);
    }

    let port_text = port.to_string();
    let title = title_case(name);
    let vars = [("app", name), ("title", &title), ("port", &port_text)];
    let files = match args.stack {
        Stack::Axum => templates::AXUM_APP,
        Stack::Dioxus => templates::DIOXUS_APP,
    };
    for file in files {
        let path = app_dir.join(file.path);
        generator::write_file(&path, &templates::render(file.contents, &vars), false)?;
        println!("  created apps/{name}/{}", file.path);
    }

    let updated = apps::insert_domain(&text, &args.domain, name, port);
    Config::parse(&updated).context("generated an invalid apps.toml (this is a bug)")?;
    fs::write(&config_path, updated)
        .with_context(|| format!("failed to update {}", config_path.display()))?;
    println!(
        "  registered {} → apps/{name} (port {port}) in {}",
        args.domain,
        apps::CONFIG
    );

    println!("App {name} [apps/{name}] created successfully.");
    if args.stack == Stack::Dioxus && !crate::dioxus::dx_installed() {
        println!(
            "note: Dioxus apps are built with `dx`; {}",
            crate::dioxus::INSTALL_HINT
        );
    }
    println!(
        "Run `slave serve` and open {}",
        gateway_hint(&config, &args.domain)
    );
    Ok(())
}

/// The next free app port after the highest one in use.
fn next_port(config: &Config) -> u16 {
    config
        .domains
        .iter()
        .filter_map(|domain| domain.port)
        .filter(|&port| port >= FIRST_PORT)
        .max()
        .map_or(FIRST_PORT, |port| port.saturating_add(1))
}

fn gateway_hint(config: &Config, host: &str) -> String {
    let port = config
        .gateway
        .listen
        .rsplit_once(':')
        .map_or("8000", |(_, port)| port);
    let host = host.replace('{', "<").replace('}', ">");
    format!("http://{host}:{port}")
}

/// `admin-panel` → `Admin Panel`.
fn title_case(name: &str) -> String {
    name.split(['-', '_'])
        .filter(|word| !word.is_empty())
        .map(|word| {
            let mut chars = word.chars();
            chars
                .next()
                .map(|first| first.to_ascii_uppercase().to_string() + chars.as_str())
                .unwrap_or_default()
        })
        .collect::<Vec<_>>()
        .join(" ")
}

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

    #[test]
    fn titles() {
        assert_eq!(title_case("dashboard"), "Dashboard");
        assert_eq!(title_case("admin-panel"), "Admin Panel");
        assert_eq!(title_case("shop_front2"), "Shop Front2");
    }

    #[test]
    fn ports_follow_the_highest_app_port() {
        let config = |text: &str| Config::parse(text).unwrap();
        assert_eq!(next_port(&config("")), 4000);
        let two = "[[domain]]\nhost = \"a.localhost\"\napp = \"a\"\nport = 4000\n\
                   [[domain]]\nhost = \"b.localhost\"\napp = \"b\"\nport = 4007\n";
        assert_eq!(next_port(&config(two)), 4008);
    }
}