slave 0.9.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
//! 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 runtime 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 route through the runtime, e.g.
    /// `http://<toko>.localhost:8000` or `http://localhost:8000/console`.
    /// Path-only routes are shown on `localhost`.
    pub fn url(&self, host: Option<&str>, path: Option<&str>) -> String {
        gateway_url(&self.listen, host, path)
    }
}

/// The runtime URL for `host` and `path`, e.g. `http://<toko>.localhost:8000`.
pub fn gateway_url(listen: &str, host: Option<&str>, path: Option<&str>) -> String {
    let port = listen.rsplit_once(':').map_or("", |(_, port)| port);
    let host = host
        .unwrap_or("localhost")
        .replace('{', "<")
        .replace('}', ">");
    let path = path.unwrap_or("");
    match port {
        "80" | "" => format!("http://{host}{path}"),
        port => format!("http://{host}:{port}{path}"),
    }
}

/// Appends a `[[domain]]` entry to the text of `apps.toml`, keeping comments.
/// Order does not matter: the runtime tries the most specific entry first.
/// A `separate` app runs on its own (`embed = false`).
pub fn append_domain(
    text: &str,
    host: Option<&str>,
    path: Option<&str>,
    app: &str,
    port: u16,
    separate: bool,
) -> String {
    let mut block = String::from("[[domain]]\n");
    if let Some(host) = host {
        block.push_str(&format!("host = \"{host}\"\n"));
    }
    if let Some(path) = path {
        block.push_str(&format!("path = \"{path}\"\n"));
    }
    block.push_str(&format!("app = \"{app}\"\nport = {port}\n"));
    if separate {
        block.push_str("embed = false\n");
    }
    format!("{}\n\n{block}", text.trim_end())
}

/// Sets `embed = false` on every `[[domain]]` entry of `app` (`separate`), or
/// removes `embed` from them, keeping everything else as written.
pub fn set_separate(text: &str, app: &str, separate: bool) -> String {
    let app_line = format!("app = \"{app}\"");
    let mut out: Vec<String> = Vec::new();
    let mut block: Vec<String> = Vec::new();
    let flush = |block: &mut Vec<String>, out: &mut Vec<String>| {
        let ours = block.iter().any(|line| line.trim() == app_line);
        if ours {
            block.retain(|line| {
                let key = line.trim_start();
                !(key.starts_with("embed") && key["embed".len()..].trim_start().starts_with('='))
            });
            if separate {
                let at = block
                    .iter()
                    .position(|line| line.trim() == app_line)
                    .map_or(block.len(), |i| i + 1);
                block.insert(at, "embed = false".to_owned());
            }
        }
        out.append(block);
    };
    for line in text.lines() {
        if line.trim_start().starts_with('[') {
            flush(&mut block, &mut out);
        }
        block.push(line.to_owned());
    }
    flush(&mut block, &mut out);
    out.join("\n") + "\n"
}

#[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_entries_keeping_comments() {
        let text = append_domain(BASE, Some("localhost"), None, "site", 4000, false);
        let text = append_domain(&text, None, Some("/admin"), "dashboard", 4001, true);
        let text = append_domain(
            &text,
            Some("localhost"),
            Some("/panel"),
            "panel",
            4002,
            false,
        );
        let config = Config::parse(&text).unwrap();
        let labels: Vec<String> = config.domains.iter().map(|d| d.label()).collect();
        assert_eq!(
            labels,
            ["mcp.localhost", "localhost", "*/admin", "localhost/panel"]
        );
        assert!(text.starts_with("# routes\n"), "{text}");
        assert!(!config.domains[2].is_embedded());
    }

    #[test]
    fn separates_and_embeds_an_app() {
        let text = append_domain(BASE, Some("a.localhost"), None, "site", 4000, false);
        let separate = set_separate(&text, "site", true);
        let config = Config::parse(&separate).unwrap();
        assert!(!config.domains[1].is_embedded(), "{separate}");
        assert!(config.domains[0].is_embedded());
        let embedded = set_separate(&separate, "site", false);
        assert!(Config::parse(&embedded).unwrap().domains[1].is_embedded());
        assert!(!embedded.contains("embed"), "{embedded}");
    }

    #[test]
    fn builds_gateway_urls() {
        assert_eq!(
            gateway_url("127.0.0.1:8000", Some("{toko}.localhost"), None),
            "http://<toko>.localhost:8000"
        );
        assert_eq!(
            gateway_url("127.0.0.1:8000", None, Some("/console")),
            "http://localhost:8000/console"
        );
        assert_eq!(
            gateway_url("0.0.0.0:80", Some("example.com"), None),
            "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");
    }
}