slave 0.8.1

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles and Toasty models and migrations, add server-rendered web apps, and route them by domain or path
//! The optional domain/path gateway (`slave-gateway`, built on Pingora).
//!
//! New projects start without it: compiling Pingora takes a while and needs
//! CMake. It is installed by `slave install:gateway`, `slave new --gateway`,
//! or the first `slave make:app --domain/--path`.

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

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

use crate::{generator, templates};

/// The gateway's package directory inside a project.
pub const DIR: &str = "gateway";
/// Where projects created by slave 0.4–0.5 kept it (inside the backend package).
pub const LEGACY_BIN: &str = "src/bin/gateway.rs";

/// How the gateway is laid out in a project.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Layout {
    /// `gateway/`, its own workspace package (current).
    Package,
    /// `src/bin/gateway.rs` in the backend package (slave 0.4–0.5): it is
    /// built together with the backend.
    Legacy,
}

pub fn layout(root: &Path) -> Option<Layout> {
    if root.join(DIR).join("Cargo.toml").is_file() {
        Some(Layout::Package)
    } else if root.join(LEGACY_BIN).is_file() {
        Some(Layout::Legacy)
    } else {
        None
    }
}

pub fn is_installed(root: &Path) -> bool {
    layout(root).is_some()
}

/// Adds the gateway package to the project at `root`. Returns `false` if it
/// was already installed.
pub fn install(root: &Path) -> Result<bool> {
    if is_installed(root) {
        return Ok(false);
    }
    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 manifest = add_to_workspace(&manifest)?;
    for file in templates::GATEWAY {
        generator::write_file(&root.join(DIR).join(file.path), file.contents, false)?;
    }
    fs::write(&manifest_path, manifest)
        .with_context(|| format!("failed to update {}", manifest_path.display()))?;
    println!("  installed the gateway ({DIR}/, a workspace package using slave-gateway)");
    Ok(true)
}

/// Adds `gateway` to the workspace members and `slave-gateway` to
/// `[workspace.dependencies]` (referenced like `slave-framework`).
fn add_to_workspace(manifest: &str) -> Result<String> {
    let mut lines: Vec<String> = manifest.lines().map(str::to_owned).collect();
    let mut section = String::new();
    let mut members = None;
    let mut framework = 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.dependencies]" && trimmed.starts_with("slave-framework") {
            framework = Some(index);
        }
    }
    let (Some(members), Some(framework)) = (members, framework) else {
        bail!(
            "could not find `members` in [workspace] and slave-framework in [workspace.dependencies] \
             of Cargo.toml; add `\"{DIR}\"` to the members and `slave-gateway = \"{}\"` yourself",
            templates::GATEWAY_VERSION
        );
    };
    let dependency = gateway_dependency(&lines[framework])?;
    lines.insert(framework + 1, dependency);
    let line = &lines[members];
    let Some(close) = line.rfind(']') else {
        bail!("cannot parse the workspace `members` line: {line}");
    };
    let (head, tail) = line.split_at(close);
    let separator = if head.trim_end().ends_with('[') {
        ""
    } else {
        ", "
    };
    lines[members] = format!("{}{separator}\"{DIR}\"{tail}", head.trim_end());
    Ok(lines.join("\n") + "\n")
}

/// `slave-gateway = "0.7"`, or a path next to a local `slave-framework` checkout.
fn gateway_dependency(framework_line: &str) -> Result<String> {
    let local = framework_line
        .split_once("path = \"")
        .and_then(|(_, rest)| rest.split_once('"'))
        .map(|(path, _)| Path::new(path).with_file_name("gateway"))
        .filter(|path| path.join("Cargo.toml").is_file());
    match local {
        Some(path) => {
            let path = crate::commands::new::local_crate(&path, "slave-gateway")?;
            Ok(format!("slave-gateway = {{ path = \"{path}\" }}"))
        }
        None => Ok(format!(
            "slave-gateway = \"{}\"",
            templates::GATEWAY_VERSION
        )),
    }
}

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

    #[test]
    fn adds_the_package_to_the_workspace() {
        let manifest = "[package]\nname = \"app\"\n\n[dependencies]\naxum = \"0.8\"\n\n\
                        [workspace]\nmembers = [\"apps/*\"]\n\n[workspace.dependencies]\n\
                        slave-framework = { version = \"0.7\", default-features = false }\n";
        let updated = add_to_workspace(manifest).unwrap();
        assert!(
            updated.contains("members = [\"apps/*\", \"gateway\"]"),
            "{updated}"
        );
        assert!(
            updated.ends_with(&format!(
                "default-features = false }}\nslave-gateway = \"{}\"\n",
                templates::GATEWAY_VERSION
            )),
            "{updated}"
        );
        assert!(
            !updated.contains("slave-gateway.workspace"),
            "backend does not depend on it"
        );
        assert!(add_to_workspace("[package]\nname = \"x\"\n").is_err());
    }
}