1use crate::cli::auto_commit::{
9 commit_paths, print_push_summary, print_skip, push_current_branch, AutoCommitRequest,
10 AutoCommitResult,
11};
12use crate::commands::project_services::{auto_populate_services, discover_service_version_targets};
13use crate::strategies::deployment_config::XbpConfig;
14use crate::strategies::project_detector::{
15 infer_project_name as shared_infer_project_name, DeploymentRecommendations, PackageJsonInfo,
16 ProjectDetector, ProjectType,
17};
18use crate::strategies::{
19 legacy_service_from_config, normalize_config_paths_for_persistence, validate_services,
20 DeploymentConfig, ServiceCommands, ServiceConfig,
21};
22use crate::utils::{
23 collapse_project_path, default_project_yaml_config_path, find_xbp_config_upwards,
24 parse_env_file, to_env_references, FoundXbpConfig,
25};
26use std::collections::{HashMap, HashSet};
27use std::env;
28use std::fs;
29use std::path::{Path, PathBuf};
30
31use dialoguer::{Confirm, Input, Select};
32use regex::Regex;
33use tokio::process::Command;
34use tracing::debug;
35
36const SERVICE_DISCOVERY_MARKERS: &[&str] = &[
37 "package.json",
38 "Cargo.toml",
39 "pyproject.toml",
40 "requirements.txt",
41 "setup.py",
42 "Dockerfile",
43 "docker-compose.yml",
44 "docker-compose.yaml",
45 "compose.yml",
46 "compose.yaml",
47 "railway.json",
48 "railway.toml",
49 "vercel.json",
50 "go.mod",
51];
52
53const SERVICE_VERSION_MANIFESTS: &[&str] = &[
54 "package.json",
55 "Cargo.toml",
56 "pyproject.toml",
57 "composer.json",
58 "deno.json",
59 "deno.jsonc",
60 "Chart.yaml",
61 "app.json",
62 "manifest.json",
63 "pom.xml",
64 "build.gradle",
65 "build.gradle.kts",
66];
67
68pub async fn run_init(_debug: bool) -> Result<(), String> {
69 let current_dir: PathBuf =
70 env::current_dir().map_err(|e| format!("Failed to read current directory: {}", e))?;
71
72 if let Some(found) = find_xbp_config_upwards(¤t_dir) {
73 if found.project_root != current_dir {
74 return run_nested_service_init(found, current_dir).await;
75 }
76
77 let proceed = Confirm::new()
78 .with_prompt(format!(
79 "An XBP config already exists at {}. Overwrite?",
80 found.location
81 ))
82 .default(false)
83 .interact()
84 .map_err(|e| format!("Prompt failed: {}", e))?;
85
86 if !proceed {
87 return Ok(());
88 }
89 }
90
91 let project_type: ProjectType = ProjectDetector::detect_project_type(¤t_dir)
92 .await
93 .unwrap_or(ProjectType::Unknown);
94 debug!(?project_type, "Detected project type");
95
96 let recommendations =
97 ProjectDetector::get_deployment_recommendations(¤t_dir, &project_type);
98 let inferred_name = infer_project_name(&project_type, ¤t_dir, &recommendations);
99 let app_type_guess = infer_app_type(&project_type);
100 let port_guess = detect_port(¤t_dir, &project_type, &recommendations);
101 let env_vars = detect_environment_from_env_files(¤t_dir);
102
103 let project_name: String = Input::new()
104 .with_prompt("Project name")
105 .with_initial_text(inferred_name)
106 .interact_text()
107 .map_err(|e| format!("Prompt failed: {}", e))?;
108
109 let app_type: String =
110 select_app_type(app_type_guess.clone()).map_err(|e| format!("Prompt failed: {}", e))?;
111
112 let port: u16 = Input::new()
113 .with_prompt("Primary port")
114 .default(port_guess)
115 .interact_text()
116 .map_err(|e| format!("Prompt failed: {}", e))?;
117
118 let build_dir = collapse_project_path(¤t_dir, ¤t_dir.to_string_lossy());
119
120 let mut config = XbpConfig {
121 project_name,
122 version: "0.1.0".to_string(),
123 port,
124 build_dir,
125 app_type: Some(app_type.clone()),
126 build_command: recommendations.build_command.clone(),
127 start_command: recommendations.start_command.clone(),
128 install_command: recommendations.install_command.clone(),
129 environment: if env_vars.is_empty() {
130 None
131 } else {
132 Some(env_vars)
133 },
134 services: None,
135 systemd_service_name: None,
136 systemd: None,
137 kafka_brokers: None,
138 kafka_topic: None,
139 kafka_public_url: None,
140 log_files: None,
141 monitor_url: None,
142 monitor_method: None,
143 monitor_expected_code: None,
144 monitor_interval: None,
145 database: None,
146 target: Some(app_type),
147 branch: current_git_branch().await,
148 crate_name: None,
149 npm_script: None,
150 port_storybook: None,
151 url: None,
152 url_storybook: None,
153 linear: None,
154 github: None,
155 publish: None,
156 version_targets: Vec::new(),
157 };
158 auto_populate_services(&mut config, ¤t_dir, &project_type).await?;
159
160 let yaml_path = default_project_yaml_config_path(¤t_dir);
161 let written_paths = write_configs(&config, ¤t_dir, &yaml_path)?;
162
163 let legacy_json_path = yaml_path
164 .parent()
165 .map(|parent| parent.join("xbp.json"))
166 .ok_or_else(|| "Invalid YAML config path".to_string())?;
167 if legacy_json_path.exists() {
168 println!(
169 "Created {} (synced legacy {})",
170 yaml_path.display(),
171 legacy_json_path.display()
172 );
173 } else {
174 println!("Created {}", yaml_path.display());
175 }
176
177 match commit_paths(AutoCommitRequest {
178 project_root: ¤t_dir,
179 paths: written_paths,
180 message: "chore(xbp): initialize project config".to_string(),
181 action_label: "xbp init",
182 })
183 .await
184 {
185 Ok(AutoCommitResult::Committed(_)) => match push_current_branch(¤t_dir).await {
186 Ok(Some(outcome)) => print_push_summary(&outcome),
187 Ok(None) => {}
188 Err(e) => print_skip("xbp init", &format!("git push failed: {}", e)),
189 },
190 Ok(AutoCommitResult::Skipped(reason)) => print_skip("xbp init", &reason),
191 Err(e) => print_skip("xbp init", &e),
192 }
193
194 Ok(())
195}
196
197#[derive(Debug, Clone)]
198struct NestedServiceCandidate {
199 service_root: PathBuf,
200 project_type: ProjectType,
201}
202
203async fn run_nested_service_init(
204 found: FoundXbpConfig,
205 current_dir: PathBuf,
206) -> Result<(), String> {
207 let candidate = resolve_nested_service_candidate(&found.project_root, ¤t_dir)
208 .await?
209 .ok_or_else(|| {
210 format!(
211 "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.",
212 found.project_root.display(),
213 current_dir.display()
214 )
215 })?;
216
217 let recommendations = ProjectDetector::get_deployment_recommendations(
218 &candidate.service_root,
219 &candidate.project_type,
220 );
221 let inferred_name = infer_project_name(
222 &candidate.project_type,
223 &candidate.service_root,
224 &recommendations,
225 );
226 let app_type_guess = infer_app_type(&candidate.project_type);
227 let port_guess = detect_port(
228 &candidate.service_root,
229 &candidate.project_type,
230 &recommendations,
231 );
232 let env_vars = detect_environment_from_env_files(&candidate.service_root);
233 let version_targets =
234 discover_service_version_targets(&candidate.service_root, &found.project_root);
235
236 let service_name: String = Input::new()
237 .with_prompt("Service name")
238 .with_initial_text(inferred_name)
239 .interact_text()
240 .map_err(|e| format!("Prompt failed: {}", e))?;
241
242 let app_type =
243 select_app_type(app_type_guess.clone()).map_err(|e| format!("Prompt failed: {}", e))?;
244
245 let port: u16 = Input::new()
246 .with_prompt("Primary port")
247 .default(port_guess)
248 .interact_text()
249 .map_err(|e| format!("Prompt failed: {}", e))?;
250
251 let service_root_relative = collapse_project_path(
252 &found.project_root,
253 &candidate.service_root.to_string_lossy(),
254 );
255 let service_config = ServiceConfig {
256 name: service_name.clone(),
257 target: app_type.clone(),
258 branch: current_git_branch()
259 .await
260 .unwrap_or_else(|| "main".to_string()),
261 port,
262 root_directory: Some(service_root_relative.clone()),
263 environment: if env_vars.is_empty() {
264 None
265 } else {
266 Some(env_vars)
267 },
268 url: None,
269 healthcheck_path: None,
270 restart_policy: Some("on_failure".to_string()),
271 restart_policy_max_failure_count: Some(10),
272 start_wrapper: Some("pm2".to_string()),
273 commands: Some(ServiceCommands {
274 pre: None,
275 install: recommendations.install_command.clone(),
276 build: recommendations.build_command.clone(),
277 start: recommendations.start_command.clone(),
278 dev: None,
279 }),
280 force_run_from_root: Some(false),
281 version_targets: if version_targets.is_empty() {
282 None
283 } else {
284 Some(version_targets.clone())
285 },
286 systemd_service_name: None,
287 systemd: None,
288 };
289
290 let mut config = DeploymentConfig::load_xbp_config(Some(found.config_path.clone())).await?;
291 ensure_root_service_entry(&mut config, &found.project_root, &version_targets);
292 upsert_service_config(
293 &mut config,
294 service_config,
295 &service_root_relative,
296 &version_targets,
297 );
298 merge_project_version_targets(&mut config, &version_targets);
299
300 if let Some(services) = &config.services {
301 validate_services(services)?;
302 }
303
304 let yaml_path = default_project_yaml_config_path(&found.project_root);
305 let written_paths = write_configs(&config, &found.project_root, &yaml_path)?;
306 println!(
307 "Updated {} and registered nested service `{}` at {}",
308 yaml_path.display(),
309 service_name,
310 service_root_relative
311 );
312
313 match commit_paths(AutoCommitRequest {
314 project_root: &found.project_root,
315 paths: written_paths,
316 message: format!("chore(xbp): register service {}", service_name),
317 action_label: "xbp init",
318 })
319 .await
320 {
321 Ok(AutoCommitResult::Committed(_)) => {
322 match push_current_branch(&found.project_root).await {
323 Ok(Some(outcome)) => print_push_summary(&outcome),
324 Ok(None) => {}
325 Err(e) => print_skip("xbp init", &format!("git push failed: {}", e)),
326 }
327 }
328 Ok(AutoCommitResult::Skipped(reason)) => print_skip("xbp init", &reason),
329 Err(e) => print_skip("xbp init", &e),
330 }
331
332 Ok(())
333}
334
335fn infer_project_name(
336 project_type: &ProjectType,
337 current_dir: &Path,
338 recommendations: &DeploymentRecommendations,
339) -> String {
340 shared_infer_project_name(current_dir, project_type, recommendations)
341}
342
343fn infer_app_type(project_type: &ProjectType) -> Option<String> {
344 match project_type {
345 ProjectType::NextJs { .. } => Some("nextjs".to_string()),
346 ProjectType::NodeJs { package_json } => {
347 if has_express_dependency(package_json) {
348 Some("expressjs".to_string())
349 } else {
350 Some("nodejs".to_string())
351 }
352 }
353 ProjectType::Rust { .. } => Some("rust".to_string()),
354 ProjectType::DockerCompose { .. } => Some("docker-compose".to_string()),
355 ProjectType::Docker { .. } => Some("docker".to_string()),
356 ProjectType::Railway { .. } => Some("railway".to_string()),
357 ProjectType::Vercel { .. } => Some("vercel".to_string()),
358 ProjectType::Python { .. } => Some("python".to_string()),
359 _ => None,
360 }
361}
362
363fn has_express_dependency(package_json: &PackageJsonInfo) -> bool {
364 package_json
365 .dependencies
366 .keys()
367 .any(|k| k.eq_ignore_ascii_case("express"))
368 || package_json
369 .dev_dependencies
370 .keys()
371 .any(|k| k.eq_ignore_ascii_case("express"))
372}
373
374fn select_app_type(detected: Option<String>) -> Result<String, String> {
375 let mut options: Vec<String> = vec![
376 "nextjs".to_string(),
377 "expressjs".to_string(),
378 "rust".to_string(),
379 "nodejs".to_string(),
380 "python".to_string(),
381 "docker".to_string(),
382 "railway".to_string(),
383 "vercel".to_string(),
384 "docker-compose".to_string(),
385 "custom...".to_string(),
386 ];
387
388 let default_index = if let Some(ref guess) = detected {
389 if let Some(pos) = options.iter().position(|o| o == guess) {
390 pos
391 } else {
392 options.insert(0, format!("{} (detected)", guess));
393 0
394 }
395 } else {
396 0
397 };
398
399 let selection = Select::new()
400 .with_prompt("App type")
401 .items(&options)
402 .default(default_index)
403 .interact()
404 .map_err(|e| format!("Prompt failed: {}", e))?;
405
406 let choice = options
407 .get(selection)
408 .cloned()
409 .unwrap_or_else(|| "nextjs".to_string());
410
411 if choice == "custom..." {
412 Input::<String>::new()
413 .with_prompt("Enter app type")
414 .interact_text()
415 .map_err(|e| format!("Prompt failed: {}", e))
416 } else if let Some(stripped) = choice.strip_suffix(" (detected)") {
417 Ok(stripped.to_string())
418 } else {
419 Ok(choice)
420 }
421}
422
423fn detect_port(
424 project_root: &Path,
425 project_type: &ProjectType,
426 recommendations: &DeploymentRecommendations,
427) -> u16 {
428 if let Ok(port_env) = env::var("PORT") {
429 if let Ok(port) = port_env.parse::<u16>() {
430 return port;
431 }
432 }
433
434 for name in [".env", ".env.local", ".env.development", ".env.production"] {
435 if let Some(port) = parse_port_from_env_file(&project_root.join(name)) {
436 return port;
437 }
438 }
439
440 if let Some(port) = detect_port_from_package_json(project_root) {
441 return port;
442 }
443
444 if let ProjectType::DockerCompose { detected_ports, .. } = project_type {
445 if let Some(port) = detected_ports.first() {
446 return *port;
447 }
448 }
449
450 recommendations.default_port
451}
452
453fn parse_port_from_env_file(path: &Path) -> Option<u16> {
454 if let Ok(parsed) = parse_env_file(path) {
455 if let Some(port) = parsed
456 .get("PORT")
457 .and_then(|value| value.parse::<u16>().ok())
458 {
459 return Some(port);
460 }
461 }
462
463 let contents = fs::read_to_string(path).ok()?;
464 for line in contents.lines() {
465 if let Some(port) = extract_port_from_str(line.trim()) {
466 return Some(port);
467 }
468 }
469 None
470}
471
472fn detect_port_from_package_json(project_root: &Path) -> Option<u16> {
473 let pkg_path = project_root.join("package.json");
474 let content = fs::read_to_string(&pkg_path).ok()?;
475 let value: serde_json::Value = serde_json::from_str(&content).ok()?;
476
477 if let Some(port) = value.get("port").and_then(|v| v.as_u64()) {
478 return Some(port as u16);
479 }
480
481 if let Some(scripts) = value.get("scripts").and_then(|v| v.as_object()) {
482 for script in scripts.values() {
483 if let Some(text) = script.as_str() {
484 if let Some(port) = extract_port_from_str(text) {
485 return Some(port);
486 }
487 }
488 }
489 }
490
491 None
492}
493
494fn extract_port_from_str(text: &str) -> Option<u16> {
495 let patterns = [
496 r"PORT\s*[:=]\s*(\d{2,5})",
497 r"port\s*[:=]\s*(\d{2,5})",
498 r"--port\s+(\d{2,5})",
499 r"-p\s+(\d{2,5})",
500 ];
501
502 for pat in patterns {
503 if let Ok(re) = Regex::new(pat) {
504 if let Some(caps) = re.captures(text) {
505 if let Some(m) = caps.get(1) {
506 if let Ok(port) = m.as_str().parse::<u16>() {
507 return Some(port);
508 }
509 }
510 }
511 }
512 }
513 None
514}
515
516fn detect_environment_from_env_files(project_root: &Path) -> HashMap<String, String> {
517 let mut env_map = HashMap::new();
518 for name in [".env", ".env.local", ".env.development", ".env.production"] {
519 let path = project_root.join(name);
520 if !path.exists() {
521 continue;
522 }
523 if let Ok(parsed) = parse_env_file(&path) {
524 for (key, value) in parsed {
525 env_map.entry(key).or_insert(value);
526 }
527 }
528 }
529 to_env_references(&env_map)
530}
531
532fn write_configs(
533 config: &XbpConfig,
534 project_root: &Path,
535 yaml_path: &Path,
536) -> Result<Vec<PathBuf>, String> {
537 if let Some(parent) = yaml_path.parent() {
538 fs::create_dir_all(parent)
539 .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
540 }
541
542 let mut persisted = config.clone();
543 normalize_config_paths_for_persistence(&mut persisted, project_root);
544
545 let yaml = serde_yaml::to_string(&persisted)
546 .map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
547 fs::write(yaml_path, yaml)
548 .map_err(|e| format!("Failed to write {}: {}", yaml_path.display(), e))?;
549
550 let mut written_paths = vec![yaml_path.to_path_buf()];
551
552 let json_path = yaml_path
553 .parent()
554 .map(|parent| parent.join("xbp.json"))
555 .ok_or_else(|| "Invalid YAML config path".to_string())?;
556 if json_path.exists() {
557 let json = serde_json::to_string_pretty(&persisted)
558 .map_err(|e| format!("Failed to serialize JSON config: {}", e))?;
559 fs::write(&json_path, json)
560 .map_err(|e| format!("Failed to write {}: {}", json_path.display(), e))?;
561 written_paths.push(json_path);
562 }
563
564 Ok(written_paths)
565}
566
567async fn current_git_branch() -> Option<String> {
568 let output = Command::new("git")
569 .args(["rev-parse", "--abbrev-ref", "HEAD"])
570 .output()
571 .await
572 .ok()?;
573
574 if !output.status.success() {
575 return None;
576 }
577
578 String::from_utf8(output.stdout)
579 .ok()
580 .map(|s| s.trim().to_string())
581}
582
583async fn resolve_nested_service_candidate(
584 project_root: &Path,
585 current_dir: &Path,
586) -> Result<Option<NestedServiceCandidate>, String> {
587 for candidate in ancestor_dirs_between(current_dir, project_root) {
588 if !contains_service_discovery_marker(&candidate) {
589 continue;
590 }
591
592 let project_type = ProjectDetector::detect_project_type(&candidate)
593 .await
594 .unwrap_or(ProjectType::Unknown);
595 if !matches!(project_type, ProjectType::Unknown)
596 || !discover_service_version_targets(&candidate, project_root).is_empty()
597 {
598 return Ok(Some(NestedServiceCandidate {
599 service_root: candidate,
600 project_type,
601 }));
602 }
603 }
604
605 Ok(None)
606}
607
608fn ancestor_dirs_between(current_dir: &Path, project_root: &Path) -> Vec<PathBuf> {
609 let mut dirs = Vec::new();
610 let mut cursor = Some(current_dir);
611 while let Some(dir) = cursor {
612 if dir == project_root {
613 break;
614 }
615 dirs.push(dir.to_path_buf());
616 cursor = dir.parent();
617 }
618 dirs
619}
620
621fn contains_service_discovery_marker(dir: &Path) -> bool {
622 SERVICE_DISCOVERY_MARKERS
623 .iter()
624 .any(|marker| dir.join(marker).exists())
625}
626
627fn ensure_root_service_entry(
628 config: &mut XbpConfig,
629 project_root: &Path,
630 claimed_targets: &[String],
631) {
632 if config.services.is_some() {
633 return;
634 }
635
636 let mut root_service = legacy_service_from_config(config);
637 let claimed: HashSet<&str> = claimed_targets.iter().map(String::as_str).collect();
638 let remaining_targets = collect_service_manifest_targets_from_config(config, project_root)
639 .into_iter()
640 .filter(|target| !claimed.contains(target.as_str()))
641 .collect::<Vec<_>>();
642
643 root_service.version_targets = if remaining_targets.is_empty() {
644 None
645 } else {
646 Some(remaining_targets)
647 };
648 config.services = Some(vec![root_service]);
649}
650
651fn collect_service_manifest_targets_from_config(
652 config: &XbpConfig,
653 project_root: &Path,
654) -> Vec<String> {
655 let mut seen = HashSet::new();
656 let mut manifests = Vec::new();
657
658 for target in &config.version_targets {
659 let relative = collapse_project_path(project_root, target);
660 if is_service_version_manifest(&relative) && seen.insert(relative.clone()) {
661 manifests.push(relative);
662 }
663 }
664
665 if let Some(publish) = &config.publish {
666 for manifest_path in [publish.npm.as_ref(), publish.crates.as_ref()]
667 .into_iter()
668 .flatten()
669 .filter_map(|target| target.manifest_path.as_ref())
670 {
671 let relative = collapse_project_path(project_root, manifest_path);
672 if is_service_version_manifest(&relative) && seen.insert(relative.clone()) {
673 manifests.push(relative);
674 }
675 }
676 }
677
678 manifests
679}
680
681fn is_service_version_manifest(path: &str) -> bool {
682 let file_name = Path::new(path)
683 .file_name()
684 .and_then(|value| value.to_str())
685 .unwrap_or_default();
686 SERVICE_VERSION_MANIFESTS.contains(&file_name)
687}
688
689fn upsert_service_config(
690 config: &mut XbpConfig,
691 service: ServiceConfig,
692 service_root_relative: &str,
693 version_targets: &[String],
694) {
695 let services = config.services.get_or_insert_with(Vec::new);
696 let service_target_set: HashSet<&str> = version_targets.iter().map(String::as_str).collect();
697
698 let existing_index = services.iter().position(|existing| {
699 existing.root_directory.as_deref() == Some(service_root_relative)
700 || existing
701 .version_targets
702 .as_ref()
703 .map(|targets| {
704 targets
705 .iter()
706 .any(|target| service_target_set.contains(target.as_str()))
707 })
708 .unwrap_or(false)
709 || existing.name.eq_ignore_ascii_case(&service.name)
710 });
711
712 if let Some(index) = existing_index {
713 let existing = services.remove(index);
714 services.insert(index, merge_service_config(existing, service));
715 } else {
716 services.push(service);
717 }
718}
719
720fn merge_service_config(existing: ServiceConfig, detected: ServiceConfig) -> ServiceConfig {
721 ServiceConfig {
722 name: detected.name,
723 target: detected.target,
724 branch: detected.branch,
725 port: detected.port,
726 root_directory: detected.root_directory,
727 environment: merge_environment_maps(existing.environment, detected.environment),
728 url: existing.url.or(detected.url),
729 healthcheck_path: existing.healthcheck_path.or(detected.healthcheck_path),
730 restart_policy: existing.restart_policy.or(detected.restart_policy),
731 restart_policy_max_failure_count: existing
732 .restart_policy_max_failure_count
733 .or(detected.restart_policy_max_failure_count),
734 start_wrapper: existing.start_wrapper.or(detected.start_wrapper),
735 commands: merge_service_commands(existing.commands, detected.commands),
736 force_run_from_root: existing
737 .force_run_from_root
738 .or(detected.force_run_from_root),
739 version_targets: detected.version_targets.or(existing.version_targets),
740 systemd_service_name: existing
741 .systemd_service_name
742 .or(detected.systemd_service_name),
743 systemd: existing.systemd.or(detected.systemd),
744 }
745}
746
747fn merge_environment_maps(
748 existing: Option<HashMap<String, String>>,
749 detected: Option<HashMap<String, String>>,
750) -> Option<HashMap<String, String>> {
751 match (existing, detected) {
752 (None, None) => None,
753 (Some(existing), None) => Some(existing),
754 (None, Some(detected)) => Some(detected),
755 (Some(mut existing), Some(detected)) => {
756 for (key, value) in detected {
757 existing.insert(key, value);
758 }
759 Some(existing)
760 }
761 }
762}
763
764fn merge_service_commands(
765 existing: Option<ServiceCommands>,
766 detected: Option<ServiceCommands>,
767) -> Option<ServiceCommands> {
768 match (existing, detected) {
769 (None, None) => None,
770 (Some(existing), None) => Some(existing),
771 (None, Some(detected)) => Some(detected),
772 (Some(existing), Some(detected)) => Some(ServiceCommands {
773 pre: existing.pre.or(detected.pre),
774 install: existing.install.or(detected.install),
775 build: existing.build.or(detected.build),
776 start: existing.start.or(detected.start),
777 dev: existing.dev.or(detected.dev),
778 }),
779 }
780}
781
782fn merge_project_version_targets(config: &mut XbpConfig, version_targets: &[String]) {
783 let mut seen: HashSet<String> = config.version_targets.iter().cloned().collect();
784 for target in version_targets {
785 if seen.insert(target.clone()) {
786 config.version_targets.push(target.clone());
787 }
788 }
789 config.version_targets.sort();
790 config.version_targets.dedup();
791}
792
793#[cfg(test)]
794mod tests {
795 use super::{
796 ancestor_dirs_between, contains_service_discovery_marker, discover_service_version_targets,
797 ensure_root_service_entry, merge_project_version_targets,
798 };
799 use crate::strategies::{ServiceConfig, XbpConfig};
800 use std::fs;
801 use std::path::PathBuf;
802
803 fn temp_dir(name: &str) -> PathBuf {
804 let dir = std::env::temp_dir().join(format!("xbp-init-{name}-{}", std::process::id()));
805 let _ = fs::remove_dir_all(&dir);
806 fs::create_dir_all(&dir).expect("create temp dir");
807 dir
808 }
809
810 fn base_config() -> XbpConfig {
811 XbpConfig {
812 project_name: "demo".to_string(),
813 version: "0.1.0".to_string(),
814 port: 3000,
815 build_dir: ".".to_string(),
816 app_type: Some("rust".to_string()),
817 build_command: Some("cargo build --release".to_string()),
818 start_command: Some("./target/release/demo".to_string()),
819 install_command: None,
820 environment: None,
821 services: None,
822 systemd_service_name: None,
823 systemd: None,
824 kafka_brokers: None,
825 kafka_topic: None,
826 kafka_public_url: None,
827 log_files: None,
828 monitor_url: None,
829 monitor_method: None,
830 monitor_expected_code: None,
831 monitor_interval: None,
832 database: None,
833 target: Some("rust".to_string()),
834 branch: Some("main".to_string()),
835 crate_name: None,
836 npm_script: None,
837 port_storybook: None,
838 url: None,
839 url_storybook: None,
840 linear: None,
841 github: None,
842 publish: None,
843 version_targets: vec![
844 "crates/cli/Cargo.toml".to_string(),
845 "apps/web/package.json".to_string(),
846 ],
847 }
848 }
849
850 #[test]
851 fn ancestor_dir_scan_stops_before_project_root() {
852 let root = PathBuf::from("C:/repo");
853 let nested = PathBuf::from("C:/repo/apps/web/src");
854 let dirs = ancestor_dirs_between(&nested, &root);
855 assert_eq!(
856 dirs,
857 vec![
858 PathBuf::from("C:/repo/apps/web/src"),
859 PathBuf::from("C:/repo/apps/web"),
860 PathBuf::from("C:/repo/apps"),
861 ]
862 );
863 }
864
865 #[test]
866 fn discovery_markers_and_version_targets_detect_nested_package() {
867 let project_root = temp_dir("markers");
868 let service_root = project_root.join("apps").join("web");
869 fs::create_dir_all(&service_root).expect("create service root");
870 fs::write(service_root.join("package.json"), "{ \"name\": \"web\" }")
871 .expect("write package");
872
873 assert!(contains_service_discovery_marker(&service_root));
874 assert_eq!(
875 discover_service_version_targets(&service_root, &project_root),
876 vec!["apps/web/package.json".to_string()]
877 );
878
879 let _ = fs::remove_dir_all(project_root);
880 }
881
882 #[test]
883 fn ensuring_root_service_claims_remaining_targets() {
884 let project_root = temp_dir("root-service");
885 let mut config = base_config();
886 ensure_root_service_entry(
887 &mut config,
888 &project_root,
889 &["apps/web/package.json".to_string()],
890 );
891
892 let services = config.services.expect("services");
893 assert_eq!(services.len(), 1);
894 assert_eq!(services[0].name, "demo");
895 assert_eq!(
896 services[0].version_targets,
897 Some(vec!["crates/cli/Cargo.toml".to_string()])
898 );
899
900 let _ = fs::remove_dir_all(project_root);
901 }
902
903 #[test]
904 fn project_version_targets_merge_without_duplicates() {
905 let mut config = base_config();
906 merge_project_version_targets(
907 &mut config,
908 &[
909 "apps/web/package.json".to_string(),
910 "apps/api/pyproject.toml".to_string(),
911 ],
912 );
913
914 assert_eq!(
915 config.version_targets,
916 vec![
917 "apps/api/pyproject.toml".to_string(),
918 "apps/web/package.json".to_string(),
919 "crates/cli/Cargo.toml".to_string(),
920 ]
921 );
922 }
923
924 #[test]
925 fn merge_root_service_tests_reference_service_config_type() {
926 let _: Option<ServiceConfig> = None;
927 }
928}