use crate::strategies::project_detector::{
infer_project_name as shared_infer_project_name, DeploymentRecommendations, PackageJsonInfo,
ProjectDetector, ProjectType,
};
use crate::strategies::{
legacy_service_from_config, normalize_config_paths_for_persistence, validate_services,
ServiceCommands, ServiceConfig, ServiceDeployConfig, ServiceDeployEnvConfig, ServiceOciConfig,
XbpConfig,
};
use crate::utils::{
collapse_project_path, load_project_xbp_ignore, parse_env_file,
to_env_references, XbpIgnoreSet, DEFAULT_DISCOVERY_SKIP_DIRS,
};
use dialoguer::Confirm;
use regex::Regex;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fs;
use std::io::IsTerminal;
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] = DEFAULT_DISCOVERY_SKIP_DIRS;
pub fn apply_service_defaults_after_init(config: &mut XbpConfig, project_root: &Path) {
let default_env = config
.deploy
.as_ref()
.and_then(|d| d.default_env.clone())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "production".to_string());
let project_name = config.project_name.clone();
let k8s = config.kubernetes.clone();
let project_oci = config.oci.clone();
let top_build = config.build_command.clone();
let top_start = config.start_command.clone();
let top_install = config.install_command.clone();
let Some(services) = config.services.as_mut() else {
return;
};
for service in services.iter_mut() {
enrich_service_commands(
service,
project_root,
top_build.as_deref(),
top_start.as_deref(),
top_install.as_deref(),
);
if service.oci.is_none() {
service.oci = derive_service_oci_defaults(
project_root,
&project_name,
&service.name,
service.root_directory.as_deref(),
project_oci.as_ref(),
);
}
let needs_deploy_env = service
.deploy
.as_ref()
.map(|d| !d.envs.contains_key(&default_env))
.unwrap_or(true);
if needs_deploy_env {
let derived = crate::commands::deploy_engine::setup::derive_service_deploy(
project_root,
&project_name,
&default_env,
service,
k8s.as_ref(),
);
let mut deploy = service.deploy.take().unwrap_or(ServiceDeployConfig {
provider: Some(derived.provider.clone()),
envs: HashMap::new(),
});
if deploy
.provider
.as_ref()
.map(|s| s.trim().is_empty())
.unwrap_or(true)
{
deploy.provider = Some(derived.provider.clone());
}
deploy.envs.insert(
default_env.clone(),
ServiceDeployEnvConfig {
namespace: derived.namespace,
replicas: derived.replicas,
health: derived.health,
kubernetes: derived.kubernetes,
},
);
service.deploy = Some(deploy);
}
}
}
fn enrich_service_commands(
service: &mut ServiceConfig,
project_root: &Path,
top_build: Option<&str>,
top_start: Option<&str>,
top_install: Option<&str>,
) {
let service_root = resolve_service_root_for_defaults(project_root, service.root_directory.as_deref());
let mut commands = service.commands.take().unwrap_or_default();
if commands.build.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
commands.build = top_build
.map(str::to_string)
.filter(|s| !s.trim().is_empty())
.or_else(|| default_build_for_target(&service.target));
}
if commands.start.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
commands.start = top_start
.map(str::to_string)
.filter(|s| !s.trim().is_empty())
.or_else(|| default_start_for_target(&service.target, &service.name));
}
if commands.install.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
commands.install = top_install
.map(str::to_string)
.filter(|s| !s.trim().is_empty())
.or_else(|| default_install_for_target(&service.target, &service_root));
}
if commands.dev.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
commands.dev = default_dev_for_target(&service.target, &service_root);
}
for (key, value) in default_custom_commands_for_target(&service.target, &service_root) {
commands.custom.entry(key).or_insert(value);
}
for (key, value) in detect_custom_service_commands(&service_root) {
commands.custom.entry(key).or_insert(value);
}
service.commands = Some(commands);
if service
.start_wrapper
.as_ref()
.map(|s| s.trim().is_empty())
.unwrap_or(true)
{
service.start_wrapper = Some("pm2".to_string());
}
}
fn resolve_service_root_for_defaults(project_root: &Path, root_directory: Option<&str>) -> PathBuf {
match root_directory.map(str::trim).filter(|s| !s.is_empty() && *s != "." && *s != "./") {
Some(rel) => project_root.join(rel),
None => project_root.to_path_buf(),
}
}
fn default_build_for_target(target: &str) -> Option<String> {
match target.trim().to_ascii_lowercase().as_str() {
"rust" => Some("cargo build --release".into()),
"nextjs" | "nodejs" | "node" => Some("npm run build".into()),
"python" => None,
"docker" => Some("docker build -t $IMAGE .".into()),
_ => None,
}
}
fn default_start_for_target(target: &str, service_name: &str) -> Option<String> {
match target.trim().to_ascii_lowercase().as_str() {
"rust" => Some(format!("./target/release/{service_name}")),
"nextjs" => Some("npm run start".into()),
"nodejs" | "node" => Some("npm run start".into()),
"python" => Some("python -m uvicorn main:app --host 0.0.0.0".into()),
_ => None,
}
}
fn default_install_for_target(target: &str, service_root: &Path) -> Option<String> {
match target.trim().to_ascii_lowercase().as_str() {
"rust" => None,
"nextjs" | "nodejs" | "node" => {
if service_root.join("pnpm-lock.yaml").exists() {
Some("pnpm install".into())
} else if service_root.join("yarn.lock").exists() {
Some("yarn install".into())
} else {
Some("npm install".into())
}
}
"python" => Some("pip install -r requirements.txt".into()),
_ => None,
}
}
fn default_dev_for_target(target: &str, service_root: &Path) -> Option<String> {
match target.trim().to_ascii_lowercase().as_str() {
"rust" => Some("cargo run".into()),
"nextjs" | "nodejs" | "node" => {
if service_root.join("package.json").exists() {
Some("npm run dev".into())
} else {
None
}
}
"python" => Some("python -m uvicorn main:app --reload --host 0.0.0.0".into()),
_ => None,
}
}
fn default_custom_commands_for_target(
target: &str,
_service_root: &Path,
) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
match target.trim().to_ascii_lowercase().as_str() {
"rust" => {
out.insert("test".into(), "cargo test".into());
out.insert("check".into(), "cargo check".into());
out.insert(
"clippy".into(),
"cargo clippy --all-targets -- -D warnings".into(),
);
}
"nextjs" | "nodejs" | "node" => {
out.insert("test".into(), "npm test".into());
out.insert("lint".into(), "npm run lint".into());
}
"python" => {
out.insert("test".into(), "pytest".into());
}
_ => {}
}
out
}
fn derive_service_oci_defaults(
project_root: &Path,
project_name: &str,
service_name: &str,
root_directory: Option<&str>,
project_oci: Option<&crate::strategies::deployment_config::OciProjectConfig>,
) -> Option<ServiceOciConfig> {
let oci = project_oci?;
let registry = oci
.default_registry
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())?;
let username = oci
.registries
.get(registry)
.and_then(|r| r.username.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty());
let image = match username {
Some(user) => format!("{registry}/{user}/{service_name}"),
None => format!("{registry}/{project_name}/{service_name}"),
};
let service_root = resolve_service_root_for_defaults(project_root, root_directory);
let dockerfile = ["Dockerfile", "dockerfile"]
.into_iter()
.find(|name| service_root.join(name).exists())
.map(|s| s.to_string())
.or_else(|| {
if project_root.join("Dockerfile").exists() {
Some("Dockerfile".into())
} else {
None
}
});
Some(ServiceOciConfig {
image: Some(image),
tag: Some("latest".into()),
tag_from: Some("version".into()),
dockerfile,
context: Some(root_directory.unwrap_or(".").to_string()),
platforms: vec!["linux/amd64".into()],
build_args: HashMap::new(),
})
}
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())
{
apply_service_defaults_after_init(config, project_root);
return Ok(());
}
let mut services = Vec::new();
let mut used_names = HashSet::new();
let mut used_ports = HashSet::new();
let ignore = load_project_xbp_ignore(project_root, &config.ignore_paths);
if !matches!(root_project_type, ProjectType::Unknown)
&& !ignore.ignores_service_root("./")
&& !ignore.ignores_service_root(".")
{
let root_targets = discover_service_version_targets(project_root, project_root, &ignore);
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, &ignore);
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, &ignore) {
let Some(service) = build_discovered_service(
project_root,
&service_root,
&branch,
&mut used_names,
&mut used_ports,
&ignore,
)
.await?
else {
continue;
};
if service.is_versioning_enabled() {
if let Some(version_targets) = &service.version_targets {
merge_project_version_targets(config, version_targets, &ignore);
}
}
services.push(service);
}
if services.is_empty() {
return Ok(());
}
validate_services(&services)?;
config.services = Some(services);
apply_service_defaults_after_init(config, project_root);
Ok(())
}
async fn build_discovered_service(
project_root: &Path,
service_root: &Path,
branch: &str,
used_names: &mut HashSet<String>,
used_ports: &mut HashSet<u16>,
ignore: &XbpIgnoreSet,
) -> Result<Option<ServiceConfig>, String> {
build_discovered_service_with_prefs(
project_root,
service_root,
branch,
used_names,
used_ports,
ignore,
None,
None,
)
.await
}
async fn build_discovered_service_with_prefs(
project_root: &Path,
service_root: &Path,
branch: &str,
used_names: &mut HashSet<String>,
used_ports: &mut HashSet<u16>,
ignore: &XbpIgnoreSet,
preferred_name: Option<&str>,
preferred_port: Option<u16>,
) -> Result<Option<ServiceConfig>, String> {
let root_directory = collapse_project_path(project_root, &service_root.to_string_lossy());
if ignore.ignores_service_root(&root_directory) {
return Ok(None);
}
if is_packaging_artifact_service_root(&root_directory) {
return Ok(None);
}
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, ignore);
let environment = detect_environment_from_env_files(service_root);
let inferred_name = infer_project_name(service_root, &project_type, &recommendations);
let target = infer_app_type(&project_type)
.or_else(|| Some(project_type.kind_slug().to_string()))
.unwrap_or_else(|| "unknown".to_string());
let port = if let Some(port) = preferred_port {
used_ports.insert(port);
port
} else {
reserve_service_port(
detect_port(service_root, &project_type, &recommendations),
used_ports,
)
};
let name = if let Some(name) = preferred_name {
used_names.insert(name.to_string());
name.to_string()
} else {
reserve_service_name(inferred_name, &root_directory, used_names)
};
let watch_paths = infer_service_watch_paths(&root_directory, &version_targets)
.map(|paths| {
paths
.into_iter()
.filter(|path| !ignore.is_ignored_relative(path, true))
.collect::<Vec<_>>()
})
.filter(|paths: &Vec<String>| !paths.is_empty());
Ok(Some(ServiceConfig {
name,
target,
target_freeze: None,
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: recommendations.dev_command,
custom: detect_custom_service_commands(service_root),
}),
force_run_from_root: Some(false),
version_targets: if version_targets.is_empty() {
None
} else {
Some(version_targets)
},
depends_on: None,
watch_paths,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
discord: None,
file_associations: Vec::new(),
}))
}
fn detect_custom_service_commands(service_root: &Path) -> BTreeMap<String, String> {
let package_json = service_root.join("package.json");
let Ok(content) = fs::read_to_string(package_json) else {
return BTreeMap::new();
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else {
return BTreeMap::new();
};
let runner = if service_root.join("pnpm-lock.yaml").exists() {
"pnpm run"
} else if service_root.join("yarn.lock").exists() {
"yarn run"
} else if service_root.join("bun.lock").exists() || service_root.join("bun.lockb").exists() {
"bun run"
} else {
"npm run"
};
value
.get("scripts")
.and_then(serde_json::Value::as_object)
.into_iter()
.flatten()
.filter(|(name, _)| !matches!(name.as_str(), "build" | "start" | "dev"))
.filter_map(|(name, command)| {
command
.as_str()
.map(|_| (name.clone(), format!("{runner} {name}")))
})
.collect()
}
fn infer_service_watch_paths(
root_directory: &str,
version_targets: &[String],
) -> Option<Vec<String>> {
let mut paths = Vec::new();
let normalized_root = root_directory.replace('\\', "/");
let trimmed_root = normalized_root.trim().trim_matches('/');
if !trimmed_root.is_empty() && trimmed_root != "." {
paths.push(trimmed_root.to_string());
}
if paths.is_empty() {
for target in version_targets {
let relative = watch_root_from_version_target(target);
let trimmed = relative.trim().trim_matches('/');
if !trimmed.is_empty() && trimmed != "." {
paths.push(trimmed.to_string());
}
}
}
if paths.is_empty() {
return None;
}
paths.sort();
paths.dedup();
Some(paths)
}
fn watch_root_from_version_target(target: &str) -> String {
let normalized = target.replace('\\', "/");
let path = Path::new(&normalized);
let looks_like_file = path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| !ext.is_empty())
|| path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
matches!(
name,
"Cargo.toml"
| "package.json"
| "pyproject.toml"
| "composer.json"
| "Chart.yaml"
| "Chart.yml"
)
});
if looks_like_file {
path.parent()
.map(|parent| parent.to_string_lossy().replace('\\', "/"))
.filter(|value| !value.is_empty())
.unwrap_or_else(|| ".".to_string())
} else {
normalized
}
}
fn discover_nested_service_roots(project_root: &Path, ignore: &XbpIgnoreSet) -> Vec<PathBuf> {
let mut roots = BTreeSet::new();
for entry in WalkDir::new(project_root)
.min_depth(1)
.into_iter()
.filter_entry(|entry| should_walk_service_tree(entry, project_root, ignore))
{
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 {
let relative = collapse_project_path(project_root, &parent.to_string_lossy());
if ignore.ignores_service_root(&relative) {
continue;
}
if is_packaging_artifact_service_root(&relative) {
continue;
}
roots.insert(parent.to_path_buf());
}
}
}
roots.into_iter().collect()
}
fn should_walk_service_tree(
entry: &DirEntry,
project_root: &Path,
ignore: &XbpIgnoreSet,
) -> bool {
if !entry.file_type().is_dir() {
return true;
}
let name = entry.file_name().to_string_lossy();
if SERVICE_DISCOVERY_SKIP_DIRS.contains(&name.as_ref()) || ignore.skips_dir_name(&name) {
return false;
}
if is_packaging_skip_dir_name(&name) {
return false;
}
if let Ok(relative) = entry.path().strip_prefix(project_root) {
let rel = relative.to_string_lossy().replace('\\', "/");
if ignore.is_ignored_relative(&rel, true) {
return false;
}
if is_packaging_artifact_service_root(&rel) {
return false;
}
}
true
}
fn is_packaging_skip_dir_name(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower == "target-publish"
|| lower.starts_with("target-publish")
|| lower == "cargo-target"
|| lower.ends_with("-target") && lower.contains("publish")
}
pub(crate) fn is_packaging_artifact_service_root(relative_root: &str) -> bool {
let normalized = relative_root
.replace('\\', "/")
.trim_start_matches("./")
.to_ascii_lowercase();
if normalized.is_empty() || normalized == "." {
return false;
}
let segments: Vec<&str> = normalized
.split('/')
.filter(|s| !s.is_empty() && *s != ".")
.collect();
if segments.is_empty() {
return false;
}
for window in segments.windows(2) {
if window[0] == "target" && window[1] == "package" {
return true;
}
}
if segments.iter().any(|s| {
*s == "target-publish" || s.starts_with("target-publish") || *s == "cargo-package"
}) {
return true;
}
if segments.len() >= 2 {
let last = segments[segments.len() - 1];
let parent = segments[segments.len() - 2];
if parent == "package" && looks_like_versioned_crate_dirname(last) {
let under_build = segments.iter().any(|s| {
*s == "target"
|| s.starts_with("target-")
|| *s == "dist"
|| *s == "build"
|| *s == "out"
});
if under_build {
return true;
}
}
}
false
}
fn looks_like_versioned_crate_dirname(name: &str) -> bool {
static RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
Regex::new(r"(?i).+-\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$").expect("versioned crate regex")
});
RE.is_match(name)
}
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,
ignore: &XbpIgnoreSet,
) -> Vec<String> {
SERVICE_VERSION_MANIFESTS
.iter()
.filter_map(|manifest| {
let path = service_root.join(manifest);
if !path.is_file() {
return None;
}
let relative = collapse_project_path(project_root, &path.to_string_lossy());
if ignore.ignores_version_target(&relative) {
return None;
}
Some(relative)
})
.collect()
}
pub(crate) fn discover_service_version_targets_for_project(
service_root: &Path,
project_root: &Path,
ignore_paths: &[String],
) -> Vec<String> {
let ignore = load_project_xbp_ignore(project_root, ignore_paths);
discover_service_version_targets(service_root, project_root, &ignore)
}
fn merge_project_version_targets(
config: &mut XbpConfig,
version_targets: &[String],
ignore: &XbpIgnoreSet,
) {
let mut seen: HashSet<String> = config.version_targets.iter().cloned().collect();
for target in version_targets {
if ignore.ignores_version_target(target) {
continue;
}
if seen.insert(target.clone()) {
config.version_targets.push(target.clone());
}
}
config
.version_targets
.retain(|target| !ignore.ignores_version_target(target));
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 {
let base_name = sanitize_service_base_name(&base_name);
if base_name.is_empty() {
let fallback = sanitize_service_base_name(
&meaningful_service_leaf_slug(service_root_relative).unwrap_or_else(|| "service".into()),
);
let base_name = if fallback.is_empty() {
"service".to_string()
} else {
fallback
};
return reserve_service_name_unique(base_name, service_root_relative, used_names);
}
reserve_service_name_unique(base_name, service_root_relative, used_names)
}
fn reserve_service_name_unique(
base_name: String,
service_root_relative: &str,
used_names: &mut HashSet<String>,
) -> String {
if used_names.insert(base_name.clone()) {
return base_name;
}
for candidate in path_disambiguated_service_names(&base_name, service_root_relative) {
if used_names.insert(candidate.clone()) {
return candidate;
}
}
let mut suffix = 2usize;
loop {
let candidate = format!("{base_name}-{suffix}");
if used_names.insert(candidate.clone()) {
return candidate;
}
suffix += 1;
}
}
fn sanitize_service_base_name(name: &str) -> String {
let trimmed = name.trim();
if trimmed.is_empty() {
return String::new();
}
let unscoped = if let Some(rest) = trimmed.strip_prefix('@') {
rest.replace('/', "-")
} else {
trimmed.replace('/', "-")
};
let mut out = String::with_capacity(unscoped.len());
let mut last_sep = true; for ch in unscoped.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
last_sep = false;
} else if ch == '_' || ch == '-' || ch == '.' || ch == ' ' {
if !last_sep && !out.is_empty() {
out.push(if ch == '_' { '_' } else { '-' });
last_sep = true;
}
}
}
while out.ends_with('-') || out.ends_with('_') {
out.pop();
}
strip_embedded_semver_suffix(&out)
}
fn path_disambiguated_service_names(base_name: &str, service_root_relative: &str) -> Vec<String> {
let segments = path_segments_for_service_naming(service_root_relative);
if segments.is_empty() {
return Vec::new();
}
let base_lower = base_name.to_ascii_lowercase();
let mut out = Vec::new();
let mut push_unique = |raw: String| {
let candidate = sanitize_service_base_name(&raw);
if candidate.is_empty() {
return;
}
if candidate.eq_ignore_ascii_case(base_name) {
return;
}
if is_doubled_service_name(&candidate, &base_lower) {
return;
}
if !out.iter().any(|existing: &String| existing == &candidate) {
out.push(candidate);
}
};
if let Some(leaf) = segments.last() {
if !leaf.eq_ignore_ascii_case(base_name) {
push_unique(leaf.clone());
}
}
for n in 2..=segments.len() {
let slice = &segments[segments.len() - n..];
push_unique(slice.join("-"));
}
if segments.len() >= 2 {
let parent = &segments[segments.len() - 2];
push_unique(format!("{parent}-{base_name}"));
}
if segments.len() > 1 {
push_unique(segments.join("-"));
}
out
}
fn is_doubled_service_name(candidate: &str, base_lower: &str) -> bool {
let expected = format!("{base_lower}-{base_lower}");
candidate.eq_ignore_ascii_case(&expected)
}
fn path_segments_for_service_naming(service_root_relative: &str) -> Vec<String> {
let drop_always = [
"src",
"package", "target",
"target-publish",
"dist",
"build",
"out",
"node_modules",
".git",
];
service_root_relative
.split(['\\', '/'])
.filter(|segment| !segment.is_empty() && *segment != ".")
.filter(|segment| {
let lower = segment.to_ascii_lowercase();
!drop_always.contains(&lower.as_str()) && !looks_like_versioned_crate_dirname(segment)
})
.map(|s| s.to_string())
.collect()
}
fn meaningful_service_leaf_slug(service_root_relative: &str) -> Option<String> {
let noise = [
"src",
"package",
"packages",
"apps",
"services",
"crates",
"libs",
"lib",
"target",
"target-publish",
"dist",
"build",
"out",
];
service_root_relative
.split(['\\', '/'])
.filter(|segment| !segment.is_empty() && *segment != ".")
.filter(|segment| {
let lower = segment.to_ascii_lowercase();
!noise.contains(&lower.as_str()) && !looks_like_versioned_crate_dirname(segment)
})
.last()
.map(|s| s.to_string())
}
fn strip_embedded_semver_suffix(name: &str) -> String {
static RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
Regex::new(r"(?i)^(.+?)-\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$").expect("semver suffix regex")
});
if let Some(caps) = RE.captures(name) {
if let Some(base) = caps.get(1) {
let base = base.as_str();
if !base.is_empty() {
return base.to_string();
}
}
}
name.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DiscoveredServiceProject {
pub service_root: PathBuf,
pub root_directory: String,
pub marker: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ServiceRegistrationAction {
Added,
Updated,
Unchanged,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct RegisterServicesReport {
pub discovered: usize,
pub added: usize,
pub updated: usize,
pub unchanged: usize,
pub removed: usize,
pub renamed: usize,
pub wrote_config: bool,
}
impl RegisterServicesReport {
pub fn has_mutations(&self) -> bool {
self.added > 0 || self.updated > 0 || self.removed > 0 || self.renamed > 0
}
}
#[derive(Debug, Clone)]
pub(crate) struct RegisterServicesOptions {
pub dry_run: bool,
pub no_register: bool,
}
pub(crate) async fn discover_marker_service_projects(
project_root: &Path,
) -> Result<Vec<DiscoveredServiceProject>, String> {
discover_marker_service_projects_with_ignore(
project_root,
&load_project_xbp_ignore(project_root, &[]),
)
.await
}
pub(crate) async fn discover_marker_service_projects_with_ignore(
project_root: &Path,
ignore: &XbpIgnoreSet,
) -> Result<Vec<DiscoveredServiceProject>, String> {
let mut candidates = vec![project_root.to_path_buf()];
candidates.extend(discover_nested_service_roots(project_root, ignore));
let mut discovered = Vec::new();
let mut seen_roots = BTreeSet::new();
for service_root in candidates {
let canonical_root = canonicalize_or_fallback(&service_root);
if !seen_roots.insert(canonical_root) {
continue;
}
let root_directory = collapse_project_path(project_root, &service_root.to_string_lossy());
if ignore.ignores_service_root(&root_directory) {
continue;
}
let Some(marker) = find_service_discovery_marker(&service_root) else {
continue;
};
let project_type = ProjectDetector::detect_project_type(&service_root)
.await
.unwrap_or(ProjectType::Unknown);
if matches!(project_type, ProjectType::Unknown) {
continue;
}
discovered.push(DiscoveredServiceProject {
service_root,
root_directory,
marker,
});
}
discovered.sort_by(|left, right| left.root_directory.cmp(&right.root_directory));
Ok(discovered)
}
pub(crate) async fn register_discovered_services(
project_root: &Path,
root_config_path: &Path,
config: &mut XbpConfig,
options: &RegisterServicesOptions,
) -> Result<RegisterServicesReport, String> {
let ignore = load_project_xbp_ignore(project_root, &config.ignore_paths);
let discovered =
discover_marker_service_projects_with_ignore(project_root, &ignore).await?;
let mut report = RegisterServicesReport {
discovered: discovered.len(),
..RegisterServicesReport::default()
};
report.removed += cleanup_stale_configured_services(
config,
project_root,
&ignore,
None, );
let discovered_roots: HashSet<String> = discovered
.iter()
.map(|project| normalize_service_root(&project.root_directory))
.collect();
let root_branch = config.branch.clone().unwrap_or_else(|| "main".to_string());
let mut used_names = collect_service_names(config);
let mut used_ports = collect_service_ports(config);
for project in &discovered {
let existing_service = config
.services
.as_ref()
.and_then(|services| {
services.iter().find(|service| {
service_roots_equivalent(
service.root_directory.as_deref(),
Some(project.root_directory.as_str()),
)
})
})
.cloned();
let heal_name = existing_service
.as_ref()
.map(|service| {
service_name_needs_heal(
&service.name,
service.root_directory.as_deref().unwrap_or(""),
)
})
.unwrap_or(false);
let preferred_name = existing_service.as_ref().and_then(|service| {
if heal_name {
None
} else {
Some(service.name.as_str())
}
});
let preferred_port = existing_service.as_ref().map(|service| service.port);
if let Some(existing) = existing_service.as_ref() {
used_names.remove(&existing.name);
used_ports.remove(&existing.port);
}
let Some(mut service) = build_discovered_service_with_prefs(
project_root,
&project.service_root,
&root_branch,
&mut used_names,
&mut used_ports,
&ignore,
preferred_name,
preferred_port,
)
.await?
else {
if let Some(existing) = existing_service.as_ref() {
used_names.insert(existing.name.clone());
used_ports.insert(existing.port);
}
continue;
};
let is_new_service = existing_service.is_none();
if let Some(ref existing_service) = existing_service {
if heal_name && existing_service.name != service.name {
report.renamed += 1;
}
service.versioning = existing_service.versioning;
service.release = existing_service.release;
service.target_freeze = existing_service.target_freeze.or(service.target_freeze);
if existing_service.is_target_frozen() {
service.target = existing_service.target.clone();
}
} else if !options.dry_run && !options.no_register {
apply_interactive_versioning_release_flags(&mut service)?;
}
let version_targets = if service.is_versioning_enabled() {
service.version_targets.clone().unwrap_or_default()
} else {
service.version_targets = None;
Vec::new()
};
let action =
upsert_service_into_config(config, service, &project.root_directory, &version_targets);
if is_new_service || !version_targets.is_empty() {
merge_project_version_targets(config, &version_targets, &ignore);
}
match action {
ServiceRegistrationAction::Added => report.added += 1,
ServiceRegistrationAction::Updated => report.updated += 1,
ServiceRegistrationAction::Unchanged => report.unchanged += 1,
}
}
if !discovered_roots.is_empty() {
report.removed += cleanup_stale_configured_services(
config,
project_root,
&ignore,
Some(&discovered_roots),
);
report.removed +=
dedupe_services_by_name_preferring_discovered(config, &discovered_roots);
}
if let Some(services) = &config.services {
validate_services(services)?;
}
let should_write =
!options.dry_run && !options.no_register && report.has_mutations();
if should_write {
write_project_xbp_config(config, project_root, root_config_path)?;
report.wrote_config = true;
}
Ok(report)
}
fn cleanup_stale_configured_services(
config: &mut XbpConfig,
project_root: &Path,
ignore: &XbpIgnoreSet,
discovered_roots: Option<&HashSet<String>>,
) -> usize {
let Some(services) = config.services.as_mut() else {
return 0;
};
let before = services.len();
let mut seen_roots: HashSet<String> = HashSet::new();
services.retain(|service| {
let root = service.root_directory.as_deref().unwrap_or("");
let normalized = normalize_service_root(root);
if is_packaging_artifact_service_root(root)
|| service
.name
.to_ascii_lowercase()
.contains("target-publish-package")
|| service
.name
.to_ascii_lowercase()
.starts_with("target-publish")
{
return false;
}
if !root.is_empty() && ignore.ignores_service_root(root) {
return false;
}
if !root.is_empty() {
let abs = if normalized == "./" {
project_root.to_path_buf()
} else {
project_root.join(root.replace('/', std::path::MAIN_SEPARATOR_STR))
};
if !abs.exists() {
return false;
}
}
if let Some(discovered) = discovered_roots {
if !root.is_empty() && !discovered.contains(&normalized) {
return false;
}
}
if !root.is_empty() && !seen_roots.insert(normalized) {
return false;
}
true
});
let removed = before.saturating_sub(services.len());
if removed > 0 {
eprintln!(
"Removed {removed} stale/noise service(s) from config (packaging, orphans, missing paths, duplicates)."
);
}
if services.is_empty() {
config.services = None;
}
removed
}
fn dedupe_services_by_name_preferring_discovered(
config: &mut XbpConfig,
discovered_roots: &HashSet<String>,
) -> usize {
let Some(services) = config.services.as_mut() else {
return 0;
};
let mut best_by_name: HashMap<String, usize> = HashMap::new();
let mut drop_idx: HashSet<usize> = HashSet::new();
for (index, service) in services.iter().enumerate() {
let key = service.name.to_ascii_lowercase();
match best_by_name.get(&key).copied() {
None => {
best_by_name.insert(key, index);
}
Some(prev) => {
let prev_root = services[prev]
.root_directory
.as_deref()
.map(normalize_service_root)
.unwrap_or_default();
let this_root = service
.root_directory
.as_deref()
.map(normalize_service_root)
.unwrap_or_default();
let prev_discovered = discovered_roots.contains(&prev_root);
let this_discovered = discovered_roots.contains(&this_root);
if this_discovered && !prev_discovered {
drop_idx.insert(prev);
best_by_name.insert(key, index);
} else {
drop_idx.insert(index);
}
}
}
}
if drop_idx.is_empty() {
return 0;
}
let mut kept = Vec::with_capacity(services.len().saturating_sub(drop_idx.len()));
for (index, service) in services.drain(..).enumerate() {
if !drop_idx.contains(&index) {
kept.push(service);
}
}
let removed = drop_idx.len();
*services = kept;
if removed > 0 {
eprintln!("Removed {removed} duplicate service name(s) from config.");
}
removed
}
fn service_name_needs_heal(name: &str, root_directory: &str) -> bool {
let trimmed = name.trim();
if trimmed.is_empty() {
return true;
}
if trimmed.contains('@') || trimmed.contains('/') || trimmed.contains('\\') {
return true;
}
if trimmed.to_ascii_lowercase().contains("target-publish") {
return true;
}
if is_pathologically_doubled_service_name(trimmed) {
return true;
}
if looks_like_versioned_crate_dirname(trimmed) {
return true;
}
let sanitized = sanitize_service_base_name(trimmed);
if !sanitized.is_empty() && sanitized != trimmed {
return true;
}
if let Some(leaf) = meaningful_service_leaf_slug(root_directory) {
let leaf = sanitize_service_base_name(&leaf);
if !leaf.is_empty() && is_doubled_service_name(trimmed, &leaf) {
return true;
}
}
false
}
fn is_pathologically_doubled_service_name(name: &str) -> bool {
let tokens: Vec<&str> = name
.split('-')
.filter(|token| !token.is_empty())
.collect();
if tokens.len() < 2 || tokens.len() % 2 != 0 {
return false;
}
let half = tokens.len() / 2;
tokens[..half]
.iter()
.zip(tokens[half..].iter())
.all(|(left, right)| left.eq_ignore_ascii_case(right))
}
fn find_service_discovery_marker(service_root: &Path) -> Option<String> {
SERVICE_DISCOVERY_MARKERS
.iter()
.find(|marker| service_root.join(marker).is_file())
.map(|marker| (*marker).to_string())
}
fn collect_service_names(config: &XbpConfig) -> HashSet<String> {
config
.services
.as_ref()
.map(|services| {
services
.iter()
.map(|service| service.name.clone())
.collect()
})
.unwrap_or_default()
}
fn collect_service_ports(config: &XbpConfig) -> HashSet<u16> {
config
.services
.as_ref()
.map(|services| services.iter().map(|service| service.port).collect())
.unwrap_or_default()
}
fn upsert_service_into_config(
config: &mut XbpConfig,
service: ServiceConfig,
service_root_relative: &str,
version_targets: &[String],
) -> ServiceRegistrationAction {
let services = config.services.get_or_insert_with(Vec::new);
let existing_index =
find_existing_service_index(services, &service, service_root_relative, version_targets);
if let Some(index) = existing_index {
let existing = services[index].clone();
let merged = merge_service_config(existing.clone(), service);
if services_equivalent(&existing, &merged) {
ServiceRegistrationAction::Unchanged
} else {
services[index] = merged;
ServiceRegistrationAction::Updated
}
} else {
services.push(service);
ServiceRegistrationAction::Added
}
}
fn find_existing_service_index(
services: &[ServiceConfig],
service: &ServiceConfig,
service_root_relative: &str,
version_targets: &[String],
) -> Option<usize> {
if let Some(index) = services.iter().position(|existing| {
service_roots_equivalent(
existing.root_directory.as_deref(),
Some(service_root_relative),
) || service_roots_equivalent(
existing.root_directory.as_deref(),
service.root_directory.as_deref(),
)
}) {
return Some(index);
}
if let Some(index) = services
.iter()
.position(|existing| existing.name.eq_ignore_ascii_case(&service.name))
{
return Some(index);
}
let service_target_set: HashSet<&str> = version_targets.iter().map(String::as_str).collect();
if service_target_set.is_empty() {
return None;
}
let matches: Vec<usize> = services
.iter()
.enumerate()
.filter_map(|(index, existing)| {
let overlaps = existing
.version_targets
.as_ref()
.map(|targets| {
targets
.iter()
.any(|target| service_target_set.contains(target.as_str()))
})
.unwrap_or(false);
overlaps.then_some(index)
})
.collect();
if matches.len() == 1 {
Some(matches[0])
} else {
None
}
}
fn normalize_service_root(root: &str) -> String {
let normalized = root.replace('\\', "/");
let trimmed = normalized.trim().trim_end_matches('/');
if trimmed.is_empty() || trimmed == "." {
"./".to_string()
} else {
trimmed.to_string()
}
}
fn service_roots_equivalent(left: Option<&str>, right: Option<&str>) -> bool {
match (left, right) {
(Some(a), Some(b)) => normalize_service_root(a) == normalize_service_root(b),
(None, None) => true,
_ => false,
}
}
fn service_prompt_label(service: &ServiceConfig) -> String {
match service.root_directory.as_deref() {
Some(root) if !root.is_empty() && root != "./" && root != "." => {
format!("'{}' ({})", service.name, root)
}
_ => format!("'{}'", service.name),
}
}
fn apply_interactive_versioning_release_flags(service: &mut ServiceConfig) -> Result<(), String> {
if cfg!(test) || !std::io::stdin().is_terminal() {
return Ok(());
}
let label = service_prompt_label(service);
let versioning_enabled = Confirm::new()
.with_prompt(format!("Enable versioning for service {label}?"))
.default(true)
.interact()
.map_err(|e| format!("Prompt failed: {}", e))?;
let release_enabled = if versioning_enabled {
Confirm::new()
.with_prompt(format!("Enable releasing for service {label}?"))
.default(true)
.interact()
.map_err(|e| format!("Prompt failed: {}", e))?
} else {
false
};
service.versioning = if versioning_enabled {
None
} else {
Some(false)
};
service.release = if release_enabled { None } else { Some(false) };
if !versioning_enabled {
service.version_targets = None;
}
Ok(())
}
fn merge_service_config(existing: ServiceConfig, detected: ServiceConfig) -> ServiceConfig {
let target_freeze = existing.target_freeze.or(detected.target_freeze);
let target = if existing.is_target_frozen() || target_freeze.unwrap_or(false) {
existing.target
} else {
detected.target
};
ServiceConfig {
name: detected.name,
target,
target_freeze,
branch: detected.branch,
port: detected.port,
root_directory: detected.root_directory,
environment: merge_environment_maps(existing.environment, detected.environment),
url: existing.url.or(detected.url),
healthcheck_path: existing.healthcheck_path.or(detected.healthcheck_path),
restart_policy: existing.restart_policy.or(detected.restart_policy),
restart_policy_max_failure_count: existing
.restart_policy_max_failure_count
.or(detected.restart_policy_max_failure_count),
start_wrapper: existing.start_wrapper.or(detected.start_wrapper),
commands: merge_service_commands(existing.commands, detected.commands),
force_run_from_root: existing
.force_run_from_root
.or(detected.force_run_from_root),
version_targets: detected.version_targets.or(existing.version_targets),
depends_on: existing.depends_on.or(detected.depends_on),
watch_paths: existing.watch_paths.or(detected.watch_paths),
versioning: existing.versioning.or(detected.versioning),
release: existing.release.or(detected.release),
systemd_service_name: existing
.systemd_service_name
.or(detected.systemd_service_name),
systemd: existing.systemd.or(detected.systemd),
openapi: existing.openapi.or(detected.openapi),
oci: existing.oci.or(detected.oci),
deploy: existing.deploy.or(detected.deploy),
discord: existing.discord.or(detected.discord),
file_associations: if existing.file_associations.is_empty() {
detected.file_associations
} else {
existing.file_associations
},
}
}
fn merge_environment_maps(
existing: Option<HashMap<String, String>>,
detected: Option<HashMap<String, String>>,
) -> Option<HashMap<String, String>> {
match (existing, detected) {
(Some(mut left), Some(right)) => {
for (key, value) in right {
left.entry(key).or_insert(value);
}
Some(left)
}
(Some(left), None) => Some(left),
(None, Some(right)) => Some(right),
(None, None) => None,
}
}
fn merge_service_commands(
existing: Option<ServiceCommands>,
detected: Option<ServiceCommands>,
) -> Option<ServiceCommands> {
match (existing, detected) {
(Some(mut left), Some(right)) => {
left.pre = left.pre.or(right.pre);
left.install = left.install.or(right.install);
left.build = left.build.or(right.build);
left.start = left.start.or(right.start);
left.dev = left.dev.or(right.dev);
for (name, command) in right.custom {
left.custom.entry(name).or_insert(command);
}
Some(left)
}
(Some(left), None) => Some(left),
(None, Some(right)) => Some(right),
(None, None) => None,
}
}
fn services_equivalent(left: &ServiceConfig, right: &ServiceConfig) -> bool {
left.name == right.name
&& left.target == right.target
&& left.is_target_frozen() == right.is_target_frozen()
&& left.branch == right.branch
&& left.port == right.port
&& left.root_directory == right.root_directory
&& left.version_targets == right.version_targets
&& left.watch_paths == right.watch_paths
&& left.versioning == right.versioning
&& left.release == right.release
&& left.openapi == right.openapi
&& service_commands_equivalent(left.commands.as_ref(), right.commands.as_ref())
}
fn service_commands_equivalent(
left: Option<&ServiceCommands>,
right: Option<&ServiceCommands>,
) -> bool {
match (left, right) {
(None, None) => true,
(Some(left), Some(right)) => {
left.pre == right.pre
&& left.install == right.install
&& left.build == right.build
&& left.start == right.start
&& left.dev == right.dev
&& left.custom == right.custom
}
(None, Some(commands)) | (Some(commands), None) => {
commands.pre.is_none()
&& commands.install.is_none()
&& commands.build.is_none()
&& commands.start.is_none()
&& commands.dev.is_none()
&& commands.custom.is_empty()
}
}
}
fn write_project_xbp_config(
config: &XbpConfig,
project_root: &Path,
config_path: &Path,
) -> Result<(), String> {
let mut persisted = config.clone();
normalize_config_paths_for_persistence(&mut persisted, project_root);
crate::utils::write_xbp_project_config_at_path(config_path, &persisted)
}
fn canonicalize_or_fallback(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
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::{ServiceConfig, XbpConfig};
use std::collections::{HashMap, HashSet};
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,
openapi: None,
workers: 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,
oci: None,
kubernetes: None,
deploy: 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(),
version_domains: Vec::new(),
ignore_paths: Vec::new(),
watch_ignore_paths: Vec::new(),
versioning_disabled: Vec::new(),
release_disabled: Vec::new(),
versioning: None,
discord: None,
}
}
#[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 discover_marker_service_projects_finds_package_and_cargo_roots() {
let project_root = temp_dir("marker-discovery");
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 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 discovered = super::discover_marker_service_projects(&project_root)
.await
.expect("discover marker projects");
assert_eq!(discovered.len(), 2);
assert!(discovered
.iter()
.any(|project| project.root_directory == "./" && project.marker == "package.json"));
assert!(discovered.iter().any(|project| {
project.root_directory == "crates/api" && project.marker == "Cargo.toml"
}));
let _ = fs::remove_dir_all(project_root);
}
#[tokio::test]
async fn discover_respects_dot_xbpignore_and_yaml_ignore_paths() {
let project_root = temp_dir("ignore-discovery");
fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir");
fs::write(
project_root.join("package.json"),
r#"{
"name": "root-app",
"version": "0.1.0",
"scripts": { "start": "node server.js --port 3000" },
"dependencies": { "express": "^4.0.0" }
}"#,
)
.expect("write root package");
let icons = project_root.join("packages").join("icons");
fs::create_dir_all(&icons).expect("icons dir");
fs::write(
icons.join("package.json"),
r#"{ "name": "@scope/icons", "version": "0.1.0" }"#,
)
.expect("write icons package");
let api = project_root.join("packages").join("api");
fs::create_dir_all(&api).expect("api dir");
fs::write(
api.join("package.json"),
r#"{
"name": "api",
"version": "0.1.0",
"scripts": { "start": "node index.js --port 3001" },
"dependencies": { "express": "^4.0.0" }
}"#,
)
.expect("write api package");
fs::write(
project_root.join(".xbp").join(".xbpignore"),
"# skip package packages as services/version targets\npackages/icons/\n",
)
.expect("write .xbpignore");
let ignore =
crate::utils::load_project_xbp_ignore(&project_root, &["packages/api".to_string()]);
let discovered =
super::discover_marker_service_projects_with_ignore(&project_root, &ignore)
.await
.expect("discover with ignore");
assert!(
discovered
.iter()
.any(|project| project.root_directory == "./"),
"root service should remain: {discovered:?}"
);
assert!(
!discovered
.iter()
.any(|project| project.root_directory == "packages/icons"),
"icons should be ignored via .xbpignore: {discovered:?}"
);
assert!(
!discovered
.iter()
.any(|project| project.root_directory == "packages/api"),
"api should be ignored via yaml ignore_paths: {discovered:?}"
);
let targets = super::discover_service_version_targets(&icons, &project_root, &ignore);
assert!(
targets.is_empty(),
"ignored package version targets should be empty, got {targets:?}"
);
let _ = fs::remove_dir_all(project_root);
}
#[tokio::test]
async fn register_does_not_duplicate_when_version_targets_overlap() {
let project_root = temp_dir("shared-version-targets");
let xbp_dir = project_root.join(".xbp");
fs::create_dir_all(&xbp_dir).expect("create root xbp dir");
let root_config = xbp_dir.join("xbp.yaml");
fs::write(
&root_config,
"project_name: XBP\nversion: 0.1.0\nport: 3000\nbuild_dir: .\ntarget: rust\n",
)
.expect("write root config");
fs::write(
project_root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/cli\"]\n",
)
.expect("write workspace manifest");
fs::write(project_root.join("requirements.txt"), "requests\n")
.expect("write requirements so root is detectable");
let cli_root = project_root.join("crates").join("cli");
fs::create_dir_all(cli_root.join("src")).expect("create cli root");
fs::write(
cli_root.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.expect("write cli manifest");
fs::write(cli_root.join("src").join("main.rs"), "fn main() {}\n").expect("write cli main");
let mut config = base_config("XBP", "python");
config.services = Some(vec![
ServiceConfig {
name: "xbp-2".to_string(),
target: "python".to_string(),
target_freeze: None,
branch: "main".to_string(),
port: 8001,
root_directory: Some("./".to_string()),
environment: None,
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: None,
force_run_from_root: Some(false),
version_targets: Some(vec!["crates/cli/Cargo.toml".to_string()]),
depends_on: None,
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
discord: None,
file_associations: Vec::new(),
},
ServiceConfig {
name: "crates-cli".to_string(),
target: "rust".to_string(),
target_freeze: None,
branch: "main".to_string(),
port: 8082,
root_directory: Some("crates/cli".to_string()),
environment: None,
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: None,
force_run_from_root: Some(false),
version_targets: Some(vec!["crates/cli/Cargo.toml".to_string()]),
depends_on: None,
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
discord: None,
file_associations: Vec::new(),
},
]);
let options = super::RegisterServicesOptions {
dry_run: true,
no_register: false,
};
let report =
super::register_discovered_services(&project_root, &root_config, &mut config, &options)
.await
.expect("registration should not fail on shared version_targets");
assert!(report.discovered >= 2);
let services = config.services.expect("services");
let names: Vec<&str> = services.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names.iter().filter(|n| **n == "crates-cli").count(),
1,
"crates-cli must appear exactly once, got {names:?}"
);
assert!(
services.iter().any(
|s| s.root_directory.as_deref() == Some("crates/cli") && s.name == "crates-cli"
),
"crates/cli root must stay named crates-cli"
);
assert!(
services.iter().any(|s| {
super::service_roots_equivalent(s.root_directory.as_deref(), Some("./"))
}),
"root service must remain present"
);
let _ = fs::remove_dir_all(project_root);
}
#[tokio::test]
async fn register_discovered_services_is_idempotent() {
let project_root = temp_dir("register-marker");
let xbp_dir = project_root.join(".xbp");
fs::create_dir_all(&xbp_dir).expect("create root xbp dir");
let root_config = xbp_dir.join("xbp.yaml");
fs::write(
&root_config,
"project_name: root\nversion: 0.1.0\nport: 3000\nbuild_dir: .\ntarget: rust\n",
)
.expect("write root config");
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", "rust");
let options = super::RegisterServicesOptions {
dry_run: false,
no_register: false,
};
let first =
super::register_discovered_services(&project_root, &root_config, &mut config, &options)
.await
.expect("first registration");
assert_eq!(first.discovered, 1);
assert_eq!(first.added, 1);
assert!(first.wrote_config);
let second =
super::register_discovered_services(&project_root, &root_config, &mut config, &options)
.await
.expect("second registration");
assert_eq!(second.discovered, 1);
assert_eq!(second.added, 0);
assert_eq!(second.updated, 0);
assert_eq!(second.unchanged, 1);
let services = config.services.expect("services");
assert_eq!(services.len(), 1);
assert_eq!(services[0].root_directory.as_deref(), Some("apps/web"));
assert_eq!(
services[0].watch_paths.as_deref(),
Some(vec!["apps/web".to_string()].as_slice())
);
let _ = fs::remove_dir_all(project_root);
}
#[tokio::test]
async fn register_discovered_services_fills_null_watch_paths() {
let project_root = temp_dir("fill-watch-paths");
let xbp_dir = project_root.join(".xbp");
fs::create_dir_all(&xbp_dir).expect("create root xbp dir");
let root_config = xbp_dir.join("xbp.yaml");
fs::write(
&root_config,
"project_name: root\nversion: 0.1.0\nport: 3000\nbuild_dir: .\ntarget: rust\n",
)
.expect("write root config");
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", "rust");
config.services = Some(vec![ServiceConfig {
name: "web-app".to_string(),
target: "nextjs".to_string(),
target_freeze: None,
branch: "main".to_string(),
port: 3000,
root_directory: Some("apps/web".to_string()),
environment: None,
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: None,
force_run_from_root: Some(false),
version_targets: Some(vec!["apps/web/package.json".to_string()]),
depends_on: None,
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
discord: None,
file_associations: Vec::new(),
}]);
let options = super::RegisterServicesOptions {
dry_run: true,
no_register: false,
};
let report =
super::register_discovered_services(&project_root, &root_config, &mut config, &options)
.await
.expect("register should fill watch_paths");
assert_eq!(report.updated, 1);
let services = config.services.expect("services");
assert_eq!(services.len(), 1);
assert_eq!(
services[0].watch_paths.as_deref(),
Some(vec!["apps/web".to_string()].as_slice())
);
let commands = services[0].commands.as_ref().expect("commands filled");
assert_eq!(commands.install.as_deref(), Some("pnpm install"));
assert_eq!(commands.build.as_deref(), Some("pnpm run build"));
assert_eq!(commands.start.as_deref(), Some("pnpm run start"));
assert_eq!(commands.dev.as_deref(), Some("pnpm run dev"));
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn packaging_artifact_paths_are_detected() {
assert!(super::is_packaging_artifact_service_root(
"target-publish/package/athena-actix-4.0.0"
));
assert!(super::is_packaging_artifact_service_root(
"target/package/athena_rs-4.0.0"
));
assert!(super::is_packaging_artifact_service_root(
"target-publish/package/athena-wss-4.0.0"
));
assert!(!super::is_packaging_artifact_service_root("crates/athena-actix"));
assert!(!super::is_packaging_artifact_service_root("packages/api"));
assert!(!super::is_packaging_artifact_service_root("services/athena-log"));
}
#[test]
fn pathological_doubled_names_are_detected() {
assert!(super::is_pathologically_doubled_service_name("web-web"));
assert!(super::is_pathologically_doubled_service_name("docs-docs"));
assert!(super::is_pathologically_doubled_service_name(
"athena-actix-athena-actix"
));
assert!(super::is_pathologically_doubled_service_name("cdc-api-cdc-api"));
assert!(!super::is_pathologically_doubled_service_name("apps-web"));
assert!(!super::is_pathologically_doubled_service_name("crates-athena-actix"));
assert!(!super::is_pathologically_doubled_service_name("web"));
assert!(super::service_name_needs_heal("web-web", "apps/web"));
assert!(super::service_name_needs_heal("@lexington/web", "apps/web"));
assert!(!super::service_name_needs_heal("web", "apps/web"));
assert!(!super::service_name_needs_heal("apps-web", "apps/web"));
}
#[tokio::test]
async fn register_auto_heals_doubled_names_and_prunes_orphans() {
let project_root = temp_dir("auto-heal-prune");
let xbp_dir = project_root.join(".xbp");
fs::create_dir_all(&xbp_dir).expect("xbp dir");
let root_config = xbp_dir.join("xbp.yaml");
fs::write(
&root_config,
"project_name: monorepo\nversion: 0.1.0\nport: 3000\nbuild_dir: .\ntarget: nodejs\n",
)
.expect("root config");
let web = project_root.join("apps").join("web");
fs::create_dir_all(&web).expect("web");
fs::write(
web.join("package.json"),
r#"{ "name": "web", "version": "0.1.0", "scripts": { "start": "node x" } }"#,
)
.expect("web package");
let api = project_root.join("apps").join("api");
fs::create_dir_all(&api).expect("api");
fs::write(
api.join("package.json"),
r#"{ "name": "api", "version": "0.1.0", "scripts": { "start": "node x" } }"#,
)
.expect("api package");
let mut config = base_config("monorepo", "nodejs");
config.services = Some(vec![
ServiceConfig {
name: "web-web".to_string(),
target: "nodejs".to_string(),
target_freeze: None,
branch: "main".to_string(),
port: 3000,
root_directory: Some("apps/web".to_string()),
environment: None,
url: None,
healthcheck_path: None,
restart_policy: None,
restart_policy_max_failure_count: None,
start_wrapper: None,
commands: None,
force_run_from_root: None,
version_targets: None,
depends_on: None,
watch_paths: None,
versioning: Some(false),
release: Some(false),
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
discord: None,
file_associations: Vec::new(),
},
ServiceConfig {
name: "ghost-orphan".to_string(),
target: "nodejs".to_string(),
target_freeze: None,
branch: "main".to_string(),
port: 3999,
root_directory: Some("apps/deleted-service".to_string()),
environment: None,
url: None,
healthcheck_path: None,
restart_policy: None,
restart_policy_max_failure_count: None,
start_wrapper: None,
commands: None,
force_run_from_root: None,
version_targets: None,
depends_on: None,
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
discord: None,
file_associations: Vec::new(),
},
ServiceConfig {
name: "target-publish-package-athena-actix".to_string(),
target: "rust".to_string(),
target_freeze: None,
branch: "main".to_string(),
port: 4000,
root_directory: Some("target-publish/package/athena-actix-4.0.0".to_string()),
environment: None,
url: None,
healthcheck_path: None,
restart_policy: None,
restart_policy_max_failure_count: None,
start_wrapper: None,
commands: None,
force_run_from_root: None,
version_targets: None,
depends_on: None,
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
discord: None,
file_associations: Vec::new(),
},
]);
let options = super::RegisterServicesOptions {
dry_run: true,
no_register: false,
};
let report =
super::register_discovered_services(&project_root, &root_config, &mut config, &options)
.await
.expect("heal register");
assert!(report.renamed >= 1, "expected rename of web-web: {report:?}");
assert!(report.removed >= 2, "expected orphan + packaging removed: {report:?}");
let services = config.services.as_ref().expect("services");
let names: Vec<_> = services.iter().map(|s| s.name.as_str()).collect();
assert!(
!names.iter().any(|n| n.contains("web-web") || n.contains("target-publish") || *n == "ghost-orphan"),
"bad names still present: {names:?}"
);
assert!(
services.iter().any(|s| {
s.root_directory.as_deref() == Some("apps/web") && s.name != "web-web"
}),
"apps/web should be healed: {services:?}"
);
let web_svc = services
.iter()
.find(|s| s.root_directory.as_deref() == Some("apps/web"))
.expect("web");
assert_eq!(web_svc.versioning, Some(false));
assert_eq!(web_svc.release, Some(false));
assert!(services.iter().any(|s| s.root_directory.as_deref() == Some("apps/api")));
let names_after: Vec<String> = services.iter().map(|s| s.name.clone()).collect();
let report2 =
super::register_discovered_services(&project_root, &root_config, &mut config, &options)
.await
.expect("second heal register");
assert_eq!(report2.renamed, 0, "second pass must not re-rename: {report2:?}");
assert_eq!(report2.added, 0, "second pass must not re-add: {report2:?}");
let names_second: Vec<String> = config
.services
.as_ref()
.unwrap()
.iter()
.map(|s| s.name.clone())
.collect();
let mut a = names_after.clone();
let mut b = names_second.clone();
a.sort();
b.sort();
assert_eq!(a, b, "names must stay stable after heal");
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn reserve_service_name_never_embeds_semver_path_slug() {
let mut used = HashSet::new();
used.insert("athena-actix".to_string());
let name = super::reserve_service_name(
"athena-actix".to_string(),
"target-publish/package/athena-actix-4.0.0",
&mut used,
);
assert!(!name.contains("4.0.0"), "got {name}");
assert!(!name.contains("target-publish"), "got {name}");
assert_eq!(name, "athena-actix-2");
}
#[test]
fn reserve_service_name_uses_path_prefix_not_doubled_leaf() {
let mut used = HashSet::new();
used.insert("web".to_string());
let name = super::reserve_service_name("web".to_string(), "apps/web", &mut used);
assert_eq!(name, "apps-web");
assert_ne!(name, "web-web");
let mut used = HashSet::new();
used.insert("athena-actix".to_string());
let name = super::reserve_service_name(
"athena-actix".to_string(),
"crates/athena-actix",
&mut used,
);
assert_eq!(name, "crates-athena-actix");
assert_ne!(name, "athena-actix-athena-actix");
let mut used = HashSet::new();
used.insert("docs".to_string());
let name = super::reserve_service_name("docs".to_string(), "apps/docs", &mut used);
assert_eq!(name, "apps-docs");
let mut used = HashSet::new();
used.insert("cdc-api".to_string());
let name = super::reserve_service_name("cdc-api".to_string(), "apps/cdc-api", &mut used);
assert_eq!(name, "apps-cdc-api");
}
#[test]
fn reserve_service_name_sanitizes_npm_scope() {
let mut used = HashSet::new();
let name =
super::reserve_service_name("@lexington/web".to_string(), "apps/marketing/apps/web", &mut used);
assert_eq!(name, "lexington-web");
let name =
super::reserve_service_name("@lexington/web".to_string(), "apps/marketing/apps/web", &mut used);
assert_eq!(name, "web");
assert!(!name.contains("web-web"), "got {name}");
assert_ne!(name, "lexington-web-lexington-web");
let name =
super::reserve_service_name("@lexington/web".to_string(), "apps/marketing/apps/web", &mut used);
assert!(
name == "apps-web"
|| name.contains("marketing")
|| name.ends_with("-2")
|| name.starts_with("apps-"),
"unexpected scoped collision name: {name}"
);
assert!(!name.contains("web-web"), "got {name}");
}
#[tokio::test]
async fn register_rediscovery_is_idempotent_for_names_and_flags() {
let project_root = temp_dir("discover-idempotent");
let xbp_dir = project_root.join(".xbp");
fs::create_dir_all(&xbp_dir).expect("xbp dir");
let root_config = xbp_dir.join("xbp.yaml");
fs::write(
&root_config,
"project_name: monorepo\nversion: 0.1.0\nport: 3000\nbuild_dir: .\ntarget: nodejs\n",
)
.expect("root config");
fs::write(
project_root.join("package.json"),
r#"{ "name": "monorepo", "version": "0.1.0", "scripts": { "start": "node x" } }"#,
)
.expect("root package");
let web = project_root.join("apps").join("web");
fs::create_dir_all(&web).expect("web dir");
fs::write(
web.join("package.json"),
r#"{ "name": "web", "version": "0.1.0", "scripts": { "start": "node x" } }"#,
)
.expect("web package");
let api = project_root.join("apps").join("api");
fs::create_dir_all(&api).expect("api dir");
fs::write(
api.join("package.json"),
r#"{ "name": "web", "version": "0.1.0", "scripts": { "start": "node x" } }"#,
)
.expect("api package shares name web");
let mut config = base_config("monorepo", "nodejs");
let options = super::RegisterServicesOptions {
dry_run: true,
no_register: false,
};
let report1 =
super::register_discovered_services(&project_root, &root_config, &mut config, &options)
.await
.expect("first register");
assert!(report1.added >= 2, "expected nested services, got {report1:?}");
let services_first = config.services.clone().expect("services");
let names_first: Vec<_> = services_first.iter().map(|s| s.name.clone()).collect();
assert!(
names_first.iter().all(|n| !n.contains("-web-web") && n != "web-web"),
"doubled names on first pass: {names_first:?}"
);
if let Some(services) = config.services.as_mut() {
for service in services.iter_mut() {
if service.root_directory.as_deref() == Some("apps/web") {
service.versioning = Some(false);
service.release = Some(false);
}
}
}
let report2 =
super::register_discovered_services(&project_root, &root_config, &mut config, &options)
.await
.expect("second register");
assert_eq!(report2.added, 0, "rediscovery must not re-add: {report2:?}");
let services_second = config.services.expect("services after second");
let by_root: HashMap<_, _> = services_second
.iter()
.filter_map(|s| s.root_directory.as_ref().map(|r| (r.as_str(), s)))
.collect();
let web_svc = by_root.get("apps/web").expect("apps/web present");
assert_eq!(web_svc.versioning, Some(false));
assert_eq!(web_svc.release, Some(false));
let names_second: Vec<_> = services_second.iter().map(|s| s.name.clone()).collect();
let mut first_sorted = names_first.clone();
let mut second_sorted = names_second.clone();
first_sorted.sort();
second_sorted.sort();
assert_eq!(first_sorted, second_sorted, "names must be stable on rediscovery");
let _ = fs::remove_dir_all(project_root);
}
#[tokio::test]
async fn discover_skips_target_publish_package_trees() {
let project_root = temp_dir("skip-target-publish");
fs::write(
project_root.join("package.json"),
r#"{ "name": "root-app", "version": "0.1.0", "scripts": { "start": "node x" } }"#,
)
.expect("root package");
let real = project_root.join("crates").join("athena-actix");
fs::create_dir_all(real.join("src")).expect("real crate");
fs::write(
real.join("Cargo.toml"),
"[package]\nname = \"athena-actix\"\nversion = \"4.0.0\"\nedition = \"2021\"\n",
)
.expect("real Cargo.toml");
let artifact = project_root
.join("target-publish")
.join("package")
.join("athena-actix-4.0.0");
fs::create_dir_all(artifact.join("src")).expect("artifact");
fs::write(
artifact.join("Cargo.toml"),
"[package]\nname = \"athena-actix\"\nversion = \"4.0.0\"\nedition = \"2021\"\n",
)
.expect("artifact Cargo.toml");
let discovered = super::discover_marker_service_projects(&project_root)
.await
.expect("discover");
assert!(
discovered
.iter()
.all(|p| !p.root_directory.contains("target-publish")),
"discovered: {:?}",
discovered
.iter()
.map(|p| p.root_directory.as_str())
.collect::<Vec<_>>()
);
assert!(discovered
.iter()
.any(|p| p.root_directory == "crates/athena-actix"));
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn merge_service_config_respects_target_freeze() {
let existing = ServiceConfig {
name: "web".to_string(),
target: "nextjs".to_string(),
target_freeze: Some(true),
branch: "main".to_string(),
port: 3000,
root_directory: Some("apps/web".to_string()),
environment: None,
url: None,
healthcheck_path: None,
restart_policy: None,
restart_policy_max_failure_count: None,
start_wrapper: None,
commands: None,
force_run_from_root: None,
version_targets: None,
depends_on: None,
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
discord: None,
file_associations: Vec::new(),
};
let detected = ServiceConfig {
name: "web".to_string(),
target: "docker".to_string(),
target_freeze: None,
branch: "main".to_string(),
port: 3000,
root_directory: Some("apps/web".to_string()),
environment: None,
url: None,
healthcheck_path: None,
restart_policy: None,
restart_policy_max_failure_count: None,
start_wrapper: None,
commands: None,
force_run_from_root: None,
version_targets: None,
depends_on: None,
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
discord: None,
file_associations: Vec::new(),
};
let merged = super::merge_service_config(existing, detected);
assert_eq!(merged.target, "nextjs");
assert!(merged.is_target_frozen());
}
#[test]
fn merge_service_config_updates_target_when_not_frozen() {
let existing = ServiceConfig {
name: "web".to_string(),
target: "nextjs".to_string(),
target_freeze: None,
branch: "main".to_string(),
port: 3000,
root_directory: Some("apps/web".to_string()),
environment: None,
url: None,
healthcheck_path: None,
restart_policy: None,
restart_policy_max_failure_count: None,
start_wrapper: None,
commands: None,
force_run_from_root: None,
version_targets: None,
depends_on: None,
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
discord: None,
file_associations: Vec::new(),
};
let detected = ServiceConfig {
name: "web".to_string(),
target: "docker".to_string(),
target_freeze: None,
branch: "main".to_string(),
port: 3000,
root_directory: Some("apps/web".to_string()),
environment: None,
url: None,
healthcheck_path: None,
restart_policy: None,
restart_policy_max_failure_count: None,
start_wrapper: None,
commands: None,
force_run_from_root: None,
version_targets: None,
depends_on: None,
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
discord: None,
file_associations: Vec::new(),
};
let merged = super::merge_service_config(existing, detected);
assert_eq!(merged.target, "docker");
assert!(!merged.is_target_frozen());
}
#[tokio::test]
async fn register_discovered_services_preserves_existing_commands() {
let project_root = temp_dir("preserve-commands");
let xbp_dir = project_root.join(".xbp");
fs::create_dir_all(&xbp_dir).expect("create root xbp dir");
let root_config = xbp_dir.join("xbp.yaml");
fs::write(
&root_config,
"project_name: root\nversion: 0.1.0\nport: 3000\nbuild_dir: .\ntarget: rust\n",
)
.expect("write root config");
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");
fs::write(web_root.join("package-lock.json"), "{}\n").expect("write npm lockfile");
let mut config = base_config("root", "rust");
config.services = Some(vec![ServiceConfig {
name: "web-app".to_string(),
target: "nextjs".to_string(),
target_freeze: None,
branch: "main".to_string(),
port: 3000,
root_directory: Some("apps/web".to_string()),
environment: None,
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(crate::strategies::ServiceCommands {
pre: None,
install: Some("npm ci".to_string()),
build: Some("npm run build:prod".to_string()),
start: None,
dev: None,
custom: Default::default(),
}),
force_run_from_root: Some(false),
version_targets: Some(vec!["apps/web/package.json".to_string()]),
depends_on: None,
watch_paths: Some(vec!["apps/web".to_string()]),
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
discord: None,
file_associations: Vec::new(),
}]);
let options = super::RegisterServicesOptions {
dry_run: true,
no_register: false,
};
let report =
super::register_discovered_services(&project_root, &root_config, &mut config, &options)
.await
.expect("register should fill only null commands");
assert_eq!(report.updated, 1);
let services = config.services.expect("services");
let commands = services[0].commands.as_ref().expect("commands present");
assert_eq!(commands.install.as_deref(), Some("npm ci"));
assert_eq!(commands.build.as_deref(), Some("npm run build:prod"));
assert_eq!(commands.start.as_deref(), Some("npm run start"));
assert_eq!(commands.dev.as_deref(), Some("npm run dev"));
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);
}
#[test]
fn apply_service_defaults_fills_commands_oci_and_deploy() {
use crate::strategies::deployment_config::{
DeployProjectConfig, OciProjectConfig, OciRegistryConfig,
};
use std::collections::HashMap;
let project_root = temp_dir("service-defaults");
fs::write(project_root.join("Cargo.toml"), "[package]\nname=\"demo\"\n").unwrap();
fs::write(project_root.join("Dockerfile"), "FROM scratch\n").unwrap();
let mut registries = HashMap::new();
registries.insert(
"ghcr.io".to_string(),
OciRegistryConfig {
provider: Some("github".into()),
username: Some("acme".into()),
token_source: Some("${GITHUB_TOKEN}".into()),
url: None,
},
);
let mut config = base_config("demo", "rust");
config.build_command = Some("cargo build --release".into());
config.start_command = Some("./target/release/demo".into());
config.oci = Some(OciProjectConfig {
registries,
default_registry: Some("ghcr.io".into()),
});
config.deploy = Some(DeployProjectConfig {
default_env: Some("production".into()),
history_dir: Some(".xbp/deployments".into()),
lock_file: Some(".xbp/deploy.lock".into()),
groups: HashMap::new(),
});
config.services = Some(vec![ServiceConfig {
name: "demo".into(),
target: "rust".into(),
target_freeze: None,
branch: "main".into(),
port: 8080,
root_directory: Some("./".into()),
environment: None,
url: None,
healthcheck_path: None,
restart_policy: Some("on_failure".into()),
restart_policy_max_failure_count: Some(10),
start_wrapper: None,
commands: Some(crate::strategies::ServiceCommands {
pre: None,
install: None,
build: None,
start: None,
dev: None,
custom: Default::default(),
}),
force_run_from_root: Some(false),
version_targets: Some(vec!["Cargo.toml".into()]),
depends_on: None,
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
discord: None,
file_associations: Vec::new(),
}]);
super::apply_service_defaults_after_init(&mut config, &project_root);
let service = &config.services.as_ref().unwrap()[0];
let commands = service.commands.as_ref().unwrap();
assert_eq!(commands.build.as_deref(), Some("cargo build --release"));
assert_eq!(commands.start.as_deref(), Some("./target/release/demo"));
assert_eq!(commands.dev.as_deref(), Some("cargo run"));
assert_eq!(commands.get("test").map(String::as_str), Some("cargo test"));
assert_eq!(service.start_wrapper.as_deref(), Some("pm2"));
let oci = service.oci.as_ref().expect("oci");
assert_eq!(oci.image.as_deref(), Some("ghcr.io/acme/demo"));
assert_eq!(oci.dockerfile.as_deref(), Some("Dockerfile"));
let deploy = service.deploy.as_ref().expect("deploy");
assert!(deploy.envs.contains_key("production"));
assert_eq!(deploy.provider.as_deref(), Some("kubernetes"));
let _ = fs::remove_dir_all(project_root);
}
}