use std::collections::BTreeSet;
use std::io::IsTerminal;
use colored::Colorize;
use dialoguer::Input;
use xbp_deploy::DeployMode;
use crate::cli::interactive::{
confirm, pad_label, print_picker_header, searchable_multi_select, searchable_select_required,
select_one, xbp_theme,
};
use crate::strategies::XbpConfig;
use xbp_deploy::{
filter_deploy_plan, retain_services_in_plan, service_plan_labels, unique_service_names,
DeployPlan,
};
use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct ResolvedDeployInvocation {
pub target: String,
pub env: Option<String>,
pub mode: DeployMode,
pub promote_tag: Option<String>,
pub local_image: bool,
pub skip_build: bool,
pub dry_run: bool,
pub skip_doctor: bool,
pub skip_health: bool,
pub skip_rollout: bool,
pub skip_apply: bool,
pub skip_oci: bool,
pub skip_expose: bool,
}
#[derive(Debug, Clone, Default)]
pub struct DeployInvocationOptions {
pub promote_tag: Option<String>,
pub local_image: bool,
pub push_image: bool,
pub skip_build: bool,
pub dry_run: bool,
pub skip_doctor: bool,
pub skip_health: bool,
pub skip_rollout: bool,
pub skip_apply: bool,
pub skip_oci: bool,
pub skip_expose: bool,
pub kube_context: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ImageBuildStrategy {
LocalLoad,
PushRegistry,
SkipBuild,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TargetDestProfile {
CloudflareOnly,
KubernetesOnly,
Mixed,
Unknown,
}
impl TargetDestProfile {
fn has_cloudflare(self) -> bool {
matches!(self, Self::CloudflareOnly | Self::Mixed)
}
fn has_kubernetes(self) -> bool {
matches!(self, Self::KubernetesOnly | Self::Mixed)
}
fn is_cloudflare_only(self) -> bool {
matches!(self, Self::CloudflareOnly)
}
}
pub const DEPLOY_MODE_POSITIONALS: &[&str] = &["plan", "run", "verify", "status", "history"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeeledDeployArgs {
pub target: Option<String>,
pub mode: DeployMode,
pub mode_explicit: bool,
}
pub fn peel_deploy_mode_positional(
target: Option<String>,
mode: DeployMode,
mode_explicit: bool,
) -> Result<PeeledDeployArgs, String> {
let Some(raw) = target
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
else {
return Ok(PeeledDeployArgs {
target: None,
mode,
mode_explicit,
});
};
let lower = raw.to_ascii_lowercase();
let positional_mode = match lower.as_str() {
"plan" => Some(DeployMode::Plan),
"run" => Some(DeployMode::Run),
"verify" => Some(DeployMode::Verify),
"status" => Some(DeployMode::Status),
"history" => Some(DeployMode::History),
_ => None,
};
let Some(from_positional) = positional_mode else {
return Ok(PeeledDeployArgs {
target: Some(raw),
mode,
mode_explicit,
});
};
if mode_explicit {
let flag_label = mode_flag_label(mode);
let pos_label = mode_flag_label(from_positional);
if std::mem::discriminant(&mode) != std::mem::discriminant(&from_positional) {
return Err(format!(
"`{raw}` is a deploy *mode*, not a service name — and it conflicts with `{flag_label}`.\n\n\
Plan only (no mutations):\n xbp deploy --plan\n xbp deploy <service> --plan\n\n\
Apply after reviewing the plan:\n xbp deploy --run\n xbp deploy <service> --run\n\n\
Do not combine `{pos_label}` with `{flag_label}`."
));
}
return Ok(PeeledDeployArgs {
target: None,
mode,
mode_explicit: true,
});
}
Ok(PeeledDeployArgs {
target: None,
mode: from_positional,
mode_explicit: true,
})
}
fn mode_flag_label(mode: DeployMode) -> &'static str {
match mode {
DeployMode::Plan => "--plan",
DeployMode::Run => "--run",
DeployMode::Verify => "--verify",
DeployMode::Status => "--status",
DeployMode::History => "--history",
DeployMode::Promote => "--promote",
}
}
pub fn resolve_deploy_invocation(
config: &XbpConfig,
target: Option<String>,
env: Option<String>,
mode: DeployMode,
mode_explicit: bool,
yes: bool,
force_non_interactive: bool,
options: DeployInvocationOptions,
) -> Result<ResolvedDeployInvocation, String> {
let interactive = is_interactive() && !force_non_interactive && !yes;
let target_raw = target
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let choices = deploy_target_choices(config);
if choices.is_empty() {
return Err(
"No services[] defined in project config. Run `xbp init` or `xbp version discover` first."
.into(),
);
}
let resolved_target = match target_raw {
Some(t) => t,
None if interactive => prompt_target(config, &choices)?,
None if choices.len() == 1 => {
let only = choices[0].value.clone();
println!(
"{} using sole deploy target `{}`",
"deploy".bright_cyan().bold(),
only.bright_white()
);
only
}
None => {
return Err(format!(
"deploy target is required.\n\nAvailable targets:\n{}\n\n\
Examples:\n xbp deploy {}\n xbp deploy --plan\n xbp deploy all --env production --plan\n\n\
In a TTY, omit <TARGET> to pick interactively.",
format_target_help(&choices),
choices[0].value
));
}
};
let resolved_mode = if mode_explicit || !interactive {
mode
} else {
prompt_mode(mode)?
};
let resolved_env = match env
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
{
Some(e) => Some(e),
None if interactive => prompt_env(config, &resolved_target)?,
None => None, };
let promote_tag = if matches!(resolved_mode, DeployMode::Promote) {
match options
.promote_tag
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
{
Some(tag) => Some(tag),
None if interactive => Some(prompt_promote_tag(config, &resolved_target, &resolved_env)?),
None if yes => {
return Err(
"promote mode requires a tag. Pass `--promote <TAG>` (e.g. `--promote stable`) or run interactively."
.into(),
);
}
None => {
return Err(
"promote mode requires `--promote <TAG>`.\n\nExample:\n xbp deploy athena --promote stable --dry-run\n\nIn a TTY, choose promote mode and enter the tag when prompted."
.into(),
);
}
}
} else {
None
};
let dest_profile = target_dest_profile(config, &resolved_target);
let needs_image_options = matches!(
resolved_mode,
DeployMode::Plan | DeployMode::Run | DeployMode::Promote | DeployMode::Verify
);
let (
local_image,
skip_build,
dry_run,
skip_doctor,
skip_health,
skip_rollout,
skip_apply,
skip_oci,
skip_expose,
) = if interactive && needs_image_options {
prompt_deploy_build_and_apply_options(
config,
&resolved_target,
&resolved_mode,
&options,
dest_profile,
)?
} else {
resolve_image_options_noninteractive(&options, dest_profile)
};
if interactive {
match resolved_mode {
DeployMode::Plan => {
let image_note = image_strategy_note(dest_profile, local_image, skip_build);
println!(
" {} plan only (no mutations) for `{}`{} · {} — re-run with {} to apply",
"·".bright_cyan(),
resolved_target.bright_white(),
resolved_env
.as_deref()
.map(|e| format!(" (env: {e})"))
.unwrap_or_default()
.bright_black(),
image_note.bright_yellow(),
"--run".bright_green().bold()
);
}
DeployMode::Run | DeployMode::Promote => {
let label = match resolved_mode {
DeployMode::Promote => {
format!(
"promote → :{}",
promote_tag.as_deref().unwrap_or("?")
)
}
_ => {
if dest_profile.is_cloudflare_only() {
"run (Cloudflare Worker deploy)".to_string()
} else {
"run (apply)".to_string()
}
}
};
let image_note = image_strategy_note(dest_profile, local_image, skip_build);
let mut extras = Vec::new();
if dry_run {
extras.push("dry-run");
}
if skip_doctor {
extras.push("skip-doctor");
}
if skip_health {
extras.push("skip-health");
}
if skip_rollout {
extras.push("skip-rollout");
}
if skip_expose && dest_profile.has_kubernetes() {
extras.push("skip-expose");
}
if skip_apply {
if dest_profile.is_cloudflare_only() && local_image {
extras.push("local-build-only (no wrangler deploy)");
} else {
extras.push("skip-apply");
}
}
let extras_s = if extras.is_empty() {
String::new()
} else {
format!(" · {}", extras.join(", "))
};
let ok = confirm(
&format!(
"Apply deploy `{label}` for target `{}`{} · {}{}?",
resolved_target,
resolved_env
.as_deref()
.map(|e| format!(" (env: {e})"))
.unwrap_or_default(),
image_note,
extras_s
),
matches!(resolved_mode, DeployMode::Promote),
)?;
if !ok {
return Err("deploy aborted by user".into());
}
}
_ => {}
}
} else if local_image && !skip_build && !options.skip_oci && dest_profile.has_kubernetes() {
if !force_non_interactive {
println!(
"{} OCI images: local Docker load only (no registry push). Use {} or the interactive picker to push.",
"i".bright_blue().bold(),
"--push-image".bright_yellow()
);
}
}
Ok(ResolvedDeployInvocation {
target: resolved_target,
env: resolved_env,
mode: resolved_mode,
promote_tag,
local_image,
skip_build,
dry_run,
skip_doctor,
skip_health,
skip_rollout,
skip_apply,
skip_oci,
skip_expose,
})
}
type DeployOptionBundle = (bool, bool, bool, bool, bool, bool, bool, bool, bool);
fn image_strategy_note(profile: TargetDestProfile, local_image: bool, skip_build: bool) -> &'static str {
if skip_build {
return if profile.is_cloudflare_only() {
"reuse existing CF image / config-only"
} else {
"skip image build"
};
}
if profile.is_cloudflare_only() {
return if local_image {
"local Docker container pre-build + Wrangler"
} else {
"OpenNext / Wrangler build + deploy"
};
}
if local_image {
"local Docker load"
} else {
"registry push"
}
}
fn resolve_image_options_noninteractive(
options: &DeployInvocationOptions,
profile: TargetDestProfile,
) -> DeployOptionBundle {
let local_image = if options.push_image {
false
} else if options.local_image {
true
} else if profile.is_cloudflare_only() {
false
} else {
looks_like_local_kube_context(options.kube_context.as_deref())
};
(
local_image,
options.skip_build,
options.dry_run,
options.skip_doctor,
options.skip_health,
options.skip_rollout,
options.skip_apply,
options.skip_oci,
options.skip_expose,
)
}
fn prompt_deploy_build_and_apply_options(
config: &XbpConfig,
target: &str,
mode: &DeployMode,
options: &DeployInvocationOptions,
profile: TargetDestProfile,
) -> Result<DeployOptionBundle, String> {
let has_dockerfile = target_has_dockerfile(config, target);
let local_default = profile.has_kubernetes()
&& looks_like_local_kube_context(options.kube_context.as_deref());
let strategy = if options.skip_build {
ImageBuildStrategy::SkipBuild
} else if options.push_image {
ImageBuildStrategy::PushRegistry
} else if options.local_image {
ImageBuildStrategy::LocalLoad
} else if profile.is_cloudflare_only() {
if matches!(mode, DeployMode::History | DeployMode::Status) {
ImageBuildStrategy::SkipBuild
} else {
prompt_cloudflare_build_strategy(has_dockerfile)?
}
} else if !has_dockerfile {
ImageBuildStrategy::SkipBuild
} else if matches!(mode, DeployMode::History | DeployMode::Status) {
ImageBuildStrategy::SkipBuild
} else {
prompt_image_build_strategy(local_default, has_dockerfile, profile)?
};
let (local_image, skip_build) = match strategy {
ImageBuildStrategy::LocalLoad => (true, false),
ImageBuildStrategy::PushRegistry => (false, false),
ImageBuildStrategy::SkipBuild => (false, true),
};
if matches!(mode, DeployMode::Plan | DeployMode::Verify) {
return Ok((
local_image,
skip_build,
options.dry_run,
options.skip_doctor,
options.skip_health,
options.skip_rollout,
options.skip_apply,
options.skip_oci,
options.skip_expose,
));
}
if matches!(mode, DeployMode::History | DeployMode::Status) {
return Ok(resolve_image_options_noninteractive(options, profile));
}
let apply = prompt_apply_options(options, profile)?;
Ok((
local_image,
skip_build,
apply.dry_run,
apply.skip_doctor,
apply.skip_health,
apply.skip_rollout,
apply.skip_apply,
apply.skip_oci,
apply.skip_expose,
))
}
#[derive(Debug, Clone, Copy)]
struct ApplyOptionToggles {
dry_run: bool,
skip_doctor: bool,
skip_health: bool,
skip_rollout: bool,
skip_apply: bool,
skip_oci: bool,
skip_expose: bool,
}
fn prompt_cloudflare_build_strategy(has_dockerfile: bool) -> Result<ImageBuildStrategy, String> {
print_picker_header(
"xbp deploy · Cloudflare build",
if has_dockerfile {
"Worker + Container: OpenNext/Wrangler deploys by default; optional local Docker pre-build"
} else {
"Cloudflare Worker: OpenNext install/build + wrangler deploy (no cluster / GHCR)"
},
);
let options = [
(
ImageBuildStrategy::PushRegistry, "full CF deploy · OpenNext/Wrangler build + deploy Worker (and containers if configured)",
),
(
ImageBuildStrategy::LocalLoad,
"local container · docker build container image locally, then Wrangler deploy/push",
),
(
ImageBuildStrategy::SkipBuild,
"config-only · skip local pre-build; allow unchanged container digest if present",
),
];
let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
let idx = select_one("Cloudflare build strategy", &labels, 0)?
.ok_or_else(|| "Selection cancelled".to_string())?;
Ok(options[idx].0)
}
fn prompt_image_build_strategy(
local_default: bool,
has_dockerfile: bool,
profile: TargetDestProfile,
) -> Result<ImageBuildStrategy, String> {
let subtitle = if profile == TargetDestProfile::Mixed {
"Mixed k8s + Cloudflare target — OCI options apply to cluster half; CF still builds via Wrangler"
} else if local_default {
"Local kube context detected — default is load into Docker (no ghcr push)"
} else if has_dockerfile {
"Services declare oci.dockerfile — choose build + load or push to registry"
} else {
"No oci.dockerfile on selected services — skip build uses existing images"
};
print_picker_header("xbp deploy · OCI images", subtitle);
let options = [
(
ImageBuildStrategy::LocalLoad,
"local load · docker build into local engine (no registry push) ← docker-desktop / kind",
),
(
ImageBuildStrategy::PushRegistry,
"registry push · docker build + push (needs docker login for ghcr/…)",
),
(
ImageBuildStrategy::SkipBuild,
"skip build · use existing image ref (no docker build)",
),
];
let default = if !has_dockerfile {
2
} else if local_default {
0
} else {
1
};
let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
let idx = select_one("Image build strategy", &labels, default)?
.ok_or_else(|| "Selection cancelled".to_string())?;
Ok(options[idx].0)
}
fn prompt_apply_options(
seed: &DeployInvocationOptions,
profile: TargetDestProfile,
) -> Result<ApplyOptionToggles, String> {
let header = if profile.is_cloudflare_only() {
"Space toggles · Enter confirms · leave all off for a normal Worker deploy"
} else {
"Space toggles · Enter confirms · leave all off for a normal apply"
};
print_picker_header("xbp deploy · apply options", header);
let skip_apply_label = if profile.is_cloudflare_only() {
"skip apply · skip Wrangler/Worker deploy (with local container strategy → build-only)"
} else if profile == TargetDestProfile::Mixed {
"skip apply · skip kubectl + Wrangler apply (still may build)"
} else {
"skip apply · skip kubectl apply (still may build/verify)"
};
let skip_doctor_label = if profile.is_cloudflare_only() {
"skip doctor · skip Cloudflare preflight (Wrangler/account)"
} else if profile.has_cloudflare() {
"skip doctor · skip cluster + Cloudflare preflight"
} else {
"skip doctor · skip cluster preflight"
};
let dry_run_label = if profile.is_cloudflare_only() {
"dry-run · print CF actions only (no Wrangler mutations)"
} else {
"dry-run · print actions only (no registry/cluster mutations)"
};
let mut items: Vec<(&str, bool)> = vec![
(dry_run_label, seed.dry_run),
(skip_doctor_label, seed.skip_doctor),
(
"skip health · skip HTTP health probes after rollout",
seed.skip_health,
),
(
"skip rollout wait · do not wait for workload / container ready",
seed.skip_rollout,
),
(skip_apply_label, seed.skip_apply),
];
if profile.has_kubernetes() {
items.push((
"skip OCI resolve · skip registry digest lookups",
seed.skip_oci,
));
items.push((
"skip expose · skip port-forward / hosts DNS after rollout",
seed.skip_expose,
));
}
let labels: Vec<&str> = items.iter().map(|(l, _)| *l).collect();
let defaults: Vec<bool> = items.iter().map(|(_, d)| *d).collect();
let selected = searchable_multi_select("Apply toggles", &labels, &defaults)?;
let on = |i: usize| selected.contains(&i);
Ok(ApplyOptionToggles {
dry_run: on(0),
skip_doctor: on(1),
skip_health: on(2),
skip_rollout: on(3),
skip_apply: on(4),
skip_oci: profile.has_kubernetes() && on(5),
skip_expose: profile.has_kubernetes() && on(6),
})
}
fn target_dest_profile(config: &XbpConfig, target: &str) -> TargetDestProfile {
let names = services_for_target(config, target);
let services = config.services.as_deref().unwrap_or(&[]);
let selected: Vec<&crate::strategies::ServiceConfig> = if names.is_empty() {
services.iter().collect()
} else {
services
.iter()
.filter(|s| names.iter().any(|n| n == &s.name))
.collect()
};
if selected.is_empty() {
return TargetDestProfile::Unknown;
}
let mut any_cf = false;
let mut any_k8s = false;
for svc in selected {
let Some(deploy) = svc.deploy.as_ref() else {
continue;
};
if !deploy.destinations.is_empty() {
for (name, dest) in &deploy.destinations {
if dest.enabled == Some(false) {
continue;
}
let provider = dest
.provider
.as_deref()
.unwrap_or(name.as_str())
.to_ascii_lowercase();
let key = name.to_ascii_lowercase();
if provider.contains("cloudflare")
|| provider.contains("worker")
|| key.contains("cloudflare")
|| key == "worker"
|| key == "cf"
{
any_cf = true;
}
if provider.contains("kubernetes")
|| provider.contains("k8s")
|| key.contains("kubernetes")
|| key == "k8s"
{
any_k8s = true;
}
}
} else if let Some(provider) = deploy.provider.as_deref() {
let p = provider.to_ascii_lowercase();
if p.contains("cloudflare") || p.contains("worker") {
any_cf = true;
}
if p.contains("kubernetes") || p.contains("k8s") {
any_k8s = true;
}
}
}
match (any_cf, any_k8s) {
(true, false) => TargetDestProfile::CloudflareOnly,
(false, true) => TargetDestProfile::KubernetesOnly,
(true, true) => TargetDestProfile::Mixed,
(false, false) => TargetDestProfile::Unknown,
}
}
fn target_has_dockerfile(config: &XbpConfig, target: &str) -> bool {
let names = services_for_target(config, target);
let services = config.services.as_deref().unwrap_or(&[]);
if names.is_empty() {
return services.iter().any(|s| {
s.oci
.as_ref()
.and_then(|o| o.dockerfile.as_ref())
.is_some_and(|d| !d.trim().is_empty())
});
}
names.iter().any(|name| {
services.iter().any(|s| {
s.name == *name
&& s.oci
.as_ref()
.and_then(|o| o.dockerfile.as_ref())
.is_some_and(|d| !d.trim().is_empty())
})
})
}
pub fn looks_like_local_kube_context(explicit: Option<&str>) -> bool {
let ctx = explicit
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(current_kubectl_context_name);
let Some(ctx) = ctx else {
return false;
};
let c = ctx.to_ascii_lowercase();
c == "docker-desktop"
|| c == "docker-for-desktop"
|| c == "rancher-desktop"
|| c == "minikube"
|| c.starts_with("kind-")
|| c.starts_with("k3d-")
|| c.contains("desktop")
}
fn current_kubectl_context_name() -> Option<String> {
let output = std::process::Command::new("kubectl")
.args(["config", "current-context"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
#[derive(Debug, Clone)]
struct TargetChoice {
value: String,
label: String,
}
fn deploy_target_choices(config: &XbpConfig) -> Vec<TargetChoice> {
let mut out = Vec::new();
let all_services = config.services.as_deref().unwrap_or(&[]);
let services: Vec<_> = all_services
.iter()
.filter(|s| crate::commands::deploy_engine::setup::service_is_deploy_candidate(s))
.collect();
let name_w = services
.iter()
.map(|s| s.name.chars().count())
.chain(
config
.deploy
.as_ref()
.map(|d| d.groups.keys().map(|k| k.chars().count()).collect::<Vec<_>>())
.unwrap_or_default(),
)
.max()
.unwrap_or(8)
.clamp(8, 28);
if let Some(deploy) = config.deploy.as_ref() {
let known: std::collections::BTreeSet<&str> =
all_services.iter().map(|s| s.name.as_str()).collect();
let mut group_names: Vec<_> = deploy.groups.keys().cloned().collect();
group_names.sort();
for name in group_names {
let group = &deploy.groups[&name];
let members_src = if group.order.is_empty() {
&group.services
} else {
&group.order
};
let members: Vec<&str> = members_src
.iter()
.map(String::as_str)
.filter(|n| known.contains(*n))
.collect();
if members.is_empty() {
continue;
}
let members_preview = if members.len() > 8 {
format!(
"{}, … (+{})",
members[..8].join(", "),
members.len() - 8
)
} else {
members.join(", ")
};
let desc = group
.description
.as_deref()
.filter(|s| !s.is_empty())
.map(|d| format!(" · {d}"))
.unwrap_or_default();
out.push(TargetChoice {
value: name.clone(),
label: format!(
"{} {} [{} svc] [{}]{}",
pad_label("GROUP", 7),
pad_label(&name, name_w),
members.len(),
members_preview,
desc
),
});
}
}
for svc in &services {
let provider = svc
.deploy
.as_ref()
.and_then(|d| d.provider.as_deref())
.filter(|s| !s.is_empty())
.unwrap_or(svc.target.as_str());
let envs: Vec<_> = svc
.deploy
.as_ref()
.map(|d| {
let mut keys: Vec<_> = d.envs.keys().cloned().collect();
keys.sort();
keys
})
.unwrap_or_default();
let env_hint = if envs.is_empty() {
"no deploy.envs".to_string()
} else {
format!("envs {}", envs.join(", "))
};
out.push(TargetChoice {
value: svc.name.clone(),
label: format!(
"{} {} {} · {} · :{}",
pad_label("SVC", 7),
pad_label(&svc.name, name_w),
pad_label(provider, 12),
env_hint,
svc.port
),
});
}
if !services.is_empty() {
out.push(TargetChoice {
value: "all".into(),
label: format!(
"{} {} every deployable service with deploy.envs ({})",
pad_label("ALL", 7),
pad_label("all", name_w),
services.len()
),
});
}
out
}
fn format_target_help(choices: &[TargetChoice]) -> String {
choices
.iter()
.map(|c| format!(" • {}", c.value))
.collect::<Vec<_>>()
.join("\n")
}
fn prompt_target(config: &XbpConfig, choices: &[TargetChoice]) -> Result<String, String> {
print_picker_header(
"xbp deploy · pick a target",
"GROUP / SVC / ALL · type to fuzzy-filter · Enter selects · Esc cancels",
);
let default_idx = default_target_index(config, choices);
let labels: Vec<&str> = choices.iter().map(|c| c.label.as_str()).collect();
let idx = searchable_select_required("Deploy target", &labels, default_idx)?;
Ok(choices[idx].value.clone())
}
fn default_target_index(config: &XbpConfig, choices: &[TargetChoice]) -> usize {
let services = config.services.as_deref().unwrap_or(&[]);
if services.len() == 1 {
if let Some(i) = choices.iter().position(|c| c.value == services[0].name) {
return i;
}
}
if let Some(i) = choices.iter().position(|c| c.label.starts_with("GROUP")) {
return i;
}
0
}
fn prompt_mode(current: DeployMode) -> Result<DeployMode, String> {
print_picker_header(
"xbp deploy · mode",
"How should this deploy run? (plan is safe / no mutations)",
);
let modes = [
(DeployMode::Run, "run · apply providers (k8s / worker / …)"),
(DeployMode::Plan, "plan · print plan only (no mutations)"),
(DeployMode::Verify, "verify · read-only rollout/health checks"),
(DeployMode::Status, "status · status snapshot"),
(DeployMode::History, "history · recent deploy records"),
(
DeployMode::Promote,
"promote · retag OCI images by digest (prompts for tag)",
),
];
let default = if matches!(current, DeployMode::Plan) {
0 } else {
modes
.iter()
.position(|(m, _)| std::mem::discriminant(m) == std::mem::discriminant(¤t))
.unwrap_or(0)
};
let labels: Vec<&str> = modes.iter().map(|(_, l)| *l).collect();
let idx = select_one("Deploy mode", &labels, default)?
.ok_or_else(|| "Selection cancelled".to_string())?;
Ok(modes[idx].0)
}
fn prompt_promote_tag(
config: &XbpConfig,
target: &str,
env: &Option<String>,
) -> Result<String, String> {
println!();
println!(
"{} retag images for `{}` by digest (no rebuild)",
"promote".bright_cyan().bold(),
target.bright_white()
);
println!(
"{}",
"The source digest comes from the plan/lock; the tag is the new mutable pointer (e.g. stable, production)."
.bright_black()
);
let mut suggestions: Vec<String> = vec![
"stable".into(),
"latest".into(),
"production".into(),
"staging".into(),
"canary".into(),
];
if let Some(e) = env.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
if !suggestions.iter().any(|s| s == e) {
suggestions.insert(0, e.to_string());
}
}
if let Some(default_env) = config
.deploy
.as_ref()
.and_then(|d| d.default_env.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty())
{
if !suggestions.iter().any(|s| s == default_env) {
suggestions.insert(0, default_env.to_string());
}
}
let mut seen = BTreeSet::new();
suggestions.retain(|s| seen.insert(s.clone()));
let mut labels: Vec<String> = suggestions
.iter()
.map(|t| format!(":{t}"))
.collect();
labels.push("custom tag…".into());
let idx = select_one("Promotion tag", &labels, 0)?
.ok_or_else(|| "Selection cancelled".to_string())?;
let tag = if idx < suggestions.len() {
suggestions[idx].clone()
} else {
Input::with_theme(&xbp_theme())
.with_prompt("Custom promotion tag (no leading colon)")
.validate_with(|input: &String| -> Result<(), &str> {
let t = input.trim();
if t.is_empty() {
return Err("tag must not be empty");
}
if t.contains(char::is_whitespace) {
return Err("tag must not contain whitespace");
}
if t.starts_with(':') {
return Err("omit the leading colon (enter stable, not :stable)");
}
if t.contains('/') || t.contains('@') {
return Err("enter a bare tag, not a full image reference");
}
Ok(())
})
.interact_text()
.map_err(|e| e.to_string())?
.trim()
.to_string()
};
println!(
"{} will retag plan digests → `…:{}`",
"promote".bright_green().bold(),
tag.bright_white()
);
Ok(tag)
}
fn prompt_env(config: &XbpConfig, target: &str) -> Result<Option<String>, String> {
let mut envs: BTreeSet<String> = BTreeSet::new();
if let Some(default) = config
.deploy
.as_ref()
.and_then(|d| d.default_env.clone())
.filter(|s| !s.trim().is_empty())
{
envs.insert(default);
}
envs.insert("production".into());
envs.insert("staging".into());
envs.insert("development".into());
let services = config.services.as_deref().unwrap_or(&[]);
let names = services_for_target(config, target);
for name in names {
if let Some(svc) = services.iter().find(|s| s.name == name) {
if let Some(deploy) = &svc.deploy {
for env in deploy.envs.keys() {
envs.insert(env.clone());
}
}
}
}
let mut list: Vec<String> = envs.into_iter().collect();
list.sort();
if let Some(default) = config
.deploy
.as_ref()
.and_then(|d| d.default_env.as_deref())
{
if let Some(i) = list.iter().position(|e| e == default) {
let d = list.remove(i);
list.insert(0, d);
}
} else if let Some(i) = list.iter().position(|e| e == "production") {
let d = list.remove(i);
list.insert(0, d);
}
if list.len() == 1 {
return Ok(Some(list[0].clone()));
}
let labels: Vec<String> = list
.iter()
.map(|e| {
if config
.deploy
.as_ref()
.and_then(|d| d.default_env.as_deref())
== Some(e.as_str())
{
format!("{e} (project default)")
} else {
e.clone()
}
})
.collect();
print_picker_header(
"xbp deploy · environment",
"Environment keys from deploy.default_env + service deploy.envs",
);
let idx = select_one("Deploy environment", &labels, 0)?
.ok_or_else(|| "Selection cancelled".to_string())?;
Ok(Some(list[idx].clone()))
}
fn services_for_target(config: &XbpConfig, target: &str) -> Vec<String> {
let raw = target.trim();
let services = config.services.as_deref().unwrap_or(&[]);
if raw.eq_ignore_ascii_case("all") {
return services.iter().map(|s| s.name.clone()).collect();
}
if let Some(group) = config.deploy.as_ref().and_then(|d| d.groups.get(raw)) {
return if group.order.is_empty() {
group.services.clone()
} else {
group.order.clone()
};
}
if services.iter().any(|s| s.name == raw) {
return vec![raw.to_string()];
}
Vec::new()
}
fn is_interactive() -> bool {
std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
&& std::env::var_os("XBP_NON_INTERACTIVE").is_none()
}
pub fn maybe_edit_plan_services(
plan: DeployPlan,
yes: bool,
force_non_interactive: bool,
) -> Result<DeployPlan, String> {
let names = unique_service_names(&plan);
if names.len() <= 1 {
return Ok(plan);
}
let interactive = is_interactive() && !force_non_interactive && !yes;
if !interactive {
return Ok(plan);
}
print_picker_header(
"xbp deploy · plan services",
&format!(
"{} services from target `{}` · adjust before apply",
names.len(),
plan.target.label()
),
);
let actions = [
format!("Continue with all {} services", names.len()),
"Edit services…".to_string(),
"Cancel deploy".to_string(),
];
let default = if names.len() > 3 { 1 } else { 0 };
let idx = select_one("Plan services", &actions, default)?
.ok_or_else(|| "Selection cancelled".to_string())?;
match idx {
0 => Ok(plan),
2 => Err("deploy cancelled".into()),
_ => {
let labeled = service_plan_labels(&plan);
let labels: Vec<&str> = labeled.iter().map(|(_, l)| l.as_str()).collect();
let defaults = vec![true; labeled.len()];
let selected = searchable_multi_select(
"Services to include in this deploy",
&labels,
&defaults,
)?;
if selected.is_empty() {
return Err("deploy cancelled: no services selected".into());
}
let keep: HashSet<String> = selected
.into_iter()
.filter_map(|i| labeled.get(i).map(|(n, _)| n.clone()))
.collect();
let filtered = retain_services_in_plan(plan, &keep).map_err(|e| e.to_string())?;
println!(
"{} plan now has {} service(s): {}",
"deploy".bright_cyan().bold(),
unique_service_names(&filtered).len(),
unique_service_names(&filtered).join(", ")
);
Ok(filtered)
}
}
}
pub fn apply_cli_service_filters(
plan: DeployPlan,
only: &[String],
exclude: &[String],
) -> Result<DeployPlan, String> {
filter_deploy_plan(plan, only, exclude).map_err(|e| e.to_string())
}
pub fn maybe_confirm_multi_service_apply(
plan: &DeployPlan,
mode: DeployMode,
yes: bool,
force_non_interactive: bool,
) -> Result<(), String> {
if !matches!(mode, DeployMode::Run | DeployMode::Promote) {
return Ok(());
}
if yes || force_non_interactive || !is_interactive() {
return Ok(());
}
let n = unique_service_names(plan).len();
if n <= 1 {
return Ok(());
}
let ok = confirm(
&format!("Apply deploy for {} service(s)?", n),
false,
)?;
if !ok {
return Err("deploy cancelled".into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_config() -> XbpConfig {
serde_json::from_value(serde_json::json!({
"project_name": "demo",
"version": "1.0.0",
"port": 8080,
"build_dir": ".",
"services": [
{"name": "api", "target": "rust", "branch": "main", "port": 8080},
{"name": "web", "target": "nextjs", "branch": "main", "port": 3000}
],
"deploy": {
"default_env": "production",
"groups": {
"core": {
"services": ["api", "web"],
"description": "core stack"
}
}
}
}))
.expect("sample config")
}
#[test]
fn target_choices_include_group_service_and_all() {
let cfg = sample_config();
let choices = deploy_target_choices(&cfg);
let values: Vec<_> = choices.iter().map(|c| c.value.as_str()).collect();
assert!(values.contains(&"core"));
assert!(values.contains(&"api"));
assert!(values.contains(&"web"));
assert!(values.contains(&"all"));
}
#[test]
fn default_target_prefers_group_when_multiple_services() {
let cfg = sample_config();
let choices = deploy_target_choices(&cfg);
let idx = default_target_index(&cfg, &choices);
assert_eq!(choices[idx].value, "core");
}
#[test]
fn sole_service_is_default_when_no_group() {
let cfg: XbpConfig = serde_json::from_value(serde_json::json!({
"project_name": "solo",
"version": "0.1.0",
"port": 1,
"build_dir": ".",
"services": [
{"name": "xbp-github-runner", "target": "rust", "branch": "main", "port": 3599}
]
}))
.expect("solo config");
let choices = deploy_target_choices(&cfg);
let idx = default_target_index(&cfg, &choices);
assert_eq!(choices[idx].value, "xbp-github-runner");
}
#[test]
fn peel_plan_positional_sets_plan_mode() {
let peeled = peel_deploy_mode_positional(
Some("plan".into()),
DeployMode::Plan,
false,
)
.expect("peel");
assert_eq!(peeled.target, None);
assert!(matches!(peeled.mode, DeployMode::Plan));
assert!(peeled.mode_explicit);
}
#[test]
fn peel_run_positional_sets_run_mode() {
let peeled = peel_deploy_mode_positional(Some("run".into()), DeployMode::Plan, false)
.expect("peel");
assert_eq!(peeled.target, None);
assert!(matches!(peeled.mode, DeployMode::Run));
}
#[test]
fn peel_plan_with_run_flag_errors() {
let err = peel_deploy_mode_positional(Some("plan".into()), DeployMode::Run, true)
.expect_err("conflict");
assert!(err.contains("mode"), "{err}");
assert!(err.contains("--run") || err.contains("--plan"), "{err}");
}
#[test]
fn peel_service_name_unchanged() {
let peeled = peel_deploy_mode_positional(
Some("athena".into()),
DeployMode::Plan,
false,
)
.expect("peel");
assert_eq!(peeled.target.as_deref(), Some("athena"));
assert!(!peeled.mode_explicit);
}
}