lovely-packager 0.2.1

A LÖVE >= 11 distribution toolchain for web, desktop, and Steam builds.
Documentation
use crate::runtime::cache_dir;
use crate::{LovelyError, Result};
use std::env;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const BUTLER_BASE_URL: &str = "https://broth.itch.zone/butler";

#[derive(Debug, Clone)]
pub struct Butler {
    path: PathBuf,
}

impl Butler {
    pub fn resolve() -> Result<Self> {
        if let Some(path) = env::var_os("LOVELY_BUTLER_PATH") {
            let path = PathBuf::from(path);
            if path.is_file() {
                return Ok(Self { path });
            }
            return Err(LovelyError::Command(format!(
                "LOVELY_BUTLER_PATH does not point to a file: {}",
                path.display()
            )));
        }

        if let Some(path) = find_on_path("butler") {
            return Ok(Self { path });
        }

        let path = cached_butler_path();
        if path.is_file() {
            return Ok(Self { path });
        }

        fetch_butler(&path)?;
        Ok(Self { path })
    }

    pub fn push(&self, artifact: &Path, destination: &str) -> Result<()> {
        let status = Command::new(&self.path)
            .arg("push")
            .arg(artifact)
            .arg(destination)
            .status()
            .map_err(|err| LovelyError::io(&self.path, err))?;

        if !status.success() {
            return Err(LovelyError::Command(format!(
                "butler push failed with status {status}"
            )));
        }

        Ok(())
    }

    pub fn path(&self) -> &Path {
        &self.path
    }
}

fn fetch_butler(destination: &Path) -> Result<()> {
    let Some(platform) = butler_platform() else {
        return Err(LovelyError::Command(
            "automatic Butler install is not supported on this platform; install Butler or set LOVELY_BUTLER_PATH".to_string(),
        ));
    };

    let Some(parent) = destination.parent() else {
        return Err(LovelyError::Command(
            "invalid Butler cache destination".to_string(),
        ));
    };
    fs::create_dir_all(parent).map_err(|err| LovelyError::io(parent, err))?;

    let archive = parent.join("butler.zip");
    let url = env::var("LOVELY_BUTLER_URL")
        .unwrap_or_else(|_| format!("{BUTLER_BASE_URL}/{platform}/LATEST/archive/default"));

    run_tool(
        "curl",
        &[
            OsString::from("-fsSL"),
            OsString::from("-o"),
            archive.as_os_str().to_os_string(),
            OsString::from(url),
        ],
        "download Butler",
    )?;

    #[cfg(windows)]
    {
        run_tool(
            "powershell",
            &[
                OsString::from("-NoProfile"),
                OsString::from("-Command"),
                OsString::from(format!(
                    "Expand-Archive -Force -LiteralPath '{}' -DestinationPath '{}'",
                    archive.display(),
                    parent.display()
                )),
            ],
            "extract Butler",
        )?;
    }

    #[cfg(not(windows))]
    {
        run_tool(
            "unzip",
            &[
                OsString::from("-o"),
                archive.as_os_str().to_os_string(),
                OsString::from("-d"),
                parent.as_os_str().to_os_string(),
            ],
            "extract Butler",
        )?;
    }

    if !destination.is_file() {
        return Err(LovelyError::Command(format!(
            "downloaded Butler archive did not contain {}",
            destination.display()
        )));
    }

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut permissions = fs::metadata(destination)
            .map_err(|err| LovelyError::io(destination, err))?
            .permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(destination, permissions)
            .map_err(|err| LovelyError::io(destination, err))?;
    }

    Ok(())
}

fn run_tool(tool: &str, args: &[OsString], action: &str) -> Result<()> {
    let output = Command::new(tool).args(args).output().map_err(|err| {
        LovelyError::Command(format!(
            "could not {action}: {tool} is required for automatic Butler install ({err})"
        ))
    })?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(LovelyError::Command(format!(
            "could not {action}: {tool} exited with status {}; {}",
            output.status,
            stderr.trim()
        )));
    }

    Ok(())
}

fn cached_butler_path() -> PathBuf {
    cache_dir()
        .join("tools")
        .join("butler")
        .join(butler_platform().unwrap_or("unknown"))
        .join(butler_binary_name())
}

fn find_on_path(binary: &str) -> Option<PathBuf> {
    let path = env::var_os("PATH")?;
    for dir in env::split_paths(&path) {
        for name in path_binary_names(binary) {
            let candidate = dir.join(name);
            if candidate.is_file() {
                return Some(candidate);
            }
        }
    }
    None
}

fn path_binary_names(binary: &str) -> Vec<String> {
    #[cfg(windows)]
    {
        if Path::new(binary).extension().is_some() {
            return vec![binary.to_string()];
        }
        vec![
            format!("{binary}.exe"),
            format!("{binary}.cmd"),
            format!("{binary}.bat"),
        ]
    }

    #[cfg(not(windows))]
    {
        vec![binary.to_string()]
    }
}

fn butler_binary_name() -> &'static str {
    if cfg!(windows) {
        "butler.exe"
    } else {
        "butler"
    }
}

fn butler_platform() -> Option<&'static str> {
    match (env::consts::OS, env::consts::ARCH) {
        ("linux", "x86_64") => Some("linux-amd64"),
        ("linux", "aarch64") => Some("linux-arm64"),
        ("macos", "x86_64") => Some("darwin-amd64"),
        ("macos", "aarch64") => Some("darwin-arm64"),
        ("windows", "x86_64") => Some("windows-amd64"),
        ("windows", "aarch64") => Some("windows-arm64"),
        _ => None,
    }
}