use std::collections::BTreeSet;
use std::io::IsTerminal;
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Select};
use xbp_deploy::DeployMode;
use crate::strategies::XbpConfig;
#[derive(Debug, Clone)]
pub struct ResolvedDeployInvocation {
pub target: String,
pub env: Option<String>,
pub mode: DeployMode,
}
pub fn resolve_deploy_invocation(
config: &XbpConfig,
target: Option<String>,
env: Option<String>,
mode: DeployMode,
mode_explicit: bool,
yes: bool,
force_non_interactive: bool,
) -> 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, };
if interactive && matches!(resolved_mode, DeployMode::Run | DeployMode::Promote) {
let label = match resolved_mode {
DeployMode::Promote => "promote",
_ => "run",
};
let ok = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Apply deploy `{label}` for target `{}`{}?",
resolved_target,
resolved_env
.as_deref()
.map(|e| format!(" (env: {e})"))
.unwrap_or_default()
))
.default(matches!(resolved_mode, DeployMode::Promote))
.interact()
.map_err(|e| e.to_string())?;
if !ok {
return Err("deploy aborted by user".into());
}
}
Ok(ResolvedDeployInvocation {
target: resolved_target,
env: resolved_env,
mode: resolved_mode,
})
}
#[derive(Debug, Clone)]
struct TargetChoice {
value: String,
label: String,
}
fn deploy_target_choices(config: &XbpConfig) -> Vec<TargetChoice> {
let mut out = Vec::new();
let services = config.services.as_deref().unwrap_or(&[]);
if let Some(deploy) = config.deploy.as_ref() {
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 = if group.order.is_empty() {
group.services.join(", ")
} else {
group.order.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!("group:{name} [{members}]{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 yet".to_string()
} else {
format!("envs: {}", envs.join(", "))
};
out.push(TargetChoice {
value: svc.name.clone(),
label: format!("service:{} ({provider}; {env_hint})", svc.name),
});
}
if !services.is_empty() {
out.push(TargetChoice {
value: "all".into(),
label: format!(
"all (every service with deploy.envs for the chosen environment — {} service(s))",
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> {
println!();
println!(
"{} {}",
"XBP deploy".bright_magenta().bold(),
"— pick a target".bright_white()
);
println!(
"{}",
"Service name, deploy.groups entry, or all. Type to fuzzy-filter."
.bright_black()
);
let default_idx = default_target_index(config, choices);
let labels: Vec<&str> = choices.iter().map(|c| c.label.as_str()).collect();
let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Deploy target")
.items(&labels)
.default(default_idx)
.interact()
.map_err(|e| e.to_string())?;
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> {
let modes = [
(DeployMode::Plan, "plan — print plan only (no mutations)"),
(DeployMode::Run, "run — apply providers (k8s / worker / …)"),
(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 (set --promote TAG on CLI for the tag)",
),
];
let default = 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::with_theme(&ColorfulTheme::default())
.with_prompt("Deploy mode")
.items(&labels)
.default(default)
.interact()
.map_err(|e| e.to_string())?;
Ok(modes[idx].0)
}
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();
let idx = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Deploy environment")
.items(&labels)
.default(0)
.interact()
.map_err(|e| e.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()
}
#[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");
}
}