slave 0.5.0

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles and Turso migrations, add Axum or Dioxus web apps, and route them by domain or path
//! `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))?;
    let (host, path) = (args.domain.as_deref(), args.path.as_deref());
    if host.is_none() && path.is_none() {
        bail!("route the app with --domain <host>, --path </prefix>, or both");
    }
    if let Some(host) = host {
        HostPattern::parse(host).map_err(|err| anyhow!("--domain: {err}"))?;
    }
    if let Some(path) = path {
        slave_gateway::routes::validate_path(path).map_err(|err| anyhow!("--path: {err}"))?;
    }
    let route = slave_gateway::routes::label(host, path);

    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.as_deref() == host && d.path.as_deref() == path)
    {
        bail!(
            "`{route}` is already routed to `{}` in {}",
            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 updated = apps::append_domain(&text, host, path, name, port);
    Config::parse(&updated).with_context(|| format!("cannot route `{route}` to `{name}`"))?;

    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);
    }

    fs::write(&config_path, updated)
        .with_context(|| format!("failed to update {}", config_path.display()))?;
    println!(
        "  registered {route} → apps/{name} (port {port}) in {}",
        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 {}",
        apps::gateway_url(&config.gateway.listen, host, path)
    );
    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))
}

/// `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);
    }
}