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::templates::Engine;
use crate::{apps, generator, project, runtime, session, templates};
const FIRST_PORT: u16 = 4000;
#[derive(Clone, Copy)]
pub enum Kind {
Theme(&'static Engine),
Admin,
}
pub struct Spec<'a> {
pub name: &'a str,
pub host: Option<&'a str>,
pub path: Option<&'a str>,
pub port: Option<u16>,
pub separate: bool,
pub customer: bool,
pub team: Option<String>,
pub kind: Kind,
}
pub fn run(args: &MakeAppArgs) -> Result<()> {
let root = match &args.project {
Some(path) => project::find_root_from(path)?,
None => match project::find_root() {
Ok(root) => root,
Err(err) => {
let root = session::remembered_project().ok_or(err)?;
println!("Project: {}", root.display());
root
}
},
};
runtime::require(&root)?;
let kind = Kind::Theme(match args.stack {
Stack::Minijinja => &templates::MINIJINJA,
Stack::Tera => &templates::TERA,
Stack::Handlebars => &templates::HANDLEBARS,
Stack::Liquid => &templates::LIQUID,
});
let spec = Spec {
name: &args.name,
host: args.domain.as_deref(),
path: args.path.as_deref(),
port: args.port,
separate: args.separate,
customer: args.customer,
team: Some(choose_team(&root, args.team.as_deref())?),
kind,
};
let url = create(&root, &spec)?;
println!(
"App {} [apps/{}] created successfully.",
spec.name, spec.name
);
println!("Run `slave build`, then `slave serve`, and open {url}");
Ok(())
}
pub fn create(root: &Path, spec: &Spec) -> Result<String> {
let name = spec.name;
slave_gateway::validate_app_name(name).map_err(|err| anyhow!(err))?;
let (host, path) = (spec.host, spec.path);
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 = root.to_path_buf();
let package = project::package_name(&root)?;
if [package.as_str(), "migrate", "runtime"].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 spec.kind {
Kind::Theme(engine) => engine,
Kind::Admin => &templates::MINIJINJA,
};
let port = match spec.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 mut updated = apps::append_domain(&text, host, path, name, port, spec.separate);
if spec.customer {
updated.push_str("access = \"customer\"\n");
}
if let Some(team) = &spec.team {
updated.push_str(&format!("team = \"{team}\"\n"));
}
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 crate_name = name.replace('-', "_");
let vars = [
("app", name),
("crate_name", &crate_name),
("title", &title),
("port", &port_text),
("env", &env),
("engine_dependency", engine.dependency),
("engine_name", engine.name),
("customer", if spec.customer { "true" } else { "false" }),
];
let (rendered, raw) = match spec.kind {
Kind::Theme(engine) => (templates::THEME_APP, engine.files),
Kind::Admin => (templates::ADMIN_APP, templates::ADMIN_RAW),
};
for file in rendered {
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 raw {
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()))?;
let mode = if spec.separate {
"a process of its own, proxied by the runtime"
} else {
"in the runtime"
};
if routed {
println!(
" registered {route} → apps/{name} ({mode}; port {port}) in {}",
apps::CONFIG
);
} else {
println!(
" registered apps/{name} on port {port} ({mode}; direct, add --domain or --path to route it) in {}",
apps::CONFIG
);
}
if !spec.separate {
runtime::add_app(&root, name, &crate_name)?;
}
Ok(if routed {
apps::gateway_url(&config.gateway.listen, host, path)
} else {
format!("http://127.0.0.1:{port}")
})
}
pub fn env_prefix(name: &str) -> String {
name.to_ascii_uppercase().replace('-', "_")
}
fn choose_team(root: &Path, wanted: Option<&str>) -> Result<String> {
if cfg!(debug_assertions)
&& let Ok(team) = std::env::var("SLAVE_TEST_TEAM")
{
return Ok(team);
}
let url = session::project_url(root)?;
let token = session::token(&url)?;
let teams = session::call(&url, Some(&token), "team_list", serde_json::json!({}))?;
let teams: Vec<(String, String)> = teams["teams"]
.as_array()
.into_iter()
.flatten()
.filter(|team| team["manages_members"] == true)
.filter_map(|team| {
Some((
team["slug"].as_str()?.to_owned(),
team["name"].as_str()?.to_owned(),
))
})
.collect();
if let Some(wanted) = wanted {
return teams
.iter()
.any(|(slug, _)| slug == wanted)
.then(|| wanted.to_owned())
.ok_or_else(|| anyhow!("you are not an owner or admin of team `{wanted}`"));
}
match teams.as_slice() {
[] => bail!("you own or administer no team yet; create one in the admin app first"),
[(slug, _)] => Ok(slug.clone()),
teams => {
for (i, (slug, name)) in teams.iter().enumerate() {
println!(" {}. {name} ({slug})", i + 1);
}
let choice = session::prompt("Team that owns the app (number): ")?;
let index = choice
.parse::<usize>()
.ok()
.filter(|n| (1..=teams.len()).contains(n))
.ok_or_else(|| anyhow!("`{choice}` is not one of the numbers above"))?;
Ok(teams[index - 1].0.clone())
}
}
}
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);
}
}