Skip to main content

xbp_cli/commands/
init.rs

1//! Init command module
2//!
3//! Guides the user through creating a project-local XBP configuration
4//! under `.xbp/` by detecting framework, port, and sensible defaults.
5//! Writes a canonical YAML config and optionally syncs legacy JSON when present.
6//! Optionally commits/pushes the changes to git.
7
8use 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(&current_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(&current_dir)
92        .await
93        .unwrap_or(ProjectType::Unknown);
94    debug!(?project_type, "Detected project type");
95
96    let recommendations =
97        ProjectDetector::get_deployment_recommendations(&current_dir, &project_type);
98    let inferred_name = infer_project_name(&project_type, &current_dir, &recommendations);
99    let app_type_guess = infer_app_type(&project_type);
100    let port_guess = detect_port(&current_dir, &project_type, &recommendations);
101    let env_vars = detect_environment_from_env_files(&current_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(&current_dir, &current_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, &current_dir, &project_type).await?;
159
160    let yaml_path = default_project_yaml_config_path(&current_dir);
161    let written_paths = write_configs(&config, &current_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: &current_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(&current_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, &current_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}