slave 0.7.0

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles and Turso migrations, add server-rendered 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 — an Axum server that renders
//! pages with a template engine — 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 std::path::Path;

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

use crate::cli::{MakeAppArgs, Stack};
use crate::{apps, gateway, 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 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))?;
    let routed = host.is_some() || path.is_some();
    if let Some(domain) = config
        .domains
        .iter()
        .find(|d| routed && 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 engine = match args.stack {
        Stack::Minijinja => &templates::MINIJINJA,
        Stack::Tera => &templates::TERA,
        Stack::Handlebars => &templates::HANDLEBARS,
        Stack::Liquid => &templates::LIQUID,
    };

    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 env = env_prefix(name);
    let vars = [
        ("app", name),
        ("title", &title),
        ("port", &port_text),
        ("env", &env),
        ("engine_dependency", engine.dependency),
        ("engine_name", engine.name),
    ];
    for file in templates::THEME_APP {
        let path = app_dir.join(file.path);
        generator::write_file(&path, &templates::render(file.contents, &vars), false)?;
        println!("  created apps/{name}/{}", file.path);
    }
    for file in engine.files {
        generator::write_file(&app_dir.join(file.path), file.contents, false)?;
        println!("  created apps/{name}/{}", file.path);
    }

    fs::write(&config_path, updated)
        .with_context(|| format!("failed to update {}", config_path.display()))?;
    add_sign_in_settings(&root, name, &env)?;
    if routed {
        println!(
            "  registered {route} → apps/{name} (port {port}) in {}",
            apps::CONFIG
        );
        gateway::install(&root)?;
    } else {
        println!(
            "  registered apps/{name} on port {port} in {} (direct; add --domain or --path to route it through the gateway)",
            apps::CONFIG
        );
    }

    println!("App {name} [apps/{name}] created successfully.");
    let url = if routed {
        apps::gateway_url(&config.gateway.listen, host, path)
    } else {
        format!("http://127.0.0.1:{port}")
    };
    println!("Run `slave build`, then `slave serve`, and open {url}");
    Ok(())
}

/// `storefront` → `STOREFRONT`, `admin-panel` → `ADMIN_PANEL`: the prefix of
/// the app's own settings in `.env`.
pub fn env_prefix(name: &str) -> String {
    name.to_ascii_uppercase().replace('-', "_")
}

/// Adds the app's sign-in settings to `.env.example` and `.env` (if present).
fn add_sign_in_settings(root: &Path, name: &str, env: &str) -> Result<()> {
    let block = format!(
        "\n# apps/{name}: its client ID at the OpenID Connect provider. Add it to\n\
         # OIDC_AUDIENCES too, so the backend accepts its ID tokens.\n{env}_OIDC_CLIENT_ID=\n\
         # Only if the provider issued a secret for it (confidential client).\n\
         {env}_OIDC_CLIENT_SECRET=\n"
    );
    for file in [".env.example", ".env"] {
        let path = root.join(file);
        if let Ok(text) = fs::read_to_string(&path)
            && !text.contains(&format!("{env}_OIDC_CLIENT_ID="))
        {
            let text = format!("{}\n{block}", text.trim_end());
            fs::write(&path, text).with_context(|| format!("failed to update {file}"))?;
        }
    }
    println!(
        "  added {env}_OIDC_CLIENT_ID to .env and .env.example (also list it in OIDC_AUDIENCES)"
    );
    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);
    }
}