use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
use crate::commands::service::load_xbp_config_with_root;
use crate::logging::{log_info, log_success, log_warn};
use crate::strategies::{ServiceConfig, XbpConfig};
pub struct GenerateSystemdArgs {
pub output_dir: PathBuf,
pub service: Option<String>,
}
pub async fn run_generate_systemd(args: GenerateSystemdArgs, _debug: bool) -> Result<(), String> {
let (project_root, config) = load_xbp_config_with_root().await?;
let services = 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 generated = Vec::new();
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 path = write_unit(&unit, &args.output_dir)?;
let _ = log_info(
"generate-systemd",
"Wrote systemd unit",
Some(&format!("{}", path.display())),
)
.await;
generated.push(path);
} else {
for service in selected {
let unit = build_service_unit(&project_root, &config, &service)?;
let path = write_unit(&unit, &args.output_dir)?;
let _ = log_info(
"generate-systemd",
"Wrote systemd unit",
Some(&format!("{}", path.display())),
)
.await;
generated.push(path);
}
}
if generated.is_empty() {
return Err("No systemd units were generated.".to_string());
}
let _ = log_success(
"generate-systemd",
"Generated systemd units.",
Some(&format!(
"Wrote {} file(s) to {}",
generated.len(),
args.output_dir.display()
)),
)
.await;
Ok(())
}
struct UnitSpec {
slug: String,
description: String,
working_dir: PathBuf,
start_command: String,
environment: BTreeMap<String, String>,
}
fn build_service_unit(
project_root: &Path,
config: &XbpConfig,
service: &ServiceConfig,
) -> Result<UnitSpec, 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(config.environment.as_ref(), service.environment.as_ref());
environment = ensure_service_port(environment, service.port);
let project_label = project_name_or_default(config);
let slug = slugify(&[project_label, &service.name]);
let description = format!("{} service ({})", project_label, service.name);
Ok(UnitSpec {
slug,
description,
working_dir,
start_command: wrap_exec_command(&start_command),
environment,
})
}
fn build_project_unit(project_root: &Path, config: &XbpConfig) -> Result<UnitSpec, 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(config.environment.as_ref(), None);
if config.port > 0 {
environment.insert("PORT".to_string(), config.port.to_string());
}
let project_label = project_name_or_default(config);
let slug = slugify(&[project_label, "project"]);
let description = format!("{} project service", project_label);
Ok(UnitSpec {
slug,
description,
working_dir,
start_command: wrap_exec_command(start_command),
environment,
})
}
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(
global: Option<&HashMap<String, String>>,
service: Option<&HashMap<String, String>>,
) -> BTreeMap<String, String> {
let mut merged = BTreeMap::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());
}
}
merged
}
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 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 unit_file_name(slug: &str) -> String {
format!("xbp-{}.service", slug)
}
fn render_unit(spec: &UnitSpec) -> String {
let mut lines = vec![
"[Unit]".to_string(),
format!("Description={}", spec.description),
"After=network.target".to_string(),
String::new(),
"[Service]".to_string(),
"Type=simple".to_string(),
format!("WorkingDirectory={}", spec.working_dir.display()),
];
for (key, value) in &spec.environment {
lines.push(format!(
"Environment=\"{}={}\"",
key,
escape_environment(value)
));
}
lines.extend([
format!("ExecStart={}", spec.start_command),
"Restart=always".to_string(),
"RestartSec=10".to_string(),
String::new(),
"[Install]".to_string(),
"WantedBy=multi-user.target".to_string(),
]);
lines.join("\n")
}
fn escape_environment(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn write_unit(spec: &UnitSpec, directory: &Path) -> Result<PathBuf, String> {
let filename = unit_file_name(&spec.slug);
let path = directory.join(&filename);
let content = render_unit(spec);
fs::write(&path, content).map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
Ok(path)
}
fn project_name_or_default(config: &XbpConfig) -> &str {
if config.project_name.trim().is_empty() {
"xbp"
} else {
&config.project_name
}
}