lovely-packager 0.1.0

A LÖVE >= 11 distribution toolchain for web, desktop, and Steam builds.
Documentation
use crate::Result;
use crate::archive::{self, ArchiveEntry};
use crate::check::{Diagnostic, DiagnosticReport, Severity};
use crate::config::{Config, DesktopTargetConfig};
use crate::fsutil;
use crate::lockfile::LockFile;
use crate::runtime::{DEFAULT_CHANNEL, RuntimeKind, RuntimeRegistry};
use crate::targets::{BuildOutput, TargetAdapter};
use std::path::Path;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DesktopPlatform {
    Windows,
    Macos,
    Linux,
}

pub struct DesktopAdapter {
    platform: DesktopPlatform,
}

impl DesktopAdapter {
    pub fn new(platform: DesktopPlatform) -> Self {
        Self { platform }
    }

    fn config<'a>(&self, config: &'a Config) -> &'a DesktopTargetConfig {
        match self.platform {
            DesktopPlatform::Windows => &config.targets.windows,
            DesktopPlatform::Macos => &config.targets.macos,
            DesktopPlatform::Linux => &config.targets.linux,
        }
    }

    fn slug(&self) -> &'static str {
        match self.platform {
            DesktopPlatform::Windows => "windows",
            DesktopPlatform::Macos => "macos",
            DesktopPlatform::Linux => "linux",
        }
    }

    fn artifact_name(&self, game_id: &str) -> String {
        match self.platform {
            DesktopPlatform::Windows => format!("{game_id}-windows-x64.zip"),
            DesktopPlatform::Macos => format!("{game_id}-macos-universal.app.zip"),
            DesktopPlatform::Linux => format!("{game_id}-linux-x86_64.tar"),
        }
    }
}

impl TargetAdapter for DesktopAdapter {
    fn name(&self) -> &'static str {
        self.slug()
    }

    fn doctor(&self, _root: &Path, config: &Config, lock: &LockFile) -> Result<DiagnosticReport> {
        let mut report = DiagnosticReport::default();
        let target_config = self.config(config);
        if !target_config.enabled {
            report.push(Diagnostic {
                id: "desktop.disabled",
                severity: Severity::Warning,
                message: format!("{} target is disabled in lovely.toml", self.slug()),
                path: None,
            });
        }
        if target_config.runtime_archive.is_none()
            && RuntimeRegistry::new()
                .find(self.slug(), &lock.runtime_channel)?
                .is_none()
        {
            report.push(Diagnostic {
                id: "runtime.missing",
                severity: Severity::Warning,
                message: format!(
                    "{} has no pinned runtime configured or cached; build will emit a depot-ready skeleton around game.love. Run lovely runtime fetch {} <path> to install one.",
                    self.slug(),
                    self.slug()
                ),
                path: None,
            });
        }
        if lock.runtime_channel != DEFAULT_CHANNEL {
            report.push(Diagnostic {
                id: "runtime.channel",
                severity: Severity::Warning,
                message: format!(
                    "expected runtime channel {}, found {}",
                    DEFAULT_CHANNEL, lock.runtime_channel
                ),
                path: None,
            });
        }
        Ok(report)
    }

    fn build(&self, root: &Path, config: &Config, lock: &LockFile) -> Result<BuildOutput> {
        let source = root.join(&config.paths.source);
        let base = root.join(&config.paths.output);
        let work = base.join(self.slug());
        fsutil::ensure_dir(&work)?;

        let love_path = work.join(format!("{}.love", config.game.id));
        archive::create_love_archive(
            &source,
            &love_path,
            &config.paths.includes,
            &config.paths.excludes,
        )?;

        let cached_runtime = RuntimeRegistry::new().find(self.slug(), &lock.runtime_channel)?;
        let runtime_available =
            self.config(config).runtime_archive.is_some() || cached_runtime.is_some();
        let readme = desktop_readme(self.slug(), config, lock, runtime_available);
        let mut entries = vec![
            ArchiveEntry::file(
                format!("{}/{}.love", config.game.id, config.game.id),
                std::fs::read(&love_path).map_err(|err| crate::LovelyError::io(&love_path, err))?,
            )?,
            ArchiveEntry::file(
                format!("{}/README-Lovely.txt", config.game.id),
                readme.into_bytes(),
            )?,
        ];

        if let Some(runtime) = &self.config(config).runtime_archive {
            let runtime_path = root.join(runtime);
            append_runtime_entries(&runtime_path, &config.game.id, &mut entries)?;
        } else if let Some(runtime) = &cached_runtime {
            append_cached_runtime_entries(runtime, &config.game.id, &mut entries)?;
        }

        entries.sort_by(|a, b| a.name.cmp(&b.name));
        let artifact = base.join(self.artifact_name(&config.game.id));
        match self.platform {
            DesktopPlatform::Linux => archive::write_tar(&artifact, &entries)?,
            DesktopPlatform::Windows | DesktopPlatform::Macos => {
                archive::write_zip(&artifact, &entries)?
            }
        }

        let steam_dir = base.join("steam").join(self.slug());
        fsutil::ensure_dir(&steam_dir)?;
        fsutil::copy_file(
            &love_path,
            &steam_dir.join(format!("{}.love", config.game.id)),
        )?;
        if let Some(runtime) = &self.config(config).runtime_archive {
            copy_runtime_to_depot(&root.join(runtime), &steam_dir)?;
        } else if let Some(runtime) = &cached_runtime {
            copy_cached_runtime_to_depot(runtime, &steam_dir)?;
        }
        fsutil::write_string(
            &steam_dir.join("depot_build.vdf"),
            &depot_vdf(self.slug(), config, &steam_dir),
        )?;
        fsutil::write_string(&base.join("steam").join("app_build.vdf"), &app_vdf(config))?;

        Ok(BuildOutput {
            target: self.slug().to_string(),
            artifacts: vec![love_path, artifact, steam_dir.join("depot_build.vdf")],
        })
    }
}

