use crate::cli::auto_commit::{
commit_paths, print_push_summary, print_skip, push_current_branch, AutoCommitRequest,
AutoCommitResult,
};
use crate::commands::project_services::{auto_populate_services, discover_service_version_targets};
use crate::strategies::deployment_config::XbpConfig;
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,
DeploymentConfig, ServiceCommands, ServiceConfig,
};
use crate::utils::{
collapse_project_path, default_project_yaml_config_path, find_xbp_config_upwards,
parse_env_file, to_env_references, FoundXbpConfig,
};
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use dialoguer::{Confirm, Input, Select};
use regex::Regex;
use tokio::process::Command;
use tracing::debug;
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",
];
pub async fn run_init(_debug: bool) -> Result<(), String> {
let current_dir: PathBuf =
env::current_dir().map_err(|e| format!("Failed to read current directory: {}", e))?;
if let Some(found) = find_xbp_config_upwards(¤t_dir) {
if found.project_root != current_dir {
return run_nested_service_init(found, current_dir).await;
}
let proceed = Confirm::new()
.with_prompt(format!(
"An XBP config already exists at {}. Overwrite?",
found.location
))
.default(false)
.interact()
.map_err(|e| format!("Prompt failed: {}", e))?;
if !proceed {
return Ok(());
}
}
let project_type: ProjectType = ProjectDetector::detect_project_type(¤t_dir)
.await
.unwrap_or(ProjectType::Unknown);
debug!(?project_type, "Detected project type");
let recommendations =
ProjectDetector::get_deployment_recommendations(¤t_dir, &project_type);
let inferred_name = infer_project_name(&project_type, ¤t_dir, &recommendations);
let app_type_guess = infer_app_type(&project_type);
let port_guess = detect_port(¤t_dir, &project_type, &recommendations);
let env_vars = detect_environment_from_env_files(¤t_dir);
let project_name: String = Input::new()
.with_prompt("Project name")
.with_initial_text(inferred_name)
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))?;
let app_type: String =
select_app_type(app_type_guess.clone()).map_err(|e| format!("Prompt failed: {}", e))?;
let port: u16 = Input::new()
.with_prompt("Primary port")
.default(port_guess)
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))?;
let build_dir = collapse_project_path(¤t_dir, ¤t_dir.to_string_lossy());
let mut config = XbpConfig {
project_name,
version: "0.1.0".to_string(),
port,
build_dir,
app_type: Some(app_type.clone()),
build_command: recommendations.build_command.clone(),
start_command: recommendations.start_command.clone(),
install_command: recommendations.install_command.clone(),
environment: if env_vars.is_empty() {
None
} else {
Some(env_vars)
},
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(app_type),
branch: current_git_branch().await,
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
publish: None,
version_targets: Vec::new(),
};
auto_populate_services(&mut config, ¤t_dir, &project_type).await?;
let yaml_path = default_project_yaml_config_path(¤t_dir);
let written_paths = write_configs(&config, ¤t_dir, &yaml_path)?;
let legacy_json_path = yaml_path
.parent()
.map(|parent| parent.join("xbp.json"))
.ok_or_else(|| "Invalid YAML config path".to_string())?;
if legacy_json_path.exists() {
println!(
"Created {} (synced legacy {})",
yaml_path.display(),
legacy_json_path.display()
);
} else {
println!("Created {}", yaml_path.display());
}
match commit_paths(AutoCommitRequest {
project_root: ¤t_dir,
paths: written_paths,
message: "chore(xbp): initialize project config".to_string(),
action_label: "xbp init",
})
.await
{
Ok(AutoCommitResult::Committed(_)) => match push_current_branch(¤t_dir).await {
Ok(Some(outcome)) => print_push_summary(&outcome),
Ok(None) => {}
Err(e) => print_skip("xbp init", &format!("git push failed: {}", e)),
},
Ok(AutoCommitResult::Skipped(reason)) => print_skip("xbp init", &reason),
Err(e) => print_skip("xbp init", &e),
}
Ok(())
}
#[derive(Debug, Clone)]
struct NestedServiceCandidate {
service_root: PathBuf,
project_type: ProjectType,
}
async fn run_nested_service_init(
found: FoundXbpConfig,
current_dir: PathBuf,
) -> Result<(), String> {
let candidate = resolve_nested_service_candidate(&found.project_root, ¤t_dir)
.await?
.ok_or_else(|| {
format!(
"Found existing XBP project at {}, but no nested package/service markers were found between {} and the project root. Run `xbp init` from a folder that contains a package manifest such as package.json, Cargo.toml, or pyproject.toml.",
found.project_root.display(),
current_dir.display()
)
})?;
let recommendations = ProjectDetector::get_deployment_recommendations(
&candidate.service_root,
&candidate.project_type,
);
let inferred_name = infer_project_name(
&candidate.project_type,
&candidate.service_root,
&recommendations,
);
let app_type_guess = infer_app_type(&candidate.project_type);
let port_guess = detect_port(
&candidate.service_root,
&candidate.project_type,
&recommendations,
);
let env_vars = detect_environment_from_env_files(&candidate.service_root);
let version_targets =
discover_service_version_targets(&candidate.service_root, &found.project_root);
let service_name: String = Input::new()
.with_prompt("Service name")
.with_initial_text(inferred_name)
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))?;
let app_type =
select_app_type(app_type_guess.clone()).map_err(|e| format!("Prompt failed: {}", e))?;
let port: u16 = Input::new()
.with_prompt("Primary port")
.default(port_guess)
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))?;
let service_root_relative = collapse_project_path(
&found.project_root,
&candidate.service_root.to_string_lossy(),
);
let service_config = ServiceConfig {
name: service_name.clone(),
target: app_type.clone(),
branch: current_git_branch()
.await
.unwrap_or_else(|| "main".to_string()),
port,
root_directory: Some(service_root_relative.clone()),
environment: if env_vars.is_empty() {
None
} else {
Some(env_vars)
},
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.clone(),
build: recommendations.build_command.clone(),
start: recommendations.start_command.clone(),
dev: None,
}),
force_run_from_root: Some(false),
version_targets: if version_targets.is_empty() {
None
} else {
Some(version_targets.clone())
},
systemd_service_name: None,
systemd: None,
};
let mut config = DeploymentConfig::load_xbp_config(Some(found.config_path.clone())).await?;
ensure_root_service_entry(&mut config, &found.project_root, &version_targets);
upsert_service_config(
&mut config,
service_config,
&service_root_relative,
&version_targets,
);
merge_project_version_targets(&mut config, &version_targets);
if let Some(services) = &config.services {
validate_services(services)?;
}
let yaml_path = default_project_yaml_config_path(&found.project_root);
let written_paths = write_configs(&config, &found.project_root, &yaml_path)?;
println!(
"Updated {} and registered nested service `{}` at {}",
yaml_path.display(),
service_name,
service_root_relative
);
match commit_paths(AutoCommitRequest {
project_root: &found.project_root,
paths: written_paths,
message: format!("chore(xbp): register service {}", service_name),
action_label: "xbp init",
})
.await
{
Ok(AutoCommitResult::Committed(_)) => {
match push_current_branch(&found.project_root).await {
Ok(Some(outcome)) => print_push_summary(&outcome),
Ok(None) => {}
Err(e) => print_skip("xbp init", &format!("git push failed: {}", e)),
}
}
Ok(AutoCommitResult::Skipped(reason)) => print_skip("xbp init", &reason),
Err(e) => print_skip("xbp init", &e),
}
Ok(())
}
fn infer_project_name(
project_type: &ProjectType,
current_dir: &Path,
recommendations: &DeploymentRecommendations,
) -> String {
shared_infer_project_name(current_dir, 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()),
_ => None,
}
}
fn has_express_dependency(package_json: &PackageJsonInfo) -> bool {
package_json
.dependencies
.keys()
.any(|k| k.eq_ignore_ascii_case("express"))
|| package_json
.dev_dependencies
.keys()
.any(|k| k.eq_ignore_ascii_case("express"))
}
fn select_app_type(detected: Option<String>) -> Result<String, String> {
let mut options: Vec<String> = vec![
"nextjs".to_string(),
"expressjs".to_string(),
"rust".to_string(),
"nodejs".to_string(),
"python".to_string(),
"docker".to_string(),
"railway".to_string(),
"vercel".to_string(),
"docker-compose".to_string(),
"custom...".to_string(),
];
let default_index = if let Some(ref guess) = detected {
if let Some(pos) = options.iter().position(|o| o == guess) {
pos
} else {
options.insert(0, format!("{} (detected)", guess));
0
}
} else {
0
};
let selection = Select::new()
.with_prompt("App type")
.items(&options)
.default(default_index)
.interact()
.map_err(|e| format!("Prompt failed: {}", e))?;
let choice = options
.get(selection)
.cloned()
.unwrap_or_else(|| "nextjs".to_string());
if choice == "custom..." {
Input::<String>::new()
.with_prompt("Enter app type")
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))
} else if let Some(stripped) = choice.strip_suffix(" (detected)") {
Ok(stripped.to_string())
} else {
Ok(choice)
}
}
fn detect_port(
project_root: &Path,
project_type: &ProjectType,
recommendations: &DeploymentRecommendations,
) -> u16 {
if let Ok(port_env) = env::var("PORT") {
if let Ok(port) = port_env.parse::<u16>() {
return port;
}
}
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 pkg_path = project_root.join("package.json");
let content = fs::read_to_string(&pkg_path).ok()?;
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
if let Some(port) = value.get("port").and_then(|v| v.as_u64()) {
return Some(port as u16);
}
if let Some(scripts) = value.get("scripts").and_then(|v| v.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 pat in patterns {
if let Ok(re) = Regex::new(pat) {
if let Some(caps) = re.captures(text) {
if let Some(m) = caps.get(1) {
if let Ok(port) = m.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)
}
fn write_configs(
config: &XbpConfig,
project_root: &Path,
yaml_path: &Path,
) -> Result<Vec<PathBuf>, String> {
if let Some(parent) = yaml_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
}
let mut persisted = config.clone();
normalize_config_paths_for_persistence(&mut persisted, project_root);
let yaml = serde_yaml::to_string(&persisted)
.map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
fs::write(yaml_path, yaml)
.map_err(|e| format!("Failed to write {}: {}", yaml_path.display(), e))?;
let mut written_paths = vec![yaml_path.to_path_buf()];
let json_path = yaml_path
.parent()
.map(|parent| parent.join("xbp.json"))
.ok_or_else(|| "Invalid YAML config path".to_string())?;
if json_path.exists() {
let json = serde_json::to_string_pretty(&persisted)
.map_err(|e| format!("Failed to serialize JSON config: {}", e))?;
fs::write(&json_path, json)
.map_err(|e| format!("Failed to write {}: {}", json_path.display(), e))?;
written_paths.push(json_path);
}
Ok(written_paths)
}
async fn current_git_branch() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout)
.ok()
.map(|s| s.trim().to_string())
}
async fn resolve_nested_service_candidate(
project_root: &Path,
current_dir: &Path,
) -> Result<Option<NestedServiceCandidate>, String> {
for candidate in ancestor_dirs_between(current_dir, project_root) {
if !contains_service_discovery_marker(&candidate) {
continue;
}
let project_type = ProjectDetector::detect_project_type(&candidate)
.await
.unwrap_or(ProjectType::Unknown);
if !matches!(project_type, ProjectType::Unknown)
|| !discover_service_version_targets(&candidate, project_root).is_empty()
{
return Ok(Some(NestedServiceCandidate {
service_root: candidate,
project_type,
}));
}
}
Ok(None)
}
fn ancestor_dirs_between(current_dir: &Path, project_root: &Path) -> Vec<PathBuf> {
let mut dirs = Vec::new();
let mut cursor = Some(current_dir);
while let Some(dir) = cursor {
if dir == project_root {
break;
}
dirs.push(dir.to_path_buf());
cursor = dir.parent();
}
dirs
}
fn contains_service_discovery_marker(dir: &Path) -> bool {
SERVICE_DISCOVERY_MARKERS
.iter()
.any(|marker| dir.join(marker).exists())
}
fn ensure_root_service_entry(
config: &mut XbpConfig,
project_root: &Path,
claimed_targets: &[String],
) {
if config.services.is_some() {
return;
}
let mut root_service = legacy_service_from_config(config);
let claimed: HashSet<&str> = claimed_targets.iter().map(String::as_str).collect();
let remaining_targets = collect_service_manifest_targets_from_config(config, project_root)
.into_iter()
.filter(|target| !claimed.contains(target.as_str()))
.collect::<Vec<_>>();
root_service.version_targets = if remaining_targets.is_empty() {
None
} else {
Some(remaining_targets)
};
config.services = Some(vec![root_service]);
}
fn collect_service_manifest_targets_from_config(
config: &XbpConfig,
project_root: &Path,
) -> Vec<String> {
let mut seen = HashSet::new();
let mut manifests = Vec::new();
for target in &config.version_targets {
let relative = collapse_project_path(project_root, target);
if is_service_version_manifest(&relative) && seen.insert(relative.clone()) {
manifests.push(relative);
}
}
if let Some(publish) = &config.publish {
for manifest_path in [publish.npm.as_ref(), publish.crates.as_ref()]
.into_iter()
.flatten()
.filter_map(|target| target.manifest_path.as_ref())
{
let relative = collapse_project_path(project_root, manifest_path);
if is_service_version_manifest(&relative) && seen.insert(relative.clone()) {
manifests.push(relative);
}
}
}
manifests
}
fn is_service_version_manifest(path: &str) -> bool {
let file_name = Path::new(path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default();
SERVICE_VERSION_MANIFESTS.contains(&file_name)
}
fn upsert_service_config(
config: &mut XbpConfig,
service: ServiceConfig,
service_root_relative: &str,
version_targets: &[String],
) {
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.remove(index);
services.insert(index, merge_service_config(existing, service));
} else {
services.push(service);
}
}
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) {
(None, None) => None,
(Some(existing), None) => Some(existing),
(None, Some(detected)) => Some(detected),
(Some(mut existing), Some(detected)) => {
for (key, value) in detected {
existing.insert(key, value);
}
Some(existing)
}
}
}
fn merge_service_commands(
existing: Option<ServiceCommands>,
detected: Option<ServiceCommands>,
) -> Option<ServiceCommands> {
match (existing, detected) {
(None, None) => None,
(Some(existing), None) => Some(existing),
(None, Some(detected)) => Some(detected),
(Some(existing), Some(detected)) => Some(ServiceCommands {
pre: existing.pre.or(detected.pre),
install: existing.install.or(detected.install),
build: existing.build.or(detected.build),
start: existing.start.or(detected.start),
dev: existing.dev.or(detected.dev),
}),
}
}
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();
}
#[cfg(test)]
mod tests {
use super::{
ancestor_dirs_between, contains_service_discovery_marker, discover_service_version_targets,
ensure_root_service_entry, merge_project_version_targets,
};
use crate::strategies::{ServiceConfig, XbpConfig};
use std::fs;
use std::path::PathBuf;
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("xbp-init-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn base_config() -> XbpConfig {
XbpConfig {
project_name: "demo".to_string(),
version: "0.1.0".to_string(),
port: 3000,
build_dir: ".".to_string(),
app_type: Some("rust".to_string()),
build_command: Some("cargo build --release".to_string()),
start_command: Some("./target/release/demo".to_string()),
install_command: None,
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("rust".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![
"crates/cli/Cargo.toml".to_string(),
"apps/web/package.json".to_string(),
],
}
}
#[test]
fn ancestor_dir_scan_stops_before_project_root() {
let root = PathBuf::from("C:/repo");
let nested = PathBuf::from("C:/repo/apps/web/src");
let dirs = ancestor_dirs_between(&nested, &root);
assert_eq!(
dirs,
vec![
PathBuf::from("C:/repo/apps/web/src"),
PathBuf::from("C:/repo/apps/web"),
PathBuf::from("C:/repo/apps"),
]
);
}
#[test]
fn discovery_markers_and_version_targets_detect_nested_package() {
let project_root = temp_dir("markers");
let service_root = project_root.join("apps").join("web");
fs::create_dir_all(&service_root).expect("create service root");
fs::write(service_root.join("package.json"), "{ \"name\": \"web\" }")
.expect("write package");
assert!(contains_service_discovery_marker(&service_root));
assert_eq!(
discover_service_version_targets(&service_root, &project_root),
vec!["apps/web/package.json".to_string()]
);
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn ensuring_root_service_claims_remaining_targets() {
let project_root = temp_dir("root-service");
let mut config = base_config();
ensure_root_service_entry(
&mut config,
&project_root,
&["apps/web/package.json".to_string()],
);
let services = config.services.expect("services");
assert_eq!(services.len(), 1);
assert_eq!(services[0].name, "demo");
assert_eq!(
services[0].version_targets,
Some(vec!["crates/cli/Cargo.toml".to_string()])
);
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn project_version_targets_merge_without_duplicates() {
let mut config = base_config();
merge_project_version_targets(
&mut config,
&[
"apps/web/package.json".to_string(),
"apps/api/pyproject.toml".to_string(),
],
);
assert_eq!(
config.version_targets,
vec![
"apps/api/pyproject.toml".to_string(),
"apps/web/package.json".to_string(),
"crates/cli/Cargo.toml".to_string(),
]
);
}
#[test]
fn merge_root_service_tests_reference_service_config_type() {
let _: Option<ServiceConfig> = None;
}
}