xbp 10.7.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
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};

/// Arguments passed down by the CLI command.
pub struct GenerateSystemdArgs {
    pub output_dir: PathBuf,
    pub service: Option<String>,
}

/// Generate systemd units for the configured services.
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
    }
}