use crate::strategies::project_detector::{
infer_project_name as shared_infer_project_name, DeploymentRecommendations, PackageJsonInfo,
ProjectDetector, ProjectType,
};
use crate::strategies::{
legacy_service_from_config, validate_services, ServiceCommands, ServiceConfig, XbpConfig,
};
use crate::utils::{collapse_project_path, parse_env_file, to_env_references};
use regex::Regex;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::{DirEntry, WalkDir};
const SERVICE_DISCOVERY_MARKERS: &[&str] = &[
"package.json",
"Cargo.toml",
"pyproject.toml",
"requirements.txt",
"setup.py",
"Dockerfile",
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
"railway.json",
"railway.toml",
"vercel.json",
"go.mod",
];
const SERVICE_VERSION_MANIFESTS: &[&str] = &[
"package.json",
"Cargo.toml",
"pyproject.toml",
"composer.json",
"deno.json",
"deno.jsonc",
"Chart.yaml",
"app.json",
"manifest.json",
"pom.xml",
"build.gradle",
"build.gradle.kts",
];
const SERVICE_DISCOVERY_SKIP_DIRS: &[&str] = &[
".git",
".github",
".next",
".turbo",
".venv",
".vercel",
".xbp",
"build",
"coverage",
"dist",
"node_modules",
"out",
"target",
"tmp",
"vendor",
"venv",
];
pub(crate) async fn auto_populate_services(
config: &mut XbpConfig,
project_root: &Path,
root_project_type: &ProjectType,
) -> Result<(), String> {
if config
.services
.as_ref()
.is_some_and(|services| !services.is_empty())
{
return Ok(());
}
let mut services = Vec::new();
let mut used_names = HashSet::new();
let mut used_ports = HashSet::new();
if !matches!(root_project_type, ProjectType::Unknown) {
let root_targets = discover_service_version_targets(project_root, project_root);
let mut root_service = legacy_service_from_config(config);
root_service.name = reserve_service_name(root_service.name, ".", &mut used_names);
root_service.port = reserve_service_port(root_service.port, &mut used_ports);
root_service.version_targets = if root_targets.is_empty() {
None
} else {
Some(root_targets.clone())
};
merge_project_version_targets(config, &root_targets);
services.push(root_service);
}
let branch = config.branch.clone().unwrap_or_else(|| "main".to_string());
for service_root in discover_nested_service_roots(project_root) {
let Some(service) = build_discovered_service(
project_root,
&service_root,
&branch,
&mut used_names,
&mut used_ports,
)
.await?
else {
continue;
};
if let Some(version_targets) = &service.version_targets {
merge_project_version_targets(config, version_targets);
}
services.push(service);
}
if services.is_empty() {
return Ok(());
}
validate_services(&services)?;
config.services = Some(services);
Ok(())
}
async fn build_discovered_service(
project_root: &Path,
service_root: &Path,
branch: &str,
used_names: &mut HashSet<String>,
used_ports: &mut HashSet<u16>,
) -> Result<Option<ServiceConfig>, String> {
let project_type = ProjectDetector::detect_project_type(service_root)
.await
.unwrap_or(ProjectType::Unknown);
if matches!(project_type, ProjectType::Unknown) {
return Ok(None);
}
let recommendations =
ProjectDetector::get_deployment_recommendations(service_root, &project_type);
let version_targets = discover_service_version_targets(service_root, project_root);
let environment = detect_environment_from_env_files(service_root);
let inferred_name = infer_project_name(service_root, &project_type, &recommendations);
let root_directory = collapse_project_path(project_root, &service_root.to_string_lossy());
let target = infer_app_type(&project_type)
.or_else(|| Some(project_type.kind_slug().to_string()))
.unwrap_or_else(|| "unknown".to_string());
let port = reserve_service_port(
detect_port(service_root, &project_type, &recommendations),
used_ports,
);
let name = reserve_service_name(inferred_name, &root_directory, used_names);
Ok(Some(ServiceConfig {
name,
target,
branch: branch.to_string(),
port,
root_directory: Some(root_directory),
environment: if environment.is_empty() {
None
} else {
Some(environment)
},
url: None,
healthcheck_path: None,
restart_policy: Some("on_failure".to_string()),
restart_policy_max_failure_count: Some(10),
start_wrapper: Some("pm2".to_string()),
commands: Some(ServiceCommands {
pre: None,
install: recommendations.install_command,
build: recommendations.build_command,
start: recommendations.start_command,
dev: None,
}),
force_run_from_root: Some(false),
version_targets: if version_targets.is_empty() {
None
} else {
Some(version_targets)
},
systemd_service_name: None,
systemd: None,
}))
}
fn discover_nested_service_roots(project_root: &Path) -> Vec<PathBuf> {
let mut roots = BTreeSet::new();
for entry in WalkDir::new(project_root)
.min_depth(1)
.into_iter()
.filter_entry(should_walk_service_tree)
{
let Ok(entry) = entry else {
continue;
};
if !entry.file_type().is_file() {
continue;
}
let file_name = entry.file_name().to_string_lossy();
if !SERVICE_DISCOVERY_MARKERS.contains(&file_name.as_ref()) {
continue;
}
if let Some(parent) = entry.path().parent() {
if parent != project_root {
roots.insert(parent.to_path_buf());
}
}
}
roots.into_iter().collect()
}
fn should_walk_service_tree(entry: &DirEntry) -> bool {
if !entry.file_type().is_dir() {
return true;
}
let name = entry.file_name().to_string_lossy();
!SERVICE_DISCOVERY_SKIP_DIRS.contains(&name.as_ref())
}
fn infer_project_name(
project_root: &Path,
project_type: &ProjectType,
recommendations: &DeploymentRecommendations,
) -> String {
shared_infer_project_name(project_root, project_type, recommendations)
}
fn infer_app_type(project_type: &ProjectType) -> Option<String> {
match project_type {
ProjectType::NextJs { .. } => Some("nextjs".to_string()),
ProjectType::NodeJs { package_json } => {
if has_express_dependency(package_json) {
Some("expressjs".to_string())
} else {
Some("nodejs".to_string())
}
}
ProjectType::Rust { .. } => Some("rust".to_string()),
ProjectType::DockerCompose { .. } => Some("docker-compose".to_string()),
ProjectType::Docker { .. } => Some("docker".to_string()),
ProjectType::Railway { .. } => Some("railway".to_string()),
ProjectType::Vercel { .. } => Some("vercel".to_string()),
ProjectType::Python { .. } => Some("python".to_string()),
ProjectType::OpenApi { .. } => Some("openapi".to_string()),
ProjectType::Terraform { .. } => Some("terraform".to_string()),
ProjectType::Unknown => None,
}
}
fn has_express_dependency(package_json: &PackageJsonInfo) -> bool {
package_json
.dependencies
.keys()
.any(|key| key.eq_ignore_ascii_case("express"))
|| package_json
.dev_dependencies
.keys()
.any(|key| key.eq_ignore_ascii_case("express"))
}
fn detect_port(
project_root: &Path,
project_type: &ProjectType,
recommendations: &DeploymentRecommendations,
) -> u16 {
for name in [".env", ".env.local", ".env.development", ".env.production"] {
if let Some(port) = parse_port_from_env_file(&project_root.join(name)) {
return port;
}
}
if let Some(port) = detect_port_from_package_json(project_root) {
return port;
}
if let ProjectType::DockerCompose { detected_ports, .. } = project_type {
if let Some(port) = detected_ports.first() {
return *port;
}
}
recommendations.default_port
}
fn parse_port_from_env_file(path: &Path) -> Option<u16> {
if let Ok(parsed) = parse_env_file(path) {
if let Some(port) = parsed
.get("PORT")
.and_then(|value| value.parse::<u16>().ok())
{
return Some(port);
}
}
let contents = fs::read_to_string(path).ok()?;
for line in contents.lines() {
if let Some(port) = extract_port_from_str(line.trim()) {
return Some(port);
}
}
None
}
fn detect_port_from_package_json(project_root: &Path) -> Option<u16> {
let package_path = project_root.join("package.json");
let content = fs::read_to_string(&package_path).ok()?;
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
if let Some(port) = value.get("port").and_then(|value| value.as_u64()) {
return Some(port as u16);
}
if let Some(scripts) = value.get("scripts").and_then(|value| value.as_object()) {
for script in scripts.values() {
if let Some(text) = script.as_str() {
if let Some(port) = extract_port_from_str(text) {
return Some(port);
}
}
}
}
None
}
fn extract_port_from_str(text: &str) -> Option<u16> {
let patterns = [
r"PORT\s*[:=]\s*(\d{2,5})",
r"port\s*[:=]\s*(\d{2,5})",
r"--port\s+(\d{2,5})",
r"-p\s+(\d{2,5})",
];
for pattern in patterns {
let Ok(regex) = Regex::new(pattern) else {
continue;
};
let Some(captures) = regex.captures(text) else {
continue;
};
let Some(port_match) = captures.get(1) else {
continue;
};
if let Ok(port) = port_match.as_str().parse::<u16>() {
return Some(port);
}
}
None
}
fn detect_environment_from_env_files(project_root: &Path) -> HashMap<String, String> {
let mut env_map = HashMap::new();
for name in [".env", ".env.local", ".env.development", ".env.production"] {
let path = project_root.join(name);
if !path.exists() {
continue;
}
if let Ok(parsed) = parse_env_file(&path) {
for (key, value) in parsed {
env_map.entry(key).or_insert(value);
}
}
}
to_env_references(&env_map)
}
pub(crate) fn discover_service_version_targets(
service_root: &Path,
project_root: &Path,
) -> Vec<String> {
SERVICE_VERSION_MANIFESTS
.iter()
.filter_map(|manifest| {
let path = service_root.join(manifest);
if !path.is_file() {
return None;
}
Some(collapse_project_path(project_root, &path.to_string_lossy()))
})
.collect()
}
fn merge_project_version_targets(config: &mut XbpConfig, version_targets: &[String]) {
let mut seen: HashSet<String> = config.version_targets.iter().cloned().collect();
for target in version_targets {
if seen.insert(target.clone()) {
config.version_targets.push(target.clone());
}
}
config.version_targets.sort();
config.version_targets.dedup();
}
fn reserve_service_name(
base_name: String,
service_root_relative: &str,
used_names: &mut HashSet<String>,
) -> String {
if used_names.insert(base_name.clone()) {
return base_name;
}
let slug = service_root_relative
.split(['\\', '/'])
.filter(|segment| !segment.is_empty() && *segment != ".")
.collect::<Vec<_>>()
.join("-");
if !slug.is_empty() && used_names.insert(slug.clone()) {
return slug;
}
let mut suffix = 2usize;
loop {
let candidate = format!("{base_name}-{suffix}");
if used_names.insert(candidate.clone()) {
return candidate;
}
suffix += 1;
}
}
fn reserve_service_port(port: u16, used_ports: &mut HashSet<u16>) -> u16 {
let mut candidate = if port == 0 { 3000 } else { port };
while !used_ports.insert(candidate) {
candidate = candidate.saturating_add(1);
if candidate == 0 {
candidate = 3000;
}
}
candidate
}
#[cfg(test)]
mod tests {
use super::auto_populate_services;
use crate::strategies::project_detector::ProjectType;
use crate::strategies::XbpConfig;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"xbp-project-services-{name}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn base_config(project_name: &str, target: &str) -> XbpConfig {
XbpConfig {
project_name: project_name.to_string(),
version: "0.1.0".to_string(),
port: 3000,
build_dir: ".".to_string(),
app_type: Some(target.to_string()),
build_command: Some("npm run build".to_string()),
start_command: Some("npm run start".to_string()),
install_command: Some("npm install".to_string()),
environment: None,
services: None,
systemd_service_name: None,
systemd: None,
kafka_brokers: None,
kafka_topic: None,
kafka_public_url: None,
log_files: None,
monitor_url: None,
monitor_method: None,
monitor_expected_code: None,
monitor_interval: None,
database: None,
target: Some(target.to_string()),
branch: Some("main".to_string()),
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
publish: None,
version_targets: Vec::new(),
}
}
#[tokio::test]
async fn auto_populate_services_adds_root_and_nested_services_with_unique_ports() {
let project_root = temp_dir("root-and-nested");
fs::write(
project_root.join("package.json"),
r#"{
"name": "root-app",
"version": "0.1.0",
"scripts": { "start": "node server.js --port 3000", "build": "echo build" },
"dependencies": { "express": "^4.0.0" }
}"#,
)
.expect("write root package");
let web_root = project_root.join("apps").join("web");
fs::create_dir_all(&web_root).expect("create web root");
fs::write(
web_root.join("package.json"),
r#"{
"name": "web-app",
"version": "0.1.0",
"scripts": { "dev": "next dev -p 3000", "build": "next build", "start": "next start" },
"dependencies": { "next": "15.0.0" }
}"#,
)
.expect("write web package");
let mut config = base_config("root-app", "nodejs");
auto_populate_services(
&mut config,
&project_root,
&ProjectType::NodeJs {
package_json: crate::strategies::project_detector::PackageJsonInfo {
name: "root-app".to_string(),
version: "0.1.0".to_string(),
scripts: HashMap::new(),
dependencies: HashMap::new(),
dev_dependencies: HashMap::new(),
main: None,
},
},
)
.await
.expect("populate services");
let services = config.services.expect("services");
assert_eq!(services.len(), 2);
assert_eq!(services[0].name, "root-app");
assert_eq!(services[0].port, 3000);
assert_eq!(services[1].root_directory.as_deref(), Some("apps/web"));
assert_eq!(services[1].port, 3001);
assert!(config.version_targets.contains(&"package.json".to_string()));
assert!(config
.version_targets
.contains(&"apps/web/package.json".to_string()));
let _ = fs::remove_dir_all(project_root);
}
#[tokio::test]
async fn auto_populate_services_skips_unknown_workspace_root() {
let project_root = temp_dir("workspace-root");
fs::write(
project_root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/api\"]\n",
)
.expect("write workspace manifest");
let api_root = project_root.join("crates").join("api");
fs::create_dir_all(api_root.join("src")).expect("create api root");
fs::write(
api_root.join("Cargo.toml"),
"[package]\nname = \"api\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.expect("write api manifest");
let mut config = base_config("workspace", "unknown");
auto_populate_services(&mut config, &project_root, &ProjectType::Unknown)
.await
.expect("populate services");
let services = config.services.expect("services");
assert_eq!(services.len(), 1);
assert_eq!(services[0].name, "api");
assert_eq!(services[0].root_directory.as_deref(), Some("crates/api"));
assert!(!config.version_targets.contains(&"Cargo.toml".to_string()));
assert!(config
.version_targets
.contains(&"crates/api/Cargo.toml".to_string()));
let _ = fs::remove_dir_all(project_root);
}
}