Skip to main content

xbp_cli/commands/deploy_engine/
interactive.rs

1//! Interactive deploy target / mode / env resolution.
2//!
3//! Makes `xbp deploy` (and `xbp deploy --plan`) usable without memorizing
4//! service names — pick from services[], deploy.groups, or `all`.
5
6use 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
22/// Resolve missing target / mode / env via TTY prompts when possible.
23pub 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, // engine / default_env
85    };
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 passed to the engine.
119    value: String,
120    /// Human label for the picker.
121    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    // Prefer sole service, else first group, else first choice.
225    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(&current))
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    // Prefer project default first in the list.
292    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}