slave 0.13.1

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles, teams and SurrealDB migrations, add server-rendered web apps, and route them by domain or path
//! `slave build`: compile everything `slave serve` runs.
//!
//! One `cargo build` covers the backend, the runtime (which includes every
//! embedded app) and each separate app (`embed = false`).
//! The first build compiles all dependencies and takes a while; later builds
//! only recompile what changed.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Instant;

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

use crate::cli::BuildArgs;
use crate::{apps, cargo, project, runtime};

/// A web app in `apps/<name>` listed in `config/apps.toml`.
#[derive(Clone)]
pub struct LocalApp {
    pub name: String,
    pub port: u16,
    /// Path prefix it is routed under (`SLAVE_BASE_PATH`), if any.
    pub base_path: Option<String>,
    /// Served in-process by the runtime; otherwise a process of its own.
    pub embedded: bool,
    /// Reached on its port only (no `host`/`path` entry).
    pub direct: bool,
}

/// Something `slave build` can build on its own.
pub struct Component {
    /// `backend`, `runtime`, or a separate app's name.
    pub name: String,
    /// The Cargo package that holds it.
    pub package: String,
    pub description: String,
    /// Paths whose changes require rebuilding it (for CI path filters).
    pub watch: Vec<String>,
}

/// The project's buildable components: the backend, the runtime and every
/// separate app in `apps/` listed in `config/apps.toml`.
pub fn components(root: &Path) -> Result<Vec<Component>> {
    runtime::require(root)?;
    let backend = project::package_name(root)?;
    let shared = ["Cargo.toml", "Cargo.lock"].map(String::from);
    let mut list = vec![
        Component {
            name: "backend".into(),
            package: backend,
            description: "MCP backend and migrations".into(),
            watch: ["src/**", "database/**"]
                .map(String::from)
                .into_iter()
                .chain(shared.clone())
                .collect(),
        },
        Component {
            name: "runtime".into(),
            package: runtime::DIR.into(),
            description: "the backend and every embedded app in one process".into(),
            watch: ["src/**", "apps/**", "runtime/**", apps::CONFIG]
                .map(String::from)
                .into_iter()
                .chain(shared.clone())
                .collect(),
        },
    ];
    if let Some(config) = load_config(root)? {
        for app in local_apps(root, &config)?
            .into_iter()
            .filter(|app| !app.embedded)
        {
            list.push(Component {
                name: app.name.clone(),
                package: app.name.clone(),
                description: format!("separate app on port {}", app.port),
                watch: std::iter::once(format!("apps/{}/**", app.name))
                    .chain(shared.clone())
                    .collect(),
            });
        }
    }
    Ok(list)
}

pub fn run(args: &BuildArgs) -> Result<()> {
    let root = project::find_root()?;
    let started = Instant::now();
    let all = components(&root)?;

    if args.list {
        let width = all.iter().map(|c| c.name.len()).max().unwrap_or(0);
        println!("Components (build one with `slave build <name>`):\n");
        for component in &all {
            println!(
                "  {:<width$}  {}\n  {:<width$}  rebuild when changed: {}",
                component.name,
                component.description,
                "",
                component.watch.join(", ")
            );
        }
        return Ok(());
    }

    let selected: Vec<&Component> = if args.components.is_empty() {
        all.iter().collect()
    } else {
        args.components
            .iter()
            .map(|name| {
                all.iter()
                    .find(|c| &c.name == name || &c.package == name)
                    .with_context(|| {
                        let names: Vec<&str> = all.iter().map(|c| c.name.as_str()).collect();
                        format!(
                            "unknown component `{name}`; choose from: {}",
                            names.join(", ")
                        )
                    })
            })
            .collect::<Result<_>>()?
    };
    let mut packages: Vec<&str> = selected.iter().map(|c| c.package.as_str()).collect();
    packages.dedup();
    let mut cargo_args = vec!["build", "--bins"];
    for package in &packages {
        cargo_args.extend(["-p", package]);
    }
    if args.release {
        cargo_args.push("--release");
    }
    if !cargo::run(&root, &cargo_args, &[])?.success() {
        bail!("build failed");
    }

    let built: Vec<String> = selected
        .iter()
        .map(|c| {
            if c.name == "backend" {
                format!("{} (MCP backend)", c.package)
            } else {
                c.name.clone()
            }
        })
        .collect();
    println!("\nBuilt {} in {:.0?}.", built.join(", "), started.elapsed());
    let release = if args.release { " --release" } else { "" };
    println!("Run them with `slave serve{release}`.");
    Ok(())
}

/// `config/apps.toml`, if the project has one.
pub fn load_config(root: &Path) -> Result<Option<Config>> {
    let path = root.join(apps::CONFIG);
    if !path.is_file() {
        return Ok(None);
    }
    Ok(Some(Config::load(&path)?))
}

/// Web apps from `apps.toml` that live in `apps/<name>`. Apps routed to an
/// explicit `upstream` run elsewhere and are not built or started here.
pub fn local_apps(root: &Path, config: &Config) -> Result<Vec<LocalApp>> {
    let mut found: BTreeMap<String, (u16, Option<String>)> = BTreeMap::new();
    let mut flags: BTreeMap<String, (bool, bool)> = BTreeMap::new();
    for domain in &config.domains {
        if domain.is_mcp() || domain.upstream.is_some() {
            continue;
        }
        let Some(port) = domain.port else { continue };
        if !root
            .join("apps")
            .join(&domain.app)
            .join("Cargo.toml")
            .is_file()
        {
            println!(
                "  note: apps/{} not found; start `{}` on port {port} yourself",
                domain.app, domain.app
            );
            continue;
        }
        let flag = flags.entry(domain.app.clone()).or_insert((true, true));
        flag.0 &= domain.is_embedded();
        flag.1 &= !domain.is_routed();
        let entry = (port, domain.path.clone());
        if let Some(previous) = found.insert(domain.app.clone(), entry.clone())
            && previous != entry
        {
            bail!(
                "app `{}` is listed with different ports or paths in {}; one app serves one port and base path",
                domain.app,
                apps::CONFIG
            );
        }
    }
    Ok(found
        .into_iter()
        .map(|(name, (port, base_path))| {
            let (embedded, direct) = flags[&name];
            LocalApp {
                name,
                port,
                base_path,
                embedded,
                direct,
            }
        })
        .collect())
}

/// The workspace's target directory, as Cargo reports it.
pub fn target_dir(root: &Path) -> Result<PathBuf> {
    let output = cargo::command()
        .args(["metadata", "--format-version", "1", "--no-deps"])
        .current_dir(root)
        .stderr(Stdio::inherit())
        .output()
        .context("failed to run `cargo metadata`")?;
    if !output.status.success() {
        bail!("`cargo metadata` failed");
    }
    let metadata: serde_json::Value =
        serde_json::from_slice(&output.stdout).context("unexpected `cargo metadata` output")?;
    metadata["target_directory"]
        .as_str()
        .map(PathBuf::from)
        .context("`cargo metadata` did not report a target directory")
}