use std::fs;
use std::path::Path;
use anyhow::{Context, Result, anyhow, bail};
use slave_gateway::Config;
use slave_gateway::routes::HostPattern;
use crate::cli::{MakeAppArgs, Stack};
use crate::{apps, gateway, generator, project, templates};
const FIRST_PORT: u16 = 4000;
pub fn run(args: &MakeAppArgs) -> Result<()> {
let name = args.name.as_str();
slave_gateway::validate_app_name(name).map_err(|err| anyhow!(err))?;
let (host, path) = (args.domain.as_deref(), args.path.as_deref());
if let Some(host) = host {
HostPattern::parse(host).map_err(|err| anyhow!("--domain: {err}"))?;
}
if let Some(path) = path {
slave_gateway::routes::validate_path(path).map_err(|err| anyhow!("--path: {err}"))?;
}
let route = slave_gateway::routes::label(host, path);
let root = project::find_root()?;
let package = project::package_name(&root)?;
if [package.as_str(), "gateway", "migrate"].contains(&name) {
bail!("`{name}` is already a binary of this project; choose another app name");
}
let config_path = root.join(apps::CONFIG);
let text = fs::read_to_string(&config_path).with_context(|| {
format!(
"{} not found; projects created before slave 0.4 need it (copy it from a new project)",
apps::CONFIG
)
})?;
let config = Config::parse(&text).with_context(|| format!("invalid {}", apps::CONFIG))?;
let routed = host.is_some() || path.is_some();
if let Some(domain) = config
.domains
.iter()
.find(|d| routed && d.host.as_deref() == host && d.path.as_deref() == path)
{
bail!(
"`{route}` is already routed to `{}` in {}",
domain.app,
apps::CONFIG
);
}
if config.domains.iter().any(|d| d.app == name) {
bail!(
"app `{name}` already exists in {}; add another [[domain]] for it there",
apps::CONFIG
);
}
let app_dir = root.join("apps").join(name);
if app_dir.exists() {
bail!("apps/{name} already exists");
}
let engine = match args.stack {
Stack::Minijinja => &templates::MINIJINJA,
Stack::Tera => &templates::TERA,
Stack::Handlebars => &templates::HANDLEBARS,
Stack::Liquid => &templates::LIQUID,
};
let port = match args.port {
Some(0) => bail!("--port must be between 1 and 65535"),
Some(port) => port,
None => next_port(&config),
};
if let Some(domain) = config.domains.iter().find(|d| d.port == Some(port)) {
bail!("port {port} is already used by app `{}`", domain.app);
}
let updated = apps::append_domain(&text, host, path, name, port);
Config::parse(&updated).with_context(|| format!("cannot route `{route}` to `{name}`"))?;
let port_text = port.to_string();
let title = title_case(name);
let env = env_prefix(name);
let vars = [
("app", name),
("title", &title),
("port", &port_text),
("env", &env),
("engine_dependency", engine.dependency),
("engine_name", engine.name),
];
for file in templates::THEME_APP {
let path = app_dir.join(file.path);
generator::write_file(&path, &templates::render(file.contents, &vars), false)?;
println!(" created apps/{name}/{}", file.path);
}
for file in engine.files {
generator::write_file(&app_dir.join(file.path), file.contents, false)?;
println!(" created apps/{name}/{}", file.path);
}
fs::write(&config_path, updated)
.with_context(|| format!("failed to update {}", config_path.display()))?;
add_sign_in_settings(&root, name, &env)?;
if routed {
println!(
" registered {route} → apps/{name} (port {port}) in {}",
apps::CONFIG
);
gateway::install(&root)?;
} else {
println!(
" registered apps/{name} on port {port} in {} (direct; add --domain or --path to route it through the gateway)",
apps::CONFIG
);
}
println!("App {name} [apps/{name}] created successfully.");
let url = if routed {
apps::gateway_url(&config.gateway.listen, host, path)
} else {
format!("http://127.0.0.1:{port}")
};
println!("Run `slave build`, then `slave serve`, and open {url}");
Ok(())
}
pub fn env_prefix(name: &str) -> String {
name.to_ascii_uppercase().replace('-', "_")
}
fn add_sign_in_settings(root: &Path, name: &str, env: &str) -> Result<()> {
let block = format!(
"\n# apps/{name}: its client ID at the OpenID Connect provider. Add it to\n\
# OIDC_AUDIENCES too, so the backend accepts its ID tokens.\n{env}_OIDC_CLIENT_ID=\n\
# Only if the provider issued a secret for it (confidential client).\n\
{env}_OIDC_CLIENT_SECRET=\n"
);
for file in [".env.example", ".env"] {
let path = root.join(file);
if let Ok(text) = fs::read_to_string(&path)
&& !text.contains(&format!("{env}_OIDC_CLIENT_ID="))
{
let text = format!("{}\n{block}", text.trim_end());
fs::write(&path, text).with_context(|| format!("failed to update {file}"))?;
}
}
println!(
" added {env}_OIDC_CLIENT_ID to .env and .env.example (also list it in OIDC_AUDIENCES)"
);
Ok(())
}
fn next_port(config: &Config) -> u16 {
config
.domains
.iter()
.filter_map(|domain| domain.port)
.filter(|&port| port >= FIRST_PORT)
.max()
.map_or(FIRST_PORT, |port| port.saturating_add(1))
}
fn title_case(name: &str) -> String {
name.split(['-', '_'])
.filter(|word| !word.is_empty())
.map(|word| {
let mut chars = word.chars();
chars
.next()
.map(|first| first.to_ascii_uppercase().to_string() + chars.as_str())
.unwrap_or_default()
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn titles() {
assert_eq!(title_case("dashboard"), "Dashboard");
assert_eq!(title_case("admin-panel"), "Admin Panel");
assert_eq!(title_case("shop_front2"), "Shop Front2");
}
#[test]
fn ports_follow_the_highest_app_port() {
let config = |text: &str| Config::parse(text).unwrap();
assert_eq!(next_port(&config("")), 4000);
let two = "[[domain]]\nhost = \"a.localhost\"\napp = \"a\"\nport = 4000\n\
[[domain]]\nhost = \"b.localhost\"\napp = \"b\"\nport = 4007\n";
assert_eq!(next_port(&config(two)), 4008);
}
}