use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use slave_gateway::{Config, Routes};
pub const CONFIG: &str = "config/apps.toml";
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())
}
}
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}")
}
pub struct Apps {
pub config: Config,
pub routes: Routes,
pub listen: String,
}
impl Apps {
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,
}))
}
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}"),
}
}
}
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");
}
}