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: Option<&str>, path: Option<&str>) -> String {
gateway_url(&self.listen, host, path)
}
}
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}"),
}
}
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())
}
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");
}
}