xbp_cli/commands/deploy_engine/
interactive.rs1use std::collections::BTreeSet;
7use std::io::IsTerminal;
8
9use colored::Colorize;
10use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Select};
11use xbp_deploy::DeployMode;
12
13use crate::strategies::XbpConfig;
14
15#[derive(Debug, Clone)]
16pub struct ResolvedDeployInvocation {
17 pub target: String,
18 pub env: Option<String>,
19 pub mode: DeployMode,
20}
21
22pub fn resolve_deploy_invocation(
24 config: &XbpConfig,
25 target: Option<String>,
26 env: Option<String>,
27 mode: DeployMode,
28 mode_explicit: bool,
29 yes: bool,
30 force_non_interactive: bool,
31) -> Result<ResolvedDeployInvocation, String> {
32 let interactive = is_interactive() && !force_non_interactive && !yes;
33 let target_raw = target
34 .as_deref()
35 .map(str::trim)
36 .filter(|s| !s.is_empty())
37 .map(str::to_string);
38
39 let choices = deploy_target_choices(config);
40 if choices.is_empty() {
41 return Err(
42 "No services[] defined in project config. Run `xbp init` or `xbp version discover` first."
43 .into(),
44 );
45 }
46
47 let resolved_target = match target_raw {
48 Some(t) => t,
49 None if interactive => prompt_target(config, &choices)?,
50 None if choices.len() == 1 => {
51 let only = choices[0].value.clone();
52 println!(
53 "{} using sole deploy target `{}`",
54 "deploy".bright_cyan().bold(),
55 only.bright_white()
56 );
57 only
58 }
59 None => {
60 return Err(format!(
61 "deploy target is required.\n\nAvailable targets:\n{}\n\n\
62 Examples:\n xbp deploy {}\n xbp deploy --plan\n xbp deploy all --env production --plan\n\n\
63 In a TTY, omit <TARGET> to pick interactively.",
64 format_target_help(&choices),
65 choices[0].value
66 ));
67 }
68 };
69
70 let resolved_mode = if mode_explicit || !interactive {
71 mode
72 } else {
73 prompt_mode(mode)?
74 };
75
76 let resolved_env = match env
77 .as_deref()
78 .map(str::trim)
79 .filter(|s| !s.is_empty())
80 .map(str::to_string)
81 {
82 Some(e) => Some(e),
83 None if interactive => prompt_env(config, &resolved_target)?,
84 None => None, };
86
87 if interactive && matches!(resolved_mode, DeployMode::Run | DeployMode::Promote) {
88 let label = match resolved_mode {
89 DeployMode::Promote => "promote",
90 _ => "run",
91 };
92 let ok = Confirm::with_theme(&ColorfulTheme::default())
93 .with_prompt(format!(
94 "Apply deploy `{label}` for target `{}`{}?",
95 resolved_target,
96 resolved_env
97 .as_deref()
98 .map(|e| format!(" (env: {e})"))
99 .unwrap_or_default()
100 ))
101 .default(matches!(resolved_mode, DeployMode::Promote))
102 .interact()
103 .map_err(|e| e.to_string())?;
104 if !ok {
105 return Err("deploy aborted by user".into());
106 }
107 }
108
109 Ok(ResolvedDeployInvocation {
110 target: resolved_target,
111 env: resolved_env,
112 mode: resolved_mode,
113 })
114}
115
116#[derive(Debug, Clone)]
117struct TargetChoice {
118 value: String,
120 label: String,
122}
123
124fn deploy_target_choices(config: &XbpConfig) -> Vec<TargetChoice> {
125 let mut out = Vec::new();
126 let services = config.services.as_deref().unwrap_or(&[]);
127
128 if let Some(deploy) = config.deploy.as_ref() {
129 let mut group_names: Vec<_> = deploy.groups.keys().cloned().collect();
130 group_names.sort();
131 for name in group_names {
132 let group = &deploy.groups[&name];
133 let members = if group.order.is_empty() {
134 group.services.join(", ")
135 } else {
136 group.order.join(", ")
137 };
138 let desc = group
139 .description
140 .as_deref()
141 .filter(|s| !s.is_empty())
142 .map(|d| format!(" — {d}"))
143 .unwrap_or_default();
144 out.push(TargetChoice {
145 value: name.clone(),
146 label: format!("group:{name} [{members}]{desc}"),
147 });
148 }
149 }
150
151 for svc in services {
152 let provider = svc
153 .deploy
154 .as_ref()
155 .and_then(|d| d.provider.as_deref())
156 .filter(|s| !s.is_empty())
157 .unwrap_or(svc.target.as_str());
158 let envs: Vec<_> = svc
159 .deploy
160 .as_ref()
161 .map(|d| {
162 let mut keys: Vec<_> = d.envs.keys().cloned().collect();
163 keys.sort();
164 keys
165 })
166 .unwrap_or_default();
167 let env_hint = if envs.is_empty() {
168 "no deploy.envs yet".to_string()
169 } else {
170 format!("envs: {}", envs.join(", "))
171 };
172 out.push(TargetChoice {
173 value: svc.name.clone(),
174 label: format!("service:{} ({provider}; {env_hint})", svc.name),
175 });
176 }
177
178 if !services.is_empty() {
179 out.push(TargetChoice {
180 value: "all".into(),
181 label: format!(
182 "all (every service with deploy.envs for the chosen environment — {} service(s))",
183 services.len()
184 ),
185 });
186 }
187
188 out
189}
190
191fn format_target_help(choices: &[TargetChoice]) -> String {
192 choices
193 .iter()
194 .map(|c| format!(" • {}", c.value))
195 .collect::<Vec<_>>()
196 .join("\n")
197}
198
199fn prompt_target(config: &XbpConfig, choices: &[TargetChoice]) -> Result<String, String> {
200 println!();
201 println!(
202 "{} {}",
203 "XBP deploy".bright_magenta().bold(),
204 "— pick a target".bright_white()
205 );
206 println!(
207 "{}",
208 "Service name, deploy.groups entry, or all. Type to fuzzy-filter."
209 .bright_black()
210 );
211
212 let default_idx = default_target_index(config, choices);
213 let labels: Vec<&str> = choices.iter().map(|c| c.label.as_str()).collect();
214 let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
215 .with_prompt("Deploy target")
216 .items(&labels)
217 .default(default_idx)
218 .interact()
219 .map_err(|e| e.to_string())?;
220 Ok(choices[idx].value.clone())
221}
222
223fn default_target_index(config: &XbpConfig, choices: &[TargetChoice]) -> usize {
224 let services = config.services.as_deref().unwrap_or(&[]);
226 if services.len() == 1 {
227 if let Some(i) = choices.iter().position(|c| c.value == services[0].name) {
228 return i;
229 }
230 }
231 if let Some(i) = choices.iter().position(|c| c.label.starts_with("group:")) {
232 return i;
233 }
234 0
235}
236
237fn prompt_mode(current: DeployMode) -> Result<DeployMode, String> {
238 let modes = [
239 (DeployMode::Plan, "plan — print plan only (no mutations)"),
240 (DeployMode::Run, "run — apply providers (k8s / worker / …)"),
241 (DeployMode::Verify, "verify — read-only rollout/health checks"),
242 (DeployMode::Status, "status — status snapshot"),
243 (DeployMode::History, "history — recent deploy records"),
244 (
245 DeployMode::Promote,
246 "promote — retag OCI images by digest (set --promote TAG on CLI for the tag)",
247 ),
248 ];
249 let default = modes
250 .iter()
251 .position(|(m, _)| std::mem::discriminant(m) == std::mem::discriminant(¤t))
252 .unwrap_or(0);
253 let labels: Vec<&str> = modes.iter().map(|(_, l)| *l).collect();
254 let idx = Select::with_theme(&ColorfulTheme::default())
255 .with_prompt("Deploy mode")
256 .items(&labels)
257 .default(default)
258 .interact()
259 .map_err(|e| e.to_string())?;
260 Ok(modes[idx].0)
261}
262
263fn prompt_env(config: &XbpConfig, target: &str) -> Result<Option<String>, String> {
264 let mut envs: BTreeSet<String> = BTreeSet::new();
265 if let Some(default) = config
266 .deploy
267 .as_ref()
268 .and_then(|d| d.default_env.clone())
269 .filter(|s| !s.trim().is_empty())
270 {
271 envs.insert(default);
272 }
273 envs.insert("production".into());
274 envs.insert("staging".into());
275 envs.insert("development".into());
276
277 let services = config.services.as_deref().unwrap_or(&[]);
278 let names = services_for_target(config, target);
279 for name in names {
280 if let Some(svc) = services.iter().find(|s| s.name == name) {
281 if let Some(deploy) = &svc.deploy {
282 for env in deploy.envs.keys() {
283 envs.insert(env.clone());
284 }
285 }
286 }
287 }
288
289 let mut list: Vec<String> = envs.into_iter().collect();
290 list.sort();
291 if let Some(default) = config
293 .deploy
294 .as_ref()
295 .and_then(|d| d.default_env.as_deref())
296 {
297 if let Some(i) = list.iter().position(|e| e == default) {
298 let d = list.remove(i);
299 list.insert(0, d);
300 }
301 } else if let Some(i) = list.iter().position(|e| e == "production") {
302 let d = list.remove(i);
303 list.insert(0, d);
304 }
305
306 if list.len() == 1 {
307 return Ok(Some(list[0].clone()));
308 }
309
310 let labels: Vec<String> = list
311 .iter()
312 .map(|e| {
313 if config
314 .deploy
315 .as_ref()
316 .and_then(|d| d.default_env.as_deref())
317 == Some(e.as_str())
318 {
319 format!("{e} (project default)")
320 } else {
321 e.clone()
322 }
323 })
324 .collect();
325 let idx = Select::with_theme(&ColorfulTheme::default())
326 .with_prompt("Deploy environment")
327 .items(&labels)
328 .default(0)
329 .interact()
330 .map_err(|e| e.to_string())?;
331 Ok(Some(list[idx].clone()))
332}
333
334fn services_for_target(config: &XbpConfig, target: &str) -> Vec<String> {
335 let raw = target.trim();
336 let services = config.services.as_deref().unwrap_or(&[]);
337 if raw.eq_ignore_ascii_case("all") {
338 return services.iter().map(|s| s.name.clone()).collect();
339 }
340 if let Some(group) = config.deploy.as_ref().and_then(|d| d.groups.get(raw)) {
341 return if group.order.is_empty() {
342 group.services.clone()
343 } else {
344 group.order.clone()
345 };
346 }
347 if services.iter().any(|s| s.name == raw) {
348 return vec![raw.to_string()];
349 }
350 Vec::new()
351}
352
353fn is_interactive() -> bool {
354 std::io::stdin().is_terminal()
355 && std::io::stdout().is_terminal()
356 && std::env::var_os("XBP_NON_INTERACTIVE").is_none()
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 fn sample_config() -> XbpConfig {
364 serde_json::from_value(serde_json::json!({
365 "project_name": "demo",
366 "version": "1.0.0",
367 "port": 8080,
368 "build_dir": ".",
369 "services": [
370 {"name": "api", "target": "rust", "branch": "main", "port": 8080},
371 {"name": "web", "target": "nextjs", "branch": "main", "port": 3000}
372 ],
373 "deploy": {
374 "default_env": "production",
375 "groups": {
376 "core": {
377 "services": ["api", "web"],
378 "description": "core stack"
379 }
380 }
381 }
382 }))
383 .expect("sample config")
384 }
385
386 #[test]
387 fn target_choices_include_group_service_and_all() {
388 let cfg = sample_config();
389 let choices = deploy_target_choices(&cfg);
390 let values: Vec<_> = choices.iter().map(|c| c.value.as_str()).collect();
391 assert!(values.contains(&"core"));
392 assert!(values.contains(&"api"));
393 assert!(values.contains(&"web"));
394 assert!(values.contains(&"all"));
395 }
396
397 #[test]
398 fn default_target_prefers_group_when_multiple_services() {
399 let cfg = sample_config();
400 let choices = deploy_target_choices(&cfg);
401 let idx = default_target_index(&cfg, &choices);
402 assert_eq!(choices[idx].value, "core");
403 }
404
405 #[test]
406 fn sole_service_is_default_when_no_group() {
407 let cfg: XbpConfig = serde_json::from_value(serde_json::json!({
408 "project_name": "solo",
409 "version": "0.1.0",
410 "port": 1,
411 "build_dir": ".",
412 "services": [
413 {"name": "xbp-github-runner", "target": "rust", "branch": "main", "port": 3599}
414 ]
415 }))
416 .expect("solo config");
417 let choices = deploy_target_choices(&cfg);
418 let idx = default_target_index(&cfg, &choices);
419 assert_eq!(choices[idx].value, "xbp-github-runner");
420 }
421}