use std::collections::{BTreeMap, HashMap};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use crate::commands::service::load_xbp_config_with_root;
use crate::commands::systemd_unit::RenderedUnit;
use crate::commands::systemd_unit::{
create_install_script, render_unit_and_store, SystemdUnitSpec,
};
use crate::logging::{log_info, log_success, log_warn};
use crate::strategies::{ServiceConfig, SystemdConfig, XbpConfig};
use crate::utils::resolve_env_placeholders;
pub struct GenerateSystemdArgs {
pub output_dir: PathBuf,
pub service: Option<String>,
pub api: bool,
}
pub async fn run_generate_systemd(args: GenerateSystemdArgs, _debug: bool) -> Result<(), String> {
let (project_root, config) = load_xbp_config_with_root().await?;
let services: Vec<ServiceConfig> = config.services.clone().unwrap_or_default();
let selected: Vec<ServiceConfig> = if services.is_empty() {
Vec::new()
} else if let Some(ref name) = args.service {
let matches: Vec<ServiceConfig> = services
.iter()
.filter(|s| s.name == *name)
.cloned()
.collect();
if matches.is_empty() {
return Err(format!("Service '{}' not found in configuration", name));
}
matches
} else {
services
};
if let Err(e) = fs::create_dir_all(&args.output_dir) {
return Err(format!(
"Failed to prepare systemd directory {}: {}",
args.output_dir.display(),
e
));
}
let mut rendered_units = Vec::new();
if args.api {
let unit = build_api_unit()?;
let rendered = render_unit_and_store(&unit, &args.output_dir)?;
if let Some(ref path) = rendered.written_path {
let _ = log_info(
"generate-systemd",
"Wrote XBP API systemd unit",
Some(&format!("{}", path.display())),
)
.await;
}
rendered_units.push(rendered);
}
if selected.is_empty() {
let _ = log_warn(
"generate-systemd",
"No services configured; generating a single project-level unit.",
None,
)
.await;
let unit = build_project_unit(&project_root, &config)?;
let rendered = render_unit_and_store(&unit, &args.output_dir)?;
if let Some(ref path) = rendered.written_path {
let _ = log_info(
"generate-systemd",
"Wrote systemd unit",
Some(&format!("{}", path.display())),
)
.await;
}
rendered_units.push(rendered);
} else {
for service in selected {
let unit = build_service_unit(&project_root, &config, &service)?;
let rendered = render_unit_and_store(&unit, &args.output_dir)?;
if let Some(ref path) = rendered.written_path {
let _ = log_info(
"generate-systemd",
"Wrote systemd unit",
Some(&format!("{}", path.display())),
)
.await;
}
rendered_units.push(rendered);
}
}
if rendered_units.is_empty() {
return Err("No systemd units were generated.".to_string());
}
let written_count = rendered_units
.iter()
.filter(|unit| unit.written_path.is_some())
.count();
let failed_units: Vec<RenderedUnit> = rendered_units
.iter()
.filter(|unit| unit.write_error.is_some())
.cloned()
.collect();
let script_path = if !failed_units.is_empty() {
Some(create_install_script(
&project_root,
&args.output_dir,
&failed_units,
)?)
} else {
None
};
if let Some(ref path) = script_path {
let _ = log_warn(
"generate-systemd",
"Permission denied writing some units; run the generated install script with sudo.",
Some(&format!("{}", path.display())),
)
.await;
}
let success_message = if let Some(ref path) = script_path {
let failed_count = failed_units.len();
format!(
"Wrote {} file(s) to {}; run {} to install {} unit(s) that need sudo",
written_count,
args.output_dir.display(),
path.display(),
failed_count
)
} else {
format!(
"Wrote {} file(s) to {}",
written_count,
args.output_dir.display()
)
};
let _ = log_success(
"generate-systemd",
"Generated systemd units.",
Some(&success_message),
)
.await;
Ok(())
}
fn build_service_unit(
project_root: &Path,
config: &XbpConfig,
service: &ServiceConfig,
) -> Result<SystemdUnitSpec, String> {
let start_command = resolve_start_command(service, config)?;
let working_dir = resolve_working_dir(project_root, service.root_directory.as_deref());
let mut environment = merge_environment(
project_root,
config.environment.as_ref(),
service.environment.as_ref(),
);
environment = ensure_service_port(environment, service.port);
let systemd = merge_systemd_config(config.systemd.as_ref(), service.systemd.as_ref());
let project_label = project_name_or_default(config);
let slug = if let Some(ref name) = service.systemd_service_name {
name.clone()
} else {
slugify(&[project_label, &service.name])
};
let description = format!("{} service ({})", project_label, service.name);
Ok(SystemdUnitSpec {
slug,
description,
working_dir,
start_command: wrap_exec_command(&start_command),
unit_after: vec!["network.target".to_string()],
unit_wants: Vec::new(),
environment,
environment_files: systemd
.as_ref()
.map(|cfg| cfg.environment_files.clone())
.unwrap_or_default(),
config_paths: systemd
.as_ref()
.map(|cfg| cfg.config_paths.clone())
.unwrap_or_default(),
read_write_paths: systemd
.as_ref()
.map(|cfg| cfg.read_write_paths.clone())
.unwrap_or_default(),
runtime_directories: systemd
.as_ref()
.map(|cfg| cfg.runtime_directories.clone())
.unwrap_or_default(),
state_directories: systemd
.as_ref()
.map(|cfg| cfg.state_directories.clone())
.unwrap_or_default(),
service_directives: Vec::new(),
})
}
fn build_project_unit(project_root: &Path, config: &XbpConfig) -> Result<SystemdUnitSpec, String> {
let start_command = config
.start_command
.as_ref()
.filter(|cmd| !cmd.trim().is_empty())
.ok_or_else(|| {
"Project start command is missing; cannot generate systemd unit.".to_string()
})?;
let working_dir = resolve_working_dir(project_root, Some(config.build_dir.as_str()));
let mut environment = merge_environment(project_root, config.environment.as_ref(), None);
if config.port > 0 {
environment.insert("PORT".to_string(), config.port.to_string());
}
let systemd = config.systemd.as_ref();
let project_label = project_name_or_default(config);
let slug = if let Some(ref name) = config.systemd_service_name {
name.clone()
} else {
slugify(&[project_label])
};
let description = format!("{} project service", project_label);
Ok(SystemdUnitSpec {
slug,
description,
working_dir,
start_command: wrap_exec_command(start_command),
unit_after: vec!["network.target".to_string()],
unit_wants: Vec::new(),
environment,
environment_files: systemd
.map(|cfg| cfg.environment_files.clone())
.unwrap_or_default(),
config_paths: systemd
.map(|cfg| cfg.config_paths.clone())
.unwrap_or_default(),
read_write_paths: systemd
.map(|cfg| cfg.read_write_paths.clone())
.unwrap_or_default(),
runtime_directories: systemd
.map(|cfg| cfg.runtime_directories.clone())
.unwrap_or_default(),
state_directories: systemd
.map(|cfg| cfg.state_directories.clone())
.unwrap_or_default(),
service_directives: Vec::new(),
})
}
fn build_api_unit() -> Result<SystemdUnitSpec, String> {
let exe =
env::current_exe().map_err(|e| format!("Failed to resolve current executable: {}", e))?;
let working_dir = exe
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
let port = env::var("PORT_XBP_API").unwrap_or_else(|_| "8080".to_string());
let port = port.parse::<u16>().unwrap_or(8080);
Ok(crate::commands::build_api_unit_spec(
&working_dir,
&exe,
port,
))
}
fn resolve_start_command(service: &ServiceConfig, config: &XbpConfig) -> Result<String, String> {
let candidate = service
.commands
.as_ref()
.and_then(|commands| commands.start.clone())
.filter(|cmd| !cmd.trim().is_empty())
.or_else(|| {
config
.start_command
.clone()
.filter(|cmd| !cmd.trim().is_empty())
});
candidate.ok_or_else(|| {
format!(
"No start command configured for service '{}' and the project fallback is unset.",
service.name
)
})
}
fn resolve_working_dir(project_root: &Path, override_dir: Option<&str>) -> PathBuf {
if let Some(dir) = override_dir {
let candidate = PathBuf::from(dir);
if candidate.is_absolute() {
candidate
} else {
project_root.join(candidate)
}
} else {
project_root.to_path_buf()
}
}
fn merge_environment(
project_root: &Path,
global: Option<&HashMap<String, String>>,
service: Option<&HashMap<String, String>>,
) -> BTreeMap<String, String> {
let mut merged = HashMap::new();
if let Some(globals) = global {
for (k, v) in globals {
merged.insert(k.clone(), v.clone());
}
}
if let Some(custom) = service {
for (k, v) in custom {
merged.insert(k.clone(), v.clone());
}
}
resolve_env_placeholders(project_root, &merged)
.into_iter()
.collect::<BTreeMap<_, _>>()
}
fn ensure_service_port(mut env: BTreeMap<String, String>, port: u16) -> BTreeMap<String, String> {
if port > 0 {
env.entry("PORT".to_string())
.or_insert_with(|| port.to_string());
}
env
}
fn merge_systemd_config(
project: Option<&SystemdConfig>,
service: Option<&SystemdConfig>,
) -> Option<SystemdConfig> {
let mut combined = SystemdConfig::default();
let mut any = false;
if let Some(cfg) = project {
append_systemd_config(&mut combined, cfg);
any = true;
}
if let Some(cfg) = service {
append_systemd_config(&mut combined, cfg);
any = true;
}
if any {
Some(combined)
} else {
None
}
}
fn append_systemd_config(target: &mut SystemdConfig, source: &SystemdConfig) {
merge_unique(&mut target.environment_files, &source.environment_files);
merge_unique(&mut target.config_paths, &source.config_paths);
merge_unique(&mut target.read_write_paths, &source.read_write_paths);
merge_unique(&mut target.runtime_directories, &source.runtime_directories);
merge_unique(&mut target.state_directories, &source.state_directories);
}
fn merge_unique(target: &mut Vec<String>, source: &[String]) {
for value in source {
if !target.iter().any(|existing| existing == value) {
target.push(value.clone());
}
}
}
fn slugify(parts: &[&str]) -> String {
parts
.join("-")
.to_lowercase()
.chars()
.map(|ch| match ch {
'a'..='z' | '0'..='9' => ch,
_ => '-',
})
.collect::<String>()
.split('-')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn wrap_exec_command(command: &str) -> String {
let escaped = command.replace('\'', r"'\''");
format!("/bin/sh -c '{}'", escaped)
}
fn project_name_or_default(config: &XbpConfig) -> &str {
if config.project_name.trim().is_empty() {
"xbp"
} else {
&config.project_name
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base_config() -> XbpConfig {
XbpConfig {
project_name: "demo".to_string(),
version: "0.1.0".to_string(),
port: 3000,
build_dir: "/srv/demo".to_string(),
app_type: None,
build_command: None,
start_command: Some("node server.js".to_string()),
install_command: None,
environment: None,
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,
}
}
#[test]
fn merge_systemd_config_preserves_order_and_dedupes() {
let project = SystemdConfig {
environment_files: vec!["/etc/default/demo".to_string()],
config_paths: vec!["/etc/demo/config.yaml".to_string()],
read_write_paths: vec!["/var/lib/demo".to_string()],
runtime_directories: vec!["demo".to_string()],
state_directories: vec!["demo".to_string()],
};
let service = SystemdConfig {
environment_files: vec![
"/etc/default/demo".to_string(),
"/etc/default/demo-service".to_string(),
],
config_paths: vec!["/etc/demo/config.yaml".to_string()],
read_write_paths: vec!["/var/lib/demo-service".to_string()],
runtime_directories: vec!["demo".to_string(), "demo-worker".to_string()],
state_directories: vec!["demo-worker".to_string()],
};
let merged = merge_systemd_config(Some(&project), Some(&service)).unwrap();
assert_eq!(
merged.environment_files,
vec![
"/etc/default/demo".to_string(),
"/etc/default/demo-service".to_string()
]
);
assert_eq!(merged.runtime_directories, vec!["demo", "demo-worker"]);
}
#[test]
fn build_project_unit_uses_configured_systemd_paths() {
let mut config = base_config();
config.systemd = Some(SystemdConfig {
environment_files: vec!["/etc/default/demo".to_string()],
config_paths: vec!["/etc/demo/config.yaml".to_string()],
read_write_paths: vec!["/var/lib/demo".to_string()],
runtime_directories: vec!["demo".to_string()],
state_directories: vec!["demo".to_string()],
});
let unit = build_project_unit(Path::new("/srv"), &config).expect("unit");
assert!(unit
.environment_files
.contains(&"/etc/default/demo".to_string()));
assert!(unit
.config_paths
.contains(&"/etc/demo/config.yaml".to_string()));
assert!(unit.read_write_paths.contains(&"/var/lib/demo".to_string()));
assert!(unit.runtime_directories.contains(&"demo".to_string()));
}
}