use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Instant;
use anyhow::{Context, Result, bail};
use slave_gateway::Config;
use crate::cli::BuildArgs;
use crate::{apps, cargo, gateway, project};
#[derive(Clone)]
pub struct LocalApp {
pub name: String,
pub port: u16,
pub base_path: Option<String>,
}
pub struct Component {
pub name: String,
pub package: String,
pub description: String,
pub watch: Vec<String>,
}
pub fn components(root: &Path) -> Result<Vec<Component>> {
let backend = project::package_name(root)?;
let shared = ["Cargo.toml", "Cargo.lock"].map(String::from);
let mut list = vec![Component {
name: "backend".into(),
package: backend.clone(),
description: "MCP backend and migrations".into(),
watch: ["src/**", "database/**"]
.map(String::from)
.into_iter()
.chain(shared.clone())
.collect(),
}];
if let Some(layout) = gateway::layout(root) {
let (package, watch) = match layout {
gateway::Layout::Package => ("gateway".to_owned(), "gateway/**"),
gateway::Layout::Legacy => (backend.clone(), gateway::LEGACY_BIN),
};
list.push(Component {
name: "gateway".into(),
package,
description: "domain/path gateway (Pingora)".into(),
watch: [watch, apps::CONFIG]
.map(String::from)
.into_iter()
.chain(shared.clone())
.collect(),
});
}
if let Some(config) = load_config(root)? {
for app in local_apps(root, &config)? {
list.push(Component {
name: app.name.clone(),
package: app.name.clone(),
description: format!("web app on port {}", app.port),
watch: std::iter::once(format!("apps/{}/**", app.name))
.chain(shared.clone())
.collect(),
});
}
}
Ok(list)
}
pub fn run(args: &BuildArgs) -> Result<()> {
let root = project::find_root()?;
let started = Instant::now();
let all = components(&root)?;
if args.list {
let width = all.iter().map(|c| c.name.len()).max().unwrap_or(0);
println!("Components (build one with `slave build <name>`):\n");
for component in &all {
println!(
" {:<width$} {}\n {:<width$} rebuild when changed: {}",
component.name,
component.description,
"",
component.watch.join(", ")
);
}
return Ok(());
}
let selected: Vec<&Component> = if args.components.is_empty() {
all.iter().collect()
} else {
args.components
.iter()
.map(|name| {
all.iter()
.find(|c| &c.name == name || &c.package == name)
.with_context(|| {
let names: Vec<&str> = all.iter().map(|c| c.name.as_str()).collect();
format!(
"unknown component `{name}`; choose from: {}",
names.join(", ")
)
})
})
.collect::<Result<_>>()?
};
let mut packages: Vec<&str> = selected.iter().map(|c| c.package.as_str()).collect();
packages.dedup();
let mut cargo_args = vec!["build", "--bins"];
for package in &packages {
cargo_args.extend(["-p", package]);
}
if args.release {
cargo_args.push("--release");
}
if !cargo::run(&root, &cargo_args, &[])?.success() {
bail!("build failed");
}
let built: Vec<String> = selected
.iter()
.map(|c| {
if c.name == "backend" {
format!("{} (MCP backend)", c.package)
} else {
c.name.clone()
}
})
.collect();
println!("\nBuilt {} in {:.0?}.", built.join(", "), started.elapsed());
let release = if args.release { " --release" } else { "" };
println!("Run them with `slave serve{release}`.");
Ok(())
}
pub fn load_config(root: &Path) -> Result<Option<Config>> {
let path = root.join(apps::CONFIG);
if !path.is_file() {
return Ok(None);
}
Ok(Some(Config::load(&path)?))
}
pub fn local_apps(root: &Path, config: &Config) -> Result<Vec<LocalApp>> {
let mut found: BTreeMap<String, (u16, Option<String>)> = BTreeMap::new();
for domain in &config.domains {
if domain.is_mcp() || domain.upstream.is_some() {
continue;
}
let Some(port) = domain.port else { continue };
if !root
.join("apps")
.join(&domain.app)
.join("Cargo.toml")
.is_file()
{
println!(
" note: apps/{} not found; start `{}` on port {port} yourself",
domain.app, domain.app
);
continue;
}
let entry = (port, domain.path.clone());
if let Some(previous) = found.insert(domain.app.clone(), entry.clone())
&& previous != entry
{
bail!(
"app `{}` is listed with different ports or paths in {}; one app serves one port and base path",
domain.app,
apps::CONFIG
);
}
}
Ok(found
.into_iter()
.map(|(name, (port, base_path))| LocalApp {
name,
port,
base_path,
})
.collect())
}
pub fn target_dir(root: &Path) -> Result<PathBuf> {
let output = cargo::command()
.args(["metadata", "--format-version", "1", "--no-deps"])
.current_dir(root)
.stderr(Stdio::inherit())
.output()
.context("failed to run `cargo metadata`")?;
if !output.status.success() {
bail!("`cargo metadata` failed");
}
let metadata: serde_json::Value =
serde_json::from_slice(&output.stdout).context("unexpected `cargo metadata` output")?;
metadata["target_directory"]
.as_str()
.map(PathBuf::from)
.context("`cargo metadata` did not report a target directory")
}