xbp 10.15.4

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! service command module
//!
//! handles service related commands list build install start dev
//! manages service configuration loading and command execution
//! supports root directory and force run from root logic
//! wraps start commands with pm2 when configured

use std::env;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::process::Command;
use tracing::info;

use crate::commands::pm2::pm2_start;
use crate::logging::{get_prefix, log_debug, log_info, log_success};
use crate::strategies::{get_all_services, get_service_by_name, ServiceConfig, XbpConfig};
use crate::utils::{
    collapse_home_to_env, expand_home_in_string, find_xbp_config_upwards,
    parse_config_with_auto_heal,
};

/// List all services from the current xbp.json config
pub async fn list_services(_debug: bool) -> Result<(), String> {
    let config: XbpConfig = load_xbp_config().await?;
    let services: Vec<ServiceConfig> = get_all_services(&config);

    if services.is_empty() {
        let _ = log_info("services", "No services configured.", None).await;
        println!("No services configured.");
        return Ok(());
    }

    println!("Services:");
    println!("{:-<80}", "");
    println!(
        "{:<20} | {:<12} | {:<6} | {:<15} | {:<20}",
        "Name", "Target", "Port", "Branch", "URL"
    );
    println!("{:-<80}", "");

    for service in &services {
        let url = service.url.as_deref().unwrap_or("-");
        println!(
            "{:<20} | {:<12} | {:<6} | {:<15} | {:<20}",
            service.name, service.target, service.port, service.branch, url
        );
    }

    println!("{:-<80}", "");
    println!("Total: {} service(s)", services.len());

    Ok(())
}

/// Get service by name from config
pub async fn get_service_config(name: &str) -> Result<ServiceConfig, String> {
    let config = load_xbp_config().await?;
    get_service_by_name(&config, name)
}

/// Run a service command (build, install, start, dev)
pub async fn run_service_command(
    command: &str,
    service_name: &str,
    debug: bool,
) -> Result<(), String> {
    let (project_root, config): (PathBuf, XbpConfig) = load_xbp_config_with_root().await?;
    let service: ServiceConfig = get_service_by_name(&config, service_name)?;

    // Determine working directory
    let working_dir = if service.force_run_from_root.unwrap_or(false) {
        // If force_run_from_root is true, use project root
        project_root.clone()
    } else if let Some(root_dir) = &service.root_directory {
        // Use root_directory if specified
        let expanded = expand_home_in_string(root_dir);
        let candidate = PathBuf::from(expanded);
        if candidate.is_absolute() {
            candidate
        } else {
            project_root.clone().join(root_dir)
        }
    } else {
        // Default to project root
        project_root.clone()
    };

    let _ = log_info(
        "service",
        &format!("Running '{}' for service '{}'", command, service_name),
        Some(&format!("Working directory: {}", working_dir.display())),
    )
    .await;

    // Get the command to run
    let cmd_str: Option<&String> = match command {
        "pre" => service.commands.as_ref().and_then(|c| c.pre.as_ref()),
        "install" => service.commands.as_ref().and_then(|c| c.install.as_ref()),
        "build" => service.commands.as_ref().and_then(|c| c.build.as_ref()),
        "start" => service.commands.as_ref().and_then(|c| c.start.as_ref()),
        "dev" => service.commands.as_ref().and_then(|c| c.dev.as_ref()),
        _ => {
            return Err(format!(
                "Unknown command: {}. Valid commands: pre, install, build, start, dev",
                command
            ))
        }
    };

    let cmd_str = cmd_str.ok_or_else(|| {
        format!(
            "Command '{}' not configured for service '{}'",
            command, service_name
        )
    })?;

    // Handle empty build command
    if command == "build" && cmd_str.is_empty() {
        let _ = log_info("service", "Build command is empty, skipping", None).await;
        return Ok(());
    }

    // For start command, wrap with PM2 if start_wrapper is pm2
    if command == "start" && service.start_wrapper.as_deref() == Some("pm2") {
        // Create log directory
        let log_dir = project_root.join(".xbp").join("logs").join(&service.name);
        std::fs::create_dir_all(&log_dir)
            .map_err(|e| format!("Failed to create log directory: {}", e))?;

        // Build PM2 start command with port argument
        let pm2_command: String = format!("{} --port {}", cmd_str, service.port);

        let _ = log_info(
            "service",
            &format!("Starting service '{}' with PM2", service_name),
            Some(&pm2_command),
        )
        .await;

        let envs = merge_envs(config.environment.as_ref(), service.environment.as_ref());
        pm2_start(
            &service.name,
            &pm2_command,
            Some(&log_dir),
            envs.as_ref(),
            debug,
        )
        .await?;

        let _ = log_success(
            "service",
            &format!("Service '{}' started successfully", service_name),
            None,
        )
        .await;

        return Ok(());
    }

    let envs = merge_envs(config.environment.as_ref(), service.environment.as_ref());

    // Run pre-command if it exists and we're not running pre itself
    if command != "pre" {
        if let Some(pre_cmd) = service.commands.as_ref().and_then(|c| c.pre.as_ref()) {
            if !pre_cmd.is_empty() {
                let _ = log_info("service", "Running pre-command", Some(pre_cmd)).await;
                run_command_in_dir(&working_dir, pre_cmd, envs.as_ref(), debug).await?;
            }
        }
    }

    // Execute the command
    run_command_in_dir(&working_dir, cmd_str, envs.as_ref(), debug).await?;

    let _ = log_success(
        "service",
        &format!(
            "Command '{}' completed for service '{}'",
            command, service_name
        ),
        None,
    )
    .await;

    Ok(())
}

