Skip to main content

xbp_cli/commands/deploy_engine/
setup.rs

1//! Interactive / derived setup for missing `services[].deploy.envs.<env>`.
2//!
3//! When `xbp deploy <target> --env <env>` hits a service with no deploy env,
4//! offer a wizard (TTY) or accept derived defaults (`--yes`) and write
5//! `.xbp/xbp.toml` before planning continues.
6
7use std::collections::HashMap;
8use std::io::IsTerminal;
9use std::path::{Path, PathBuf};
10
11use colored::Colorize;
12use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
13
14use crate::strategies::deployment_config::{
15    DeployProjectConfig, KubernetesProjectConfig, ServiceDeployConfig, ServiceDeployEnvConfig,
16    ServiceDeployKubernetesConfig, ServiceDeployManifestsConfig, ServiceConfig, XbpConfig,
17};
18use crate::utils::find_xbp_config_upwards;
19
20/// Ensure every service needed for `target`+`env` has `deploy.envs.<env>`.
21///
22/// Returns an updated config (possibly written to disk). On non-interactive
23/// terminals without `--yes`, returns a structured error that lists derived defaults.
24pub fn ensure_deploy_env_for_target(
25    project_root: &Path,
26    mut config: XbpConfig,
27    target: &str,
28    env: &str,
29    yes: bool,
30) -> Result<XbpConfig, String> {
31    let env = env.trim();
32    if env.is_empty() {
33        return Err("deploy env name is empty".into());
34    }
35
36    let missing = services_missing_deploy_env(&config, target, env)?;
37    if missing.is_empty() {
38        return Ok(config);
39    }
40
41    let interactive = std::io::stdin().is_terminal()
42        && std::io::stdout().is_terminal()
43        && std::env::var_os("XBP_NON_INTERACTIVE").is_none();
44
45    println!();
46    println!(
47        "{} {}",
48        "Deploy setup".bright_cyan().bold(),
49        format!("— missing deploy.envs.{env} for {} service(s)", missing.len()).dimmed()
50    );
51    for name in &missing {
52        println!("  {} {}", "•".bright_yellow(), name.bright_white());
53    }
54    println!();
55
56    let mut derived_all: Vec<(String, DerivedServiceDeploy)> = Vec::new();
57    for name in &missing {
58        let svc = config
59            .services
60            .as_ref()
61            .and_then(|list| list.iter().find(|s| s.name == *name))
62            .ok_or_else(|| format!("service `{name}` disappeared during deploy setup"))?;
63        let derived = derive_service_deploy(
64            project_root,
65            &config.project_name,
66            env,
67            svc,
68            config.kubernetes.as_ref(),
69        );
70        print_derived(name, env, &derived);
71        derived_all.push((name.clone(), derived));
72    }
73
74    if !interactive && !yes {
75        return Err(format!(
76            "service(s) missing deploy.envs.{env}: {}\n\n\
77             Derived defaults are shown above.\n\
78             Re-run in an interactive terminal to configure with the wizard, or pass `--yes` to accept derived defaults and write them to .xbp/xbp.toml.",
79            missing.join(", ")
80        ));
81    }
82
83    let proceed = if yes {
84        true
85    } else {
86        Confirm::with_theme(&ColorfulTheme::default())
87            .with_prompt(format!(
88                "Configure deploy.envs.{env} for {} service(s) and continue?",
89                missing.len()
90            ))
91            .default(true)
92            .interact()
93            .map_err(|e| e.to_string())?
94    };
95
96    if !proceed {
97        return Err(format!(
98            "deploy aborted: service(s) still missing deploy.envs.{env} ({})",
99            missing.join(", ")
100        ));
101    }
102
103    for (name, mut derived) in derived_all {
104        if interactive && !yes {
105            derived = refine_derived_interactively(name.as_str(), env, derived)?;
106        }
107        apply_derived_to_config(&mut config, &name, env, derived)?;
108    }
109
110    // Project-level deploy defaults (default_env / history paths).
111    ensure_project_deploy_defaults(&mut config, env);
112
113    let config_path = resolve_config_write_path(project_root)?;
114    write_project_config(project_root, &config_path, &config)?;
115    println!(
116        "{} wrote deploy env config → {}",
117        "✓".bright_green().bold(),
118        config_path.display().to_string().dimmed()
119    );
120
121    Ok(config)
122}
123
124/// Services referenced by `target` that lack `deploy.envs.<env>`.
125pub fn services_missing_deploy_env(
126    config: &XbpConfig,
127    target: &str,
128    env: &str,
129) -> Result<Vec<String>, String> {
130    let raw = target.trim();
131    if raw.is_empty() {
132        return Err("deploy target is required".into());
133    }
134
135    let services = config.services.as_deref().unwrap_or(&[]);
136
137    if raw.eq_ignore_ascii_case("all") {
138        // `all` only deploys services that already declare the env — if none do,
139        // offer to set up every known service.
140        let any = services.iter().any(|s| service_has_env(s, env));
141        if any {
142            return Ok(Vec::new());
143        }
144        return Ok(services.iter().map(|s| s.name.clone()).collect());
145    }
146
147    if let Some(group) = config
148        .deploy
149        .as_ref()
150        .and_then(|d| d.groups.get(raw))
151    {
152        let order = if group.order.is_empty() {
153            &group.services
154        } else {
155            &group.order
156        };
157        let mut missing = Vec::new();
158        for name in order {
159            // Skip unknown members (healed groups should already prune these).
160            let Some(svc) = services.iter().find(|s| s.name == *name) else {
161                continue;
162            };
163            if !service_has_env(svc, env) {
164                missing.push(name.clone());
165            }
166        }
167        return Ok(missing);
168    }
169
170    if let Some(svc) = services.iter().find(|s| s.name == raw) {
171        return Ok(if service_has_env(svc, env) {
172            Vec::new()
173        } else {
174            vec![svc.name.clone()]
175        });
176    }
177
178    // Unique prefix (same rules as engine, but only report missing for the match).
179    let group_keys: Vec<&String> = config
180        .deploy
181        .as_ref()
182        .map(|d| d.groups.keys().filter(|k| k.starts_with(raw)).collect())
183        .unwrap_or_default();
184    let service_names: Vec<&String> = services
185        .iter()
186        .map(|s| &s.name)
187        .filter(|n| n.starts_with(raw))
188        .collect();
189
190    if group_keys.len() == 1 && service_names.is_empty() {
191        return services_missing_deploy_env(config, group_keys[0], env);
192    }
193    if service_names.len() == 1 && group_keys.is_empty() {
194        return services_missing_deploy_env(config, service_names[0], env);
195    }
196
197    // Unknown target — let the engine produce the full suggestion list.
198    Ok(Vec::new())
199}
200
201fn service_has_env(svc: &ServiceConfig, env: &str) -> bool {
202    svc.deploy
203        .as_ref()
204        .map(|d| d.envs.contains_key(env))
205        .unwrap_or(false)
206}
207
208#[derive(Debug, Clone)]
209pub struct DerivedServiceDeploy {
210    pub provider: String,
211    pub namespace: Option<String>,
212    pub replicas: Option<u32>,
213    pub health: Vec<String>,
214    pub kubernetes: Option<ServiceDeployKubernetesConfig>,
215}
216
217/// Derive a sensible `deploy.envs.<env>` block from service target, URL, and tree layout.
218pub fn derive_service_deploy(
219    project_root: &Path,
220    project_name: &str,
221    env: &str,
222    service: &ServiceConfig,
223    project_k8s: Option<&KubernetesProjectConfig>,
224) -> DerivedServiceDeploy {
225    let provider = derive_provider(&service.target);
226    let namespace = project_k8s
227        .and_then(|k| k.default_namespace.clone())
228        .filter(|s| !s.trim().is_empty())
229        .or_else(|| Some(sanitize_k8s_name(project_name)))
230        .filter(|s| !s.is_empty());
231
232    let health = derive_health_urls(service);
233    let kubernetes = if matches!(provider.as_str(), "kubernetes" | "kubernetes-operator") {
234        discover_kubernetes_block(project_root, service, env)
235    } else {
236        None
237    };
238
239    DerivedServiceDeploy {
240        provider,
241        namespace,
242        replicas: Some(1),
243        health,
244        kubernetes,
245    }
246}
247
248fn derive_provider(target: &str) -> String {
249    let t = target.trim().to_ascii_lowercase();
250    if t.contains("worker") || t.contains("cloudflare") {
251        return "worker".into();
252    }
253    if t == "kubernetes-operator" || t.contains("operator") {
254        return "kubernetes-operator".into();
255    }
256    // Default engine path for compose/docker/rust/next/etc. Plan still works with
257    // health-only when no manifests exist.
258    "kubernetes".into()
259}
260
261fn derive_health_urls(service: &ServiceConfig) -> Vec<String> {
262    let Some(url) = service.url.as_deref().map(str::trim).filter(|s| !s.is_empty()) else {
263        return Vec::new();
264    };
265    let path = service
266        .healthcheck_path
267        .as_deref()
268        .map(str::trim)
269        .filter(|s| !s.is_empty())
270        .unwrap_or("/");
271
272    match join_health_url(url, path) {
273        Some(u) => vec![u],
274        None => {
275            // Bare URL without valid parse — only keep if it already looks like a URL.
276            if url.starts_with("http://") || url.starts_with("https://") {
277                vec![url.to_string()]
278            } else {
279                Vec::new()
280            }
281        }
282    }
283}
284
285fn join_health_url(base: &str, path: &str) -> Option<String> {
286    let base = base.trim().trim_end_matches('/');
287    if !(base.starts_with("http://") || base.starts_with("https://")) {
288        return None;
289    }
290    if path.trim().is_empty() || path.trim() == "/" {
291        return Some(format!("{base}/"));
292    }
293    let path = path.trim();
294    if path.starts_with('/') {
295        Some(format!("{base}{path}"))
296    } else {
297        Some(format!("{base}/{path}"))
298    }
299}
300
301fn sanitize_k8s_name(raw: &str) -> String {
302    let mut out = String::new();
303    for ch in raw.chars() {
304        let c = ch.to_ascii_lowercase();
305        if c.is_ascii_alphanumeric() {
306            out.push(c);
307        } else if c == '-' || c == '_' || c == '.' {
308            if !out.ends_with('-') {
309                out.push('-');
310            }
311        }
312    }
313    let out = out.trim_matches('-').to_string();
314    if out.is_empty() {
315        "default".into()
316    } else {
317        out.chars().take(63).collect()
318    }
319}
320
321fn discover_kubernetes_block(
322    project_root: &Path,
323    service: &ServiceConfig,
324    env: &str,
325) -> Option<ServiceDeployKubernetesConfig> {
326    let service_root = service
327        .root_directory
328        .as_deref()
329        .map(|r| project_root.join(r))
330        .unwrap_or_else(|| project_root.to_path_buf());
331
332    let base_candidates = [
333        service_root.join("k8s/base"),
334        service_root.join("kubernetes/base"),
335        service_root.join("deploy/k8s/base"),
336        service_root.join("manifests/base"),
337        project_root.join("k8s/base"),
338        project_root.join(format!("k8s/{}", service.name)),
339        project_root.join(format!("deploy/{}", service.name)),
340    ];
341    let overlay_candidates = [
342        service_root.join(format!("k8s/overlays/{env}")),
343        service_root.join(format!("kubernetes/overlays/{env}")),
344        service_root.join(format!("deploy/k8s/overlays/{env}")),
345        project_root.join(format!("k8s/overlays/{env}")),
346        project_root.join(format!("k8s/{}/overlays/{env}", service.name)),
347    ];
348
349    let base = base_candidates
350        .iter()
351        .find(|p| p.is_dir() || p.is_file())
352        .map(|p| collapse_rel(project_root, p));
353    let overlay = overlay_candidates
354        .iter()
355        .find(|p| p.is_dir() || p.is_file())
356        .map(|p| collapse_rel(project_root, p));
357
358    if base.is_none() && overlay.is_none() {
359        // Still set workload so plan can describe a rollout wait when manifests are added later.
360        return Some(ServiceDeployKubernetesConfig {
361            manifests: None,
362            workload: Some(service.name.clone()),
363            service: Some(service.name.clone()),
364            crds_path: None,
365            install_path: None,
366            selector: Some(format!("app={}", service.name)),
367        });
368    }
369
370    Some(ServiceDeployKubernetesConfig {
371        manifests: Some(ServiceDeployManifestsConfig { base, overlay }),
372        workload: Some(service.name.clone()),
373        service: Some(service.name.clone()),
374        crds_path: None,
375        install_path: None,
376        selector: Some(format!("app={}", service.name)),
377    })
378}
379
380fn collapse_rel(project_root: &Path, path: &Path) -> String {
381    path.strip_prefix(project_root)
382        .map(|p| p.to_string_lossy().replace('\\', "/"))
383        .unwrap_or_else(|_| path.to_string_lossy().replace('\\', "/"))
384}
385
386fn print_derived(service: &str, env: &str, derived: &DerivedServiceDeploy) {
387    println!(
388        "{} {} {}",
389        "derived".bright_black(),
390        service.bright_white().bold(),
391        format!("deploy.envs.{env}").dimmed()
392    );
393    println!("    provider:   {}", derived.provider.bright_green());
394    println!(
395        "    namespace:  {}",
396        derived
397            .namespace
398            .as_deref()
399            .unwrap_or("(none)")
400            .bright_yellow()
401    );
402    if let Some(r) = derived.replicas {
403        println!("    replicas:   {r}");
404    }
405    if derived.health.is_empty() {
406        println!("    health:     {}", "(none)".dimmed());
407    } else {
408        println!("    health:");
409        for h in &derived.health {
410            println!("      - {}", h.bright_cyan());
411        }
412    }
413    if let Some(k8s) = &derived.kubernetes {
414        if let Some(m) = &k8s.manifests {
415            if let Some(base) = &m.base {
416                println!("    manifests.base:    {base}");
417            }
418            if let Some(overlay) = &m.overlay {
419                println!("    manifests.overlay: {overlay}");
420            }
421        }
422        if let Some(w) = &k8s.workload {
423            println!("    workload:  {w}");
424        }
425    }
426    println!();
427}
428
429fn refine_derived_interactively(
430    service: &str,
431    env: &str,
432    mut derived: DerivedServiceDeploy,
433) -> Result<DerivedServiceDeploy, String> {
434    let theme = ColorfulTheme::default();
435    let accept = Confirm::with_theme(&theme)
436        .with_prompt(format!(
437            "Accept derived deploy.envs.{env} for `{service}`?"
438        ))
439        .default(true)
440        .interact()
441        .map_err(|e| e.to_string())?;
442    if accept {
443        return Ok(derived);
444    }
445
446    let providers = ["kubernetes", "kubernetes-operator", "worker"];
447    let default_idx = providers
448        .iter()
449        .position(|p| *p == derived.provider.as_str())
450        .unwrap_or(0);
451    let idx = Select::with_theme(&theme)
452        .with_prompt("Deploy provider")
453        .items(&providers)
454        .default(default_idx)
455        .interact()
456        .map_err(|e| e.to_string())?;
457    derived.provider = providers[idx].to_string();
458
459    let ns: String = Input::with_theme(&theme)
460        .with_prompt("Kubernetes namespace (empty to clear)")
461        .with_initial_text(derived.namespace.clone().unwrap_or_default())
462        .allow_empty(true)
463        .interact_text()
464        .map_err(|e| e.to_string())?;
465    derived.namespace = {
466        let t = ns.trim();
467        if t.is_empty() {
468            None
469        } else {
470            Some(t.to_string())
471        }
472    };
473
474    let health_default = derived.health.join(", ");
475    let health_raw: String = Input::with_theme(&theme)
476        .with_prompt("Health URLs (comma-separated, empty for none)")
477        .with_initial_text(health_default)
478        .allow_empty(true)
479        .interact_text()
480        .map_err(|e| e.to_string())?;
481    derived.health = health_raw
482        .split(',')
483        .map(str::trim)
484        .filter(|s| !s.is_empty())
485        .map(str::to_string)
486        .collect();
487
488    if matches!(
489        derived.provider.as_str(),
490        "kubernetes" | "kubernetes-operator"
491    ) {
492        let mut k8s = derived.kubernetes.unwrap_or_default();
493        let base: String = Input::with_theme(&theme)
494            .with_prompt("Manifests base path (empty to skip)")
495            .with_initial_text(
496                k8s.manifests
497                    .as_ref()
498                    .and_then(|m| m.base.clone())
499                    .unwrap_or_default(),
500            )
501            .allow_empty(true)
502            .interact_text()
503            .map_err(|e| e.to_string())?;
504        let overlay: String = Input::with_theme(&theme)
505            .with_prompt(format!("Manifests overlay path for {env} (empty to skip)"))
506            .with_initial_text(
507                k8s.manifests
508                    .as_ref()
509                    .and_then(|m| m.overlay.clone())
510                    .unwrap_or_default(),
511            )
512            .allow_empty(true)
513            .interact_text()
514            .map_err(|e| e.to_string())?;
515        let workload: String = Input::with_theme(&theme)
516            .with_prompt("Workload name (Deployment/StatefulSet)")
517            .with_initial_text(k8s.workload.clone().unwrap_or_else(|| service.to_string()))
518            .allow_empty(true)
519            .interact_text()
520            .map_err(|e| e.to_string())?;
521
522        let base = empty_to_none(base);
523        let overlay = empty_to_none(overlay);
524        k8s.manifests = if base.is_some() || overlay.is_some() {
525            Some(ServiceDeployManifestsConfig { base, overlay })
526        } else {
527            None
528        };
529        k8s.workload = empty_to_none(workload);
530        k8s.service = k8s.service.or_else(|| Some(service.to_string()));
531        k8s.selector = k8s
532            .selector
533            .or_else(|| Some(format!("app={service}")));
534        derived.kubernetes = Some(k8s);
535    } else {
536        derived.kubernetes = None;
537    }
538
539    Ok(derived)
540}
541
542fn empty_to_none(s: String) -> Option<String> {
543    let t = s.trim();
544    if t.is_empty() {
545        None
546    } else {
547        Some(t.to_string())
548    }
549}
550
551fn apply_derived_to_config(
552    config: &mut XbpConfig,
553    service_name: &str,
554    env: &str,
555    derived: DerivedServiceDeploy,
556) -> Result<(), String> {
557    let services = config
558        .services
559        .as_mut()
560        .ok_or_else(|| "project has no services list".to_string())?;
561    let svc = services
562        .iter_mut()
563        .find(|s| s.name == service_name)
564        .ok_or_else(|| format!("service `{service_name}` not found in config"))?;
565
566    let mut deploy = svc.deploy.take().unwrap_or(ServiceDeployConfig {
567        provider: Some(derived.provider.clone()),
568        ..ServiceDeployConfig::default()
569    });
570    // Keep an existing non-empty provider; otherwise use derived.
571    if deploy
572        .provider
573        .as_ref()
574        .map(|s| s.trim().is_empty())
575        .unwrap_or(true)
576    {
577        deploy.provider = Some(derived.provider.clone());
578    }
579
580    deploy.envs.insert(
581        env.to_string(),
582        ServiceDeployEnvConfig {
583            namespace: derived.namespace,
584            replicas: derived.replicas,
585            health: derived.health,
586            kubernetes: derived.kubernetes,
587            ..Default::default()
588        },
589    );
590    svc.deploy = Some(deploy);
591    Ok(())
592}
593
594fn ensure_project_deploy_defaults(config: &mut XbpConfig, env: &str) {
595    let deploy = config.deploy.get_or_insert_with(|| DeployProjectConfig {
596        default_env: Some(env.to_string()),
597        history_dir: Some(".xbp/deployments".into()),
598        lock_file: Some(".xbp/deploy-lock.json".into()),
599        groups: HashMap::new(),
600    });
601    if deploy.default_env.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
602        deploy.default_env = Some(env.to_string());
603    }
604    if deploy.history_dir.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
605        deploy.history_dir = Some(".xbp/deployments".into());
606    }
607    if deploy.lock_file.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
608        deploy.lock_file = Some(".xbp/deploy-lock.json".into());
609    }
610}
611
612/// Drop group members that are not real `services[]` entries and rebuild `groups.all`
613/// from known service names when present.
614///
615/// Scoped npm packages (`@org/pkg`), inventable discovery noise, and renamed services
616/// often linger in `deploy.groups.all` and fail validation. This heals silently.
617pub fn heal_deploy_groups(config: &mut XbpConfig) -> Vec<String> {
618    let Some(deploy) = config.deploy.as_mut() else {
619        return Vec::new();
620    };
621    if deploy.groups.is_empty() {
622        return Vec::new();
623    }
624
625    let known: std::collections::BTreeSet<String> = config
626        .services
627        .as_ref()
628        .map(|list| list.iter().map(|s| s.name.clone()).collect())
629        .unwrap_or_default();
630
631    let mut notes = Vec::new();
632    let mut removed_total = 0usize;
633
634    for (group_name, group) in deploy.groups.iter_mut() {
635        let before_svc = group.services.len();
636        group.services.retain(|name| known.contains(name));
637        group.services.sort();
638        group.services.dedup();
639
640        let before_order = group.order.len();
641        group.order.retain(|name| known.contains(name));
642        // Keep relative order of remaining members; drop unknowns only.
643        group.order.dedup();
644
645        let dropped = (before_svc.saturating_sub(group.services.len()))
646            + (before_order.saturating_sub(group.order.len()));
647        if dropped > 0 {
648            removed_total += dropped;
649            notes.push(format!(
650                "deploy.groups.{group_name}: removed {dropped} unknown service reference(s)"
651            ));
652        }
653    }
654
655    // Keep `all` aligned with current services[] (deployable units only when possible).
656    if deploy.groups.contains_key("all") {
657        let deployable: Vec<String> = config
658            .services
659            .as_ref()
660            .map(|list| {
661                list.iter()
662                    .filter(|s| service_is_deploy_candidate(s))
663                    .map(|s| s.name.clone())
664                    .collect()
665            })
666            .unwrap_or_default();
667        if !deployable.is_empty() {
668            if let Some(all) = deploy.groups.get_mut("all") {
669                let mut changed = false;
670                if all.services != deployable {
671                    all.services = deployable.clone();
672                    changed = true;
673                }
674                // Prefer order that lists only deployable names; preserve relative order when possible.
675                let mut new_order: Vec<String> = all
676                    .order
677                    .iter()
678                    .filter(|n| deployable.iter().any(|d| d == *n))
679                    .cloned()
680                    .collect();
681                for name in &deployable {
682                    if !new_order.iter().any(|n| n == name) {
683                        new_order.push(name.clone());
684                    }
685                }
686                if all.order != new_order {
687                    all.order = new_order;
688                    changed = true;
689                }
690                if changed {
691                    notes.push(format!(
692                        "deploy.groups.all: rebuilt from {} deployable service(s)",
693                        deployable.len()
694                    ));
695                }
696            }
697        }
698    }
699
700    if removed_total > 0 && notes.is_empty() {
701        notes.push(format!(
702            "deploy.groups: pruned {removed_total} unknown service reference(s)"
703        ));
704    }
705    notes
706}
707
708/// Whether a configured service is a reasonable deploy target (not a pure TS library package).
709pub fn service_is_deploy_candidate(service: &ServiceConfig) -> bool {
710    // Explicit deploy.envs / provider always counts.
711    if let Some(deploy) = &service.deploy {
712        if !deploy.envs.is_empty() {
713            return true;
714        }
715        if deploy
716            .provider
717            .as_ref()
718            .map(|p| !p.trim().is_empty())
719            .unwrap_or(false)
720        {
721            return true;
722        }
723        if !deploy.destinations.is_empty() {
724            return true;
725        }
726    }
727    if service.oci.as_ref().and_then(|o| o.image.as_ref()).is_some() {
728        return true;
729    }
730
731    // Scoped npm package names without deploy config are libraries, not runtime services.
732    if service.name.starts_with('@') {
733        return false;
734    }
735    // Discovery noise / non-service markers.
736    let lower = service.name.to_ascii_lowercase();
737    if lower == "default"
738        || lower.contains("open-next")
739        || lower.contains("nx-cloud")
740        || lower.ends_with("-compat-reference")
741        || lower == "scripts"
742        || lower == "local"
743    {
744        return false;
745    }
746
747    // Target types that are typically deployable apps/binaries.
748    let target = service.target.to_ascii_lowercase();
749    matches!(
750        target.as_str(),
751        "rust"
752            | "docker"
753            | "kubernetes"
754            | "k8s"
755            | "nextjs"
756            | "next"
757            | "node"
758            | "nodejs"
759            | "python"
760            | "go"
761            | "worker"
762            | "cloudflare"
763            | "cloudflare-containers"
764            | "static"
765            | "web"
766    ) || target.contains("docker")
767        || target.contains("k8s")
768        || target.contains("worker")
769}
770
771fn resolve_config_write_path(project_root: &Path) -> Result<PathBuf, String> {
772    Ok(crate::utils::resolve_project_config_write_path(
773        project_root,
774        find_xbp_config_upwards(project_root).as_ref(),
775    ))
776}
777
778fn write_project_config(
779    project_root: &Path,
780    config_path: &Path,
781    config: &XbpConfig,
782) -> Result<(), String> {
783    let _ = project_root;
784    crate::utils::write_xbp_project_config_at_path(config_path, config)
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790    use crate::strategies::deployment_config::ServiceConfig;
791
792    fn svc(name: &str, target: &str, url: Option<&str>, health: Option<&str>) -> ServiceConfig {
793        ServiceConfig {
794            name: name.into(),
795            target: target.into(),
796            target_freeze: None,
797            branch: "main".into(),
798            port: 3000,
799            root_directory: Some("./".into()),
800            environment: None,
801            url: url.map(str::to_string),
802            healthcheck_path: health.map(str::to_string),
803            restart_policy: None,
804            restart_policy_max_failure_count: None,
805            start_wrapper: None,
806            commands: None,
807            force_run_from_root: None,
808            version_targets: None,
809            depends_on: None,
810            watch_paths: None,
811            versioning: None,
812            release: None,
813            systemd_service_name: None,
814            systemd: None,
815            openapi: None,
816            oci: None,
817            deploy: None,
818            discord: None,
819            file_associations: Vec::new(),
820        }
821    }
822
823    #[test]
824    fn derives_health_from_url_and_path() {
825        let d = derive_service_deploy(
826            Path::new("."),
827            "athena",
828            "production",
829            &svc(
830                "athena",
831                "docker-compose",
832                Some("https://mirror2.athena-cluster.com"),
833                Some("/"),
834            ),
835            None,
836        );
837        assert_eq!(d.provider, "kubernetes");
838        assert_eq!(d.namespace.as_deref(), Some("athena"));
839        assert_eq!(
840            d.health,
841            vec!["https://mirror2.athena-cluster.com/".to_string()]
842        );
843    }
844
845    #[test]
846    fn derives_worker_provider_for_cloudflare_target() {
847        let d = derive_service_deploy(
848            Path::new("."),
849            "demo",
850            "production",
851            &svc("edge", "cloudflare-worker", None, None),
852            None,
853        );
854        assert_eq!(d.provider, "worker");
855    }
856
857    #[test]
858    fn scoped_npm_packages_are_not_deploy_candidates() {
859        assert!(!service_is_deploy_candidate(&svc(
860            "@xylex-group/athena-auth-ui",
861            "node",
862            None,
863            None
864        )));
865        assert!(!service_is_deploy_candidate(&svc(
866            "@lexington/studio",
867            "nextjs",
868            None,
869            None
870        )));
871        assert!(!service_is_deploy_candidate(&svc(
872            "nx-cloud-client-bundle",
873            "node",
874            None,
875            None
876        )));
877        assert!(service_is_deploy_candidate(&svc(
878            "athena",
879            "rust",
880            Some("https://api.example.com"),
881            Some("/health")
882        )));
883    }
884
885    #[test]
886    fn heal_deploy_groups_prunes_unknown_and_rebuilds_all() {
887        let mut config = minimal_config(vec![
888            svc("athena", "rust", None, None),
889            svc("docs", "nextjs", None, None),
890        ]);
891        config.deploy = Some(DeployProjectConfig {
892            default_env: Some("production".into()),
893            history_dir: None,
894            lock_file: None,
895            groups: {
896                let mut g = HashMap::new();
897                g.insert(
898                    "all".into(),
899                    crate::strategies::deployment_config::DeployGroupConfig {
900                        services: vec![
901                            "athena".into(),
902                            "@xylex-group/athena-auth-ui".into(),
903                            "nx-cloud-client-bundle".into(),
904                            "docs".into(),
905                            "default".into(),
906                        ],
907                        order: vec![
908                            "athena".into(),
909                            "@lexington/studio".into(),
910                            "docs".into(),
911                        ],
912                        description: Some("everything".into()),
913                    },
914                );
915                g
916            },
917        });
918        let notes = heal_deploy_groups(&mut config);
919        assert!(!notes.is_empty());
920        let all = config.deploy.as_ref().unwrap().groups.get("all").unwrap();
921        assert!(all.services.contains(&"athena".to_string()));
922        assert!(all.services.contains(&"docs".to_string()));
923        assert!(!all.services.iter().any(|s| s.starts_with('@')));
924        assert!(!all.services.iter().any(|s| s == "default"));
925        assert!(!all.order.iter().any(|s| s.contains("lexington")));
926    }
927
928    fn minimal_config(services: Vec<ServiceConfig>) -> XbpConfig {
929        let services_json = serde_json::to_value(&services).expect("serialize services");
930        serde_json::from_value(serde_json::json!({
931            "project_name": "demo",
932            "version": "1.0.0",
933            "port": 3000,
934            "build_dir": ".",
935            "services": services_json,
936        }))
937        .expect("minimal XbpConfig")
938    }
939
940    #[test]
941    fn missing_env_lists_service() {
942        let config = minimal_config(vec![svc("api", "rust", None, None)]);
943        let missing = services_missing_deploy_env(&config, "api", "production").unwrap();
944        assert_eq!(missing, vec!["api".to_string()]);
945    }
946
947    #[test]
948    fn apply_derived_inserts_env() {
949        let mut config = minimal_config(vec![svc("api", "rust", None, None)]);
950        let derived = DerivedServiceDeploy {
951            provider: "kubernetes".into(),
952            namespace: Some("demo".into()),
953            replicas: Some(1),
954            health: vec![],
955            kubernetes: None,
956        };
957        apply_derived_to_config(&mut config, "api", "production", derived).unwrap();
958        let svc = &config.services.as_ref().unwrap()[0];
959        assert!(svc.deploy.as_ref().unwrap().envs.contains_key("production"));
960    }
961}