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();
for name in &order {
let svc = config
.services
.iter()
.find(|s| s.name == *name)
.cloned()
.ok_or_else(|| {
DeployError::Validation(format!(
"deploy group `{raw}` references unknown service `{name}`"
))
})?;
if !service_has_env(&svc, env) {
return Err(DeployError::Validation(format!(
"service `{name}` in group `{raw}` has no deploy.envs.{env}"
)));
}
services.push(svc);
}
let note = if config.services.iter().any(|s| s.name == raw) {
Some(format!(
"target `{raw}` matches both a deploy group and a service name; using group"
))
} else {
None
};
return Ok(TargetResolution {
target: DeployTarget::Group(raw.to_string()),
services,
group_order: Some(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 mut suggestions = Vec::new();
for g in config.groups.keys() {
suggestions.push(format!("- {g} (group)"));
}
for s in config.services.iter().filter(|s| service_has_env(s, env)) {
suggestions.push(format!("- {} (service)", s.name));
}
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 {
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 {
name: "athena-runtime".into(),
root_directory: None,
version: None,
depends_on: vec![],
oci: None,
deploy: Some(ServiceDeployView {
provider: "kubernetes".into(),
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(_)));
}
}