xbp 0.9.4

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! deployment configuration management module
//!
//! handles reading and writing deployment configurations
//! including xbp.json files and cli argument parsing
//! supports both legacy single service and new multi service formats
//! validates service configurations for duplicates and invalid targets

use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::fs;
use std::collections::HashMap;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceCommands {
    #[serde(default)]
    pub pre: Option<String>,
    #[serde(default)]
    pub install: Option<String>,
    #[serde(default)]
    pub build: Option<String>,
    #[serde(default)]
    pub start: Option<String>,
    #[serde(default)]
    pub dev: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceConfig {
    pub name: String,
    pub target: String,
    pub branch: String,
    pub port: u16,
    #[serde(default)]
    pub root_directory: Option<String>,
    #[serde(default)]
    pub url: Option<String>,
    #[serde(default)]
    pub healthcheck_path: Option<String>,
    #[serde(default)]
    pub restart_policy: Option<String>,
    #[serde(default)]
    pub restart_policy_max_failure_count: Option<u32>,
    #[serde(default)]
    pub start_wrapper: Option<String>,
    #[serde(default)]
    pub commands: Option<ServiceCommands>,
    #[serde(default)]
    pub force_run_from_root: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct XbpConfig {
    pub project_name: String,
    pub port: u16,
    pub build_dir: String,
    #[serde(default)]
    pub app_type: Option<String>,
    #[serde(default)]
    pub build_command: Option<String>,
    #[serde(default)]
    pub start_command: Option<String>,
    #[serde(default)]
    pub install_command: Option<String>,
    #[serde(default)]
    pub environment: Option<HashMap<String, String>>,
    #[serde(default)]
    pub services: Option<Vec<ServiceConfig>>,
    #[serde(default)]
    pub kafka_brokers: Option<String>,
    #[serde(default)]
    pub kafka_topic: Option<String>,
    #[serde(default)]
    pub kafka_public_url: Option<String>,
    #[serde(default)]
    pub log_files: Option<Vec<String>>,
    #[serde(default)]
    pub monitor_url: Option<String>,
    #[serde(default)]
    pub monitor_method: Option<String>,
    #[serde(default)]
    pub monitor_expected_code: Option<u16>,
    #[serde(default)]
    pub monitor_interval: Option<u64>,
    // Legacy fields for backward compatibility
    #[serde(default)]
    pub target: Option<String>,
    #[serde(default)]
    pub branch: Option<String>,
    #[serde(default)]
    pub crate_name: Option<String>,
    #[serde(default)]
    pub npm_script: Option<String>,
    #[serde(default)]
    pub port_storybook: Option<u16>,
    #[serde(default)]
    pub url: Option<String>,
    #[serde(default)]
    pub url_storybook: Option<String>,
}

#[derive(Debug, Clone)]
pub struct DeploymentConfig {
    pub app_name: String,
    pub port: u16,
    pub app_dir: PathBuf,
    pub build_command: Option<String>,
    pub start_command: Option<String>,
    pub install_command: Option<String>,
    pub environment: HashMap<String, String>,
}

impl DeploymentConfig {
    /// Create deployment config from CLI arguments and xbp.json fallback
    pub async fn from_args_or_config(
        app_name: Option<String>,
        port: Option<u16>,
        app_dir: Option<PathBuf>,
        config_path: Option<PathBuf>,
    ) -> Result<Self, String> {
        // Try to load from xbp.json if not all args provided
        let xbp_config = if app_name.is_none() || port.is_none() || app_dir.is_none() {
            match Self::load_xbp_config(config_path).await {
                Ok(config) => Some(config),
                Err(_) => None, // Ignore config file errors if we have CLI args
            }
        } else {
            None
        };

        let app_name = app_name
            .or_else(|| xbp_config.as_ref().map(|c| c.project_name.clone()))
            .ok_or("Missing app name")?;

        let port = port
            .or_else(|| xbp_config.as_ref().map(|c| c.port))
            .ok_or("Missing port")?;

        let app_dir = app_dir
            .or_else(|| xbp_config.as_ref().map(|c| PathBuf::from(&c.build_dir)))
            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());

        let app_dir = app_dir.canonicalize()
            .map_err(|e| format!("Failed to resolve app directory: {}", e))?;

        // Get build/start commands from config if available
        let build_command = xbp_config.as_ref().and_then(|c| c.build_command.clone());
        let start_command = xbp_config.as_ref().and_then(|c| c.start_command.clone());
        let install_command = xbp_config.as_ref().and_then(|c| c.install_command.clone());
        let environment = xbp_config.as_ref()
            .and_then(|c| c.environment.clone())
            .unwrap_or_default();

        Ok(DeploymentConfig {
            app_name,
            port,
            app_dir,
            build_command,
            start_command,
            install_command,
            environment,
        })
    }

    /// Load xbp.json configuration from file
    pub async fn load_xbp_config(config_path: Option<PathBuf>) -> Result<XbpConfig, String> {
        let config_path = config_path.unwrap_or_else(|| {
            std::env::current_dir()
                .unwrap_or_default()
                .join(".xbp")
                .join("xbp.json")
        });

        if !config_path.exists() {
            return Err(format!("Configuration file not found: {}", config_path.display()));
        }

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

        let config: XbpConfig = serde_json::from_str(&content)
            .map_err(|e| format!("Failed to parse config file: {}", e))?;

        // Validate services if present
        if let Some(services) = &config.services {
            validate_services(services)?;
        }

        Ok(config)
    }

    /// Save updated configuration back to xbp.json
    pub async fn save_xbp_config(&self, config_path: Option<PathBuf>) -> Result<(), String> {
        let config_path = config_path.unwrap_or_else(|| {
            self.app_dir.join(".xbp").join("xbp.json")
        });

        // Create .xbp directory if it doesn't exist
        if let Some(parent) = config_path.parent() {
            fs::create_dir_all(parent)
                .map_err(|e| format!("Failed to create config directory: {}", e))?;
        }

        let xbp_config = XbpConfig {
            project_name: self.app_name.clone(),
            port: self.port,
            build_dir: self.app_dir.to_string_lossy().to_string(),
            app_type: None,
            build_command: self.build_command.clone(),
            start_command: self.start_command.clone(),
            install_command: self.install_command.clone(),
            environment: if self.environment.is_empty() {
                None
            } else {
                Some(self.environment.clone())
            },
            services: None,
            kafka_brokers: None,
            kafka_topic: None,
            kafka_public_url: None,
            log_files: None,
            monitor_url: None,
            monitor_method: None,
            monitor_expected_code: None,
            monitor_interval: None,
            target: None,
            branch: None,
            crate_name: None,
            npm_script: None,
            port_storybook: None,
            url: None,
            url_storybook: None,
        };

        let content = serde_json::to_string_pretty(&xbp_config)
            .map_err(|e| format!("Failed to serialize config: {}", e))?;

        fs::write(&config_path, content)
            .map_err(|e| format!("Failed to write config file: {}", e))?;

        Ok(())
    }

    /// Update port in the configuration
    pub fn update_port(&mut self, new_port: u16) {
        self.port = new_port;
    }

    /// Merge with deployment recommendations from project detection
    pub fn merge_with_recommendations(
        &mut self,
        recommendations: &super::project_detector::DeploymentRecommendations,
    ) {
        // Use recommendations if not already set
        if self.build_command.is_none() {
            self.build_command = recommendations.build_command.clone();
        }
        
        if self.start_command.is_none() {
            self.start_command = recommendations.start_command.clone();
        }
        
        if self.install_command.is_none() {
            self.install_command = recommendations.install_command.clone();
        }

        // Use recommended process name if app_name is generic
        if let Some(recommended_name) = &recommendations.process_name {
            if self.app_name == "app" || self.app_name == "unknown" {
                self.app_name = recommended_name.clone();
            }
        }
    }
}

/// Validate services configuration
pub fn validate_services(services: &[ServiceConfig]) -> Result<(), String> {
    let mut names = std::collections::HashSet::new();
    let mut ports = std::collections::HashSet::new();
    let mut urls = std::collections::HashSet::new();

    for service in services {
        // Check for duplicate names
        if !names.insert(&service.name) {
            return Err(format!("Duplicate service name found: {}", service.name));
        }

        // Check for duplicate ports
        if !ports.insert(service.port) {
            return Err(format!("Duplicate port found: {}", service.port));
        }

        // Check for duplicate URLs
        if let Some(url) = &service.url {
            if !urls.insert(url) {
                return Err(format!("Duplicate URL found: {}", url));
            }
        }

        // Validate target
        let valid_targets = ["python", "expressjs", "nextjs", "rust"];
        if !valid_targets.contains(&service.target.as_str()) {
            return Err(format!(
                "Invalid target '{}' for service '{}'. Must be one of: python, expressjs, nextjs, rust",
                service.target, service.name
            ));
        }
    }

    Ok(())
}

/// Find a service by name
pub fn get_service_by_name(config: &XbpConfig, name: &str) -> Result<ServiceConfig, String> {
    if let Some(services) = &config.services {
        services
            .iter()
            .find(|s| s.name == name)
            .cloned()
            .ok_or_else(|| format!("Service '{}' not found in configuration", name))
    } else {
        Err("No services configured. This project uses legacy single-service format.".to_string())
    }
}

/// Get all services from config (or create a virtual one from legacy config)
pub fn get_all_services(config: &XbpConfig) -> Vec<ServiceConfig> {
    if let Some(services) = &config.services {
        services.clone()
    } else {
        // Backward compatibility: create a virtual service from top-level config
        vec![ServiceConfig {
            name: config.project_name.clone(),
            target: config.target.clone().unwrap_or_else(|| "rust".to_string()),
            branch: config.branch.clone().unwrap_or_else(|| "main".to_string()),
            port: config.port,
            root_directory: Some(config.build_dir.clone()),
            url: config.url.clone(),
            healthcheck_path: None,
            restart_policy: Some("on_failure".to_string()),
            restart_policy_max_failure_count: Some(10),
            start_wrapper: Some("pm2".to_string()),
            commands: Some(ServiceCommands {
                pre: None,
                install: config.install_command.clone(),
                build: config.build_command.clone(),
                start: config.start_command.clone(),
                dev: None,
            }),
            force_run_from_root: Some(false),
        }]
    }
}