fn append_runtime_entries(
    path: &Path,
    game_id: &str,
    entries: &mut Vec<ArchiveEntry>,
) -> Result<()> {
    if path.is_dir() {
        for file in fsutil::collect_files(path)? {
            let rel = fsutil::relative_path(path, &file)?;
            entries.push(ArchiveEntry::file(
                format!("{game_id}/{}", fsutil::normalize_slashes(&rel)),
                std::fs::read(&file).map_err(|err| crate::LovelyError::io(&file, err))?,
            )?);
        }
    } else if path.is_file() {
        let name = path
            .file_name()
            .ok_or_else(|| crate::LovelyError::Command("runtime file has no name".to_string()))?
            .to_string_lossy();
        entries.push(ArchiveEntry::file(
            format!("{game_id}/runtime/{name}"),
            std::fs::read(path).map_err(|err| crate::LovelyError::io(path, err))?,
        )?);
    }
    Ok(())
}

fn append_cached_runtime_entries(
    runtime: &crate::runtime::CachedRuntime,
    game_id: &str,
    entries: &mut Vec<ArchiveEntry>,
) -> Result<()> {
    match runtime.manifest.kind {
        RuntimeKind::Directory => append_runtime_entries(&runtime.path, game_id, entries),
        RuntimeKind::File => append_runtime_entries(&runtime.path, game_id, entries),
    }
}

fn copy_runtime_to_depot(runtime: &Path, depot: &Path) -> Result<()> {
    if runtime.is_dir() {
        fsutil::copy_dir_contents(runtime, depot)?;
    } else if runtime.is_file() {
        let name = runtime
            .file_name()
            .ok_or_else(|| crate::LovelyError::Command("runtime file has no name".to_string()))?;
        fsutil::copy_file(runtime, &depot.join("runtime").join(name))?;
    }
    Ok(())
}

fn copy_cached_runtime_to_depot(
    runtime: &crate::runtime::CachedRuntime,
    depot: &Path,
) -> Result<()> {
    copy_runtime_to_depot(&runtime.path, depot)
}

fn desktop_readme(
    target: &str,
    config: &Config,
    lock: &LockFile,
    runtime_available: bool,
) -> String {
    let runtime_note = if runtime_available {
        "This artifact includes configured or cached LÖVE runtime content. Platform-specific final fusion/signing is still target-runtime dependent."
    } else {
        "This skeleton artifact contains the normalized .love archive. Install a pinned LÖVE runtime with lovely runtime fetch before release builds."
    };
    format!(
        "{name} {version}\nTarget: {target}\nRuntime channel: {channel}\n\n{runtime_note}\n",
        name = config.game.name,
        version = config.game.version,
        target = target,
        channel = lock.runtime_channel,
        runtime_note = runtime_note
    )
}

fn depot_vdf(target: &str, config: &Config, steam_dir: &Path) -> String {
    let depot_id = match target {
        "windows" => config.steam.windows_depot_id.as_deref(),
        "macos" => config.steam.macos_depot_id.as_deref(),
        "linux" => config.steam.linux_depot_id.as_deref(),
        _ => None,
    }
    .unwrap_or("TODO_DEPOT_ID");

    format!(
        r#""DepotBuildConfig"
{{
  "DepotID" "{depot_id}"
  "ContentRoot" "{content_root}"
  "FileMapping"
  {{
    "LocalPath" "*"
    "DepotPath" "."
    "recursive" "1"
  }}
}}
"#,
        depot_id = depot_id,
        content_root = steam_dir.display()
    )
}

fn app_vdf(config: &Config) -> String {
    format!(
        r#""AppBuild"
{{
  "AppID" "{app_id}"
  "Desc" "{name} {version} generated by Lovely"
  "BuildOutput" "steam-output"
  "ContentRoot" "."
  "SetLive" ""
  "Depots"
  {{
    "{windows}" "windows/depot_build.vdf"
    "{macos}" "macos/depot_build.vdf"
    "{linux}" "linux/depot_build.vdf"
  }}
}}
"#,
        app_id = config.steam.app_id.as_deref().unwrap_or("TODO_APP_ID"),
        name = config.game.name,
        version = config.game.version,
        windows = config
            .steam
            .windows_depot_id
            .as_deref()
            .unwrap_or("TODO_WINDOWS_DEPOT_ID"),
        macos = config
            .steam
            .macos_depot_id
            .as_deref()
            .unwrap_or("TODO_MACOS_DEPOT_ID"),
        linux = config
            .steam
            .linux_depot_id
            .as_deref()
            .unwrap_or("TODO_LINUX_DEPOT_ID"),
    )
}