/// Run a shell command in a specific directory
async fn run_command_in_dir(
    dir: &PathBuf,
    command: &str,
    envs: Option<&std::collections::HashMap<String, String>>,
    _debug: bool,
) -> Result<(), String> {
    let _ = log_debug(
        "service",
        &format!("Executing: {} in {}", command, dir.display()),
        None,
    )
    .await;

    // Use shell to execute the command (supports complex commands with pipes, etc.)
    #[cfg(unix)]
    let mut cmd = Command::new("sh");
    #[cfg(unix)]
    cmd.arg("-c");

    #[cfg(windows)]
    let mut cmd = Command::new("cmd");
    #[cfg(windows)]
    cmd.arg("/C");

    cmd.arg(command);
    cmd.current_dir(dir);
    if let Some(envs) = envs {
        cmd.envs(envs);
    }
    cmd.stdout(Stdio::inherit());
    cmd.stderr(Stdio::inherit());

    let status = cmd
        .status()
        .await
        .map_err(|e| format!("Failed to execute command: {}", e))?;

    if !status.success() {
        return Err(format!(
            "Command failed with exit code: {}",
            status.code().unwrap_or(-1)
        ));
    }

    Ok(())
}

fn merge_envs(
    global: Option<&std::collections::HashMap<String, String>>,
    service: Option<&std::collections::HashMap<String, String>>,
) -> Option<std::collections::HashMap<String, String>> {
    if global.is_none() && service.is_none() {
        return None;
    }
    let mut out = std::collections::HashMap::new();
    if let Some(g) = global {
        out.extend(g.clone());
    }
    if let Some(s) = service {
        out.extend(s.clone());
    }
    Some(out)
}

fn maybe_write_yaml_codemod(project_root: &Path, cfg: &XbpConfig) {
    let yaml_path = project_root.join(".xbp").join("xbp.yaml");
    if yaml_path.exists() {
        return;
    }
    let mut yaml_cfg = cfg.clone();
    yaml_cfg.build_dir = collapse_home_to_env(&yaml_cfg.build_dir);
    if let Some(services) = &mut yaml_cfg.services {
        for s in services {
            if let Some(rd) = &s.root_directory {
                s.root_directory = Some(collapse_home_to_env(rd));
            }
        }
    }
    if let Ok(yaml) = serde_yaml::to_string(&yaml_cfg) {
        let _ = std::fs::create_dir_all(project_root.join(".xbp"));
        let _ = std::fs::write(&yaml_path, yaml);
    }
}

