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
//! The runtime (`runtime/`): one process serving the MCP backend and every
//! app, routed by `config/apps.toml`; apps with `embed = false` run on their
//! own and the runtime proxies to them.

use std::fs;
use std::path::Path;

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

use crate::templates;

/// The runtime's package directory inside a project.
pub const DIR: &str = "runtime";

pub fn is_present(root: &Path) -> bool {
    root.join(DIR).join("Cargo.toml").is_file()
}

/// Fails for projects made before slave 0.9, which have no runtime.
pub fn require(root: &Path) -> Result<()> {
    if !is_present(root) {
        bail!(
            "{DIR}/ not found: this project was created before slave 0.9; copy runtime/ from a \
             new project (`slave new`) and add your apps to runtime/src/apps.rs"
        );
    }
    Ok(())
}

/// Mounts app `name` (crate `crate_name`) in the runtime: a dependency in
/// `runtime/Cargo.toml` and a line in `runtime/src/apps.rs`.
pub fn add_app(root: &Path, name: &str, crate_name: &str) -> Result<()> {
    let manifest_path = root.join(DIR).join("Cargo.toml");
    let manifest = fs::read_to_string(&manifest_path)
        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
    let apps_path = root.join(DIR).join("src/apps.rs");
    let apps = fs::read_to_string(&apps_path)
        .with_context(|| format!("failed to read {}", apps_path.display()))?;

    let apps = mount_line(&apps, name, crate_name)?;
    let manifest = format!(
        "{}\n{name} = {{ path = \"../apps/{name}\" }}\n",
        manifest.trim_end()
    );
    fs::write(&apps_path, apps).with_context(|| format!("failed to update {DIR}/src/apps.rs"))?;
    fs::write(&manifest_path, manifest)
        .with_context(|| format!("failed to update {DIR}/Cargo.toml"))?;
    println!("  mounted {name} in the runtime ({DIR}/src/apps.rs)");
    Ok(())
}

/// Takes app `name` out of the runtime (it runs on its own): the reverse of
/// [`add_app`]. Missing lines are not an error.
pub fn remove_app(root: &Path, name: &str) -> Result<()> {
    let manifest_path = root.join(DIR).join("Cargo.toml");
    let manifest = fs::read_to_string(&manifest_path)
        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
    let apps_path = root.join(DIR).join("src/apps.rs");
    let apps = fs::read_to_string(&apps_path)
        .with_context(|| format!("failed to read {}", apps_path.display()))?;
    let dependency = format!("{name} = {{ path = \"../apps/{name}\" }}");
    let mount = format!("apps.app(\"{name}\",");
    let keep = |text: &str, drop: &dyn Fn(&str) -> bool| {
        text.lines()
            .filter(|line| !drop(line.trim()))
            .collect::<Vec<_>>()
            .join("\n")
            + "\n"
    };
    fs::write(&manifest_path, keep(&manifest, &|line| line == dependency))
        .with_context(|| format!("failed to update {DIR}/Cargo.toml"))?;
    fs::write(&apps_path, keep(&apps, &|line| line.starts_with(&mount)))
        .with_context(|| format!("failed to update {DIR}/src/apps.rs"))?;
    println!("  removed {name} from the runtime ({DIR}/src/apps.rs)");
    Ok(())
}

/// Whether app `name` is mounted in `runtime/src/apps.rs`.
pub fn has_app(root: &Path, name: &str) -> bool {
    fs::read_to_string(root.join(DIR).join("src/apps.rs"))
        .is_ok_and(|apps| apps.contains(&format!("apps.app(\"{name}\",")))
}

/// Inserts `apps.app("<name>", <crate>::embedded)?;` above the marker.
fn mount_line(apps: &str, name: &str, crate_name: &str) -> Result<String> {
    let Some(marker) = apps.find(templates::APPS_MARKER) else {
        bail!(
            "{DIR}/src/apps.rs has no `{}` line; add `apps.app(\"{name}\", {crate_name}::embedded)?;` yourself",
            templates::APPS_MARKER
        );
    };
    let line_start = apps[..marker].rfind('\n').map_or(0, |i| i + 1);
    let indent = &apps[line_start..marker];
    let line = format!("{indent}apps.app(\"{name}\", {crate_name}::embedded)?;\n");
    Ok(format!(
        "{}{line}{}",
        &apps[..line_start],
        &apps[line_start..]
    ))
}

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

    #[test]
    fn mounts_apps_above_the_marker() {
        let apps =
            "pub fn mount(apps: &mut Mount) -> Result<()> {\n    // slave:apps\n    Ok(())\n}\n";
        let apps = mount_line(apps, "shop-front", "shop_front").unwrap();
        assert!(
            apps.contains(
                "    apps.app(\"shop-front\", shop_front::embedded)?;\n    // slave:apps\n"
            ),
            "{apps}"
        );
        assert!(mount_line("fn mount() {}", "a", "a").is_err());
    }
}