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, 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;
}
}
#[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 wrote_config: bool,
}
#[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> {
let mut candidates = vec![project_root.to_path_buf()];
candidates.extend(discover_nested_service_roots(project_root));
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 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;
}
let root_directory = if service_root == project_root {
".".to_string()
} else {
collapse_project_path(project_root, &service_root.to_string_lossy())
};
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 discovered = discover_marker_service_projects(project_root).await?;
let mut report = RegisterServicesReport {
discovered: discovered.len(),
..RegisterServicesReport::default()
};
if discovered.is_empty() {
return Ok(report);
}
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.root_directory.as_deref() == Some(project.root_directory.as_str())
})
})
.cloned();
let Some(mut service) = build_discovered_service(
project_root,
&project.service_root,
&root_branch,
&mut used_names,
&mut used_ports,
)
.await?
else {
continue;
};
if let Some(existing_service) = existing_service {
service.name = existing_service.name;
service.port = existing_service.port;
}
let version_targets = service.version_targets.clone().unwrap_or_default();
let action = upsert_service_into_config(
config,
service,
&project.root_directory,
&version_targets,
);
merge_project_version_targets(config, &version_targets);
match action {
ServiceRegistrationAction::Added => report.added += 1,
ServiceRegistrationAction::Updated => report.updated += 1,
ServiceRegistrationAction::Unchanged => report.unchanged += 1,
}
}
if let Some(services) = &config.services {
validate_services(services)?;
}
let should_write = !options.dry_run && !options.no_register;
if should_write {
write_project_xbp_config(config, project_root, root_config_path)?;
report.wrote_config = true;
}
Ok(report)
}
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 service_target_set: HashSet<&str> = version_targets.iter().map(String::as_str).collect();
let existing_index = services.iter().position(|existing| {
existing.root_directory.as_deref() == Some(service_root_relative)
|| existing
.version_targets
.as_ref()
.map(|targets| {
targets
.iter()
.any(|target| service_target_set.contains(target.as_str()))
})
.unwrap_or(false)
|| existing.name.eq_ignore_ascii_case(&service.name)
});
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 merge_service_config(existing: ServiceConfig, detected: ServiceConfig) -> ServiceConfig {
ServiceConfig {
name: detected.name,
target: detected.target,
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),
systemd_service_name: existing
.systemd_service_name
.or(detected.systemd_service_name),
systemd: existing.systemd.or(detected.systemd),
}
}
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);
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.branch == right.branch
&& left.port == right.port
&& left.root_directory == right.root_directory
&& left.version_targets == right.version_targets
}
fn write_project_xbp_config(
config: &XbpConfig,
project_root: &Path,
config_path: &Path,
) -> Result<(), String> {
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create {}: {}", parent.display(), error))?;
}
let mut persisted = config.clone();
normalize_config_paths_for_persistence(&mut persisted, project_root);
let yaml = serde_yaml::to_string(&persisted)
.map_err(|error| format!("Failed to serialize XBP config: {}", error))?;
fs::write(config_path, yaml).map_err(|error| {
format!(
"Failed to write XBP config {}: {}",
config_path.display(),
error
)
})
}
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::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 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 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"));
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);
}
}