use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use crate::config::LinearConfig;
use crate::utils::{
collapse_project_path, find_xbp_config_upwards, maybe_auto_convert_legacy_xbp_json_to_yaml,
parse_config_with_auto_heal, resolve_env_placeholders, resolve_project_path,
};
fn default_xbp_version() -> String {
"0.1.0".to_string()
}
fn default_auto_push_on_commit() -> bool {
true
}
pub const DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE: &str = "releases/${GITHUB_VERSION}";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PublishTargetConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub package_name: Option<String>,
#[serde(default)]
pub working_directory: Option<String>,
#[serde(default)]
pub manifest_path: Option<String>,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub preflight_commands: Vec<String>,
#[serde(default)]
pub publish_command: Option<String>,
#[serde(default)]
pub use_wsl: Option<bool>,
#[serde(default)]
pub wsl_distribution: Option<String>,
#[serde(default)]
pub generate_npmrc: Option<bool>,
#[serde(default)]
pub access: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PublishProjectConfig {
#[serde(default)]
pub npm: Option<PublishTargetConfig>,
#[serde(default)]
pub crates: Option<PublishTargetConfig>,
}
#[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, Default)]
pub struct SystemdConfig {
#[serde(default)]
pub environment_files: Vec<String>,
#[serde(default)]
pub config_paths: Vec<String>,
#[serde(default)]
pub read_write_paths: Vec<String>,
#[serde(default)]
pub runtime_directories: Vec<String>,
#[serde(default)]
pub state_directories: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub backend: Option<String>,
#[serde(default)]
pub url_env: Option<String>,
#[serde(default)]
pub key_env: Option<String>,
#[serde(default)]
pub schema: 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 environment: Option<HashMap<String, 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>,
#[serde(default)]
pub systemd_service_name: Option<String>,
#[serde(default)]
pub systemd: Option<SystemdConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct XbpConfig {
pub project_name: String,
#[serde(default = "default_xbp_version")]
pub version: 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 systemd_service_name: Option<String>,
#[serde(default)]
pub systemd: Option<SystemdConfig>,
#[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>,
#[serde(default)]
pub database: Option<DatabaseConfig>,
#[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>,
#[serde(default)]
pub linear: Option<LinearConfig>,
#[serde(default)]
pub github: Option<GitHubProjectConfig>,
#[serde(default)]
pub publish: Option<PublishProjectConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GitHubProjectConfig {
#[serde(default = "default_auto_push_on_commit")]
pub auto_push_on_commit: bool,
#[serde(default)]
pub release_branch: Option<GitHubReleaseBranchConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GitHubReleaseBranchConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default, alias = "template")]
pub naming_template: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitHubReleaseBranchSettings {
pub naming_template: 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>,
}
pub fn normalize_config_paths_for_persistence(
config: &mut XbpConfig,
project_root: &std::path::Path,
) {
config.build_dir = collapse_project_path(project_root, &config.build_dir);
if let Some(publish) = &mut config.publish {
for target in [&mut publish.npm, &mut publish.crates] {
if let Some(target) = target {
if let Some(working_directory) = &target.working_directory {
target.working_directory =
Some(collapse_project_path(project_root, working_directory));
}
if let Some(manifest_path) = &target.manifest_path {
target.manifest_path = Some(collapse_project_path(project_root, manifest_path));
}
}
}
}
if let Some(services) = &mut config.services {
for service in services {
if let Some(root_directory) = &service.root_directory {
service.root_directory = Some(collapse_project_path(project_root, root_directory));
}
}
}
}
pub fn resolve_config_paths_for_runtime(config: &mut XbpConfig, project_root: &std::path::Path) {
config.build_dir = resolve_project_path(project_root, &config.build_dir);
if let Some(publish) = &mut config.publish {
for target in [&mut publish.npm, &mut publish.crates] {
if let Some(target) = target {
if let Some(working_directory) = &target.working_directory {
target.working_directory =
Some(resolve_project_path(project_root, working_directory));
}
if let Some(manifest_path) = &target.manifest_path {
target.manifest_path = Some(resolve_project_path(project_root, manifest_path));
}
}
}
}
if let Some(services) = &mut config.services {
for service in services {
if let Some(root_directory) = &service.root_directory {
service.root_directory = Some(resolve_project_path(project_root, root_directory));
}
}
}
}
impl XbpConfig {
pub fn auto_push_on_commit_enabled(&self) -> bool {
self.github
.as_ref()
.map(|config| config.auto_push_on_commit)
.unwrap_or(true)
}
pub fn github_release_branch_settings(&self) -> Option<GitHubReleaseBranchSettings> {
let release_branch = self
.github
.as_ref()
.and_then(|config| config.release_branch.as_ref())?;
if !release_branch.enabled {
return None;
}
let naming_template = release_branch
.naming_template
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE)
.to_string();
Some(GitHubReleaseBranchSettings { naming_template })
}
}
impl DeploymentConfig {
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> {
let xbp_config = if app_name.is_none() || port.is_none() || app_dir.is_none() {
Self::load_xbp_config(config_path).await.ok()
} 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))?;
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();
let environment = resolve_env_placeholders(&app_dir, &environment);
Ok(DeploymentConfig {
app_name,
port,
app_dir,
build_command,
start_command,
install_command,
environment,
})
}
pub async fn load_xbp_config(config_path: Option<PathBuf>) -> Result<XbpConfig, String> {
let cwd = std::env::current_dir().unwrap_or_default();
let (project_root, resolved_path, resolved_kind) = if let Some(p) = config_path.clone() {
let root = p
.parent()
.map(|pp| pp.to_path_buf())
.unwrap_or_else(|| cwd.clone());
(root, p, "auto")
} else {
let found = find_xbp_config_upwards(&cwd)
.ok_or_else(|| "Configuration file not found".to_string())?;
(found.project_root, found.config_path, found.kind)
};
let _ = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &resolved_path);
let (config_path, kind) = (resolved_path, resolved_kind);
let content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config: {}", e))?;
let effective_kind = match kind {
"yaml" | "json" => kind,
_ => {
if config_path
.extension()
.and_then(|s| s.to_str())
.map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
.unwrap_or(false)
{
"yaml"
} else {
"json"
}
}
};
let (mut config, healed_content): (XbpConfig, Option<String>) =
parse_config_with_auto_heal(&content, effective_kind).map_err(|e| {
if effective_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 _ = fs::write(&config_path, healed_content);
}
resolve_config_paths_for_runtime(&mut config, &project_root);
if let Some(services) = &config.services {
validate_services(services)?;
}
Ok(config)
}
pub async fn save_xbp_config(&self, config_path: Option<PathBuf>) -> Result<(), String> {
let dir = self.app_dir.join(".xbp");
let json_path = dir.join("xbp.json");
let yaml_path = dir.join("xbp.yaml");
fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
let mut xbp_config = XbpConfig {
project_name: self.app_name.clone(),
version: default_xbp_version(),
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,
systemd_service_name: None,
systemd: 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,
database: None,
target: None,
branch: None,
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
publish: None,
};
normalize_config_paths_for_persistence(&mut xbp_config, &self.app_dir);
let yaml = serde_yaml::to_string(&xbp_config)
.map_err(|e| format!("Failed to serialize config (yaml): {}", e))?;
let json = serde_json::to_string_pretty(&xbp_config)
.map_err(|e| format!("Failed to serialize config (json): {}", e))?;
let explicit_path = config_path;
let explicit_is_json = explicit_path
.as_ref()
.and_then(|path| path.extension().and_then(|ext| ext.to_str()))
.map(|ext| ext.eq_ignore_ascii_case("json"))
.unwrap_or(false);
fs::write(&yaml_path, &yaml)
.map_err(|e| format!("Failed to write yaml config {}: {}", yaml_path.display(), e))?;
if explicit_is_json {
let out_path = explicit_path.expect("explicit path should exist");
fs::write(&out_path, &json).map_err(|e| {
format!(
"Failed to write legacy JSON config {}: {}",
out_path.display(),
e
)
})?;
} else if json_path.exists() {
fs::write(&json_path, &json).map_err(|e| {
format!(
"Failed to sync legacy JSON config {}: {}",
json_path.display(),
e
)
})?;
}
Ok(())
}
pub fn update_port(&mut self, new_port: u16) {
self.port = new_port;
}
pub fn merge_with_recommendations(
&mut self,
recommendations: &super::project_detector::DeploymentRecommendations,
) {
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();
}
if let Some(recommended_name) = &recommendations.process_name {
if self.app_name == "app" || self.app_name == "unknown" {
self.app_name = recommended_name.clone();
}
}
}
}
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 {
if !names.insert(&service.name) {
return Err(format!("Duplicate service name found: {}", service.name));
}
if !ports.insert(service.port) {
return Err(format!("Duplicate port found: {}", service.port));
}
if let Some(url) = &service.url {
if !urls.insert(url) {
return Err(format!("Duplicate URL found: {}", url));
}
}
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(())
}
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())
}
}
pub fn get_all_services(config: &XbpConfig) -> Vec<ServiceConfig> {
if let Some(services) = &config.services {
services.clone()
} else {
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()),
environment: config.environment.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),
systemd_service_name: config.systemd_service_name.clone(),
systemd: config.systemd.clone(),
}]
}
}
#[cfg(test)]
mod tests {
use super::{XbpConfig, DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE};
#[test]
fn github_auto_push_defaults_to_true_when_missing() {
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
"#,
)
.expect("parse config");
assert!(config.auto_push_on_commit_enabled());
}
#[test]
fn github_auto_push_can_be_disabled_per_project() {
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
github:
auto_push_on_commit: false
"#,
)
.expect("parse config");
assert!(!config.auto_push_on_commit_enabled());
}
#[test]
fn github_release_branch_is_disabled_by_default() {
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
"#,
)
.expect("parse config");
assert!(config.github_release_branch_settings().is_none());
}
#[test]
fn github_release_branch_uses_default_template_when_enabled_without_one() {
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
github:
release_branch:
enabled: true
"#,
)
.expect("parse config");
let settings = config
.github_release_branch_settings()
.expect("release branch settings");
assert_eq!(
settings.naming_template,
DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE
);
}
#[test]
fn github_release_branch_uses_custom_template_when_configured() {
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
github:
release_branch:
enabled: true
naming_template: rel/${GITHUB_TAG}
"#,
)
.expect("parse config");
let settings = config
.github_release_branch_settings()
.expect("release branch settings");
assert_eq!(settings.naming_template, "rel/${GITHUB_TAG}");
}
}