Skip to main content

xbp_cli/commands/
service.rs

1//! service command module
2//!
3//! handles service related commands list build install start dev
4//! manages service configuration loading and command execution
5//! supports root directory and force run from root logic
6//! wraps start commands with pm2 when configured
7
8use std::env;
9use std::io::{IsTerminal, Write};
10use std::path::PathBuf;
11use std::process::Stdio;
12use std::time::Instant;
13
14use chrono::Local;
15use colored::Colorize;
16use dialoguer::{theme::ColorfulTheme, FuzzySelect, Select};
17use serde::{Deserialize, Serialize};
18use tokio::process::Command;
19use tracing::info;
20
21use crate::cli::ui;
22use crate::commands::pm2::pm2_start;
23use crate::commands::terminal_table::{render_table, TableStyle};
24use crate::config::global_xbp_paths;
25use crate::logging::{get_prefix, log_debug, log_info, log_success};
26use crate::strategies::{
27    get_all_services, get_service_by_name, resolve_config_paths_for_runtime, ServiceConfig,
28    XbpConfig,
29};
30use crate::utils::{
31    expand_home_in_string, find_xbp_config_upwards, maybe_auto_convert_legacy_xbp_json_to_yaml,
32    parse_config_with_auto_heal, resolve_env_placeholders,
33};
34
35/// List all services from the current XBP project config
36pub async fn list_services(_debug: bool) -> Result<(), String> {
37    let (project_root, config): (PathBuf, XbpConfig) = load_xbp_config_with_root().await?;
38    let services: Vec<ServiceConfig> = get_all_services(&config);
39
40    if services.is_empty() {
41        let _ = log_info("services", "No services configured.", None).await;
42        println!("No services configured.");
43        return Ok(());
44    }
45
46    println!();
47    println!(
48        "{}",
49        format!("Project: {}", config.project_name)
50            .bright_cyan()
51            .bold()
52    );
53    println!("{}", "Configured services:".bright_blue().bold());
54    let rows: Vec<Vec<String>> = services
55        .iter()
56        .map(|service| {
57            let url = service.url.as_deref().unwrap_or("-").to_string();
58            let avail = available_commands_for_service(service).join(" ");
59            let commands = if avail.trim().is_empty() {
60                "-".to_string()
61            } else {
62                avail
63            };
64
65            vec![
66                service.name.clone().bright_white().bold().to_string(),
67                service.target.clone().bright_yellow().to_string(),
68                service.port.to_string(),
69                if commands == "-" {
70                    commands.dimmed().to_string()
71                } else {
72                    commands.bright_green().to_string()
73                },
74                url,
75            ]
76        })
77        .collect();
78
79    print!(
80        "{}",
81        render_table(
82            &["Name", "Target", "Port", "Available Commands", "URL"],
83            &rows,
84            TableStyle::Pipe,
85            "",
86        )
87    );
88
89    println!("Total: {} service(s)", services.len());
90    println!();
91    ui::tip("Run `xbp service` (no args) for interactive picker, or `xbp service <cmd> <name>`.");
92
93    // Also sync to registry (best effort)
94    let _ = update_services_registry(&services, &project_root, Some(&config.project_name)).await;
95
96    Ok(())
97}
98
99/// Get service by name from config
100pub async fn get_service_config(name: &str) -> Result<ServiceConfig, String> {
101    let config = load_xbp_config().await?;
102    get_service_by_name(&config, name)
103}
104
105/// Run a service command (build, install, start, dev)
106pub async fn run_service_command(
107    command: &str,
108    service_name: &str,
109    debug: bool,
110) -> Result<(), String> {
111    let start_time = Instant::now();
112    let (project_root, config): (PathBuf, XbpConfig) = load_xbp_config_with_root().await?;
113    let service: ServiceConfig = get_service_by_name(&config, service_name)?;
114
115    // Determine working directory
116    let working_dir = if service.force_run_from_root.unwrap_or(false) {
117        // If force_run_from_root is true, use project root
118        project_root.clone()
119    } else if let Some(root_dir) = &service.root_directory {
120        // Use root_directory if specified
121        let expanded = expand_home_in_string(root_dir);
122        let candidate = PathBuf::from(expanded);
123        if candidate.is_absolute() {
124            candidate
125        } else {
126            project_root.clone().join(root_dir)
127        }
128    } else {
129        // Default to project root
130        project_root.clone()
131    };
132
133    let project_name = config.project_name.clone();
134    let working_dir_str = working_dir.display().to_string();
135
136    let _ = log_info(
137        "service",
138        &format!("Running '{}' for service '{}'", command, service_name),
139        Some(&format!("Working directory: {}", working_dir.display())),
140    )
141    .await;
142
143    // Execute and capture result so we can always record the run (success + failures)
144    let exec_result: Result<(), String> = (async {
145        // Get the command to run
146        let cmd_str: Option<&String> = match command {
147            "pre" => service.commands.as_ref().and_then(|c| c.pre.as_ref()),
148            "install" => service.commands.as_ref().and_then(|c| c.install.as_ref()),
149            "build" => service.commands.as_ref().and_then(|c| c.build.as_ref()),
150            "start" => service.commands.as_ref().and_then(|c| c.start.as_ref()),
151            "dev" => service.commands.as_ref().and_then(|c| c.dev.as_ref()),
152            _ => {
153                return Err(format!(
154                    "Unknown command: {}. Valid commands: pre, install, build, start, dev",
155                    command
156                ))
157            }
158        };
159
160        let cmd_str = cmd_str.ok_or_else(|| {
161            format!(
162                "Command '{}' not configured for service '{}'",
163                command, service_name
164            )
165        })?;
166
167        // Handle empty build command
168        if command == "build" && cmd_str.is_empty() {
169            let _ = log_info("service", "Build command is empty, skipping", None).await;
170            return Ok(());
171        }
172
173        // For start command, wrap with PM2 if start_wrapper is pm2
174        if command == "start" && service.start_wrapper.as_deref() == Some("pm2") {
175            // Create log directory
176            let log_dir = project_root.join(".xbp").join("logs").join(&service.name);
177            std::fs::create_dir_all(&log_dir)
178                .map_err(|e| format!("Failed to create log directory: {}", e))?;
179
180            // Build PM2 start command with port argument
181            let pm2_command: String = format!("{} --port {}", cmd_str, service.port);
182
183            let _ = log_info(
184                "service",
185                &format!("Starting service '{}' with PM2", service_name),
186                Some(&pm2_command),
187            )
188            .await;
189
190            let envs = merge_envs(
191                &project_root,
192                config.environment.as_ref(),
193                service.environment.as_ref(),
194            );
195            pm2_start(
196                &service.name,
197                &pm2_command,
198                Some(&log_dir),
199                envs.as_ref(),
200                debug,
201            )
202            .await?;
203
204            let _ = log_success(
205                "service",
206                &format!("Service '{}' started successfully", service_name),
207                None,
208            )
209            .await;
210
211            return Ok(());
212        }
213
214        let envs = merge_envs(
215            &project_root,
216            config.environment.as_ref(),
217            service.environment.as_ref(),
218        );
219
220        // Run pre-command if it exists and we're not running pre itself
221        if command != "pre" {
222            if let Some(pre_cmd) = service.commands.as_ref().and_then(|c| c.pre.as_ref()) {
223                if !pre_cmd.is_empty() {
224                    let _ = log_info("service", "Running pre-command", Some(pre_cmd)).await;
225                    run_command_in_dir(&working_dir, pre_cmd, envs.as_ref(), debug).await?;
226                }
227            }
228        }
229
230        // Execute the command
231        run_command_in_dir(&working_dir, cmd_str, envs.as_ref(), debug).await?;
232
233        let _ = log_success(
234            "service",
235            &format!(
236                "Command '{}' completed for service '{}'",
237                command, service_name
238            ),
239            None,
240        )
241        .await;
242
243        Ok(())
244    })
245    .await;
246
247    let duration_ms = start_time.elapsed().as_millis() as u64;
248    let success = exec_result.is_ok();
249    let exit_code = if success { Some(0) } else { None };
250
251    // Always record the run attempt (success or failure)
252    let _ = record_service_run(ServiceRunRecord {
253        timestamp: Local::now().to_rfc3339(),
254        project_root: project_root.to_string_lossy().to_string(),
255        project_name: Some(project_name),
256        service: service_name.to_string(),
257        command: command.to_string(),
258        working_dir: working_dir_str,
259        success,
260        duration_ms,
261        exit_code,
262    });
263
264    exec_result
265}
266
267/// Run a shell command in a specific directory
268async fn run_command_in_dir(
269    dir: &PathBuf,
270    command: &str,
271    envs: Option<&std::collections::HashMap<String, String>>,
272    _debug: bool,
273) -> Result<(), String> {
274    let _ = log_debug(
275        "service",
276        &format!("Executing: {} in {}", command, dir.display()),
277        None,
278    )
279    .await;
280
281    // Use shell to execute the command (supports complex commands with pipes, etc.)
282    #[cfg(unix)]
283    let mut cmd = Command::new("sh");
284    #[cfg(unix)]
285    cmd.arg("-c");
286
287    #[cfg(windows)]
288    let mut cmd = Command::new("cmd");
289    #[cfg(windows)]
290    cmd.arg("/C");
291
292    cmd.arg(command);
293    cmd.current_dir(dir);
294    if let Some(envs) = envs {
295        cmd.envs(envs);
296    }
297    cmd.stdout(Stdio::inherit());
298    cmd.stderr(Stdio::inherit());
299
300    let status = cmd
301        .status()
302        .await
303        .map_err(|e| format!("Failed to execute command: {}", e))?;
304
305    if !status.success() {
306        return Err(format!(
307            "Command failed with exit code: {}",
308            status.code().unwrap_or(-1)
309        ));
310    }
311
312    Ok(())
313}
314
315fn merge_envs(
316    project_root: &std::path::Path,
317    global: Option<&std::collections::HashMap<String, String>>,
318    service: Option<&std::collections::HashMap<String, String>>,
319) -> Option<std::collections::HashMap<String, String>> {
320    if global.is_none() && service.is_none() {
321        return None;
322    }
323    let mut out = std::collections::HashMap::new();
324    if let Some(g) = global {
325        out.extend(g.clone());
326    }
327    if let Some(s) = service {
328        out.extend(s.clone());
329    }
330    Some(resolve_env_placeholders(project_root, &out))
331}
332
333pub async fn load_xbp_config_with_root() -> Result<(PathBuf, XbpConfig), String> {
334    let current_dir: PathBuf =
335        env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
336
337    let found = find_xbp_config_upwards(&current_dir).ok_or_else(|| {
338        format!(
339            "{}\n\n{}\n{}",
340            "Currently not in an XBP project",
341            "No xbp.yaml/xbp.yml/xbp.json found in current directory or .xbp/",
342            "Run 'xbp' to select a project or 'xbp setup' to initialize a new project."
343        )
344    })?;
345
346    let content: String = std::fs::read_to_string(&found.config_path)
347        .map_err(|e| format!("Failed to read config file: {}", e))?;
348
349    let (mut config, healed_content): (XbpConfig, Option<String>) =
350        parse_config_with_auto_heal(&content, found.kind).map_err(|e| {
351            if found.kind == "yaml" {
352                format!("Failed to parse YAML config: {}", e)
353            } else {
354                format!("Failed to parse JSON config: {}", e)
355            }
356        })?;
357
358    if let Some(healed_content) = healed_content {
359        let _ = std::fs::write(&found.config_path, healed_content);
360    }
361
362    resolve_config_paths_for_runtime(&mut config, &found.project_root);
363
364    if found.kind == "json" {
365        let _ = maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path);
366    }
367
368    crate::data::athena::persist_project_snapshot(&found.project_root, &config, Some(found.kind))
369        .await;
370
371    Ok((found.project_root, config))
372}
373
374/// Load XBP config from current directory (or parent directories)
375pub async fn load_xbp_config() -> Result<XbpConfig, String> {
376    Ok(load_xbp_config_with_root().await?.1)
377}
378
379/// Check if we're in an xbp project
380pub async fn is_xbp_project() -> bool {
381    let current_dir = match env::current_dir() {
382        Ok(dir) => dir,
383        Err(_) => return false,
384    };
385    find_xbp_config_upwards(&current_dir).is_some()
386}
387
388/// Show help for a specific service
389pub async fn show_service_help(service_name: &str) -> Result<(), String> {
390    let service = get_service_config(service_name).await?;
391    let prefix = get_prefix();
392
393    info!("{}", prefix);
394    info!("{} Service: {}", prefix, service.name);
395    info!("{} {:-<60}", prefix, "");
396    info!("{} Target: {}", prefix, service.target);
397    info!("{} Port: {}", prefix, service.port);
398    info!("{} Branch: {}", prefix, service.branch);
399    if let Some(url) = &service.url {
400        info!(" {}URL: {}", prefix, url);
401    }
402    if let Some(root_dir) = &service.root_directory {
403        info!(" {}Root Directory: {}", prefix, root_dir);
404    }
405    info!(
406        "{} Force Run From Root: {}",
407        prefix,
408        service.force_run_from_root.unwrap_or(false)
409    );
410
411    if let Some(commands) = &service.commands {
412        info!("{}", prefix);
413        info!("{} Available Commands:", prefix);
414        info!("{} {:-<60}", prefix, "");
415        if commands.pre.is_some() {
416            info!("{} Service pre {}", prefix, service_name);
417        }
418        if commands.install.is_some() {
419            info!("{} Service install {}", prefix, service_name);
420        }
421        if commands.build.is_some() {
422            info!("{} Service build {}", prefix, service_name);
423        }
424        if commands.start.is_some() {
425            info!("{} Service start {}", prefix, service_name);
426        }
427        if commands.dev.is_some() {
428            info!("{} Service dev {}", prefix, service_name);
429        }
430    }
431
432    info!("{}", prefix);
433    info!("{} Redeploy:", prefix);
434    info!("{} Redeploy {}", prefix, service_name);
435
436    Ok(())
437}
438
439// -----------------------------------------------------------------------------
440// Service preview, interactive picker, run recording & registry (in ~/.xbp/logs/service/)
441// -----------------------------------------------------------------------------
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct ServiceRunRecord {
445    pub timestamp: String,
446    pub project_root: String,
447    pub project_name: Option<String>,
448    pub service: String,
449    pub command: String,
450    pub working_dir: String,
451    pub success: bool,
452    pub duration_ms: u64,
453    pub exit_code: Option<i32>,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize, Default)]
457pub struct ServicesRegistry {
458    pub last_updated: String,
459    pub services: Vec<RegisteredService>,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct RegisteredService {
464    pub name: String,
465    pub project_path: String,
466    pub project_name: Option<String>,
467    pub target: String,
468    pub port: u16,
469    pub available_commands: Vec<String>,
470    pub last_seen: String,
471}
472
473fn global_service_dir() -> Result<PathBuf, String> {
474    let paths = global_xbp_paths()?;
475    let dir = paths.logs_dir.join("service");
476    std::fs::create_dir_all(&dir)
477        .map_err(|e| format!("Failed to create service logs dir: {}", e))?;
478    Ok(dir)
479}
480
481pub fn service_runs_path() -> Result<PathBuf, String> {
482    Ok(global_service_dir()?.join("runs.jsonl"))
483}
484
485pub fn service_registry_path() -> Result<PathBuf, String> {
486    Ok(global_service_dir()?.join("registry.json"))
487}
488
489/// Append a run record (jsonl) for history of `xbp service` executions.
490pub fn record_service_run(record: ServiceRunRecord) -> Result<(), String> {
491    let path = service_runs_path()?;
492    let mut file = std::fs::OpenOptions::new()
493        .create(true)
494        .append(true)
495        .open(&path)
496        .map_err(|e| format!("Failed to open runs log {}: {}", path.display(), e))?;
497    let line = serde_json::to_string(&record).map_err(|e| e.to_string())?;
498    writeln!(file, "{}", line).map_err(|e| e.to_string())?;
499    Ok(())
500}
501
502/// Load the services registry (global across projects).
503pub fn load_services_registry() -> Result<ServicesRegistry, String> {
504    let path = service_registry_path()?;
505    if !path.exists() {
506        return Ok(ServicesRegistry::default());
507    }
508    let content =
509        std::fs::read_to_string(&path).map_err(|e| format!("Failed to read registry: {}", e))?;
510    let reg: ServicesRegistry =
511        serde_json::from_str(&content).map_err(|e| format!("Failed to parse registry: {}", e))?;
512    Ok(reg)
513}
514
515/// Update (merge) the registry with current project's services.
516pub async fn update_services_registry(
517    services: &[ServiceConfig],
518    project_root: &PathBuf,
519    project_name: Option<&str>,
520) -> Result<(), String> {
521    let mut reg = load_services_registry().unwrap_or_default();
522    let now = Local::now().to_rfc3339();
523    let root_str = project_root.to_string_lossy().to_string();
524
525    for svc in services {
526        let avail = available_commands_for_service(svc);
527        let reg_svc = RegisteredService {
528            name: svc.name.clone(),
529            project_path: root_str.clone(),
530            project_name: project_name.map(|s| s.to_string()),
531            target: svc.target.clone(),
532            port: svc.port,
533            available_commands: avail,
534            last_seen: now.clone(),
535        };
536
537        // replace if same (project_path + name)
538        if let Some(existing) = reg
539            .services
540            .iter_mut()
541            .find(|r| r.project_path == root_str && r.name == svc.name)
542        {
543            *existing = reg_svc;
544        } else {
545            reg.services.push(reg_svc);
546        }
547    }
548
549    reg.last_updated = now;
550
551    let path = service_registry_path()?;
552    let json = serde_json::to_string_pretty(&reg).map_err(|e| e.to_string())?;
553    std::fs::write(&path, json).map_err(|e| format!("Failed to write registry: {}", e))?;
554
555    Ok(())
556}
557
558/// Return list of configured commands that have a non-empty value for a service.
559pub fn available_commands_for_service(s: &ServiceConfig) -> Vec<String> {
560    let mut v = Vec::new();
561    if let Some(c) = &s.commands {
562        if c.pre.as_ref().map_or(false, |x| !x.trim().is_empty()) {
563            v.push("pre".to_string());
564        }
565        if c.install.as_ref().map_or(false, |x| !x.trim().is_empty()) {
566            v.push("install".to_string());
567        }
568        if c.build.as_ref().map_or(false, |x| !x.trim().is_empty()) {
569            v.push("build".to_string());
570        }
571        if c.start.as_ref().map_or(false, |x| !x.trim().is_empty()) {
572            v.push("start".to_string());
573        }
574        if c.dev.as_ref().map_or(false, |x| !x.trim().is_empty()) {
575            v.push("dev".to_string());
576        }
577    }
578    v
579}
580
581/// Pretty print preview of available services + commands (used by list and interactive entry).
582pub async fn print_service_preview() -> Result<(), String> {
583    let (project_root, config): (PathBuf, XbpConfig) = load_xbp_config_with_root().await?;
584    let services = get_all_services(&config);
585
586    println!();
587    println!(
588        "{} {}",
589        "◆".bright_blue().bold(),
590        format!("xbp service — {}", config.project_name)
591            .bright_cyan()
592            .bold()
593    );
594    ui::divider(72);
595
596    if services.is_empty() {
597        println!("No services configured in this project.");
598        return Ok(());
599    }
600
601    for svc in &services {
602        let avail = available_commands_for_service(svc);
603        let cmds_disp = if avail.is_empty() {
604            "no commands".dimmed().to_string()
605        } else {
606            avail.join(", ").bright_green().to_string()
607        };
608
609        let root_note = svc
610            .root_directory
611            .as_deref()
612            .map(|r| format!(" root={}", r))
613            .unwrap_or_default();
614
615        println!(
616            "  {} {}  {}  port {}  {} {}",
617            "•".bright_magenta(),
618            svc.name.bright_white().bold(),
619            format!("[{}]", svc.target).bright_yellow(),
620            svc.port.to_string().bright_cyan(),
621            cmds_disp,
622            root_note.dimmed()
623        );
624    }
625
626    ui::divider(72);
627    println!(
628        "{} {}",
629        "Hint:".bright_yellow().bold(),
630        "Pick interactively below, or use `xbp service <command> <name>`".dimmed()
631    );
632
633    // sync registry
634    let _ = update_services_registry(&services, &project_root, Some(&config.project_name)).await;
635
636    Ok(())
637}
638
639fn is_interactive_terminal() -> bool {
640    std::io::stdin().is_terminal()
641        && std::io::stdout().is_terminal()
642        && std::env::var_os("XBP_NON_INTERACTIVE").is_none()
643}
644
645/// Interactive picker: when you run `xbp service` bare, preview then let user choose service then command.
646pub async fn run_service_interactive(debug: bool) -> Result<(), String> {
647    // Always attempt to show preview (also updates registry)
648    if let Err(_e) = print_service_preview().await {
649        // If not in project, show a friendly message + recent registry info
650        println!();
651        println!("{}", "Not inside an XBP project directory.".bright_yellow());
652        println!(
653            "{} {}",
654            "Tip:".bright_cyan(),
655            "cd into a project with xbp.yaml (or .xbp/xbp.yaml) and try again.".dimmed()
656        );
657
658        // Show recent known services from registry as a teaser
659        if let Ok(reg) = load_services_registry() {
660            if !reg.services.is_empty() {
661                println!();
662                println!("{}", "Recently known services (from registry):".dimmed());
663                for rs in reg.services.iter().take(8) {
664                    let proj = rs.project_name.as_deref().unwrap_or("?");
665                    println!(
666                        "  {} {}  ({})  cmds: {}",
667                        "•".dimmed(),
668                        rs.name.bright_white(),
669                        proj,
670                        rs.available_commands.join(", ").dimmed()
671                    );
672                }
673                println!();
674                println!(
675                    "{}",
676                    format!(
677                        "Registry: {}",
678                        service_registry_path().unwrap_or_default().display()
679                    )
680                    .dimmed()
681                );
682            }
683        }
684        return Ok(());
685    }
686
687    if !is_interactive_terminal() {
688        ui::tip("Non-interactive terminal detected. Use `xbp service <cmd> <name>` explicitly.");
689        return Ok(());
690    }
691
692    let (_project_root, config) = load_xbp_config_with_root().await?;
693    let services = get_all_services(&config);
694    if services.is_empty() {
695        return Ok(());
696    }
697
698    // Build labels for service picker
699    let service_labels: Vec<String> = services
700        .iter()
701        .map(|s| {
702            let cmds = available_commands_for_service(s);
703            let cmd_hint = if cmds.is_empty() {
704                "".to_string()
705            } else {
706                format!(" — {}", cmds.join(", "))
707            };
708            format!("{} ({}) port {} {}", s.name, s.target, s.port, cmd_hint)
709        })
710        .collect();
711
712    let service_idx = match FuzzySelect::with_theme(&ColorfulTheme::default())
713        .with_prompt("Select service to operate on")
714        .items(&service_labels)
715        .default(0)
716        .interact_opt()
717    {
718        Ok(Some(i)) => i,
719        _ => {
720            println!("{}", "Cancelled.".dimmed());
721            return Ok(());
722        }
723    };
724
725    let chosen = &services[service_idx];
726    let avail_cmds = available_commands_for_service(chosen);
727    if avail_cmds.is_empty() {
728        println!(
729            "Service '{}' has no runnable commands configured.",
730            chosen.name
731        );
732        return Ok(());
733    }
734
735    let cmd_idx = match Select::with_theme(&ColorfulTheme::default())
736        .with_prompt(format!("Select command for '{}'", chosen.name))
737        .items(&avail_cmds)
738        .default(0)
739        .interact_opt()
740    {
741        Ok(Some(i)) => i,
742        _ => {
743            println!("{}", "Cancelled.".dimmed());
744            return Ok(());
745        }
746    };
747
748    let chosen_cmd = &avail_cmds[cmd_idx];
749
750    println!();
751    println!(
752        "{} Running {} {} {}",
753        "▶".bright_green().bold(),
754        "xbp service".bright_magenta(),
755        chosen_cmd.bright_cyan().bold(),
756        chosen.name.bright_white().bold()
757    );
758    ui::divider(40);
759
760    run_service_command(chosen_cmd, &chosen.name, debug).await
761}
762
763/// If a command (e.g. "start") is given without service name, offer interactive pick among services that support it.
764pub async fn pick_service_for_command(command: &str, debug: bool) -> Result<(), String> {
765    let (_project_root, config) = load_xbp_config_with_root().await?;
766    let all_services = get_all_services(&config);
767
768    let candidates: Vec<&ServiceConfig> = all_services
769        .iter()
770        .filter(|s| {
771            available_commands_for_service(s)
772                .iter()
773                .any(|c| c == command)
774        })
775        .collect();
776
777    if candidates.is_empty() {
778        return Err(format!(
779            "No services have a '{}' command configured. Check `xbp services`.",
780            command
781        ));
782    }
783
784    if candidates.len() == 1 {
785        let only = candidates[0];
786        println!(
787            "Only one service supports '{}': {} — running it.",
788            command, only.name
789        );
790        return run_service_command(command, &only.name, debug).await;
791    }
792
793    if !is_interactive_terminal() {
794        let names: Vec<_> = candidates.iter().map(|s| s.name.as_str()).collect();
795        return Err(format!(
796            "Multiple services support '{}'. Specify one of: {}",
797            command,
798            names.join(", ")
799        ));
800    }
801
802    let labels: Vec<String> = candidates
803        .iter()
804        .map(|s| format!("{} (port {})", s.name, s.port))
805        .collect();
806
807    let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
808        .with_prompt(format!("Select service for '{}'", command))
809        .items(&labels)
810        .default(0)
811        .interact()
812        .map_err(|e| format!("Selection failed: {}", e))?;
813
814    let chosen = candidates[idx];
815    run_service_command(command, &chosen.name, debug).await
816}
817
818#[cfg(test)]
819mod tests {
820    use crate::commands::terminal_table::{render_table, TableStyle};
821
822    #[test]
823    fn service_table_renderer_pads_colored_cells() {
824        let rendered = render_table(
825            &["Name", "Port"],
826            &[vec![
827                "\u{1b}[1;37mathena\u{1b}[0m".to_string(),
828                "3000".to_string(),
829            ]],
830            TableStyle::Box,
831            "",
832        );
833
834        assert!(rendered.contains("athena"));
835        assert!(rendered.contains("3000"));
836        assert!(rendered.contains("┌"));
837        assert!(rendered.contains("│"));
838    }
839}