pub async fn load_xbp_config_with_root() -> Result<(PathBuf, XbpConfig), String> {
    let current_dir: PathBuf =
        env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;

    let found = find_xbp_config_upwards(&current_dir).ok_or_else(|| {
        format!(
            "{}\n\n{}\n{}",
            "Currently not in an XBP project",
            "No xbp.yaml/xbp.yml/xbp.json found in current directory or .xbp/",
            "Run 'xbp' to select a project or 'xbp setup' to initialize a new project."
        )
    })?;

    let content: String = std::fs::read_to_string(&found.config_path)
        .map_err(|e| format!("Failed to read config file: {}", e))?;

    let (mut config, healed_content): (XbpConfig, Option<String>) =
        parse_config_with_auto_heal(&content, found.kind).map_err(|e| {
            if found.kind == "yaml" {
                format!("Failed to parse YAML config: {}", e)
            } else {
                format!("Failed to parse JSON config: {}", e)
            }
        })?;

    if let Some(healed_content) = healed_content {
        let _ = std::fs::write(&found.config_path, healed_content);
    }

    config.build_dir = expand_home_in_string(&config.build_dir);
    if let Some(services) = &mut config.services {
        for s in services {
            if let Some(rd) = &s.root_directory {
                s.root_directory = Some(expand_home_in_string(rd));
            }
        }
    }

    if found.kind == "json"
        && found.config_path.parent().and_then(|p| p.file_name())
            == Some(std::ffi::OsStr::new(".xbp"))
    {
        maybe_write_yaml_codemod(&found.project_root, &config);
    }

    crate::data::athena::persist_project_snapshot(&found.project_root, &config, Some(found.kind))
        .await;

    Ok((found.project_root, config))
}

/// Load xbp.json config from current directory (or parent directories)
pub async fn load_xbp_config() -> Result<XbpConfig, String> {
    Ok(load_xbp_config_with_root().await?.1)
}

/// Check if we're in an xbp project
pub async fn is_xbp_project() -> bool {
    let current_dir = match env::current_dir() {
        Ok(dir) => dir,
        Err(_) => return false,
    };
    find_xbp_config_upwards(&current_dir).is_some()
}

/// Show help for a specific service
pub async fn show_service_help(service_name: &str) -> Result<(), String> {
    let service = get_service_config(service_name).await?;
    let prefix = get_prefix();

    info!("{}", prefix);
    info!("{} Service: {}", prefix, service.name);
    info!("{} {:-<60}", prefix, "");
    info!("{} Target: {}", prefix, service.target);
    info!("{} Port: {}", prefix, service.port);
    info!("{} Branch: {}", prefix, service.branch);
    if let Some(url) = &service.url {
        info!(" {}URL: {}", prefix, url);
    }
    if let Some(root_dir) = &service.root_directory {
        info!(" {}Root Directory: {}", prefix, root_dir);
    }
    info!(
        "{} Force Run From Root: {}",
        prefix,
        service.force_run_from_root.unwrap_or(false)
    );

    if let Some(commands) = &service.commands {
        info!("{}", prefix);
        info!("{} Available Commands:", prefix);
        info!("{} {:-<60}", prefix, "");
        if commands.pre.is_some() {
            info!("{} Service pre {}", prefix, service_name);
        }
        if commands.install.is_some() {
            info!("{} Service install {}", prefix, service_name);
        }
        if commands.build.is_some() {
            info!("{} Service build {}", prefix, service_name);
        }
        if commands.start.is_some() {
            info!("{} Service start {}", prefix, service_name);
        }
        if commands.dev.is_some() {
            info!("{} Service dev {}", prefix, service_name);
        }
    }

    info!("{}", prefix);
    info!("{} Redeploy:", prefix);
    info!("{} Redeploy {}", prefix, service_name);

    Ok(())
}