slave 0.6.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 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,
        Stack::Dioxus => {
            if routed || args.port.is_some() {
                bail!(
                    "a Dioxus app is a client for web, desktop and mobile that slave neither serves \
                     nor routes; drop --domain, --path and --port"
                );
            }
            return create_dioxus(&root, name, &app_dir);
        }
    };

    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, true)?;
    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(())
}

/// A Dioxus client: its own workspace in `apps/<name>`, excluded from the
/// backend's workspace and not listed in `config/apps.toml`.
fn create_dioxus(root: &Path, name: &str, app_dir: &Path) -> Result<()> {
    let title = title_case(name);
    let env = env_prefix(name);
    let vars = [
        ("app", name),
        ("title", title.as_str()),
        ("env", env.as_str()),
    ];
    for file in templates::DIOXUS_APP {
        let path = app_dir.join(file.path);
        generator::write_file(&path, &templates::render(file.contents, &vars), false)?;
        println!("  created apps/{name}/{}", file.path);
    }
    let manifest_path = root.join("Cargo.toml");
    let manifest = fs::read_to_string(&manifest_path)
        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
    let updated = exclude_from_workspace(&manifest, &format!("apps/{name}"))?;
    fs::write(&manifest_path, updated)
        .with_context(|| format!("failed to update {}", manifest_path.display()))?;
    println!("  excluded apps/{name} from the backend's workspace (it builds on its own)");
    add_sign_in_settings(root, name, &env, false)?;

    println!("App {name} [apps/{name}] created successfully.");
    if !crate::dioxus::dx_installed() {
        println!(
            "note: Dioxus apps are built with `dx`; {}",
            crate::dioxus::INSTALL_HINT
        );
    }
    println!("Start the backend (`slave serve`), then in apps/{name}:");
    println!("  dx serve --platform web       # or desktop, ios, android");
    Ok(())
}

/// Adds `member` to `exclude` in the `[workspace]` table of a Cargo.toml.
fn exclude_from_workspace(manifest: &str, member: &str) -> Result<String> {
    let mut lines: Vec<String> = manifest.lines().map(str::to_owned).collect();
    let mut section = String::new();
    let (mut members, mut exclude) = (None, None);
    for (index, line) in lines.iter().enumerate() {
        let trimmed = line.trim();
        if trimmed.starts_with('[') {
            section = trimmed.to_owned();
        } else if section == "[workspace]" && trimmed.starts_with("members") {
            members = Some(index);
        } else if section == "[workspace]" && trimmed.starts_with("exclude") {
            exclude = Some(index);
        }
    }
    let entry = format!("\"{member}\"");
    match (exclude, members) {
        (Some(index), _) => {
            let line = lines[index].clone();
            let Some(close) = line.rfind(']') else {
                bail!("cannot parse the workspace `exclude` line: {line}");
            };
            let (head, tail) = line.split_at(close);
            let separator = if head.trim_end().ends_with('[') {
                ""
            } else {
                ", "
            };
            lines[index] = format!("{}{separator}{entry}{tail}", head.trim_end());
        }
        (None, Some(index)) => lines.insert(index + 1, format!("exclude = [{entry}]")),
        (None, None) => bail!(
            "no [workspace] members in Cargo.toml; add `exclude = [{entry}]` to [workspace] yourself"
        ),
    }
    Ok(lines.join("\n") + "\n")
}

/// `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, secret: bool) -> Result<()> {
    let mut 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"
    );
    if secret {
        block.push_str(&format!(
            "# 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 excludes_members_from_the_workspace() {
        let manifest = "[package]\nname = \"x\"\n\n[workspace]\nmembers = [\"apps/*\"]\n";
        let once = exclude_from_workspace(manifest, "apps/dashboard").unwrap();
        assert!(
            once.contains("members = [\"apps/*\"]\nexclude = [\"apps/dashboard\"]"),
            "{once}"
        );
        let twice = exclude_from_workspace(&once, "apps/mobile").unwrap();
        assert!(
            twice.contains("exclude = [\"apps/dashboard\", \"apps/mobile\"]"),
            "{twice}"
        );
        assert!(exclude_from_workspace("[package]\n", "apps/x").is_err());
    }

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