1use 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 let mut skipped_unknown = Vec::new();
56 let mut resolved_order = Vec::new();
57 for name in &order {
58 let Some(svc) = config.services.iter().find(|s| s.name == *name).cloned() else {
59 skipped_unknown.push(name.clone());
61 continue;
62 };
63 if !service_has_env(&svc, env) {
64 return Err(DeployError::Validation(format!(
65 "service `{name}` in group `{raw}` has no deploy.envs.{env}"
66 )));
67 }
68 resolved_order.push(name.clone());
69 services.push(svc);
70 }
71 if services.is_empty() {
72 return Err(DeployError::Validation(format!(
73 "deploy group `{raw}` has no known services for env `{env}`{}",
74 if skipped_unknown.is_empty() {
75 String::new()
76 } else {
77 format!(" (unknown: {})", skipped_unknown.join(", "))
78 }
79 )));
80 }
81 let mut notes = Vec::new();
82 if config.services.iter().any(|s| s.name == raw) {
83 notes.push(format!(
84 "target `{raw}` matches both a deploy group and a service name; using group"
85 ));
86 }
87 if !skipped_unknown.is_empty() {
88 notes.push(format!(
89 "deploy group `{raw}` skipped {} unknown service reference(s)",
90 skipped_unknown.len()
91 ));
92 }
93 let note = if notes.is_empty() {
94 None
95 } else {
96 Some(notes.join("; "))
97 };
98 return Ok(TargetResolution {
99 target: DeployTarget::Group(raw.to_string()),
100 services,
101 group_order: Some(resolved_order),
102 note,
103 });
104 }
105
106 if let Some(svc) = config.services.iter().find(|s| s.name == raw).cloned() {
108 if !service_has_env(&svc, env) {
109 return Err(DeployError::Validation(format!(
110 "service `{raw}` has no deploy.envs.{env}"
111 )));
112 }
113 return Ok(TargetResolution {
114 target: DeployTarget::Service(raw.to_string()),
115 services: vec![svc],
116 group_order: None,
117 note: None,
118 });
119 }
120
121 let group_prefixes: Vec<String> = config
123 .groups
124 .keys()
125 .filter(|k| k.starts_with(raw))
126 .cloned()
127 .collect();
128 let service_prefixes: Vec<String> = config
129 .services
130 .iter()
131 .map(|s| s.name.clone())
132 .filter(|n| n.starts_with(raw))
133 .collect();
134
135 if group_prefixes.len() == 1 && service_prefixes.is_empty() {
136 return resolve_target(config, &group_prefixes[0], env);
137 }
138 if service_prefixes.len() == 1 && group_prefixes.is_empty() {
139 return resolve_target(config, &service_prefixes[0], env);
140 }
141
142 Err(unknown(raw, config, env, &format!("unknown deploy target `{raw}`")))
143}
144
145fn service_has_env(svc: &ServiceConfigView, env: &str) -> bool {
146 svc.deploy
147 .as_ref()
148 .map(|d| d.envs.contains_key(env))
149 .unwrap_or(false)
150}
151
152fn unknown(target: &str, config: &ProjectConfig, env: &str, prefix: &str) -> DeployError {
153 let reserved = matches!(
155 target.trim().to_ascii_lowercase().as_str(),
156 "plan" | "run" | "verify" | "status" | "history"
157 );
158 if reserved {
159 return DeployError::UnknownTarget {
160 target: target.to_string(),
161 suggestions: format!(
162 ". `{target}` is a deploy *mode*, not a service.\n\n\
163 Plan only (no mutations):\n xbp deploy --plan\n xbp deploy <service> --plan\n\n\
164 Apply after reviewing the plan:\n xbp deploy --run\n xbp deploy <service> --run\n\n\
165 Or omit the mode word and pick a service interactively:\n xbp deploy"
166 ),
167 };
168 }
169
170 let mut suggestions = Vec::new();
171 for g in config.groups.keys() {
172 suggestions.push(format!("- {g} (group)"));
173 }
174 let mut service_count = 0usize;
176 for s in config.services.iter().filter(|s| service_has_env(s, env)) {
177 if service_count < 20 {
178 suggestions.push(format!("- {} (service)", s.name));
179 }
180 service_count += 1;
181 }
182 if service_count > 20 {
183 suggestions.push(format!(
184 "- … and {} more service(s) with deploy.envs.{env}",
185 service_count - 20
186 ));
187 }
188 suggestions.push("- all".into());
189 let body = if suggestions.is_empty() {
190 String::new()
191 } else {
192 format!("\nDid you mean:\n{}", suggestions.join("\n"))
193 };
194 DeployError::UnknownTarget {
195 target: target.to_string(),
196 suggestions: format!(". {prefix}{body}"),
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::types::*;
204 use std::collections::HashMap;
205 use std::path::PathBuf;
206
207 fn cfg() -> ProjectConfig {
208 let mut groups = HashMap::new();
209 groups.insert(
210 "athena".into(),
211 DeployGroupView {
212 description: None,
213 services: vec!["athena-runtime".into()],
214 order: vec!["athena-runtime".into()],
215 },
216 );
217 let mut envs = HashMap::new();
218 envs.insert(
219 "production".into(),
220 ServiceDeployEnvView {
221 env: Default::default(),
222 env_files: vec![],
223 config_files: vec![],
224 container_port: None,
225 required_env: vec![],
226 expose: None,
227 namespace: Some("ns".into()),
228 replicas: None,
229 health: vec![],
230 kubernetes: None,
231 },
232 );
233 ProjectConfig {
234 project_name: "demo".into(),
235 version: "1.0.0".into(),
236 project_root: PathBuf::from("."),
237 services: vec![ServiceConfigView {
238 environment: Default::default(),
239 port: None,
240 name: "athena-runtime".into(),
241 root_directory: None,
242 version: None,
243 depends_on: vec![],
244 oci: None,
245 deploy: Some(ServiceDeployView {
246 provider: "kubernetes".into(),
247 worker: None,
248 rollout: None,
249 destinations: Default::default(),
250 envs,
251 }),
252 }],
253 groups,
254 default_env: Some("production".into()),
255 kubernetes: None,
256 history_dir: PathBuf::from(".xbp/deployments"),
257 lock_file: PathBuf::from(".xbp/deploy-lock.json"),
258 git_sha: None,
259 }
260 }
261
262 #[test]
263 fn prefers_group() {
264 let r = resolve_target(&cfg(), "athena", "production").unwrap();
265 assert!(matches!(r.target, DeployTarget::Group(_)));
266 }
267
268 #[test]
269 fn prefix_unique() {
270 let r = resolve_target(&cfg(), "athena-r", "production").unwrap();
271 assert!(matches!(r.target, DeployTarget::Service(_)));
272 }
273
274 #[test]
275 fn group_soft_skips_unknown_members() {
276 let mut config = cfg();
277 config.groups.insert(
279 "edge".into(),
280 DeployGroupView {
281 description: None,
282 services: vec![
283 "athena-runtime".into(),
284 "@lexington/studio".into(),
285 "default".into(),
286 ],
287 order: vec![
288 "athena-runtime".into(),
289 "@lexington/studio".into(),
290 "default".into(),
291 ],
292 },
293 );
294 let r = resolve_target(&config, "edge", "production").unwrap();
295 assert_eq!(r.services.len(), 1);
296 assert_eq!(r.services[0].name, "athena-runtime");
297 assert!(r
298 .note
299 .as_deref()
300 .unwrap_or("")
301 .contains("skipped 2 unknown"));
302 }
303}