lovely-packager 0.1.0

A LÖVE >= 11 distribution toolchain for web, desktop, and Steam builds.
Documentation
use crate::fsutil;
use crate::{LovelyError, Result};
use std::collections::BTreeMap;
use std::path::Path;

pub const LOCK_FILE: &str = "lovely.lock";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LockFile {
    pub schema: u32,
    pub runtime_channel: String,
    pub love: LockedComponent,
    pub emscripten: LockedComponent,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LockedComponent {
    pub source: String,
    pub revision: String,
    pub sha256: String,
}

impl LockFile {
    pub fn preview_default() -> Self {
        Self {
            schema: 1,
            runtime_channel: "love-11-plus".to_string(),
            love: LockedComponent {
                source: "https://github.com/love2d/love".to_string(),
                revision: "main".to_string(),
                sha256: "unresolved".to_string(),
            },
            emscripten: LockedComponent {
                source: "emsdk".to_string(),
                revision: "pinned-by-runtime-manifest".to_string(),
                sha256: "unresolved".to_string(),
            },
        }
    }

    pub fn load_from(path: &Path) -> Result<Self> {
        let text = fsutil::read_to_string(path)?;
        Self::parse(&text)
    }

    pub fn parse(text: &str) -> Result<Self> {
        let mut values = BTreeMap::<String, String>::new();
        for (index, line) in text.lines().enumerate() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            let Some((key, value)) = line.split_once('=') else {
                return Err(LovelyError::Lock(format!(
                    "line {} is not a key/value pair",
                    index + 1
                )));
            };
            values.insert(key.trim().to_string(), unquote(value.trim()));
        }

        let schema = values
            .get("schema")
            .and_then(|value| value.parse::<u32>().ok())
            .unwrap_or(1);
        Ok(Self {
            schema,
            runtime_channel: take(&values, "runtime_channel")?,
            love: LockedComponent {
                source: take(&values, "love.source")?,
                revision: take(&values, "love.revision")?,
                sha256: take(&values, "love.sha256")?,
            },
            emscripten: LockedComponent {
                source: take(&values, "emscripten.source")?,
                revision: take(&values, "emscripten.revision")?,
                sha256: take(&values, "emscripten.sha256")?,
            },
        })
    }

    pub fn to_text(&self) -> String {
        format!(
            r#"# Generated by Lovely. Commit this file for reproducible builds.
schema = {schema}
runtime_channel = "{runtime_channel}"

love.source = "{love_source}"
love.revision = "{love_revision}"
love.sha256 = "{love_sha}"

emscripten.source = "{emscripten_source}"
emscripten.revision = "{emscripten_revision}"
emscripten.sha256 = "{emscripten_sha}"
"#,
            schema = self.schema,
            runtime_channel = escape(&self.runtime_channel),
            love_source = escape(&self.love.source),
            love_revision = escape(&self.love.revision),
            love_sha = escape(&self.love.sha256),
            emscripten_source = escape(&self.emscripten.source),
            emscripten_revision = escape(&self.emscripten.revision),
            emscripten_sha = escape(&self.emscripten.sha256),
        )
    }

    pub fn has_unresolved_checksums(&self) -> bool {
        [&self.love, &self.emscripten]
            .iter()
            .any(|component| component.sha256 == "unresolved")
    }
}

fn take(values: &BTreeMap<String, String>, key: &str) -> Result<String> {
    values
        .get(key)
        .cloned()
        .ok_or_else(|| LovelyError::Lock(format!("missing {key}")))
}

fn unquote(value: &str) -> String {
    if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
        value[1..value.len() - 1].replace("\\\"", "\"")
    } else {
        value.to_string()
    }
}

fn escape(input: &str) -> String {
    input.replace('\\', "\\\\").replace('"', "\\\"")
}