slave 0.4.0

Laravel-inspired CLI for Rust MCP servers: scaffold apps whose business operations are MCP tools over Streamable HTTP, with built-in auth, users, roles and Turso migrations
//! A project's `config/apps.toml` (domain routing) and the addresses it implies.

use std::collections::HashMap;
use std::path::Path;

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

/// Location of the domain routing table inside a project.
pub const CONFIG: &str = "config/apps.toml";

/// The project's settings from the process environment, then `.env`.
pub struct Env {
    dotenv: HashMap<String, String>,
}

impl Env {
    pub fn load(root: &Path) -> Result<Self> {
        let path = root.join(".env");
        let dotenv = match dotenvy::from_path_iter(&path) {
            Ok(iter) => iter
                .collect::<Result<_, _>>()
                .with_context(|| format!("failed to parse {}", path.display()))?,
            Err(err) if err.not_found() => HashMap::new(),
            Err(err) => {
                return Err(err).with_context(|| format!("failed to read {}", path.display()));
            }
        };
        Ok(Self { dotenv })
    }

    pub fn get(&self, key: &str) -> Option<String> {
        std::env::var(key)
            .ok()
            .or_else(|| self.dotenv.get(key).cloned())
    }
}

/// `host:port` to reach a server bound to `host:port` from this machine.
pub fn connect_address(host: &str, port: u16) -> String {
    let host = match host {
        "0.0.0.0" | "" => "127.0.0.1",
        "::" | "[::]" => "[::1]",
        host => host,
    };
    format!("{host}:{port}")
}

/// The parsed routing table, if the project has one.
pub struct Apps {
    pub config: Config,
    pub routes: Routes,
    /// Address the gateway listens on (`GATEWAY_LISTEN` overrides the file).
    pub listen: String,
}

impl Apps {
    /// Loads `config/apps.toml`; `None` for projects created before it existed.
    /// `mcp` is the `host:port` of the MCP backend.
    pub fn load(root: &Path, env: &Env, mcp: &str) -> Result<Option<Self>> {
        let path = root.join(CONFIG);
        if !path.is_file() {
            return Ok(None);
        }
        let config = Config::load(&path)?;
        let routes = Routes::new(&config, mcp);
        let listen = env
            .get("GATEWAY_LISTEN")
            .unwrap_or_else(|| config.gateway.listen.clone());
        Ok(Some(Self {
            config,
            routes,
            listen,
        }))
    }

    /// Public URL of a host pattern through the gateway, e.g.
    /// `http://<toko>.localhost:8000`.
    pub fn url(&self, host: &str) -> String {
        let port = self.listen.rsplit_once(':').map_or("", |(_, port)| port);
        let host = host.replace('{', "<").replace('}', ">");
        match port {
            "80" | "" => format!("http://{host}"),
            port => format!("http://{host}:{port}"),
        }
    }
}

/// Inserts a `[[domain]]` entry into the text of `apps.toml`, keeping comments
/// and order: specific hosts go before the first wildcard entry, wildcard
/// hosts at the end.
pub fn insert_domain(text: &str, host: &str, app: &str, port: u16) -> String {
    let block = format!("[[domain]]\nhost = \"{host}\"\napp = \"{app}\"\nport = {port}\n");
    let mut position = None;
    if !host.contains('{') {
        let mut offset = 0;
        let mut block_start = None;
        for line in text.split_inclusive('\n') {
            let trimmed = line.trim();
            if trimmed == "[[domain]]" {
                block_start = Some(offset);
            } else if let Some(start) = block_start
                && trimmed.starts_with("host")
                && trimmed.contains('{')
            {
                position = Some(start);
                break;
            }
            offset += line.len();
        }
    }
    match position {
        Some(at) => format!("{}{block}\n{}", &text[..at], &text[at..]),
        None => {
            let text = text.trim_end();
            format!("{text}\n\n{block}")
        }
    }
}

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

    const BASE: &str = "# routes\n[gateway]\nlisten = \"127.0.0.1:8000\"\n\n[[domain]]\nhost = \"mcp.localhost\"\napp = \"mcp\"\n";

    #[test]
    fn appends_and_keeps_wildcards_last() {
        let text = insert_domain(BASE, "{toko}.localhost", "storefront", 4001);
        let text = insert_domain(&text, "admin.localhost", "dashboard", 4000);
        let config = Config::parse(&text).unwrap();
        let hosts: Vec<&str> = config.domains.iter().map(|d| d.host.as_str()).collect();
        assert_eq!(
            hosts,
            ["mcp.localhost", "admin.localhost", "{toko}.localhost"]
        );
        assert!(text.starts_with("# routes\n"), "{text}");
    }

    #[test]
    fn builds_gateway_urls() {
        let apps = |listen: &str| Apps {
            config: Config::parse("").unwrap(),
            routes: Routes::new(&Config::parse("").unwrap(), "127.0.0.1:3000"),
            listen: listen.to_owned(),
        };
        assert_eq!(
            apps("127.0.0.1:8000").url("{toko}.localhost"),
            "http://<toko>.localhost:8000"
        );
        assert_eq!(apps("0.0.0.0:80").url("example.com"), "http://example.com");
    }

    #[test]
    fn connect_addresses_avoid_wildcards() {
        assert_eq!(connect_address("0.0.0.0", 3000), "127.0.0.1:3000");
        assert_eq!(connect_address("10.0.0.2", 3000), "10.0.0.2:3000");
    }
}