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