use crate::error::{DeployError, Result};
use crate::types::{DeployTarget, ProjectConfig, ServiceConfigView};
#[derive(Debug, Clone)]
pub struct TargetResolution {
pub target: DeployTarget,
pub services: Vec<ServiceConfigView>,
pub group_order: Option<Vec<String>>,
pub note: Option<String>,
}
pub fn resolve_target(
config: &ProjectConfig,
raw: &str,
env: &str,
) -> Result<TargetResolution> {
let raw = raw.trim();
if raw.is_empty() {
return Err(unknown(raw, config, env, "deploy target is required"));
}
if raw.eq_ignore_ascii_case("all") {
let services: Vec<_> = config
.services
.iter()
.filter(|s| service_has_env(s, env))
.cloned()
.collect();
if services.is_empty() {
return Err(DeployError::Validation(format!(
"no services declare deploy.envs.{env}"
)));
}
return Ok(TargetResolution {
target: DeployTarget::All,
services,
group_order: None,
note: None,
});
}
if let Some(group) = config.groups.get(raw) {
let order = if group.order.is_empty() {
group.services.clone()
} else {
group.order.clone()
};
let mut services = Vec::new();
let mut skipped_unknown = Vec::new();
let mut resolved_order = Vec::new();
for name in &order {
let Some(svc) = config.services.iter().find(|s| s.name == *name).cloned() else {
skipped_unknown.push(name.clone());
continue;
};
if !service_has_env(&svc, env) {
return Err(DeployError::Validation(format!(
"service `{name}` in group `{raw}` has no deploy.envs.{env}"
)));
}
resolved_order.push(name.clone());
services.push(svc);
}
if services.is_empty() {
return Err(DeployError::Validation(format!(
"deploy group `{raw}` has no known services for env `{env}`{}",
if skipped_unknown.is_empty() {
String::new()
} else {
format!(" (unknown: {})", skipped_unknown.join(", "))
}
)));
}
let mut notes = Vec::new();
if config.services.iter().any(|s| s.name == raw) {
notes.push(format!(
"target `{raw}` matches both a deploy group and a service name; using group"
));
}
if !skipped_unknown.is_empty() {
notes.push(format!(
"deploy group `{raw}` skipped {} unknown service reference(s)",
skipped_unknown.len()
));
}
let note = if notes.is_empty() {
None
} else {
Some(notes.join("; "))
};
return Ok(TargetResolution {
target: DeployTarget::Group(raw.to_string()),
services,
group_order: Some(resolved_order),
note,
});
}
if let Some(svc) = config.services.iter().find(|s| s.name == raw).cloned() {
if !service_has_env(&svc, env) {
return Err(DeployError::Validation(format!(
"service `{raw}` has no deploy.envs.{env}"
)));
}
return Ok(TargetResolution {
target: DeployTarget::Service(raw.to_string()),
services: vec![svc],
group_order: None,
note: None,
});
}
let group_prefixes: Vec<String> = config
.groups
.keys()
.filter(|k| k.starts_with(raw))
.cloned()
.collect();
let service_prefixes: Vec<String> = config
.services
.iter()
.map(|s| s.name.clone())
.filter(|n| n.starts_with(raw))
.collect();
if group_prefixes.len() == 1 && service_prefixes.is_empty() {
return resolve_target(config, &group_prefixes[0], env);
}
if service_prefixes.len() == 1 && group_prefixes.is_empty() {
return resolve_target(config, &service_prefixes[0], env);
}
Err(unknown(raw, config, env, &format!("unknown deploy target `{raw}`")))
}
fn service_has_env(svc: &ServiceConfigView, env: &str) -> bool {
svc.deploy
.as_ref()
.map(|d| d.envs.contains_key(env))
.unwrap_or(false)
}
fn unknown(target: &str, config: &ProjectConfig, env: &str, prefix: &str) -> DeployError {
let reserved = matches!(
target.trim().to_ascii_lowercase().as_str(),
"plan" | "run" | "verify" | "status" | "history"
);
if reserved {
return DeployError::UnknownTarget {
target: target.to_string(),
suggestions: format!(
". `{target}` is a deploy *mode*, not a service.\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\
Or omit the mode word and pick a service interactively:\n xbp deploy"
),
};
}
let mut suggestions = Vec::new();
for g in config.groups.keys() {
suggestions.push(format!("- {g} (group)"));
}
let mut service_count = 0usize;
for s in config.services.iter().filter(|s| service_has_env(s, env)) {
if service_count < 20 {
suggestions.push(format!("- {} (service)", s.name));
}
service_count += 1;
}
if service_count > 20 {
suggestions.push(format!(
"- … and {} more service(s) with deploy.envs.{env}",
service_count - 20
));
}
suggestions.push("- all".into());
let body = if suggestions.is_empty() {
String::new()
} else {
format!("\nDid you mean:\n{}", suggestions.join("\n"))
};
DeployError::UnknownTarget {
target: target.to_string(),
suggestions: format!(". {prefix}{body}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::*;
use std::collections::HashMap;
use std::path::PathBuf;
fn cfg() -> ProjectConfig {
let mut groups = HashMap::new();
groups.insert(
"athena".into(),
DeployGroupView {
description: None,
services: vec!["athena-runtime".into()],
order: vec!["athena-runtime".into()],
},
);
let mut envs = HashMap::new();
envs.insert(
"production".into(),
ServiceDeployEnvView {
env: Default::default(),
env_files: vec![],
config_files: vec![],
container_port: None,
required_env: vec![],
expose: None,
namespace: Some("ns".into()),
replicas: None,
health: vec![],
kubernetes: None,
},
);
ProjectConfig {
project_name: "demo".into(),
version: "1.0.0".into(),
project_root: PathBuf::from("."),
services: vec![ServiceConfigView {
environment: Default::default(),
port: None,
name: "athena-runtime".into(),
root_directory: None,
version: None,
depends_on: vec![],
oci: None,
deploy: Some(ServiceDeployView {
provider: "kubernetes".into(),
worker: None,
rollout: None,
destinations: Default::default(),
envs,
}),
}],
groups,
default_env: Some("production".into()),
kubernetes: None,
history_dir: PathBuf::from(".xbp/deployments"),
lock_file: PathBuf::from(".xbp/deploy-lock.json"),
git_sha: None,
}
}
#[test]
fn prefers_group() {
let r = resolve_target(&cfg(), "athena", "production").unwrap();
assert!(matches!(r.target, DeployTarget::Group(_)));
}
#[test]
fn prefix_unique() {
let r = resolve_target(&cfg(), "athena-r", "production").unwrap();
assert!(matches!(r.target, DeployTarget::Service(_)));
}
#[test]
fn group_soft_skips_unknown_members() {
let mut config = cfg();
config.groups.insert(
"edge".into(),
DeployGroupView {
description: None,
services: vec![
"athena-runtime".into(),
"@lexington/studio".into(),
"default".into(),
],
order: vec![
"athena-runtime".into(),
"@lexington/studio".into(),
"default".into(),
],
},
);
let r = resolve_target(&config, "edge", "production").unwrap();
assert_eq!(r.services.len(), 1);
assert_eq!(r.services[0].name, "athena-runtime");
assert!(r
.note
.as_deref()
.unwrap_or("")
.contains("skipped 2 unknown"));
}
}