xbp_deploy/
target_resolver.rs1use crate::error::{DeployError, Result};
2use crate::types::{DeployTarget, ProjectConfig, ServiceConfigView};
3
4#[derive(Debug, Clone)]
5pub struct TargetResolution {
6 pub target: DeployTarget,
7 pub services: Vec<ServiceConfigView>,
8 pub group_order: Option<Vec<String>>,
9 pub note: Option<String>,
10}
11
12pub fn resolve_target(
18 config: &ProjectConfig,
19 raw: &str,
20 env: &str,
21) -> Result<TargetResolution> {
22 let raw = raw.trim();
23 if raw.is_empty() {
24 return Err(unknown(raw, config, env, "deploy target is required"));
25 }
26
27 if raw.eq_ignore_ascii_case("all") {
28 let services: Vec<_> = config
29 .services
30 .iter()
31 .filter(|s| service_has_env(s, env))
32 .cloned()
33 .collect();
34 if services.is_empty() {
35 return Err(DeployError::Validation(format!(
36 "no services declare deploy.envs.{env}"
37 )));
38 }
39 return Ok(TargetResolution {
40 target: DeployTarget::All,
41 services,
42 group_order: None,
43 note: None,
44 });
45 }
46
47 if let Some(group) = config.groups.get(raw) {
49 let order = if group.order.is_empty() {
50 group.services.clone()
51 } else {
52 group.order.clone()
53 };
54 let mut services = Vec::new();
55 for name in &order {
56 let svc = config
57 .services
58 .iter()
59 .find(|s| s.name == *name)
60 .cloned()
61 .ok_or_else(|| {
62 DeployError::Validation(format!(
63 "deploy group `{raw}` references unknown service `{name}`"
64 ))
65 })?;
66 if !service_has_env(&svc, env) {
67 return Err(DeployError::Validation(format!(
68 "service `{name}` in group `{raw}` has no deploy.envs.{env}"
69 )));
70 }
71 services.push(svc);
72 }
73 let note = if config.services.iter().any(|s| s.name == raw) {
74 Some(format!(
75 "target `{raw}` matches both a deploy group and a service name; using group"
76 ))
77 } else {
78 None
79 };
80 return Ok(TargetResolution {
81 target: DeployTarget::Group(raw.to_string()),
82 services,
83 group_order: Some(order),
84 note,
85 });
86 }
87
88 if let Some(svc) = config.services.iter().find(|s| s.name == raw).cloned() {
90 if !service_has_env(&svc, env) {
91 return Err(DeployError::Validation(format!(
92 "service `{raw}` has no deploy.envs.{env}"
93 )));
94 }
95 return Ok(TargetResolution {
96 target: DeployTarget::Service(raw.to_string()),
97 services: vec![svc],
98 group_order: None,
99 note: None,
100 });
101 }
102
103 let group_prefixes: Vec<String> = config
105 .groups
106 .keys()
107 .filter(|k| k.starts_with(raw))
108 .cloned()
109 .collect();
110 let service_prefixes: Vec<String> = config
111 .services
112 .iter()
113 .map(|s| s.name.clone())
114 .filter(|n| n.starts_with(raw))
115 .collect();
116
117 if group_prefixes.len() == 1 && service_prefixes.is_empty() {
118 return resolve_target(config, &group_prefixes[0], env);
119 }
120 if service_prefixes.len() == 1 && group_prefixes.is_empty() {
121 return resolve_target(config, &service_prefixes[0], env);
122 }
123
124 Err(unknown(raw, config, env, &format!("unknown deploy target `{raw}`")))
125}
126
127fn service_has_env(svc: &ServiceConfigView, env: &str) -> bool {
128 svc.deploy
129 .as_ref()
130 .map(|d| d.envs.contains_key(env))
131 .unwrap_or(false)
132}
133
134fn unknown(target: &str, config: &ProjectConfig, env: &str, prefix: &str) -> DeployError {
135 let mut suggestions = Vec::new();
136 for g in config.groups.keys() {
137 suggestions.push(format!("- {g} (group)"));
138 }
139 for s in config.services.iter().filter(|s| service_has_env(s, env)) {
140 suggestions.push(format!("- {} (service)", s.name));
141 }
142 suggestions.push("- all".into());
143 let body = if suggestions.is_empty() {
144 String::new()
145 } else {
146 format!("\nDid you mean:\n{}", suggestions.join("\n"))
147 };
148 DeployError::UnknownTarget {
149 target: target.to_string(),
150 suggestions: format!(". {prefix}{body}"),
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use crate::types::*;
158 use std::collections::HashMap;
159 use std::path::PathBuf;
160
161 fn cfg() -> ProjectConfig {
162 let mut groups = HashMap::new();
163 groups.insert(
164 "athena".into(),
165 DeployGroupView {
166 description: None,
167 services: vec!["athena-runtime".into()],
168 order: vec!["athena-runtime".into()],
169 },
170 );
171 let mut envs = HashMap::new();
172 envs.insert(
173 "production".into(),
174 ServiceDeployEnvView {
175 namespace: Some("ns".into()),
176 replicas: None,
177 health: vec![],
178 kubernetes: None,
179 },
180 );
181 ProjectConfig {
182 project_name: "demo".into(),
183 version: "1.0.0".into(),
184 project_root: PathBuf::from("."),
185 services: vec![ServiceConfigView {
186 name: "athena-runtime".into(),
187 root_directory: None,
188 version: None,
189 depends_on: vec![],
190 oci: None,
191 deploy: Some(ServiceDeployView {
192 provider: "kubernetes".into(),
193 envs,
194 }),
195 }],
196 groups,
197 default_env: Some("production".into()),
198 kubernetes: None,
199 history_dir: PathBuf::from(".xbp/deployments"),
200 lock_file: PathBuf::from(".xbp/deploy-lock.json"),
201 git_sha: None,
202 }
203 }
204
205 #[test]
206 fn prefers_group() {
207 let r = resolve_target(&cfg(), "athena", "production").unwrap();
208 assert!(matches!(r.target, DeployTarget::Group(_)));
209 }
210
211 #[test]
212 fn prefix_unique() {
213 let r = resolve_target(&cfg(), "athena-r", "production").unwrap();
214 assert!(matches!(r.target, DeployTarget::Service(_)));
215 }
216}