xbp 10.14.2

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Setup command module
//!
//! Installs common system packages required by deployments. The command runs a
//! curated `apt install` invocation and reports progress and errors. Intended
//! for Ubuntu/Debian systems.
use std::process::Output;
use std::time::Instant;
use tokio::process::Command;
use tracing::{debug, info};

use crate::logging::{log_timed, LogLevel};
use crate::utils::command_exists;

/// Execute the `setup` command.
///
/// Runs a non-interactive set of package installs. When `debug` is true, prints
/// the command and raw output for diagnostics.
pub async fn run_setup(debug: bool) -> Result<(), String> {
    let (program, args, description) = build_setup_command()?;
    if debug {
        debug!("Running setup command: {} {}", program, args.join(" "));
    }
    let start: Instant = Instant::now();
    let output: Output = Command::new(&program)
        .args(&args)
        .output()
        .await
        .map_err(|e| format!("Failed to execute setup command: {}", e))?;

    let elapsed = start.elapsed();

    if debug {
        debug!("Setup command output: {:?}", output);
        debug!("Setup command took: {:.2?}", elapsed);
    }

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
        let details = if !stderr.is_empty() { stderr } else { stdout };
        return Err(format!(
            "Setup failed while running '{} {}': {}",
            program,
            args.join(" "),
            details
        ));
    }

    let _ = log_timed(
        LogLevel::Success,
        "setup",
        &format!("Setup completed with {}", description),
        elapsed.as_millis() as u64,
    )
    .await;

    if !output.stdout.is_empty() {
        info!("Setup output: {}", String::from_utf8_lossy(&output.stdout));
    }

    info!("Setup completed successfully!");
    Ok(())
}

fn build_setup_command() -> Result<(String, Vec<String>, String), String> {
    let target_os = if cfg!(target_os = "macos") {
        "macos"
    } else if cfg!(target_os = "linux") {
        "linux"
    } else {
        "unsupported"
    };

    build_setup_command_for(target_os, command_exists)
}

fn build_setup_command_for<F>(
    target_os: &str,
    command_exists: F,
) -> Result<(String, Vec<String>, String), String>
where
    F: Fn(&str) -> bool,
{
    if target_os == "macos" {
        if !command_exists("brew") {
            return Err(
                "Homebrew is required on macOS. Install it first from https://brew.sh/."
                    .to_string(),
            );
        }

        return Ok((
            "brew".to_string(),
            vec![
                "install".to_string(),
                "nginx".to_string(),
                "pkg-config".to_string(),
                "openssl@3".to_string(),
                "findutils".to_string(),
                "inetutils".to_string(),
                "certbot".to_string(),
                "python".to_string(),
            ],
            "Homebrew".to_string(),
        ));
    }

    if target_os == "linux" {
        let package_manager = if command_exists("apt-get") {
            "apt-get"
        } else if command_exists("apt") {
            "apt"
        } else {
            return Err(
                "Unsupported Linux package manager. Install dependencies manually or add apt/apt-get.".to_string(),
            );
        };

        let mut args = Vec::new();
        let program = if command_exists("sudo") {
            args.push(package_manager.to_string());
            "sudo".to_string()
        } else {
            package_manager.to_string()
        };

        args.extend(
            [
                "install",
                "-y",
                "net-tools",
                "nginx",
                "pkg-config",
                "libssl-dev",
                "build-essential",
                "plocate",
                "sshpass",
                "neofetch",
                "certbot",
                "python3-certbot-nginx",
            ]
            .into_iter()
            .map(str::to_string),
        );

        return Ok((program, args, package_manager.to_string()));
    }

    Err("Setup is currently supported on Linux (apt) and macOS (Homebrew).".to_string())
}

#[cfg(test)]
mod tests {
    use super::build_setup_command_for;

    #[test]
    fn linux_prefers_apt_get_and_sudo_when_available() {
        let command = build_setup_command_for("linux", |cmd| matches!(cmd, "apt-get" | "sudo"))
            .expect("linux setup command should build");

        assert_eq!(command.0, "sudo");
        assert_eq!(command.1[0], "apt-get");
        assert_eq!(command.1[1], "install");
        assert_eq!(command.2, "apt-get");
    }

    #[test]
    fn linux_falls_back_to_apt_without_sudo() {
        let command =
            build_setup_command_for("linux", |cmd| cmd == "apt").expect("apt should be accepted");

        assert_eq!(command.0, "apt");
        assert_eq!(command.1[0], "install");
        assert_eq!(command.2, "apt");
    }

    #[test]
    fn linux_errors_without_supported_package_manager() {
        let error = build_setup_command_for("linux", |_| false)
            .expect_err("linux setup should fail without apt");

        assert!(error.contains("Unsupported Linux package manager"));
    }

    #[test]
    fn macos_requires_brew() {
        let error = build_setup_command_for("macos", |_| false)
            .expect_err("macOS setup should require brew");

        assert!(error.contains("Homebrew is required on macOS"));
    }

    #[test]
    fn macos_uses_brew_install_command() {
        let command =
            build_setup_command_for("macos", |cmd| cmd == "brew").expect("brew should be accepted");

        assert_eq!(command.0, "brew");
        assert_eq!(command.1[0], "install");
        assert!(command.1.iter().any(|arg| arg == "python"));
        assert_eq!(command.2, "Homebrew");
    }
}