1use 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
20pub 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 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
124pub 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 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 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 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 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
217pub 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 "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 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 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 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
612pub 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 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 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 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
708pub fn service_is_deploy_candidate(service: &ServiceConfig) -> bool {
710 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 if service.name.starts_with('@') {
733 return false;
734 }
735 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 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}